fix: mouse-click crash, CRLF tool-call/edit matching bugs, misleading burn rate

- App.tsx: add the missing effectiveScrollTopRef the prior session's mouse-drag
  fix referenced but never declared — tsc failed (esbuild build doesn't
  type-check, so it looked fine), and any click/drag/release threw
  ReferenceError at runtime.
- partialJson.ts/nativeAdapter.ts: escape raw control characters (literal
  newline/CR/tab) found inside JSON string literals before parsing tool-call
  arguments, and add repair to the non-streaming completion path (previously
  only the streaming path had it) — local models echoing multi-line CRLF file
  content unescaped were failing JSON.parse outright.
- editFile.ts/multiEdit.ts: read_file always shows the model LF-normalized
  content regardless of the file's real line endings, but edit_file/multi_edit
  matched old_string against the raw (CRLF-preserving) file — a systematic
  "old_string not found" on every CRLF file in this project. Now detect the
  file's EOL, match/edit in LF space, and restore the original EOL on write.
- StatusBar.tsx: 🔥 burn rate now uses outputTokens/min instead of
  (inputTokens+outputTokens)/min — inputTokens sums the full resent context on
  every API call (no prompt caching), so it ballooned into misleading hundreds
  of K/min during chatty fallback-mode tool round trips.

