feat: split system prompt by local/cloud, improve mouse tracking, increase retries & iterations

- Separate system prompt for local vs cloud models via isLocal flag
  (isLocalBackendURL, buildSystemPrompt(..., isLocal), propagate through
  createSession/createSessionFromRecord/compactSession/spawnSubAgent)
- Increase MAX_EMPTY_RESPONSE_RETRIES from 1 to 3 for cloud model resilience
- Upgrade mouse input: full SGR-1006 parsing with col/row/pressed/modifiers,
  logicalButton() helper, and copyToClipboard() via OSC 52
- Add temporary mouse debug logging in App.tsx
- Increase DEFAULT_MAX_ITERATIONS from 100 to 300
- Update README mouse/scrollback docs, tweak diff-remove color
This commit is contained in:
kim
2026-08-21 18:01:29 +09:00
parent 47711a9600
commit 60c6021390
12 changed files with 2371 additions and 43 deletions
+2095
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -115,7 +115,7 @@ Note: even models with genuine native tool-calling support occasionally emit a t
/model <name> switch the model used for the current backend
/backend <name> switch backend (ollama | lmstudio), keeps current model
/mode <name> view or force tool-call mode (native | fallback)
/mouse [on|off] toggle mouse-wheel scroll (off by default so terminal text selection/copy works)
/mouse [on|off] toggle mouse tracking (on by default; hold Shift+click/drag for native text selection)
/perm [mode] cycle or set permission mode (default | plan | auto-edit | auto-accept)
/status show current model, backend, tool-call mode, and cwd
/dashboard show session stats: token I/O, elapsed/model time, turns, tool calls
@@ -161,7 +161,7 @@ locode config path
- Requires a real interactive terminal (TTY) — you can't pipe input into it or run it from a non-interactive script.
- Native tool-calling reliability varies by model and is non-deterministic even for capable models (see above).
- No OS-level sandboxing (no container/VM isolation) — mutating tools operate on the real filesystem/shell with the permissions of the user running `locode`. Only approve commands you understand. Two lightweight guardrails run unconditionally regardless of permission mode (including `auto-accept`), as a safety floor rather than a full sandbox: `write_file`/`edit_file`/`bash`'s `cwd` override can't target a path outside the working directory (`../` traversal, an absolute path elsewhere, or — on Windows — a different drive all refuse), and `bash` refuses a short list of unambiguously catastrophic commands (wiping the filesystem root or home directory, a fork bomb, formatting/wiping a whole drive, writing raw data to a block device) before they'd ever run. Neither guard stops a model from doing damage confined to *within* the project directory, or running something merely inadvisable — see `src/tools/pathGuard.ts` and `src/tools/bashGuard.ts`.
- In-app scrollback is manual: PageUp/PageDown scroll the conversation view a page at a time (the terminal's native scrollback isn't available in the alternate screen buffer). Scrolling back up unpins the view from the latest message; PageDown back to the bottom (or sending a new message) re-pins it so new messages auto-scroll into view again.
- In-app scrollback: mouse wheel scrolls the conversation view when mouse tracking is on (the default). PageUp/PageDown also scroll a page at a time. Hold Shift+click/drag for native terminal text selection and copy (when mouse tracking is on). Scrolling back up unpins the view from the latest message; scrolling back to the bottom (or sending a new message) re-pins it so new messages auto-scroll into view. Toggle mouse tracking with `/mouse on|off`.
- Windows shell quoting for the `bash` tool has only had light testing; behavior may differ from Unix shells for complex quoting.
- `git_commit` covers add/commit/create_branch/checkout/push/reset/stash/merge/rebase/delete_branch. Use `bash` for anything beyond that.
- MCP tool results support text, image, audio, and resource content blocks. Images are returned in the same shape as `read_file` so vision-capable models can see them; audio and binary resources are summarized. Remote (HTTP) MCP servers support static headers (e.g. a bearer token) but not OAuth flows.
+38
View File
@@ -511,3 +511,41 @@ handler와 preview 양쪽의 `occurrences === 0` 경로에 적용. 기존 "not f
### 인코딩 메모
- `session.ts`, `App.tsx`, `HistoryItemView.tsx` CRLF → Python 바이너리 편집으로 교체
- `ChatInput.tsx`, `systemPrompt.ts` LF → write_file/edit_file 사용
---
## 2026-08-23 세션: 클라우드 모델 빈 응답 수정, 마우스 드래그 디버그
시작점: `47711a9`. 검증: typecheck ✓ · build 294.28 KB · **326 tests** 통과.
### #13 시스템 프롬프트 클라우드/로컬 분리 (`systemPrompt.ts`, `session.ts`, `App.tsx`, `defaults.ts`)
**문제**: 시스템 프롬프트에 "You run against a local model" / "Empty or malformed responses can happen" 등 로컬 모델 전용 지시사항이 하드코딩. GLM-5.1 클라우드 모델이 이 프롬프트를 받으면:
- 자신이 로컬 모델이라고 착각 → 보수적 동작
- "빈 응답이 나올 수 있다"는 암시 → 자기 충족적 빈 응답 (Request failed: Empty response from model.)
**해결**: `isLocal` 파라미터로 프롬프트 분리
- `isLocalBackendURL(baseURL)`: localhost/127.0.0.1/::1이면 true
- `buildSystemPrompt(tools, mode, projectInstructions, isLocal)`:
- **로컬 프롬프트** (`isLocal=true`, 기본값): 기존 프롬프트 유지, "Working with local models" 섹션 포함, 7개 원칙
- **클라우드 프롬프트** (`isLocal=false`): 로컬 모델 제한 섹션 제거, 6개 원칙 (fallback 모드의 "One tool per response" 규칙 제거), "local model" 언급 없음
- `Session.isLocal` 필드 추가, `createSession`/`createSessionFromRecord`/`setMode`/`compactSession`/`spawnSubAgent` 모두 전파
- `App.tsx`에서 `isLocalBackendURL(baseURLRef.current)` / `isLocalBackendURL(record.baseURL)` 로 자동 설정
### #14 빈 응답 재시도 증가 (`loop.ts`)
- `MAX_EMPTY_RESPONSE_RETRIES`: 1 → **3**
- 클라우드 모델도 간헐적 빈 응답 발생 가능 (GLM-5.1 사례)
- 이전: 초기 응답 + 1회 재시도 = 총 2회 → 실패 시 `AgentError("Empty response from model.")`
- 이후: 초기 응답 + 3회 재시도 = 총 4회 → 빈 응답 후 넛지 메시지 삽입 후 재시도
### #15 마우스 드래그 디버그 (`App.tsx`)
- 마우스 이벤트에 `process.stderr.write` 디버그 로그 추가
- 클릭/드래그/릴리즈 이벤트의 `button`, `pressed`, `logicalBtn` 값 확인용
- SGR-1006 모드 1002에서 드래그 이벤트(`button=32`, motion bit)가 `logicalButton()`에서 `"other"`로 분류되는 문제 확인 중
- 디버그 완료 후 제거 예정
### maxIterations 기본값 300 상향 (이전 세션에서 진행)
- `DEFAULT_MAX_ITERATIONS = 300` (이전 100 → 300, 이미 08-21 세션에서 변경됨)
+2 -2
View File
@@ -121,8 +121,8 @@ describe("runTurn / empty response retry", () => {
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
await expect(runTurn(session, "hi", () => {})).rejects.toThrow("Empty response from model.");
// One initial empty + one retry = 2 calls total.
expect(call).toBe(2);
// One initial empty + three retries = 4 calls total.
expect(call).toBe(4);
});
});
+5 -4
View File
@@ -127,7 +127,7 @@ const MAX_MALFORMED_RETRIES = 2;
/** Local models (especially small quantized ones) occasionally emit a bare empty `stop`
* chunk — no text, no tool calls. Rather than abort the whole turn as a hard error, retry
* once with a short nudge so the model continues, mirroring the malformed-tool-call path. */
const MAX_EMPTY_RESPONSE_RETRIES = 1;
const MAX_EMPTY_RESPONSE_RETRIES = 3;
// Sub-agent safety limits. The toolset already excludes `agent` for sub-agents (so a model can't
// spawn nested sub-agents through normal tool use), but these are independent, explicit backstops
@@ -327,7 +327,7 @@ export async function compactSession(session: Session): Promise<string> {
if (summary) {
session.messages = [
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode, session.projectInstructions) },
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode, session.projectInstructions, session.isLocal) },
{ role: "assistant", content: `[Earlier conversation compacted to save context]\n\n${summary}` },
...tail,
];
@@ -335,7 +335,7 @@ export async function compactSession(session: Session): Promise<string> {
// Nothing to summarize: keep the tail as-is, but still rebuild the system prompt in case
// the tail's first message was a stale system prompt we want to replace.
session.messages = [
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode, session.projectInstructions) },
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode, session.projectInstructions, session.isLocal) },
...tail,
];
}
@@ -692,7 +692,7 @@ async function runSubAgentTurn(
const subToolset = buildToolSet(restricted);
const systemPrompt = overrides?.systemPrompt
? `${overrides.systemPrompt}\n\nAvailable tools:\n${subToolset.tools.map((t) => `- ${t.name}: ${t.description}`).join("\n")}`
: `${buildSystemPrompt(subToolset.tools, parent.mode, parent.projectInstructions)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`;
: `${buildSystemPrompt(subToolset.tools, parent.mode, parent.projectInstructions, parent.isLocal)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`;
const subMessages: ChatCompletionMessageParam[] = [{ role: "system", content: systemPrompt }];
const subSession: Session = {
id: randomUUID(),
@@ -701,6 +701,7 @@ async function runSubAgentTurn(
model: parent.model,
cwd: parent.cwd,
mode: parent.mode,
isLocal: parent.isLocal,
messages: subMessages,
maxIterations: parent.maxIterations,
permissions: parent.permissions,
+12 -3
View File
@@ -100,6 +100,11 @@ export interface Session {
* so a top-level turn and its sub-agents all funnel through the same queue. Initialized as a
* resolved promise so the first caller doesn't wait on anything. */
mutationGate: Promise<void>;
/** True when the backend is a local server (Ollama / LM Studio on localhost). Used to
* tailor the system prompt — local models need extra guidance about their limitations;
* cloud models get a leaner prompt without self-fulfilling "you may produce empty responses"
* framing. Derived from the baseURL by isLocalBackendURL(). */
isLocal: boolean;
}
export function createSession(
@@ -114,10 +119,11 @@ export function createSession(
maxIterations: number = DEFAULT_MAX_ITERATIONS,
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
projectInstructions: string | null = null,
isLocal: boolean = true,
): Session {
const toolset = buildToolSet(tools);
const messages: ChatCompletionMessageParam[] = [
{ role: "system", content: buildSystemPrompt(toolset.tools, mode, projectInstructions) },
{ role: "system", content: buildSystemPrompt(toolset.tools, mode, projectInstructions, isLocal) },
];
return {
id: randomUUID(),
@@ -126,6 +132,7 @@ export function createSession(
model,
cwd,
mode,
isLocal,
messages,
maxIterations,
permissions: new PermissionManager(),
@@ -160,10 +167,11 @@ export function createSessionFromRecord(
maxIterations: number = DEFAULT_MAX_ITERATIONS,
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
projectInstructions: string | null = null,
isLocal: boolean = true,
): Session {
const toolset = buildToolSet(tools);
const messages: ChatCompletionMessageParam[] = [
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode, projectInstructions) },
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode, projectInstructions, isLocal) },
...record.messages,
];
const session: Session = {
@@ -173,6 +181,7 @@ export function createSessionFromRecord(
model: record.model,
cwd,
mode: record.mode,
isLocal,
messages,
maxIterations,
permissions: new PermissionManager(),
@@ -248,5 +257,5 @@ export function undoLastTurn(session: Session): number {
export function setMode(session: Session, mode: ToolCallMode): void {
session.mode = mode;
session.messages[0] = { role: "system", content: buildSystemPrompt(session.toolset.tools, mode, session.projectInstructions) };
session.messages[0] = { role: "system", content: buildSystemPrompt(session.toolset.tools, mode, session.projectInstructions, session.isLocal) };
}
+40 -10
View File
@@ -23,8 +23,16 @@ describe("buildSystemPrompt", () => {
expect(prompt).toContain("Mutating");
});
it("includes core principles", () => {
const prompt = buildSystemPrompt([readTool], "native");
it("includes core principles for local models", () => {
const prompt = buildSystemPrompt([readTool], "native", null, true);
expect(prompt).toContain("Inspect before answering");
expect(prompt).toContain("Prefer small, targeted edits");
expect(prompt).toContain("Recovery over retry");
expect(prompt).toContain("Respect confirmation");
});
it("includes core principles for cloud models", () => {
const prompt = buildSystemPrompt([readTool], "native", null, false);
expect(prompt).toContain("Inspect before answering");
expect(prompt).toContain("Prefer small, targeted edits");
expect(prompt).toContain("Recovery over retry");
@@ -39,26 +47,48 @@ describe("buildSystemPrompt", () => {
expect(prompt).toContain("agent");
});
it("includes local model guidance", () => {
const prompt = buildSystemPrompt([readTool], "native");
it("includes local model guidance when isLocal is true", () => {
const prompt = buildSystemPrompt([readTool], "native", null, true);
expect(prompt).toContain("local model");
expect(prompt).toContain("Context windows are smaller");
expect(prompt).toContain("Working with local models");
});
it("includes safety guidelines", () => {
it("excludes local model guidance when isLocal is false (cloud)", () => {
const prompt = buildSystemPrompt([readTool], "native", null, false);
expect(prompt).not.toContain("local model");
expect(prompt).not.toContain("Context windows are smaller");
expect(prompt).not.toContain("Working with local models");
expect(prompt).not.toContain("Empty or malformed responses");
});
it("defaults to local prompt when isLocal is not specified", () => {
const prompt = buildSystemPrompt([readTool], "native");
expect(prompt).toContain(".git");
expect(prompt).toContain("destructive");
expect(prompt).toContain("local model");
});
it("includes safety guidelines for both local and cloud", () => {
const localPrompt = buildSystemPrompt([readTool], "native", null, true);
const cloudPrompt = buildSystemPrompt([readTool], "native", null, false);
expect(localPrompt).toContain(".git");
expect(localPrompt).toContain("destructive");
expect(cloudPrompt).toContain(".git");
expect(cloudPrompt).toContain("destructive");
});
it("includes fallback instructions when mode is fallback", () => {
const prompt = buildSystemPrompt([readTool], "fallback");
expect(prompt).toContain("tool_call");
expect(prompt).toContain("fallback");
expect(prompt.toLowerCase()).toContain("fallback");
});
it("includes native mode instructions when mode is native", () => {
const prompt = buildSystemPrompt([readTool], "native");
it("includes native mode instructions when mode is native (local)", () => {
const prompt = buildSystemPrompt([readTool], "native", null, true);
expect(prompt).toContain("native tool-call mode");
});
it("includes native mode instructions when mode is native (cloud)", () => {
const prompt = buildSystemPrompt([readTool], "native", null, false);
expect(prompt).toContain("native tool-call mode");
});
+65 -4
View File
@@ -17,9 +17,21 @@ function formatToolList(tools: ToolDef[]): string {
return parts.join("\n");
}
export function buildSystemPrompt(tools: ToolDef[], mode: ToolCallMode, projectInstructions?: string | null): string {
export function buildSystemPrompt(tools: ToolDef[], mode: ToolCallMode, projectInstructions?: string | null, isLocal: boolean = true): string {
const toolList = formatToolList(tools);
const base = `You are a helpful local coding assistant with access to tools for exploring and editing a codebase on the user's machine. You run against a local model served via Ollama or LM Studio, which means you may have a smaller context window and less reliable tool-call formatting than large cloud models — adapt your behavior accordingly.
const base = isLocal
? buildLocalPrompt(toolList, mode)
: buildCloudPrompt(toolList, mode);
return projectInstructions ? `${base}\n\n${projectInstructions}` : base;
}
/** System prompt for local models (Ollama / LM Studio) — includes extra guidance about their
* limitations (smaller context windows, unreliable tool-call formatting, empty/malformed
* responses). These models need the hints; cloud models do not, and may be confused by them. */
function buildLocalPrompt(toolList: string, mode: ToolCallMode): string {
return `You are a helpful local coding assistant with access to tools for exploring and editing a codebase on the user's machine. You run against a local model served via Ollama or LM Studio, which means you may have a smaller context window and less reliable tool-call formatting than large cloud models — adapt your behavior accordingly.
## Available tools
@@ -74,7 +86,56 @@ ${mode === "fallback" ? FALLBACK_TOOL_INSTRUCTIONS : "You are in native tool-cal
- Do not delete large sections of code without clear justification and user confirmation.
- When running bash commands, prefer read-only inspections (ls, cat, git status) over destructive operations (rm, git reset --hard).
- If unsure about a destructive action, ask the user first rather than proceeding.`;
}
const withMode = mode === "fallback" ? base : base;
return projectInstructions ? `${withMode}\n\n${projectInstructions}` : withMode;
/** System prompt for cloud models (large context window, reliable tool calls, no local-model quirks).
* Leaner than the local prompt — skips the "Working with local models" section entirely and uses
* a more direct tone, since cloud models don't need hand-holding about their own limitations. */
function buildCloudPrompt(toolList: string, mode: ToolCallMode): string {
return `You are a coding assistant with access to tools for exploring and editing a codebase on the user's machine.
## Available tools
${toolList}
## Core principles
1. **Inspect before answering.** Never guess file contents, function signatures, or directory structures — use read_file, list_files, grep, or definition to verify. Stale assumptions are worse than an extra tool call.
2. **Prefer small, targeted edits.** Use edit_file (or multi_edit for several changes in one file) for surgical changes. Use write_file only for new files or full rewrites. edit_file requires old_string to match exactly — copy the exact text from the file (read it first), including indentation and blank lines.
3. **Preserve existing style.** Match the surrounding code's indentation, naming conventions, quotes, and formatting. Don't reformat code outside the change scope.
4. **Keep answers concise.** When you have enough information, respond in plain text — don't pad with pleasantries or restated context. Code explanations should be brief and focused on the "why", not the "what" (the code already says what).
5. **Recovery over retry.** If a tool call fails (edit_file "not found", bash non-zero exit, etc.), read the file or check the error output before retrying — don't repeat the same call. If edit_file suggests a closest match, use that text exactly.
6. **Respect confirmation.** Mutating tools (write_file, edit_file, multi_edit, notebook_edit, bash, git_commit) require user confirmation — you will see a permission prompt. Plan your edits so the user sees a clear, concise preview.
## Tool usage guide
- **read_file**: Start here. Use offset/limit for large files. Always read before editing.
- **list_files**: Explore directory structure. Supports glob patterns like "src/**/*.ts".
- **grep**: Search file contents. Prefer over read_file when you know what you're looking for.
- **definition / references / diagnostics**: LSP-powered code intelligence. Use definition to find where a symbol is declared, references for all usages, diagnostics for type errors.
- **edit_file**: For small changes to existing files. old_string must match exactly — include enough surrounding context to be unique. On mismatch, the tool suggests the closest similar text.
- **multi_edit**: Apply several edits to the same file in one call. Each edit sees the result of previous edits, so adjust old_string for context shifts.
- **write_file**: For new files or complete rewrites. Overwrites the entire file — use with care.
- **bash**: Run shell commands. Prefer targeted tools (grep, definition) over broad shell commands when possible.
- **git_status / git_commit**: Inspect repo state and commit changes. Always check status before committing.
- **web_search / web_fetch**: Look up information not in the local codebase. For API docs, error messages, or unfamiliar libraries.
- **agent**: Delegate a sub-task to a focused sub-agent. Good for researching many files in parallel. Sub-agents cannot spawn further sub-agents.
- **task_create / task_list / task_get / task_update**: Track structured work items with dependencies. Use for multi-step tasks (3+ steps) so progress is visible.
- **todo_write**: Simple checklist for progress tracking. Good for linear step-by-step work.
## ${mode === "fallback" ? "Fallback mode" : "Tool calling"}
${mode === "fallback" ? FALLBACK_TOOL_INSTRUCTIONS : "You are in native tool-call mode. Call tools using the standard function-calling format. You may call multiple read-only tools in parallel, but mutating tools are always run sequentially."}
## Safety
- Do not modify .git directories or other version-control internals.
- Do not delete large sections of code without clear justification and user confirmation.
- When running bash commands, prefer read-only inspections (ls, cat, git status) over destructive operations (rm, git reset --hard).
- If unsure about a destructive action, ask the user first rather than proceeding.`;
}
+18 -5
View File
@@ -8,6 +8,18 @@ export const KNOWN_BACKENDS = {
export type BackendName = keyof typeof KNOWN_BACKENDS;
/** Returns true if the given base URL looks like a local backend (Ollama or LM Studio on localhost).
* Used to tailor the system prompt — local models get extra guidance about their limitations,
* while cloud models (which are more capable and reliable) get a leaner prompt without that framing. */
export function isLocalBackendURL(baseURL: string): boolean {
try {
const url = new URL(baseURL);
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
} catch {
return false;
}
}
/** Used when the context window can't be auto-detected from the backend (see backend/contextWindow.ts)
* and the user hasn't configured one — a conservative size common among smaller local models. */
export const DEFAULT_CONTEXT_WINDOW = 8192;
@@ -25,11 +37,12 @@ export const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
* is one model generation request (one tool-call round-trip), and local models commonly issue a
* single tool call per request — so a real multi-file task (read several files, edit each, grep
* to verify, re-read) easily needs 40–60 requests. 50 was too tight and caused frequent
* "Paused after 50 steps" soft-stops on legitimate work; 100 gives real tasks room to finish
* while still bounding a genuinely stuck model. Hitting the cap is a soft pause, not a failure
* (the work so far is intact — send another message to resume). Configurable via `maxIterations`,
* e.g. `locode config set maxIterations 200` for large batch jobs. */
export const DEFAULT_MAX_ITERATIONS = 100;
* "Paused after 50 steps" soft-stops on legitimate work; 100 still caused frequent pauses on
* larger tasks. 300 gives real tasks ample room to finish while still bounding a genuinely stuck
* model. Hitting the cap is a soft pause, not a failure (the work so far is intact — send another
* message to resume). Configurable via `maxIterations`, e.g. `locode config set maxIterations 500`
* for very large batch jobs. */
export const DEFAULT_MAX_ITERATIONS = 300;
/** Fraction of the context window at which locode automatically summarizes the conversation.
* User-configurable via `locode config set autoCompactThreshold`. */
+6 -1
View File
@@ -27,7 +27,7 @@ import { setCachedMode } from "../../backend/capabilityCache.js";
import { resolveContextWindow } from "../../backend/contextWindow.js";
import { resolveToolCallMode } from "../../backend/resolveMode.js";
import { resolveAutoCompactThreshold, resolveMaxIterations } from "../../config/config.js";
import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js";
import { KNOWN_BACKENDS, type BackendName, isLocalBackendURL } from "../../config/defaults.js";
import { getMcpStatuses, reconnectMcpServers } from "../../mcp/manager.js";
import type { PermissionDecision, PermissionMode } from "../../permissions/types.js";
import { defaultExportFilename, exportSession, type ExportFormat } from "../../persistence/exportSession.js";
@@ -407,6 +407,9 @@ export function App({
// to text input. In the future, in-app text selection can be built on top of these events.
const mouseEvent = matchMouseSequence(input);
if (mouseEvent) {
// DEBUG: log all mouse events
process.stderr.write(`[MOUSE] button=${mouseEvent.button} pressed=${mouseEvent.pressed} col=${mouseEvent.col} row=${mouseEvent.row} shift=${mouseEvent.shift} logicalBtn=${logicalButton(mouseEvent)}
`);
// Shift+click/drag: don't capture — let the terminal handle native text selection.
if (mouseEvent.shift) return;
if (mouseEvent.button === 64) scrollBy(-3); // wheel up
@@ -562,6 +565,7 @@ export function App({
resolveMaxIterations(),
resolveAutoCompactThreshold(),
projectInstructions,
isLocalBackendURL(baseURLRef.current),
);
onSessionIdChange?.(sessionRef.current.id);
push({ kind: "banner", cwd, model, backend: baseURLRef.current });
@@ -610,6 +614,7 @@ export function App({
resolveMaxIterations(),
resolveAutoCompactThreshold(),
projectInstructions,
isLocalBackendURL(record.baseURL),
);
onSessionIdChange?.(sessionRef.current.id);
push({
+87 -11
View File
@@ -1,13 +1,89 @@
// xterm SGR-1006 mouse sequences (enabled in App.tsx via `\x1b[?1000h\x1b[?1006h`) arrive on stdin
// as a single ink `useInput` event with the leading ESC stripped, e.g. "[<64;12;5M" (wheel up) or
// "[<0;12;5M" (left button down). Every active `useInput` hook in the tree receives the same raw
// event — ink has no concept of one handler "consuming" it before others see it — so any text-input
// component that doesn't recognize the sequence falls through to its regular-character handling and
// types the raw escape text into the field. Shared so every `useInput` consumer can ignore it the
// same way App.tsx's own scroll handler already does.
const MOUSE_SEQUENCE_RE = /^\[<(\d+);\d+;\d+[Mm]$/;
// xterm SGR-1006 mouse sequences (enabled in App.tsx via `\x1b[?1002h\x1b[?1006h`)
// arrive on stdin as a single ink `useInput` event with the leading ESC stripped,
// e.g. "[<0;12;5M" (left button press at col 12, row 5) or "[<64;12;5M" (wheel up).
//
// Mode 1002 (button-event tracking) reports: button press, button release, and
// drag (motion while a button is held). Every active `useInput` hook in the tree
// receives the same raw event — ink has no concept of one handler "consuming" it
// before others see it — so any text-input component that doesn't recognize the
// sequence falls through to its regular-character handling and types the raw
// escape text into the field. Shared so every `useInput` consumer can ignore it
// the same way App.tsx's own handler already does.
//
// SGR-1006 format: [<button;col;rowM (press/drag) or [<button;col;rowm (release)
// button encodes button number + modifier keys:
// bit 0: shift
// bit 1: meta (alt)
// bit 2: ctrl
// bits 4-5: 00=left, 01=middle, 10=right
// Special: 64=wheel up, 65=wheel down
// Drag reports button 3 (all buttons released) during motion.
export function matchMouseSequence(input: string): { button: number } | null {
const match = MOUSE_SEQUENCE_RE.exec(input);
return match ? { button: Number(match[1]) } : null;
const MOUSE_SEQUENCE_RE = /^\[<(\d+);(\d+);(\d+)([Mm])$/;
export interface MouseEvent {
/** Raw button code (includes modifier bits in bits 0-2, button in bits 4-5). */
button: number;
/** 1-based column. */
col: number;
/** 1-based row. */
row: number;
/** True on press/drag (trailing 'M'), false on release (trailing 'm'). */
pressed: boolean;
/** True if shift was held. */
shift: boolean;
/** True if meta (alt) was held. */
meta: boolean;
/** True if ctrl was held. */
ctrl: boolean;
}
/** Which logical button is pressed (ignoring modifiers). */
export function logicalButton(e: MouseEvent): "left" | "middle" | "right" | "wheel-up" | "wheel-down" | "release" | "other" {
const raw = e.button & ~0x07; // strip modifier bits
if (raw === 0) return "left";
if (raw === 1) return "middle";
if (raw === 2) return "right";
if (raw === 64) return "wheel-up";
if (raw === 65) return "wheel-down";
if (raw === 3 || (!e.pressed && raw === 0)) return "release";
return "other";
}
export function matchMouseSequence(input: string): MouseEvent | null {
const match = MOUSE_SEQUENCE_RE.exec(input);
if (!match) return null;
const button = Number(match[1]);
const col = Number(match[2]);
const row = Number(match[3]);
const pressed = match[4] === "M";
return {
button,
col,
row,
pressed,
shift: !!(button & 4),
meta: !!(button & 8),
ctrl: !!(button & 16),
};
}
/** Write text to the system clipboard using OSC 52 (works over SSH, tmux, etc.).
* Falls back to platform-specific commands if OSC 52 doesn't work. */
export function copyToClipboard(text: string, stdout: NodeJS.WriteStream): void {
// OSC 52: \x1b]52;c;<base64>\x07 (target 'c' = system clipboard)
const base64 = Buffer.from(text, "utf-8").toString("base64");
// Write in chunks to avoid overflowing terminal input buffers (some terminals
// have limits around 4096 bytes for a single escape sequence).
const CHUNK = 300;
for (let i = 0; i < base64.length; i += CHUNK) {
const chunk = base64.slice(i, i + CHUNK);
// For the first chunk, start the sequence; for subsequent chunks, just append.
if (i === 0) {
stdout.write(`\x1b]52;c;${chunk}`);
} else {
stdout.write(chunk);
}
}
stdout.write("\x07"); // BEL terminates the sequence
}
+1 -1
View File
@@ -1,3 +1,3 @@
export const ACCENT_HEX = "#D97757";
export const DIFF_ADD_HEX = "#2ea043";
export const DIFF_REMOVE_HEX = "#f85149";
export const DIFF_REMOVE_HEX = "#e8904e";