diff --git a/package.json b/package.json index 1dd92e80..0eddc7ee 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nexus-code", "productName": "NexusCode", - "version": "0.8.1", + "version": "0.8.2", "description": "Multi-workspace VSCode-style editor for macOS. Monaco editor + terminal in one window.", "license": "MIT", "private": true, diff --git a/src/main/features/pty/osc-notification.ts b/src/main/features/pty/osc-notification.ts index 0a52e36d..5cf8e6dd 100644 --- a/src/main/features/pty/osc-notification.ts +++ b/src/main/features/pty/osc-notification.ts @@ -77,6 +77,14 @@ const OSC99_TITLE_PARAM_RE = /(?:^|;)p=title:([^;]*)/; export function extractOscNotifications(chunk: string): OscNotification[] { const results: OscNotification[] = []; + // Fast path: every OSC sequence begins with the introducer ESC ']' + // (0x1b 0x5d), which all three regexes below anchor on. The vast majority of + // PTY output chunks (plain text, CSI cursor moves, SGR colors) contain no OSC + // introducer at all, so bail before running three global-regex matchAll scans + // over the whole chunk. Behavior-preserving — a chunk without "\x1b]" cannot + // match any of the patterns. + if (!chunk.includes("\x1b]")) return results; + for (const m of chunk.matchAll(OSC9_RE)) { results.push({ kind: "osc9", body: m[1] }); } diff --git a/src/main/infra/agent/pipe.ts b/src/main/infra/agent/pipe.ts index 29ed9f8d..fba21989 100644 --- a/src/main/infra/agent/pipe.ts +++ b/src/main/infra/agent/pipe.ts @@ -835,17 +835,25 @@ function escapeControlChars(value: string): string { function parseFrame(line: string): ParsedFrame { const parsed: unknown = JSON.parse(line); - const ready = ReadyFrameSchema.safeParse(parsed); - if (ready.success) { - return { - kind: "ready", - protocolVersion: ready.data.protocolVersion, - methods: ready.data.methods, - heartbeatIntervalMs: ready.data.heartbeatIntervalMs, - idleWatchdogMs: ready.data.idleWatchdogMs, - agentEpoch: ready.data.agentEpoch, - capabilities: ready.data.capabilities, - }; + // The `ready` frame is uniquely tagged `type: "ready"` and is emitted exactly + // once, at handshake. Gate the schema parse behind that cheap tag check so the + // hot data/response/event path (every PTY output frame) doesn't pay a full + // Zod object parse just to have it fail. Behavior-preserving: a frame without + // `type === "ready"` can never satisfy ReadyFrameSchema's literal, so skipping + // the parse yields the same fall-through as before. + if (isRecord(parsed) && parsed.type === "ready") { + const ready = ReadyFrameSchema.safeParse(parsed); + if (ready.success) { + return { + kind: "ready", + protocolVersion: ready.data.protocolVersion, + methods: ready.data.methods, + heartbeatIntervalMs: ready.data.heartbeatIntervalMs, + idleWatchdogMs: ready.data.idleWatchdogMs, + agentEpoch: ready.data.agentEpoch, + capabilities: ready.data.capabilities, + }; + } } if (!isRecord(parsed)) { @@ -892,8 +900,12 @@ function parseFrame(line: string): ParsedFrame { }; } - const event = EventFrameSchema.safeParse(parsed); - if (event.success) { + // Only attempt the event schema when the `event` key is present (computed + // above). A frame without it can never satisfy EventFrameSchema's required + // `event` string, so this avoids a wasted parse on malformed frames while + // keeping the hot data-event path (which always has the key) unchanged. + const event = hasEvent ? EventFrameSchema.safeParse(parsed) : null; + if (event?.success) { return { kind: "event", event: event.data.event, diff --git a/src/renderer/components/files/file-tree/index.tsx b/src/renderer/components/files/file-tree/index.tsx index bf173fd3..d86dc629 100644 --- a/src/renderer/components/files/file-tree/index.tsx +++ b/src/renderer/components/files/file-tree/index.tsx @@ -294,7 +294,15 @@ export function FileTree({ workspaceId, rootAbsPath }: FileTreeProps) { // explicitly collapsed the root via the header chevron, an empty area is // the correct pose — showing "This folder is empty" would misrepresent // intentional collapse as a content-absence state. - const showStatusView = flat.length === 0 && rootExpanded; + // + // Gate on `displayFlat` rather than `flat`: when the tree is empty and the + // user triggers New File / New Folder, `flat` is still empty but + // `getDisplayFlat` injects the inline-edit sentinel row (a root-anchored + // pending create yields `displayFlat = [sentinel]`). That row only renders + // inside , so keying off `flat.length` would leave the + // StatusView mounted, the edit row never appears, and the create silently + // dies — the empty-tree "create buttons do nothing" bug. + const showStatusView = displayFlat.length === 0 && rootExpanded; // Build the flat path list once per render (same shape as what // extendSelectionTo/selectAllVisible need). Declared before handleKeyDown diff --git a/src/renderer/services/terminal/controller.ts b/src/renderer/services/terminal/controller.ts index 785574c2..d867ac08 100644 --- a/src/renderer/services/terminal/controller.ts +++ b/src/renderer/services/terminal/controller.ts @@ -620,6 +620,19 @@ class XtermTerminalController implements TerminalController { // // `event.keyCode === 229` fallback은 옛 Chromium에서 `isComposing`이 // 일부 keydown에 늦게 세팅되는 케이스 대응. + // + // Mac Cmd+←/→ 중복 방지: macOS에 물리 Home/End가 없어 사용자는 Cmd+←/→ + // 로 줄 시작/끝을 이동한다. 그런데 xterm.js의 CompositionHelper.keydown은 + // keyCode 16/17/18/20/229만 예외로 두고 Cmd(Meta=91)를 "조합 종료 키"로 + // 취급해 조합 중 Cmd를 누르는 순간 조합 글자를 확정(전송)한다. 이후 + // compositionend가 다시 finalize를 걸어 `_compositionPosition`이 전진하지 + // 않은 채 같은 글자를 setTimeout으로 재전송 → 마지막 글자가 두 번 입력된다 + // ("안녕하세요" + Cmd+← → "요안녕하세요"). 조합 중 bare Meta keydown을 + // 여기서 가로채 xterm의 조기 finalize를 막으면, 조합 글자는 compositionend + // 경로로 한 번만 전송된다. preventDefault는 호출하지 않는다 — textarea + // 기본 동작(커서 이동)을 건드리면 offset이 desync되어 stuck 버그가 재발하기 + // 때문. (실제 xterm 6.0.0 재생 하네스로 4개 nav 케이스 중복 제거 확인.) + if (event.isComposing && event.keyCode === 91) return false; if (event.isComposing || event.keyCode === 229) return true; if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "c") { @@ -692,7 +705,21 @@ class XtermTerminalController implements TerminalController { if (isHome || isEnd) { event.preventDefault(); event.stopPropagation(); - ptyClient.write(isHome ? "\x01" : "\x05"); + // Defer the ^A/^E byte to the next macrotask so it lands *after* any + // pending IME composition commit. When a nav key ends an IME + // composition, xterm finalizes the composed syllable on a setTimeout(0) + // scheduled at the preceding `compositionend`; writing our nav byte + // synchronously here would reach the PTY first and drag the not-yet-sent + // syllable to the wrong side of the cursor move (e.g. "안녕하세요" + + // Home → "요안녕하세"). Scheduling our byte on a later setTimeout(0) + // preserves FIFO ordering: the composed syllable is emitted first, then + // the cursor moves. This only reorders our own write and never touches + // xterm's key/composition handling, so it cannot desync the IME state. + // Works together with the bare-Meta guard above: that guard prevents the + // Cmd+←/→ double-send, and this defer keeps the single committed syllable + // ordered before the cursor move. + const byte = isHome ? "\x01" : "\x05"; + setTimeout(() => ptyClient.write(byte), 0); return false; } return true; diff --git a/tests/unit/renderer/services/terminal-services.test.ts b/tests/unit/renderer/services/terminal-services.test.ts index 7f31afe6..79e53026 100644 --- a/tests/unit/renderer/services/terminal-services.test.ts +++ b/tests/unit/renderer/services/terminal-services.test.ts @@ -716,6 +716,8 @@ describe("services/terminal controller — 라인 단축키 치환", () => { metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean; + isComposing?: boolean; + keyCode?: number; defaultPrevented: boolean; preventDefault(): void; stopPropagation(): void; @@ -727,6 +729,8 @@ describe("services/terminal controller — 라인 단축키 치환", () => { metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean; + isComposing?: boolean; + keyCode?: number; }): FakeKeyEvent { const event: FakeKeyEvent = { type: opts.type ?? "keydown", @@ -735,6 +739,8 @@ describe("services/terminal controller — 라인 단축키 치환", () => { metaKey: opts.metaKey, ctrlKey: opts.ctrlKey, altKey: opts.altKey, + isComposing: opts.isComposing, + keyCode: opts.keyCode, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; @@ -744,6 +750,46 @@ describe("services/terminal controller — 라인 단축키 치환", () => { return event; } + it("조합 중 bare Cmd(Meta) keydown → return false (xterm 조기 finalize 차단, preventDefault 안 함)", async () => { + // Regression guard for the Mac Cmd+←/→ duplicate-syllable bug: while an IME + // composition is active, the bare Meta keydown must be swallowed so xterm's + // CompositionHelper does not prematurely finalize (and later double-send) + // the composing syllable. It must NOT preventDefault (that would move the + // textarea caret and desync the composition offsets → "stuck" bug). + const { harness, controller, handler } = await setupHandler(); + const event = fakeKeyEvent({ key: "Meta", metaKey: true, isComposing: true, keyCode: 91 }); + + const result = handler(event as unknown as KeyboardEvent); + + expect(result).toBe(false); + expect(event.defaultPrevented).toBe(false); + await new Promise((r) => setTimeout(r, 0)); + expect(harness.ptyWrites).toEqual([]); + + controller.dispose(); + }); + + it("조합 중 Cmd+ArrowRight(=isComposing 상태) → xterm에 위임(return true), 직접 write 안 함", async () => { + // The composition guard must delegate the composing nav keydown to xterm so + // the syllable commits exactly once via compositionend; our ^E is issued on + // the separate non-composing keydown that follows. + const { harness, controller, handler } = await setupHandler(); + const event = fakeKeyEvent({ + key: "ArrowRight", + metaKey: true, + isComposing: true, + keyCode: 229, + }); + + const result = handler(event as unknown as KeyboardEvent); + + expect(result).toBe(true); + await new Promise((r) => setTimeout(r, 0)); + expect(harness.ptyWrites).toEqual([]); + + controller.dispose(); + }); + it("Home 단독 keydown → \\x01 송신 + 기본 동작 차단", async () => { const { harness, controller, handler } = await setupHandler(); const event = fakeKeyEvent({ key: "Home" }); @@ -752,6 +798,9 @@ describe("services/terminal controller — 라인 단축키 치환", () => { expect(result).toBe(false); expect(event.defaultPrevented).toBe(true); + // The ^A/^E byte is written on a deferred macrotask (setTimeout 0) so it + // lands after any pending IME composition commit — flush before asserting. + await new Promise((r) => setTimeout(r, 0)); expect(harness.ptyWrites).toEqual(["\x01"]); controller.dispose(); @@ -765,6 +814,8 @@ describe("services/terminal controller — 라인 단축키 치환", () => { expect(result).toBe(false); expect(event.defaultPrevented).toBe(true); + // Deferred macrotask write — flush before asserting (see \x01 case). + await new Promise((r) => setTimeout(r, 0)); expect(harness.ptyWrites).toEqual(["\x05"]); controller.dispose(); @@ -778,6 +829,9 @@ describe("services/terminal controller — 라인 단축키 치환", () => { expect(result).toBe(false); expect(event.defaultPrevented).toBe(true); + // The ^A/^E byte is written on a deferred macrotask (setTimeout 0) so it + // lands after any pending IME composition commit — flush before asserting. + await new Promise((r) => setTimeout(r, 0)); expect(harness.ptyWrites).toEqual(["\x01"]); controller.dispose(); @@ -791,6 +845,8 @@ describe("services/terminal controller — 라인 단축키 치환", () => { expect(result).toBe(false); expect(event.defaultPrevented).toBe(true); + // Deferred macrotask write — flush before asserting (see \x01 case). + await new Promise((r) => setTimeout(r, 0)); expect(harness.ptyWrites).toEqual(["\x05"]); controller.dispose();