Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/main/features/pty/osc-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] });
}
Expand Down
38 changes: 25 additions & 13 deletions src/main/infra/agent/pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion src/renderer/components/files/file-tree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <FileTreeVirtualBody>, 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
Expand Down
29 changes: 28 additions & 1 deletion src/renderer/services/terminal/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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;
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/renderer/services/terminal-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,8 @@ describe("services/terminal controller — 라인 단축키 치환", () => {
metaKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
isComposing?: boolean;
keyCode?: number;
defaultPrevented: boolean;
preventDefault(): void;
stopPropagation(): void;
Expand All @@ -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",
Expand All @@ -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;
Expand All @@ -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" });
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down