339/339 tests passing (+13), tsc clean, build 295.77 KB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kn75SyL8t8SeyTwmPvADAS
This commit is contained in:
kim
2026-08-22 13:03:29 +09:00
co-authored by Claude Sonnet 5
parent 60c6021390
commit c539995b19
12 changed files with 417 additions and 80 deletions
+85
View File
@@ -549,3 +549,88 @@ handler와 preview 양쪽의 `occurrences === 0` 경로에 적용. 기존 "not f
### maxIterations 기본값 300 상향 (이전 세션에서 진행)
- `DEFAULT_MAX_ITERATIONS = 300` (이전 100 → 300, 이미 08-21 세션에서 변경됨)
## 2026-08-23 세션 (이어서): 마우스 드래그 버그 수정
시작점: 이전 세션의 디버그 로그 제거 + 드래그 버그 수정.
### 디버그 로그 제거 (`App.tsx`)
- `process.stderr.write(\`[MOUSE] ...\`)` 2줄 삭제 — 스크롤 시 로그가 쏟아지는 원인
### 마우스 드래그 버그 수정 (`mouseInput.ts`, `App.tsx`)
**문제**: 마우스 왼쪽 버튼 드래그가 작동하지 않음. 클릭은 되지만 드래그로 선택 영역을 확장할 수 없었음.
**원인**: SGR-1006 프로토콜에서 **드래그 모션**은 버튼 코드에 **bit 32**를 더해 전송:
- 왼쪽 버튼 클릭: `button=0`
- 왼쪽 버튼 **드래그**: `button=32` (0 + motion bit 32)
- 릴리스: `button=3` + trailing `m`
기존 `logicalButton()`은 `button & ~0x07`로 모디파이어 비트만 제거 → `32 & ~0x07 = 32` → `"other"`로 분류 → 드래그 이벤트가 완전히 무시됨.
이전 수정에서 `button=3`을 `pressed ? "drag" : "release"`로 바꿨으나, `button=3`은 실제로는 3번 버튼 누름(거의 발생 안 함)이고, SGR-1006 릴리스는 항상 `pressed=false`(trailing `m`)으로 오므로 `button=3` 여부와 무관하게 `!pressed → "release"`가 맞음.
**수정** (`mouseInput.ts`):
- `logicalButton()` 재작성:
- `raw === 0 && pressed` → `"left"` (클릭)
- `raw === 1 && pressed` → `"middle"`
- `raw === 2 && pressed` → `"right"`
- `raw === 32/33/34` → `"drag"` (왼쪽/가운데/오른쪽 드래그 모션)
- `raw === 64/65` → `"wheel-up"/"wheel-down"`
- `!pressed` → `"release"` (SGR-1006 릴리스)
- 나머지 → `"other"`
- SGR-1006 버튼 인코딩 전체 문서화 (bit 0-2 모디파이어, bit 4-5 버튼, bit 5 모션, bit 6 휠)
**수정** (`App.tsx`):
- 마우스 핸들러 분기 재구성:
- `"left" && pressed` → 선택 시작
- `"drag" && selectionStart` → 선택 영역 확장 (드래그)
- `"release" && selectionStart` → 클립보드 복사 + 선택 해제
- 기존 `left && !pressed` 분기 제거 (SGR-1006에서 릴리스는 항상 `button=3, pressed=false`)
- 템플릿 리터럴 내 `
` 이스케이프 깨짐 수정 (CRLF 파일 바이너리 편집으로 인해 리터럴 개행이 됨)
### 검증
- `tsc --noEmit` ✓
- `npm run build` ✓ (293.39 KB)
- `npm test` ✓ 42파일 326개 전부 통과
---
## 2026-08-22 세션 (Claude Code): 마우스 크래시, CRLF 매칭 버그 2건, 번인레이트 오해
시작점: 위 세션이 내보낸 트랜스크립트(`08-22T03-43-04-499Z.md`)를 백그라운드 서브에이전트로 리뷰. 검증: typecheck ✓ · build 295.77 KB · **339 tests** 통과 (시작 326 → +13).
### 마우스 드래그 크래시 수정 (`App.tsx`)
**문제**: 위 세션이 `mouseToContentRow` 안에 `effectiveScrollTopRef.current`를 참조하도록 고쳤지만, 그 ref를 선언하는 줄을 추가하기 전에 세션이 끊김. `npm run build`(esbuild)는 타입체크를 안 해서 "빌드 성공"으로 보였지만 `tsc --noEmit`은 `Cannot find name 'effectiveScrollTopRef'`로 실패 — 즉 실제로는 마우스를 클릭하는 순간 `ReferenceError`로 죽는 상태였음.
**수정**: `maxScrollRef`/`pinnedToBottomRef`/`scrollTopRef`와 같은 패턴으로 `effectiveScrollTopRef` 선언 + 매 렌더마다 `.current` 갱신 한 줄 추가.
**남은 불확실성**: 좌표 계산(`mouseToContentRow`) 자체가 맞는지는 실제 터미널에서 드래그해보지 않으면 확신 불가 — Yoga 레이아웃(`getAbsolutePosition`)은 0-based, SGR-1006 마우스 row는 1-based라 1줄 오프바이원 가능성이 있음. 사용자에게 최상단 줄만 드래그해서 무엇이 복사되는지 확인 요청함 (아직 회신 대기).
### CRLF/제어문자 JSON 파싱 버그 (`src/toolcalling/partialJson.ts`, `nativeAdapter.ts`)
JSON은 문자열 리터럴 안에 raw 제어문자(리터럴 개행/CR/탭)를 금지 — 로컬 모델이 멀티라인 파일 내용을 이스케이프 없이 그대로 echo하면(이 프로젝트 소스 대부분이 CRLF라 특히 흔함) `JSON.parse`가 "Bad control character" 로 통째로 실패.
- `partialJson.ts`: `repairPartialJson`에 새 (2)단계로 `escapeRawControlCharsInStrings` 추가 — 문자열 리터럴 안의 raw `\n`/`\r`/`\t`/기타 제어문자만 이스케이프(기존 `\\n` 같은 이스케이프는 건드리지 않음), 나머지 리페어 단계는 이 결과 위에서 실행.
- `nativeAdapter.ts`: `resolveToolCall`의 raw `JSON.parse`가 실패해도 리페어를 전혀 안 시도하던 것을 `repairPartialJson` 폴백 추가 (스트리밍 경로는 `loop.ts`에 이미 리페어가 있었지만 비스트리밍 completion 경로는 없었음).
- 테스트: `partialJson.test.ts` +4, `nativeAdapter.test.ts` 신규 3개.
### CRLF `edit_file`/`multi_edit` 매칭 버그 (`src/tools/editFile.ts`, `multiEdit.ts`)
**원인**: `read_file`은 항상 LF로 정규화한 내용을 모델에게 보여주는데(`content.split(/\r?\n/)` 후 `\n`으로 재조합), `edit_file`/`multi_edit`은 raw(실제 CRLF 보존) 파일과 정확히 매칭 — 모델은 자기가 본 LF 기준으로 `old_string`을 만드니 CRLF 파일마다 "old_string not found"가 체계적으로 발생하던 상태.
**수정**: `editFile.ts`에 `detectEol`/`toLF`/`fromLF` 추가 — 파일의 EOL을 감지하고, 매칭/편집은 항상 LF 공간에서 수행, 쓰기 직전에 원래 EOL로 복원. `multiEdit.ts`는 이 헬퍼들을 재사용(자체 raw 매칭 로직 있었음 — 동일 버그).
- 테스트: `editFile.test.ts` +5(EOL 헬퍼 2개 포함), `multiEdit.test.ts` +1.
### 번인레이트(🔥) 오해 소지 수정 (`StatusBar.tsx`)
`🔥 X/min`이 `(inputTokens + outputTokens) / 경과분`이었는데, `inputTokens`는 API 호출마다 **매번 재전송되는 전체 컨텍스트**의 합(프롬프트 캐싱 없음) — 폴백 모드처럼 한 턴에 툴콜 왕복이 많으면 몇 분 만에 수백K로 부풀어 보임(실제 생성 속도와 무관). `outputTokens / 경과분`으로 변경 — 실제 생성 처리량만 반영. 입력측 비용은 상단 컨텍스트 바가 이미 보여주고 있어 정보 손실 없음.
### 검증
- `tsc --noEmit` ✓
- `npm run build` ✓ (295.77 KB)
- `npm test` ✓ 43파일 **339개** 전부 통과
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { resolveToolCall } from "./nativeAdapter.js";
import type { ToolDef } from "../tools/types.js";
function makeRegistry(): Map<string, ToolDef> {
const tool: ToolDef = {
name: "write_file",
description: "d",
schema: z.object({ path: z.string(), content: z.string() }),
mutating: true,
handler: async () => ({}),
};
return new Map([["write_file", tool]]);
}
function call(args: string) {
return {
id: "1",
type: "function" as const,
function: { name: "write_file", arguments: args },
};
}
describe("resolveToolCall — argument repair", () => {
it("resolves normally when arguments are already valid JSON", () => {
const r = resolveToolCall(call('{"path":"a.ts","content":"hi"}'), makeRegistry());
expect("tool" in r).toBe(true);
});
it("repairs arguments containing a raw (unescaped) newline instead of failing outright", () => {
// A local model echoing multi-line file content into a non-streaming completion often pastes
// a real newline byte into the JSON string rather than escaping it — this path (unlike the
// streaming path in loop.ts) previously had no repair attempt at all.
const args = '{"path":"a.ts","content":"line1\nline2"}';
const r = resolveToolCall(call(args), makeRegistry());
expect("tool" in r).toBe(true);
if ("tool" in r) expect((r.args as { content: string }).content).toBe("line1\nline2");
});
it("still errors when the arguments are unsalvageable", () => {
const r = resolveToolCall(call("not json at all"), makeRegistry());
expect(r).toEqual({ error: "arguments were not valid JSON" });
});
});
+8 -1
View File
@@ -1,6 +1,7 @@
import type { ChatCompletionMessageToolCall, ChatCompletionTool } from "openai/resources/chat/completions";
import { z } from "zod";
import type { ToolDef } from "../tools/types.js";
import { repairPartialJson } from "./partialJson.js";
import { resolveToolInvocation, type ResolvedToolCall } from "./resolve.js";
export function toOpenAITools(tools: ToolDef[]): ChatCompletionTool[] {
@@ -25,7 +26,13 @@ export function resolveToolCall(
try {
parsedArgs = JSON.parse(call.function.arguments || "{}");
} catch {
return { error: "arguments were not valid JSON" };
// Non-streaming completions land here directly (unlike the streaming path in loop.ts, which
// already repairs before this point) — without a repair attempt here too, a call whose
// arguments contain e.g. an unescaped literal newline (a local model echoing multi-line file
// content raw) fails outright instead of being salvaged.
const repaired = repairPartialJson(call.function.arguments || "");
if (repaired === null) return { error: "arguments were not valid JSON" };
parsedArgs = repaired;
}
return resolveToolInvocation(call.function.name, parsedArgs, registry);
}
+19
View File
@@ -61,4 +61,23 @@ describe("repairPartialJson", () => {
expect(repairPartialJson("just prose with no json")).toBeNull();
expect(repairPartialJson("{:}")).toBeNull();
});
it("escapes a raw (unescaped) literal newline inside a string value", () => {
// A local model echoing multi-line file content often pastes real \n bytes into the JSON
// string instead of writing the two-char `\n` escape — JSON.parse rejects that outright.
expect(repairPartialJson('{"content":"line1\nline2"}')).toEqual({ content: "line1\nline2" });
});
it("escapes a raw carriage return inside a string value (CRLF source content)", () => {
expect(repairPartialJson('{"content":"line1\r\nline2"}')).toEqual({ content: "line1\r\nline2" });
});
it("leaves an already-escaped \\n sequence untouched", () => {
expect(repairPartialJson('{"content":"line1\\nline2"}')).toEqual({ content: "line1\nline2" });
});
it("combines raw-newline escaping with truncation repair", () => {
// Truncated mid-value AND containing a raw newline earlier in the string.
expect(repairPartialJson('{"content":"line1\nline2')).toEqual({ content: "line1\nline2" });
});
});
+67 -12
View File
@@ -28,6 +28,50 @@ function tryParse(s: string): unknown {
}
}
/** JSON disallows raw control characters (0x00-0x1F — notably literal newline, CR, tab) inside
* string literals; they must be written as `\n`/`\r`/`\t`/`\u00XX`. Local models echoing
* multi-line file content (very common for write_file/edit_file on this project's CRLF-heavy
* source) routinely paste it in unescaped, which makes an otherwise complete, semantically
* correct tool call fail JSON.parse with "Bad control character in string literal". Escaping
* only touches raw bytes found *inside* a string (tracked the same way `skipString` does, so an
* existing `\\n` escape sequence is left alone) — it never changes where a string starts/ends or
* where a brace/bracket falls outside one, so it's safe to run before the other repair steps. */
function escapeRawControlCharsInStrings(s: string): string {
let out = "";
let inStr = false;
let changed = false;
for (let i = 0; i < s.length; i++) {
const c = s[i]!;
if (!inStr) {
if (c === '"') inStr = true;
out += c;
continue;
}
if (c === "\\") {
// Preserve an existing escape sequence verbatim — don't touch the char after the backslash.
out += c + (s[i + 1] ?? "");
i++;
continue;
}
if (c === '"') {
inStr = false;
out += c;
continue;
}
const code = c.charCodeAt(0);
if (code < 0x20) {
changed = true;
if (c === "\n") out += "\\n";
else if (c === "\r") out += "\\r";
else if (c === "\t") out += "\\t";
else out += "\\u" + code.toString(16).padStart(4, "0");
continue;
}
out += c;
}
return changed ? out : s;
}
/** Skip past the next JSON string literal starting at `i` (the opening quote). Returns the index
* just past the closing quote. Strings are the only place braces/brackets can appear without
* affecting nesting, so we must not count them while inside one. Handles `\"` and other escapes. */
@@ -48,11 +92,13 @@ function skipString(s: string, i: number): number {
* repair produced valid JSON. Steps, applied in order of how cheap and safe they are:
*
* 1. Maybe it already parses (trailing whitespace/newlines are fine for JSON.parse) — return as-is.
* 2. Strip a trailing comma before an expected-but-absent `}` or `]` (common local-model slip).
* 3. Strip stray non-JSON tokens after the first complete top-level value (`{"a":1}\n` → `{"a":1}`,
* 2. Escape raw control characters (literal newline/CR/tab) found inside string literals — a
* model echoing multi-line file content unescaped, common on this project's CRLF sources.
* 3. Strip a trailing comma before an expected-but-absent `}` or `]` (common local-model slip).
* 4. Strip stray non-JSON tokens after the first complete top-level value (`{"a":1}\n` → `{"a":1}`,
* and `{"a":1}garbage` → `{"a":1}` — JSON.parse rejects trailing content, so trim to the first
* complete value).
* 4. Close unterminated strings, then balance still-open braces/brackets (truncation repair).
* 5. Close unterminated strings, then balance still-open braces/brackets (truncation repair).
*
* Each step re-attempts a parse, so the cheapest fix that works wins. */
export function repairPartialJson(raw: string): unknown | null {
@@ -64,31 +110,40 @@ export function repairPartialJson(raw: string): unknown | null {
const direct = tryParse(trimmed);
if (direct !== null) return direct;
// (2) Trailing comma before end-of-object/array: `{"a":1,}` or `[1,2,]`. Repeat until none
// (2) Raw control characters (literal newlines/CR/tab) inside string literals — see
// escapeRawControlCharsInStrings for why this is common. Escaping never moves a quote or
// brace, so every remaining step below runs against this version instead of the original.
const working = escapeRawControlCharsInStrings(trimmed);
if (working !== trimmed) {
const v = tryParse(working);
if (v !== null) return v;
}
// (3) Trailing comma before end-of-object/array: `{"a":1,}` or `[1,2,]`. Repeat until none
// left so a nested shape like `{"a":{"b":2,},}` clears both commas (innermost-first).
let noTrailing = trimmed;
let noTrailing = working;
let prev: string;
do {
prev = noTrailing;
noTrailing = noTrailing.replace(/,\s*([\]}]+\s*$)/, "$1");
} while (noTrailing !== prev);
if (noTrailing !== trimmed) {
if (noTrailing !== working) {
const v = tryParse(noTrailing);
if (v !== null) return v;
}
// (3) Stray trailing content after the first complete value. JSON.parse refuses trailing tokens,
// (4) Stray trailing content after the first complete value. JSON.parse refuses trailing tokens,
// but a model often emits a closing brace then a stray newline, a repeated token, or prose.
// Find the end of the first balanced top-level value and cut there.
const cut = cutToFirstCompleteValue(trimmed);
if (cut !== null && cut !== trimmed) {
const cut = cutToFirstCompleteValue(working);
if (cut !== null && cut !== working) {
const v = tryParse(cut);
if (v !== null) return v;
}
// (4) Truncation repair: close an unterminated string, then balance open braces/brackets.
const balanced = balanceAndClose(trimmed);
if (balanced !== null && balanced !== trimmed) {
// (5) Truncation repair: close an unterminated string, then balance open braces/brackets.
const balanced = balanceAndClose(working);
if (balanced !== null && balanced !== working) {
// Re-run the earlier cheap fixes on the balanced result (a trailing comma may now be exposed).
const v = tryParse(balanced);
if (v !== null) return v;
+44 -2
View File
@@ -1,8 +1,8 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { editFileTool } from "./editFile.js";
import { detectEol, editFileTool, fromLF, toLF } from "./editFile.js";
import type { ToolContext } from "./types.js";
describe("editFile tool — path containment", () => {
@@ -76,4 +76,46 @@ describe("editFile tool — path containment", () => {
),
).rejects.not.toThrow(/closest match/);
});
// read_file shows the model LF-normalized content regardless of the file's real line endings
// (see readFile.ts), so old_string/new_string from a model are always LF — matching must happen
// in that same space or every CRLF file in a project like this one fails with "not found".
it("matches an LF old_string against a CRLF file (mirrors what read_file shows the model)", async () => {
writeFileSync(path.join(cwd, "code.ts"), "function greet() {\r\n return 1;\r\n}\r\n");
const result = (await editFileTool.handler(
{ path: "code.ts", old_string: " return 1;", new_string: " return 2;" },
ctx,
)) as { replacements: number };
expect(result.replacements).toBe(1);
const onDisk = readFileSync(path.join(cwd, "code.ts"), "utf-8");
expect(onDisk).toBe("function greet() {\r\n return 2;\r\n}\r\n");
});
it("preserves CRLF line endings on disk after an edit spanning multiple lines", async () => {
writeFileSync(path.join(cwd, "code.ts"), "a\r\nb\r\nc\r\n");
await editFileTool.handler({ path: "code.ts", old_string: "a\nb", new_string: "a\nx\nb" }, ctx);
const onDisk = readFileSync(path.join(cwd, "code.ts"), "utf-8");
expect(onDisk).toBe("a\r\nx\r\nb\r\nc\r\n");
});
it("leaves a pure-LF file untouched by EOL conversion", async () => {
writeFileSync(path.join(cwd, "code.ts"), "a\nb\nc\n");
await editFileTool.handler({ path: "code.ts", old_string: "b", new_string: "x" }, ctx);
const onDisk = readFileSync(path.join(cwd, "code.ts"), "utf-8");
expect(onDisk).toBe("a\nx\nc\n");
});
});
describe("EOL helpers", () => {
it("detectEol finds CRLF, defaults to LF otherwise", () => {
expect(detectEol("a\r\nb")).toBe("\r\n");
expect(detectEol("a\nb")).toBe("\n");
expect(detectEol("a")).toBe("\n");
});
it("toLF/fromLF round-trip", () => {
expect(toLF("a\r\nb\r\nc")).toBe("a\nb\nc");
expect(fromLF("a\nb\nc", "\r\n")).toBe("a\r\nb\r\nc");
expect(fromLF("a\nb\nc", "\n")).toBe("a\nb\nc");
});
});
+38 -10
View File
@@ -12,6 +12,26 @@ const schema = z.object({
replace_all: z.boolean().optional().describe("Replace every occurrence instead of requiring a unique match."),
});
/** `read_file` shows the model LF-normalized content (`content.split(/\r?\n/).join(...)` — see
* readFile.ts), regardless of the file's actual line endings on disk. A model's `old_string`/
* `new_string` are built from what it read, so they're always LF. Matching that against this
* tool's raw (real `\r\n`-preserving) file read would fail on every CRLF file in the project —
* which is most of them (see the repo's CRLF/LF notes). Detect the file's line ending once, do
* all matching/editing in LF space (so `old_string` from the model lines up), then convert the
* result back before writing so the file's on-disk convention is preserved rather than silently
* flipped to LF. */
export function detectEol(raw: string): "\r\n" | "\n" {
return raw.includes("\r\n") ? "\r\n" : "\n";
}
export function toLF(s: string): string {
return s.replace(/\r\n/g, "\n");
}
export function fromLF(s: string, eol: "\r\n" | "\n"): string {
return eol === "\n" ? s : s.replace(/\n/g, eol);
}
export function countOccurrences(haystack: string, needle: string): number {
return needle === "" ? 0 : haystack.split(needle).length - 1;
}
@@ -148,29 +168,37 @@ export const editFileTool: ToolDef<z.infer<typeof schema>> = {
} catch (err) {
return (err as Error).message;
}
let original: string;
let raw: string;
try {
original = await fsReadFile(resolved, "utf-8");
raw = await fsReadFile(resolved, "utf-8");
} catch {
return `File ${resolved} does not exist.`;
}
const occurrences = countOccurrences(original, old_string);
const eol = detectEol(raw);
const original = toLF(raw);
const oldLF = toLF(old_string);
const newLF = toLF(new_string);
const occurrences = countOccurrences(original, oldLF);
if (occurrences === 0) {
return `Warning: old_string not found in ${resolved} — this edit will fail.${similarHint(original, old_string)}`;
return `Warning: old_string not found in ${resolved} — this edit will fail.${similarHint(original, oldLF)}`;
}
if (occurrences > 1 && !replace_all) {
return `Warning: old_string appears ${occurrences} times in ${resolved} — this edit will fail unless replace_all is set.`;
}
const updated = applyEdit(original, old_string, new_string, replace_all);
return createPatch(resolved, original, updated, "", "");
const updated = fromLF(applyEdit(original, oldLF, newLF, replace_all), eol);
return createPatch(resolved, raw, updated, "", "");
},
handler: async ({ path: filePath, old_string, new_string, replace_all }, ctx) => {
const resolved = resolveWithinCwd(ctx.cwd, filePath);
const original = await fsReadFile(resolved, "utf-8");
const occurrences = countOccurrences(original, old_string);
const raw = await fsReadFile(resolved, "utf-8");
const eol = detectEol(raw);
const original = toLF(raw);
const oldLF = toLF(old_string);
const newLF = toLF(new_string);
const occurrences = countOccurrences(original, oldLF);
if (occurrences === 0) {
throw new Error(
`old_string not found in ${filePath}. Make sure it matches the file exactly, including whitespace.${similarHint(original, old_string)}`,
`old_string not found in ${filePath}. Make sure it matches the file exactly, including whitespace.${similarHint(original, oldLF)}`,
);
}
if (occurrences > 1 && !replace_all) {
@@ -178,7 +206,7 @@ export const editFileTool: ToolDef<z.infer<typeof schema>> = {
`old_string appears ${occurrences} times in ${filePath}. Provide more surrounding context to make it unique, or set replace_all: true.`,
);
}
const updated = applyEdit(original, old_string, new_string, replace_all);
const updated = fromLF(applyEdit(original, oldLF, newLF, replace_all), eol);
// Write to a temp file in the same directory, then rename — rename is atomic within a single
// directory, so a crash mid-write can't leave the user's source file half-overwritten (the live
// file stays intact until the rename swaps in the full new content). Clean up the temp file if
+20
View File
@@ -130,4 +130,24 @@ describe("multiEdit tool", () => {
);
expect(preview).toMatch(/Edit 1: old_string not found/);
});
// Same LF-vs-CRLF mismatch as editFile.test.ts: read_file always shows the model LF content, so
// a batch's old_string/new_string must match against a CRLF file's LF-normalized text, and the
// result written back must preserve the file's original CRLF convention.
it("matches LF old_strings against a CRLF file and preserves CRLF on write", async () => {
writeFileSync(path.join(cwd, "code.ts"), "const A = 1;\r\nconst B = 2;\r\nconst C = 3;\r\n");
await multiEditTool.handler(
{
path: "code.ts",
edits: [
{ old_string: "const A = 1;", new_string: "const A = 10;" },
{ old_string: "const C = 3;", new_string: "const C = 30;" },
],
},
ctx,
);
expect(readFileSync(path.join(cwd, "code.ts"), "utf-8")).toBe(
"const A = 10;\r\nconst B = 2;\r\nconst C = 30;\r\n",
);
});
});
+16 -10
View File
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
import { readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
import { z } from "zod";
import { resolveWithinCwd } from "./pathGuard.js";
import { applyEdit, countOccurrences } from "./editFile.js";
import { applyEdit, countOccurrences, detectEol, fromLF, toLF } from "./editFile.js";
import type { ToolDef } from "./types.js";
// A single edit within a multi_edit batch. Mirrors edit_file's args minus `path` (which is shared
@@ -23,7 +23,10 @@ const schema = z.object({
/** Applies a batch of edits to an in-memory string, validating each. Throws on the first edit that
* doesn't match uniquely (unless its replace_all is set) or doesn't match at all. Edits apply to the
* running result, so an earlier edit can change the text a later edit matches. */
* running result, so an earlier edit can change the text a later edit matches. `original` and every
* edit's old_string/new_string must already be LF-normalized (see editFile.ts's detectEol/toLF —
* read_file shows the model LF-only content regardless of the file's real line endings, so matching
* must happen in that same space). */
function applyBatch(
original: string,
edits: { old_string: string; new_string: string; replace_all?: boolean }[],
@@ -31,7 +34,8 @@ function applyBatch(
): string {
let current = original;
edits.forEach((edit, i) => {
const occurrences = countOccurrences(current, edit.old_string);
const oldLF = toLF(edit.old_string);
const occurrences = countOccurrences(current, oldLF);
if (occurrences === 0) {
throw new Error(
`Edit ${i + 1}: old_string not found in ${filePath}. Earlier edits may have shifted the text — re-read the file and adjust. Make sure it matches exactly, including whitespace.`,
@@ -42,7 +46,7 @@ function applyBatch(
`Edit ${i + 1}: old_string appears ${occurrences} times in ${filePath}. Provide more surrounding context to make it unique, or set replace_all: true.`,
);
}
current = applyEdit(current, edit.old_string, edit.new_string, edit.replace_all);
current = applyEdit(current, oldLF, toLF(edit.new_string), edit.replace_all);
});
return current;
}
@@ -63,23 +67,25 @@ export const multiEditTool: ToolDef<z.infer<typeof schema>> = {
} catch (err) {
return (err as Error).message;
}
let original: string;
let raw: string;
try {
original = await fsReadFile(resolved, "utf-8");
raw = await fsReadFile(resolved, "utf-8");
} catch {
return `File ${resolved} does not exist.`;
}
try {
const updated = applyBatch(original, edits, filePath);
return createPatch(resolved, original, updated, "", "");
const eol = detectEol(raw);
const updated = fromLF(applyBatch(toLF(raw), edits, filePath), eol);
return createPatch(resolved, raw, updated, "", "");
} catch (err) {
return `Warning: ${(err as Error).message} — this edit will fail.`;
}
},
handler: async ({ path: filePath, edits }, ctx) => {
const resolved = resolveWithinCwd(ctx.cwd, filePath);
const original = await fsReadFile(resolved, "utf-8");
const updated = applyBatch(original, edits, filePath);
const raw = await fsReadFile(resolved, "utf-8");
const eol = detectEol(raw);
const updated = fromLF(applyBatch(toLF(raw), edits, filePath), eol);
// Atomic write via temp+rename (same rationale as edit_file): a crash mid-write can't leave the
// user's source file half-overwritten — the live file stays intact until the rename swaps in the
// full new content. Clean up the temp file if anything fails so a stray `.tmp` doesn't accumulate.
+47 -36
View File
@@ -59,6 +59,7 @@ import { FilePanel, type FilePanelTab, type TouchedFile } from "./FilePanel.js";
import { HistoryItemView } from "./HistoryItemView.js";
import { ModelSelect } from "./ModelSelect.js";
import { matchMouseSequence, logicalButton, copyToClipboard } from "./mouseInput.js";
import { getAbsolutePosition } from "./absolutePosition.js";
import { PermissionPrompt } from "./PermissionPrompt.js";
import { SessionSelect } from "./SessionSelect.js";
import { StatusBar } from "./StatusBar.js";
@@ -218,6 +219,10 @@ export function App({
// via OSC 52 and the selection is cleared.
const [selectionStart, setSelectionStart] = useState<{ row: number; col: number } | null>(null);
const [selectionEnd, setSelectionEnd] = useState<{ row: number; col: number } | null>(null);
// Refs mirroring selection state so the useInput handler (which uses useEffectEvent) always
// reads the latest value without waiting for a React state flush cycle.
const selectionStartRef = useRef<{ row: number; col: number } | null>(null);
const selectionEndRef = useRef<{ row: number; col: number } | null>(null);
const [streamingText, setStreamingText] = useState<string | null>(null);
const [isThinking, setIsThinking] = useState(false);
const [permMode, setPermMode] = useState<PermissionMode>("default");
@@ -301,6 +306,18 @@ export function App({
// pinned, the effective scrollTop is just maxScroll (always show the latest content).
const [scrollTop, setScrollTop] = useState(0);
const effectiveScrollTop = pinnedToBottom ? maxScroll : Math.min(scrollTop, maxScroll);
// Mouse selection coordinate helper: converts SGR-1006 (col, row) terminal coordinates
// to content-row coordinates relative to the history viewport's content. SGR-1006
// reports absolute terminal positions, but content rows start at 1 within the viewport.
// The history viewport's Y position in the terminal layout offsets the mouse row.
const mouseToContentRow = useCallback((terminalRow: number): number => {
const viewportY = historyViewportRef.current
? getAbsolutePosition(historyViewportRef.current).y
: 0;
// Content row 1 = the first row visible inside the viewport when scrollTop=0.
// When scrolled, content row = (terminal row - viewport top) + effectiveScrollTop + 1.
return (terminalRow - Math.round(viewportY)) + effectiveScrollTopRef.current + 1;
}, []);
// Refs mirroring the scroll-relevant values so the (once-registered) mouse-wheel listener can
// read the latest without re-binding on every state change (see mouseMode effect below).
@@ -310,6 +327,8 @@ export function App({
pinnedToBottomRef.current = pinnedToBottom;
const scrollTopRef = useRef(scrollTop);
scrollTopRef.current = scrollTop;
const effectiveScrollTopRef = useRef(effectiveScrollTop);
effectiveScrollTopRef.current = effectiveScrollTop;
// Scroll the history viewport by `delta` rows (negative = up, positive = down), using the same
// pin/unpin rules as the PageUp/PageDown handler above. Shared by keyboard paging and the
@@ -407,52 +426,44 @@ 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
else if (mouseEvent.button === 65) scrollBy(3); // wheel down
else if (logicalButton(mouseEvent) === "left") {
if (mouseEvent.pressed) {
// Left button press: start selection
const row = mouseEvent.row + effectiveScrollTop;
setSelectionStart({ row, col: mouseEvent.col });
setSelectionEnd({ row, col: mouseEvent.col });
} else {
// Left button release: copy selection to clipboard, then clear it
if (selectionStart) {
const row = mouseEvent.row + effectiveScrollTop;
const end = { row, col: mouseEvent.col };
const from = selectionStart.row < end.row || (selectionStart.row === end.row && selectionStart.col <= end.col) ? selectionStart : end;
const to = selectionStart.row < end.row || (selectionStart.row === end.row && selectionStart.col <= end.col) ? end : selectionStart;
const text = extractSelectionText(staticItems, streamingText, from, to);
if (text) {
copyToClipboard(text, stdout);
push({ kind: "notice", text: `Copied ${text.split("\n").length} line(s) to clipboard` });
}
}
setSelectionStart(null);
setSelectionEnd(null);
}
} else if (logicalButton(mouseEvent) === "release" && selectionStart) {
// Release event (button code 3 with pressed=false) — same as above
const row = mouseEvent.row + effectiveScrollTop;
const btn = logicalButton(mouseEvent);
if (mouseEvent.button === 64) { scrollBy(-3); } // wheel up
else if (mouseEvent.button === 65) { scrollBy(3); } // wheel down
else if (btn === "left" && mouseEvent.pressed) {
// Left button press: start in-app text selection
const row = mouseToContentRow(mouseEvent.row);
const start = { row, col: mouseEvent.col };
selectionStartRef.current = start;
selectionEndRef.current = start;
setSelectionStart(start);
setSelectionEnd(start);
push({ kind: "notice", text: "Selection started — drag to select text" });
} else if (btn === "drag" && selectionStartRef.current) {
// SGR-1006 drag motion (bit 32 set): extend selection
const row = mouseToContentRow(mouseEvent.row);
const end = { row, col: mouseEvent.col };
const from = selectionStart.row < end.row || (selectionStart.row === end.row && selectionStart.col <= end.col) ? selectionStart : end;
const to = selectionStart.row < end.row || (selectionStart.row === end.row && selectionStart.col <= end.col) ? end : selectionStart;
selectionEndRef.current = end;
setSelectionEnd(end);
} else if (btn === "release" && selectionStartRef.current) {
// Release: copy selection to clipboard, then clear it
const row = mouseToContentRow(mouseEvent.row);
const end = { row, col: mouseEvent.col };
const start = selectionStartRef.current;
const from = start.row < end.row || (start.row === end.row && start.col <= end.col) ? start : end;
const to = start.row < end.row || (start.row === end.row && start.col <= end.col) ? end : start;
const text = extractSelectionText(staticItems, streamingText, from, to);
if (text) {
copyToClipboard(text, stdout);
push({ kind: "notice", text: `Copied ${text.split("\n").length} line(s) to clipboard` });
} else {
push({ kind: "notice", text: "No text in selection range" });
}
selectionStartRef.current = null;
selectionEndRef.current = null;
setSelectionStart(null);
setSelectionEnd(null);
} else if (mouseEvent.pressed && selectionStart) {
// Drag while left button held (SGR-1006 drag reports button 3 for motion)
const row = mouseEvent.row + effectiveScrollTop;
setSelectionEnd({ row, col: mouseEvent.col });
}
return;
}
+5 -2
View File
@@ -90,9 +90,12 @@ export function StatusBar({
const approx = contextIsEstimate ? "~" : "";
const elapsedMs = Math.max(0, now - new Date(createdAt).getTime());
const totalTokens = inputTokens + outputTokens;
const elapsedMinutes = elapsedMs / 60_000;
const burnRate = elapsedMinutes >= 0.1 ? totalTokens / elapsedMinutes : null;
// Output-only: inputTokens sums the *full resent conversation context* on every API call (no
// prompt caching for local backends), so a chatty turn with many tool-call round trips inflates
// it by call count, not by anything the user would recognize as "burn rate". outputTokens/min
// reflects actual generation throughput instead.
const burnRate = elapsedMinutes >= 0.1 ? outputTokens / elapsedMinutes : null;
return (
<Box flexDirection="column" width="100%" paddingX={1}>
+23 -7
View File
@@ -38,15 +38,31 @@ export interface MouseEvent {
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";
/** Which logical button is pressed (ignoring modifiers).
*
* SGR-1006 button encoding (with modifiers stripped by ~0x07):
* 0 = left button press (no motion bit)
* 1 = middle button press
* 2 = right button press
* 3 = button release (in SGR mode, trailing 'm')
* 32 = motion/drag bit — added to the base button code during drag:
* 32 = left-drag, 33 = middle-drag, 34 = right-drag
* 64 = wheel up, 65 = wheel down
*
* When a button is released, the protocol sends button=3 with trailing 'm',
* regardless of which button was held. */
export function logicalButton(e: MouseEvent): "left" | "middle" | "right" | "wheel-up" | "wheel-down" | "release" | "drag" | "other" {
const raw = e.button & ~0x07; // strip modifier bits (shift=4, meta=8, ctrl=16)
if (raw === 0 && e.pressed) return "left";
if (raw === 1 && e.pressed) return "middle";
if (raw === 2 && e.pressed) return "right";
if (raw === 32) return "drag"; // left-drag (most common)
if (raw === 33) return "drag"; // middle-drag
if (raw === 34) return "drag"; // right-drag
if (raw === 64) return "wheel-up";
if (raw === 65) return "wheel-down";
if (raw === 3 || (!e.pressed && raw === 0)) return "release";
// SGR-1006 release: button=3 with trailing 'm' (pressed=false)
if (!e.pressed) return "release";
return "other";
}