diff --git a/docs/terminal-worker.md b/docs/terminal-worker.md index 77aa9325b7..d83dea9957 100644 --- a/docs/terminal-worker.md +++ b/docs/terminal-worker.md @@ -15,7 +15,7 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term - The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again). - Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon. -The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut. +The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. It also restores SGR mouse encoding: the addon preserves mouse tracking but omits its encoding, so switching away and back otherwise sends legacy wheel reports that Copilot CLI cannot use. These missing modes require xterm's internal services because its public modes API omits them. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut. Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon. diff --git a/packages/server/src/terminal/terminal-serializer.ts b/packages/server/src/terminal/terminal-serializer.ts index 5f79064948..2b36dcbe7b 100644 --- a/packages/server/src/terminal/terminal-serializer.ts +++ b/packages/server/src/terminal/terminal-serializer.ts @@ -16,13 +16,13 @@ export function createTerminalSerializer( // absolutely; an x==cols cursor must keep its pending wrap for the next byte. const position = buffer.cursorX < terminal.cols ? `\x1b[${buffer.cursorY + 1};${buffer.cursorX + 1}H` : ""; - return Buffer.from(data + position + serializeCursorPresentation(terminal), "utf8"); + return Buffer.from(data + position + serializeAdditionalModes(terminal), "utf8"); }; } -// Stable addon 0.14 serializes buffer/modes but omits DECSCUSR and cursor visibility. -// xterm 6 keeps the program-selected presentation in coreService, not public options. -function serializeCursorPresentation(terminal: TerminalType): string { +// Addon 0.14 omits mouse encoding, DECSCUSR and cursor visibility. xterm 6 +// retains these in internal services rather than the public modes/options. +function serializeAdditionalModes(terminal: TerminalType): string { const core = ( terminal as unknown as { _core: { @@ -30,13 +30,17 @@ function serializeCursorPresentation(terminal: TerminalType): string { isCursorHidden: boolean; decPrivateModes: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean }; }; + mouseStateService: { activeEncoding: string }; }; } - )._core.coreService; - const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`; - const cursorStyle = core.decPrivateModes.cursorStyle; + )._core; + let mouseEncoding = ""; + if (core.mouseStateService.activeEncoding === "SGR") mouseEncoding = "\x1b[?1006h"; + else if (core.mouseStateService.activeEncoding === "SGR_PIXELS") mouseEncoding = "\x1b[?1016h"; + const visibility = `${mouseEncoding}\x1b[?25${core.coreService.isCursorHidden ? "l" : "h"}`; + const cursorStyle = core.coreService.decPrivateModes.cursorStyle; // Unset presentation belongs to each renderer's configured defaults. if (cursorStyle === undefined) return visibility; const style = { block: 2, underline: 4, bar: 6 }[cursorStyle]; - return `${visibility}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`; + return `${visibility}\x1b[${style - (core.coreService.decPrivateModes.cursorBlink ? 1 : 0)} q`; } diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 3d1fcca545..d36e86d732 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1702,6 +1702,52 @@ it.each([false, true])( }, ); +it.each([ + { name: "SGR", modes: "\x1b[?1006h", wheel: "\x1b[<64;10;5M" }, + { name: "SGR pixels", modes: "\x1b[?1016h", wheel: "\x1b[<64;80;90M" }, + { name: "disabled SGR", modes: "\x1b[?1006h\x1b[?1006l", wheel: "\x1b[M`*%" }, +])("preserves $name wheel reports across repeated terminal replays", async (input) => { + const source = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true }); + const receiver = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true }); + const serialize = createTerminalSerializer(source); + const wheel = { + button: 4, + action: 0, + col: 10, + row: 5, + x: 80, + y: 90, + ctrl: false, + alt: false, + shift: false, + }; + // Mouse encoding is not exposed by xterm's public modes API. + const mouse = ( + receiver as unknown as { + _core: { + mouseStateService: { + encodeMouseEvent(event: typeof wheel): string; + }; + }; + } + )._core.mouseStateService; + try { + await new Promise((resolve) => + source.write("\x1b[?1049h\x1b[?1002h" + input.modes + "TRANSCRIPT", resolve), + ); + for (let replay = 0; replay < 2; replay++) { + receiver.reset(); + await new Promise((resolve) => receiver.write(serialize(), resolve)); + expect(receiver.buffer.active.type).toBe("alternate"); + expect(receiver.modes.mouseTrackingMode).toBe("drag"); + expect(mouse.encodeMouseEvent(wheel)).toBe(input.wheel); + } + } finally { + receiver.dispose(); + source.dispose(); + } +}); + it.each([ { name: "unset", presentation: "", style: undefined, visible: true }, {