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:
@@ -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개** 전부 통과
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user