diff --git a/apps/cli/package.json b/apps/cli/package.json index 73cb5429..0b1f78f9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -59,6 +59,7 @@ "quickjs-emscripten-core": "catalog:", "react": "catalog:", "smol-toml": "catalog:", + "string-width": "catalog:", "yaml": "catalog:", "zod": "catalog:" }, diff --git a/apps/cli/src/commands/chat-alt-hoist.test.ts b/apps/cli/src/commands/chat-alt-hoist.test.ts index 78e9013f..38a66834 100644 --- a/apps/cli/src/commands/chat-alt-hoist.test.ts +++ b/apps/cli/src/commands/chat-alt-hoist.test.ts @@ -1,3 +1,4 @@ +import { createSuspendPort, type SuspendPort } from '../render/suspend.js'; import { describe, expect, it, vi } from 'vitest'; import { @@ -32,12 +33,15 @@ describe('withHoistedAltScreen (2.6.F Step 4b-3, ADR-0068 §c)', () => { readonly lifecycle: ReplLifecycle; readonly fireExit: () => void; // the captured process.on('exit') listener readonly fireSignal: (signo: number) => void; // the captured SIGTERM/SIGHUP/SIGQUIT listener + readonly fireInterrupt: () => void; // the captured SIGINT listener (the rebuild-window net) readonly removeExit: ReturnType; readonly removeSignal: ReturnType; + readonly removeInterrupt: ReturnType; readonly setRawMode: ReturnType; readonly exit: ReturnType; readonly onProcessExit: ReturnType; readonly onTerminationSignal: ReturnType; + readonly onInterrupt: ReturnType; } const harness = (): Harness => { @@ -59,16 +63,25 @@ describe('withHoistedAltScreen (2.6.F Step 4b-3, ADR-0068 §c)', () => { signalCb = cb; return removeSignal; }); + let interruptCb: () => void = () => undefined; + const removeInterrupt = vi.fn(); + const onInterrupt = vi.fn((cb: () => void) => { + interruptCb = cb; + return removeInterrupt; + }); return { writes, outs, errs, events, - lifecycle: { onProcessExit, onTerminationSignal, setRawMode, exit }, + lifecycle: { onProcessExit, onTerminationSignal, onInterrupt, setRawMode, exit }, fireExit: () => exitCb(), fireSignal: (signo) => signalCb(signo), + fireInterrupt: () => interruptCb(), removeExit, removeSignal, + removeInterrupt, + onInterrupt, setRawMode, exit, onProcessExit, @@ -99,10 +112,16 @@ describe('withHoistedAltScreen (2.6.F Step 4b-3, ADR-0068 §c)', () => { alt.clearBetween(); // a /clear swap mid-loop return Promise.resolve({ summaryText: 'session over' }); }); - // Enter → clear (swap) → exit, then the summary on the PRIMARY buffer (after the exit). - expect(h.writes).toEqual([ENTER_SEQ, CLEAR_ALT_SCREEN, EXIT_SEQ]); - expect(h.outs).toEqual(['session over\n']); - // The last write (the alt-exit) precedes the summary print — the summary lands on the primary buffer. + // Enter → clear (swap) → exit, THEN the summary. Asserted on the COMBINED `events` log, not on `writes` and `outs` + // separately: the whole claim of this test is a CROSS-SINK order, and two per-sink assertions cannot see it — the + // summary could print into the still-entered alt buffer (where DECRST-1049 discards it) and both arrays would be + // unchanged. That is the regression this test exists to catch, and it could not (whole-phase Opus review). + expect(h.events).toEqual([ + `write:${ENTER_SEQ}`, + `write:${CLEAR_ALT_SCREEN}`, + `write:${EXIT_SEQ}`, + 'out:session over\n', + ]); expect(h.removeExit).toHaveBeenCalledTimes(1); // the exit net was removed (cannot outlive the loop) expect(h.removeSignal).toHaveBeenCalledTimes(1); }); @@ -200,4 +219,123 @@ describe('withHoistedAltScreen (2.6.F Step 4b-3, ADR-0068 §c)', () => { expect(h.removeExit).toHaveBeenCalledTimes(1); expect(h.removeSignal).toHaveBeenCalledTimes(1); }); + + /** + * THE REBUILD WINDOW (2.6.F Step 6g, whole-phase Opus review). SIGINT belongs to ink: while a tree is mounted, + * `driveInk`'s `onSigintGated` runs the cooperative `/cancel`, and during a `/scrollback` or `/edit` hatch it + * DROPS the signal so the suspension can reclaim the terminal. But a `/clear` or `/models` rebuild unmounts ink + * and mounts a fresh tree, and in that window nothing listens for SIGINT — Node's default action kills the process + * WITHOUT firing `'exit'`, so the `onProcessExit` net never runs and the alt buffer, mouse reporting and the hidden + * cursor are stranded on the user's shell. + */ + it('a THROWING alt.enter() still runs the finally — the nets are removed and the summary is not lost', async () => { + // `enter()`'s write can throw on a dead TTY. It used to run BEFORE the try, so the `finally` never ran, and the + // nets (had any been registered) would have outlived the loop. Flagged by the PR bot once 6h-1 made `enter` throw. + const h = harness(); + const throwing = { + ...opts(h, true), + write: (s_: string) => { + if (s_.includes(ENTER_ALT_SCREEN)) throw new Error('EIO'); + h.writes.push(s_); + }, + }; + await expect( + withHoistedAltScreen(throwing, () => Promise.resolve({ summaryText: 'never runs' })), + ).rejects.toThrow('EIO'); + expect(h.writes).toEqual([]); // nothing entered ⇒ nothing exited + expect(h.outs).toEqual([]); // the loop never ran, so there is no summary + }); + + describe('the SIGINT net covers the window where no ink tree is mounted', () => { + /** A port that reports whether an ink tree is attached — exactly what `createSuspendPort().current()` does. */ + const portWith = (attached: boolean): SuspendPort => { + const port = createSuspendPort(); + if (attached) port.attach((cb) => cb()); + return port; + }; + + it('restores the terminal and exits 130 when NO ink tree is attached', async () => { + const h = harness(); + await withHoistedAltScreen( + { + active: true, + write: (s_) => h.writes.push(s_), + lifecycle: h.lifecycle, + writeOut: () => undefined, + writeErr: () => undefined, + suspendPort: portWith(false), + }, + () => { + h.fireInterrupt(); + return Promise.resolve({}); + }, + ); + expect(h.exit).toHaveBeenCalledWith(130); + expect(h.writes.join('')).toContain(DISABLE_MOUSE); + expect(h.writes.join('')).toContain(EXIT_ALT_SCREEN); + expect(h.setRawMode).toHaveBeenCalledWith(false); + }); + + it('DEFERS to ink when a tree IS attached — Ctrl-C there is the cooperative /cancel, not a kill', async () => { + const h = harness(); + await withHoistedAltScreen( + { + active: true, + write: (s_) => h.writes.push(s_), + lifecycle: h.lifecycle, + writeOut: () => undefined, + writeErr: () => undefined, + suspendPort: portWith(true), + }, + () => { + h.fireInterrupt(); + return Promise.resolve({}); + }, + ); + expect(h.exit).not.toHaveBeenCalled(); + }); + + it('registers no SIGINT net when the alt screen is inactive (inline / --json)', async () => { + const h = harness(); + await withHoistedAltScreen( + { + active: false, + write: (s_) => h.writes.push(s_), + lifecycle: h.lifecycle, + writeOut: () => undefined, + writeErr: () => undefined, + suspendPort: portWith(false), + }, + () => Promise.resolve({}), + ); + expect(h.onInterrupt).not.toHaveBeenCalled(); + }); + + it('removes the SIGINT net when the loop ends — it must not outlive the hoist', async () => { + const h = harness(); + await withHoistedAltScreen( + { + active: true, + write: (s_) => h.writes.push(s_), + lifecycle: h.lifecycle, + writeOut: () => undefined, + writeErr: () => undefined, + suspendPort: portWith(false), + }, + () => Promise.resolve({}), + ); + expect(h.removeInterrupt).toHaveBeenCalledTimes(1); + }); + + it('the PRODUCTION lifecycle registers SIGINT separately from the termination signals', () => { + const before = process.listenerCount('SIGINT'); + const off = defaultReplLifecycle.onInterrupt(() => undefined); + try { + expect(process.listenerCount('SIGINT')).toBe(before + 1); + } finally { + off(); // always remove the real `process` listener, even if the assertion above throws + } + expect(process.listenerCount('SIGINT')).toBe(before); + }); + }); }); diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index bf521448..2cffd919 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -31,7 +31,12 @@ import type { GlobalOptions } from '../process/options.js'; import { selectChatDriver } from '../render/tui/chat-ink.js'; import { createChatStore, type ChatStoreController } from '../render/tui/chat-store.js'; import { captureIo, parseNdjson } from '../test-support.js'; -import { ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../render/alt-screen.js'; +import { + DISABLE_MOUSE, + ENABLE_MOUSE, + ENTER_ALT_SCREEN, + EXIT_ALT_SCREEN, +} from '../render/alt-screen.js'; import { chatCommand, chatIsInteractive, @@ -141,6 +146,7 @@ const INERT_HOIST = { lifecycle: { onProcessExit: (): (() => void) => () => undefined, onTerminationSignal: (): (() => void) => () => undefined, + onInterrupt: (): (() => void) => () => undefined, setRawMode: (): void => undefined, exit: (): void => undefined, }, @@ -692,6 +698,74 @@ describe('chatCommand', () => { expect(recC.writes).toEqual([]); }); + it('MOUSE: on by default, and `--no-mouse` reaches the real controller (2.6.F Step 5e, ADR-0068 §e)', async () => { + const exitDrive: ChatDriver = async (ctx) => { + ctx.startSession(); + await ctx.processLine('/exit'); + return { kind: ctx.stopReason() }; + }; + // The opt-out IS the safety mechanism this feature exists for: mouse reporting disables the emulator's native + // click-drag selection. The unit tests pin `resolveMouseMode` and the controller in isolation; this pins the + // ASSEMBLY — `deps.global.noMouse` → `resolveMouseMode` → `withHoistedAltScreen` → `enter()`. A mis-threaded field + // stays `boolean | undefined`-typed and would compile (Step-5e Opus review). + const a = deps([], [textTurn('hi')]); + const recA = recordingHoist(); + await chatCommand( + { agent: undefined }, + { + ...a.d, + ...recA.hoist, + io: { ...a.d.io, stdoutIsTty: true }, + openSessionStore: () => ({ store: a.store, db: client.db, close: () => undefined }), + drive: exitDrive, + }, + ); + expect(recA.writes.join('')).toContain(ENABLE_MOUSE); // the phase default arms the wheel + + const b = deps([], [textTurn('hi')]); + const recB = recordingHoist(); + await chatCommand( + { agent: undefined }, + { + ...b.d, + ...recB.hoist, + io: { ...b.d.io, stdoutIsTty: true }, + global: { ...globalOptions(cwd), noMouse: true }, + openSessionStore: () => ({ store: b.store, db: client.db, close: () => undefined }), + drive: exitDrive, + }, + ); + const written = recB.writes.join(''); + expect(written).not.toContain(ENABLE_MOUSE); // the wheel is never armed… + expect(written).toContain(ENTER_ALT_SCREEN); // …while the full-screen renderer is untouched + expect(written).toContain(DISABLE_MOUSE); // …and the teardown still disables, unconditionally + }); + + it('MOUSE: `[preferences].mouse = false` reaches the real controller (the durable opt-out)', async () => { + const exitDrive: ChatDriver = async (ctx) => { + ctx.startSession(); + await ctx.processLine('/exit'); + return { kind: ctx.stopReason() }; + }; + const c = deps([], [textTurn('hi')]); + const cfg = join(cwd, 'mouse-off.toml'); + writeFileSync(cfg, '[preferences]\nmouse = false\n'); + const rec = recordingHoist(); + await chatCommand( + { agent: undefined }, + { + ...c.d, + ...rec.hoist, + io: { ...c.d.io, stdoutIsTty: true }, + global: { ...globalOptions(cwd), configPath: cfg }, + openSessionStore: () => ({ store: c.store, db: client.db, close: () => undefined }), + drive: exitDrive, + }, + ); + expect(rec.writes.join('')).not.toContain(ENABLE_MOUSE); + expect(rec.writes.join('')).toContain(ENTER_ALT_SCREEN); + }); + it('the [preferences].alt_screen preference SURVIVES a /clear re-drive (Step-4a threading regression, ADR-0068 §e)', async () => { // The fabricated-outcome driver bypasses driveInk, so `ctx.altScreen` here is the raw threaded config pref (not // the resolved mode) — exactly what pins the wiring: config.altScreen must reach BOTH the initial AND the @@ -1047,6 +1121,88 @@ describe('chatCommand', () => { ).rejects.toThrow('db open boom'); expect(closed()).toBe(1); // the pre-loop catch tore the live connection down before rethrowing }); + + /** + * THE CAPS-LIFT, ON `relavium chat` (2.6.F Step 6h, Sonnet review). + * + * `drive-home.test.ts` proves it for the Home. It proved NOTHING for this surface: reverting `transcriptBoundFor` + * to always return the inline bound — restoring the exact 4 000-character clipping defect ADR-0068 exists to fix — + * left 2 062 of 2 063 tests green, the single failure being the Home's. `chat.ts` threads the bound through FOUR + * call sites (a fresh session, a resume, a `/clear` rebuild, a `/models` reseat) and none was covered. + * + * The headless `linesDriver` never subscribes the view store to the session stream (only `driveInk`/`drivePlain` + * do), so this drives the REAL store `chatCommand` built — with the REAL bound it was given — through the reducer. + */ + describe('the transcript bound `relavium chat` gives its view store', () => { + const LONG = 'X'.repeat(10_000); + + /** Run one turn's worth of events through the store the command created, and return the baked entry. */ + const bakeLongAnswer = (store: ChatStoreController): string => { + store.apply({ + type: 'session:turn_started', + sessionId: 's', + sequenceNumber: 1, + timestamp: '2026-01-01T00:00:00.000Z', + }); + store.apply({ + type: 'agent:token', + sessionId: 's', + sequenceNumber: 2, + timestamp: '2026-01-01T00:00:01.000Z', + token: LONG, + model: 'm', + nodeId: 'n', + }); + store.apply({ + type: 'session:turn_completed', + sessionId: 's', + sequenceNumber: 3, + timestamp: '2026-01-01T00:00:02.000Z', + stopReason: 'stop', + tokensUsed: { input: 1, output: 1 }, + }); + const transcript = store.getSnapshot().state.transcript; + return transcript.find((e) => e.role === 'assistant')?.text ?? ''; + }; + + const capturedStore = async ( + over: Partial = {}, + ): Promise => { + let live: ChatStoreController | undefined; + const capture: ChatDriver = async (ctx) => { + live = ctx.store; + ctx.startSession(); + await ctx.processLine('/exit'); + return { kind: 'exit' }; + }; + const { d } = deps([], []); + await chatCommand( + { agent: undefined }, + { ...d, ...INERT_HOIST, io: { ...d.io, stdoutIsTty: true }, drive: capture, ...over }, + ); + if (live === undefined) throw new Error('the driver never ran'); + return live; + }; + + it('the alt-screen chat keeps all 10 000 characters', async () => { + expect(bakeLongAnswer(await capturedStore())).toHaveLength(10_000); + }); + + it('`--no-alt-screen` keeps the historical trailing tail — the inline renderer has no viewport', async () => { + const store = await capturedStore({ + global: { ...globalOptions(cwd), noAltScreen: true }, + }); + const text = bakeLongAnswer(store); + expect(text).toHaveLength(4001); + expect(text.startsWith('…')).toBe(true); + }); + + it('a NON-TTY (a pipe / `--json`) also keeps the tail — it never projects a viewport', async () => { + const { d } = deps([], []); + const store = await capturedStore({ io: { ...d.io, stdoutIsTty: false } }); + expect(bakeLongAnswer(store)).toHaveLength(4001); + }); + }); }); describe('chatResumeCommand (2.N)', () => { diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 22ac30ad..b29bc967 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -71,7 +71,22 @@ import type { GlobalOptions } from '../process/options.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import { detectOutputMode, isCiEnv } from '../process/output-mode.js'; import { createAltScreenController, type AltScreenController } from '../render/alt-screen.js'; -import { resolveRenderMode } from '../render/render-mode.js'; +import { nodeCreateTempDocument, nodeSpawnEditor } from '../render/editor.js'; +import { + createHatches, + hoistedTerminal, + inertHatchPorts, + type HatchDeps, + type Hatches, +} from '../render/hatches.js'; +import { nodeWaitForContinue, nodeWriteOut } from '../render/scrollback.js'; +import { resolveCopyOnSelect, resolveMouseMode, resolveRenderMode } from '../render/render-mode.js'; +import { + FULLSCREEN_TRANSCRIPT_BOUND, + INLINE_TRANSCRIPT_BOUND, +} from '../render/tui/session-view-model.js'; +import { copyToClipboard, type ClipboardOutcome } from '../render/clipboard.js'; +import { createSuspendPort, type SuspendPort } from '../render/suspend.js'; import { DISABLE_BRACKETED_PASTE } from '../render/tui/home-input.js'; import { errorRecoveryHint, @@ -265,6 +280,17 @@ export interface ChatDriveContext { command: string, args: readonly string[], ) => Promise; + /** + * The ADR-0068 §e suspend port (2.6.F Step 5d). An INK driver attaches `useApp().suspendTerminal` to it on mount + * and detaches on unmount, which is what lets the non-React slash dispatch run `/scrollback` and `/edit`. Absent on + * a plain / `--json` driver — the hatches then surface an honest "needs an interactive terminal" notice. + */ + readonly suspendPort?: SuspendPort | undefined; + /** + * Write the mouse selection to the system clipboard over OSC 52 (2.6.F Step 6). Only an INK driver has a terminal + * to write to; a plain / `--json` driver never mounts the viewport, so nothing can be selected there. + */ + readonly clipboard?: ((text: string) => ClipboardOutcome) | undefined; } /** * How a {@link ChatDriver}'s input loop ended (ADR-0062 §7 · ADR-0059): `'exit'` ends the REPL (exit 4); `'clear'` @@ -352,6 +378,19 @@ interface ChatReplDeps { /** The process lifecycle seam for the alt-buffer exit-safety net (Step 4b-3) — `process.on('exit')`, SIGTERM/SIGHUP, * raw-mode, and exit. Default {@link defaultReplLifecycle}; a test injects fakes to drive the exit paths. */ readonly lifecycle?: ReplLifecycle; + /** + * `[preferences].copy_on_select`, ALREADY resolved against the mouse decision (`resolveCopyOnSelect`). `false` (or + * absent, on a non-interactive path) means the ink tree gets no `clipboard` prop: a released drag still highlights, + * and never touches the system clipboard. `/copy` is unaffected — it binds the clipboard through `hatchPorts`. + */ + readonly copyOnSelect?: boolean; + /** + * The ADR-0068 §e hatch ports (`/scrollback`, `/edit`) MINUS the two {@link createChatLineHandler} binds itself — + * the session's live transcript and its notice channel — so the hatches always read the CURRENT session, not a + * stale capture across a `/clear` or reseat swap. Built once per REPL by `runReplLoop` / `driveHome`, which own + * the alt-buffer state and the terminal-control sink. Absent (a unit test) ⇒ both hatches surface a notice. + */ + readonly hatchPorts?: Omit; } /** @@ -366,6 +405,9 @@ export interface ReplLifecycle { readonly onProcessExit: (listener: () => void) => () => void; /** Register SIGTERM(15)/SIGHUP(1) listeners (the signo is passed); returns a remover. */ readonly onTerminationSignal: (listener: (signo: number) => void) => () => void; + /** Register a SIGINT(2) listener; returns a remover. Kept SEPARATE from {@link onTerminationSignal} because SIGINT + * is normally ink's (the cooperative `/cancel`) — the hoist only nets the window where no ink tree is mounted. */ + readonly onInterrupt: (listener: () => void) => () => void; /** Restore the terminal from raw mode (ink's own restore is bypassed on a signal we own). */ readonly setRawMode: (raw: boolean) => void; /** Terminate the process (conventional `128 + signo`). */ @@ -378,6 +420,10 @@ export const defaultReplLifecycle: ReplLifecycle = { process.on('exit', listener); return () => process.removeListener('exit', listener); }, + onInterrupt: (listener) => { + process.on('SIGINT', listener); + return () => process.removeListener('SIGINT', listener); + }, onTerminationSignal: (listener) => { // SIGTERM(15), SIGHUP(1), SIGQUIT(3): the catchable external kills that terminate WITHOUT firing Node's `'exit'` // event (so the `onProcessExit` net alone would miss them). With ink's render option `alternateScreen:false` ink's @@ -433,8 +479,9 @@ function emitLiveNotice(io: CliIo, text: string): void { else io.writeErr(`${text}\n`); } -/** The budget-cap warning line (formatted once, routed through {@link emitLiveNotice} at all four wiring sites). */ -function budgetWarningText(warning: ChatBudgetWarning): string { +/** The budget-cap warning line (formatted once, routed through {@link emitLiveNotice} at all four wiring sites, and + * through the in-Home chat's own view store — see `drive-home.tsx`). ONE text, so the two surfaces cannot drift. */ +export function budgetWarningText(warning: ChatBudgetWarning): string { return `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached`; } @@ -450,7 +497,10 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): }); const providers = deps.providers ?? createProviderResolver(deps.io.env); const mcpSecretResolver = deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env); - const store = createChatStore(deps.global.color); + // The full-screen viewport can hold a whole answer; the inline `` path keeps its trailing tail (ADR-0068 + // Decision (c)). Resolved from `chatAltActive` — the SAME function `runReplLoop` uses for the hoist. + const transcriptBound = transcriptBoundFor(chatAltActive(deps, config.altScreen)); + const store = createChatStore(deps.global.color, undefined, transcriptBound); // The ADR-0065 §2 user-pricing overlay (2.5.G S10) — a transient read of the `model_catalog` `source='user'` // rows, so a user-priced model is enforced by `[chat].max_cost_microcents` + tracked in realized cost. NON-FATAL: // an unopenable db yields `undefined` here and surfaces cleanly through the session store open below. @@ -564,6 +614,8 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): startSession: () => built.session.start(), modelPicker: buildChatModelsPort(opened, providers, built.agent.model, now, uuid), altScreen: config.altScreen, + mouse: config.mouse, + copyOnSelect: config.copyOnSelect, ...(config.chat.maxMessages === undefined ? {} : { chatMaxMessages: config.chat.maxMessages }), @@ -635,7 +687,14 @@ export async function chatResumeCommand( // Seed the view header + persister via the SHARED resumed-wiring assembly (the same the `/models` reseat uses): // a resumed session never re-emits `session:started`, so the seeded store shows the model + carried cost/turns // from the first frame, and the persister continues past the last durable sequence number. - ({ store, persister } = seedResumedWiring(resumed, opened, deps.global.color, now, uuid)); + ({ store, persister } = seedResumedWiring( + resumed, + opened, + deps.global.color, + now, + uuid, + transcriptBoundFor(chatAltActive(deps, config.altScreen)), + )); const turns = resumed.resumeState.turnCount; // `sessionId` is only schema-constrained to a non-empty string (the CLI mints a UUID, but `history.db` is // shared with other surfaces) — sanitize it before it reaches the TTY, exactly as `chat-list` does (the @@ -722,6 +781,8 @@ export async function chatResumeCommand( intro, modelPicker: buildChatModelsPort(opened, providers, built.agent.model, now, uuid), altScreen: config.altScreen, + mouse: config.mouse, + copyOnSelect: config.copyOnSelect, ...(config.chat.maxMessages === undefined ? {} : { chatMaxMessages: config.chat.maxMessages }), @@ -752,6 +813,16 @@ interface ReplWiring { /** `[preferences].alt_screen` (2.6.F, ADR-0068 §e) — forwarded to the ink driver's ctx so it resolves the * full-screen render mode; the plain / `--json` drivers ignore it. Absent ⇒ the phase default. */ readonly altScreen?: boolean | undefined; + /** + * `[preferences].mouse` (2.6.F Step 5e, ADR-0068 §e) — mouse reporting inside the alt screen. Absent ⇒ the phase + * default. Read exactly ONCE, by `runReplLoop`, from the INITIAL wiring: the mouse is a per-invocation decision that + * lives in the hoisted `AltScreenController` above the session loop. That is why — unlike {@link altScreen}, which + * is forwarded to every per-session ink mount — the `/clear` and reseat REBUILD wirings deliberately omit it. + */ + readonly mouse?: boolean | undefined; + /** `[preferences].copy_on_select` (2.6.F Step 6e) — read exactly ONCE by {@link runReplLoop}, alongside `mouse`, + * and for the same reason: it is resolved against the hoisted mouse decision, above the session loop. */ + readonly copyOnSelect?: boolean | undefined; } /** @@ -940,6 +1011,20 @@ export function createChatLineHandler( } }; + // The ADR-0068 §e copy-and-search hatches. Bound HERE — the one place the standalone chat and the in-Home chat + // share (both call `createChatLineHandler`) — so `/scrollback` and `/edit` cannot drift between the surfaces. The + // transcript is read from the LIVE store on every invocation (never captured across a `/clear` or reseat swap), + // and the result lands on the same channel every other command's output uses. Absent ports (a unit test) ⇒ the + // hatches say so; PRESENT ports with nothing attached to the suspend port (a plain / `--json` driver has no ink + // tree at all) ⇒ `createHatches` itself surfaces the same honest notice. + // No ports wired (a unit test) ⇒ INERT ports, whose empty suspend port makes `createHatches` emit its own + // `NO_RENDERER_NOTICE`. There is deliberately no second "unavailable" string here: one string, one place, no drift. + const hatches: Hatches = createHatches({ + ...(deps.hatchPorts ?? inertHatchPorts()), + transcript: () => store.getSnapshot().state.transcript, + note: emitOutput, + }); + // The lifecycle capabilities the curated REPL commands (repl-commands.ts) run over — the slash names and the // /help + unknown-slash hint all derive from REPL_COMMANDS, so the three surfaces can never disagree. const replCtx: ReplCommandContext = { @@ -1154,6 +1239,11 @@ export function createChatLineHandler( '/models needs an interactive terminal to switch the model live. From a pipe, set `[chat].default_model` ' + 'in your config, or run `relavium` (the Home) to change the default.', ), + // The hatches (ADR-0068 §e). Unlike `/models` these need no render-layer interception: they open no overlay, so + // BOTH interactive surfaces reach them right here, through the suspend port `runReplLoop`/`driveHome` attached. + dumpScrollback: () => hatches.dumpScrollback(), + editTranscript: () => hatches.editTranscript(), + copyTranscript: () => hatches.copyTranscript(), }; // Parse + dispatch a `/name [args]` REPL line (extracted from processLine so each stays under the Sonar @@ -1276,7 +1366,14 @@ async function buildFreshChatWiring(deps: FreshChatWiringDeps, intro: string): P ...(resolvePrice.size === 0 ? {} : { resolvePrice }), onBudgetWarning: deps.onBudgetWarning, }); - const store = createChatStore(deps.global.color); + // The SAME signals `runReplLoop` used for the hoist (`deps.altScreen` is `[preferences].alt_screen`, carried here + // precisely so a `/clear` re-drive keeps the mode) — so a rebuilt session cannot silently re-acquire the 4000-char + // transcript bound mid-conversation. + const store = createChatStore( + deps.global.color, + undefined, + transcriptBoundFor(chatAltActive(deps, deps.altScreen)), + ); // A re-drive (`/clear`/reseat) runs INSIDE the hoisted alt buffer, so the MCP-skipped diagnostic routes to the fresh // session's transcript (`store.notice`) rather than a raw `io.writeErr` the alt buffer would discard (Step-4b-3). for (const line of mcpSkippedLines(built.mcpSkipped)) store.notice(line); @@ -1386,13 +1483,20 @@ function seedResumedWiring( color: boolean, now: () => number, uuid: () => string, + /** The renderer's transcript bake bound (ADR-0068 Decision (c)). Defaults to the inline tail so a caller that + * forgets keeps today's behaviour; both real callers pass the resolved one. */ + transcriptBound: number = INLINE_TRANSCRIPT_BOUND, ): { store: ChatStoreController; persister: SessionPersister } { - const store = createChatStore(color, { - agentRef: resumed.agent.id, - model: resumed.agent.model, - cumulativeCostMicrocents: resumed.resumeState.cumulativeCostMicrocents, - turnCount: resumed.resumeState.turnCount, - }); + const store = createChatStore( + color, + { + agentRef: resumed.agent.id, + model: resumed.agent.model, + cumulativeCostMicrocents: resumed.resumeState.cumulativeCostMicrocents, + turnCount: resumed.resumeState.turnCount, + }, + transcriptBound, + ); const persister = createSessionPersister({ store: opened.store, handle: resumed.handle, @@ -1482,7 +1586,14 @@ async function buildReseatWiring( }); let seeded: { store: ChatStoreController; persister: SessionPersister }; try { - seeded = seedResumedWiring(resumed, deps.opened, deps.global.color, deps.now, deps.uuid); + seeded = seedResumedWiring( + resumed, + deps.opened, + deps.global.color, + deps.now, + deps.uuid, + transcriptBoundFor(chatAltActive(deps, deps.altScreen)), + ); } catch (err) { // Acquire-then-guard: the resumed session's MCP children are already spawned — reclaim them before the failure // propagates so a persister-construction throw never orphans a stdio child (best-effort; never mask the primary). @@ -1634,6 +1745,8 @@ async function driveOneSession(wiring: ReplWiring, deps: ChatReplDeps): Promise< // The `!`-shell runner (2.5.D step 5, ADR-0061) — a thin wrapper over the session's `runUserCommand`. TTY-only. const runShellCommand = buildInteractiveShellRunner(interactive, built); + // Captured once: `deps.hatchPorts` is the per-REPL port bundle that also owns `writeControl`. + const hatchPortsForClipboard = deps.hatchPorts; // persister.start() subscribes for the turn events + adopts/inserts the session row; it does NOT consume // session:started, so it is safe before the driver. The session-open action (fresh start() / resume no-op) @@ -1675,6 +1788,25 @@ async function driveOneSession(wiring: ReplWiring, deps: ChatReplDeps): Promise< : {}), ...(mentionReader === undefined ? {} : { mentionReader }), ...(runShellCommand === undefined ? {} : { runShellCommand }), + // The ADR-0068 §e suspend port (Step 5d) — only an INK driver can attach to it (`suspendTerminal` lives inside + // the React tree). Handed to every driver; a plain / `--json` one simply never attaches, and the hatches then + // report "needs an interactive terminal" rather than failing. + ...(deps.hatchPorts?.suspendPort === undefined + ? {} + : { suspendPort: deps.hatchPorts.suspendPort }), + // The clipboard rides the SAME control-write sink as the alt-buffer toggles (Step 6). OSC 52 prints nothing and + // moves no cursor, so writing it mid-frame cannot corrupt ink's line accounting. + // ABSENT when `[preferences].copy_on_select = false` (or `--no-mouse`, which resolves it false): the selection + // still highlights, and `/copy` still copies the whole transcript. + ...(hatchPortsForClipboard === undefined || deps.copyOnSelect !== true + ? {} + : { + clipboard: (text: string): ClipboardOutcome => + copyToClipboard( + { writeControl: hatchPortsForClipboard.writeControl, env: deps.io.env }, + text, + ), + }), }); // A `/models` reseat attaches the captured target here (the one place holding the line handler); see the helper. return finalizeReseatOutcome(outcome, reseatTarget); @@ -1744,31 +1876,67 @@ export interface HoistedLoopResult { export async function withHoistedAltScreen( opts: { readonly active: boolean; + /** Enable mouse reporting with the buffer (Step 5e). Absent ⇒ `true` (the Step-5b behaviour). */ + readonly mouse?: boolean; readonly write: (sequence: string) => void; readonly lifecycle: ReplLifecycle; readonly writeOut: (text: string) => void; readonly writeErr: (text: string) => void; + /** The ONE suspend port for the loop. `current() === undefined` means NO ink tree is mounted — the only window in + * which SIGINT is unowned. Absent ⇒ no SIGINT net (a caller with no ink, e.g. a unit test). */ + readonly suspendPort?: SuspendPort; }, runLoop: (alt: AltScreenController) => Promise, ): Promise { const noop = (): void => undefined; - const alt = createAltScreenController({ write: opts.write, active: opts.active }); - alt.enter(); - const removeExitNet = opts.active ? opts.lifecycle.onProcessExit(() => alt.restore()) : noop; - const removeSignalNet = opts.active - ? opts.lifecycle.onTerminationSignal((signo) => { - alt.restore(); - opts.write(DISABLE_BRACKETED_PASTE); - try { - opts.lifecycle.setRawMode(false); - } catch { - // best-effort — a non-TTY / already-cooked stdin must not mask the exit - } - opts.lifecycle.exit(128 + signo); // conventional 143 (SIGTERM) / 130+ (SIGHUP/SIGQUIT) - }) - : noop; + const alt = createAltScreenController({ + write: opts.write, + active: opts.active, + ...(opts.mouse === undefined ? {} : { mouse: opts.mouse }), + }); + // The removers are `let`, and `alt.enter()` runs INSIDE the try: `enter()`'s write can throw on a dead TTY, and the + // `finally` must still run with valid bindings (a `const` declared after a throwing `enter` would be in the temporal + // dead zone). `restore()` is a no-op when nothing was entered, and each remover defaults to `noop`. + let removeExitNet = noop; + let removeSignalNet = noop; + let removeInterruptNet = noop; + const suspendPortForSigint = opts.suspendPort; let result: HoistedLoopResult = {}; try { + alt.enter(); + removeExitNet = opts.active ? opts.lifecycle.onProcessExit(() => alt.restore()) : noop; + // SIGINT belongs to ink: while a tree is mounted, `driveInk`'s `onSigintGated` runs the cooperative `/cancel`, and + // during a `/scrollback` or `/edit` hatch it deliberately DROPS the signal so the suspension can reclaim. But a + // `/clear` or `/models` rebuild unmounts ink and mounts a fresh tree, and in that window NOTHING listens for + // SIGINT — Node's default action then kills the process WITHOUT firing `'exit'`, so the `onProcessExit` net never + // runs and the alt buffer, mouse reporting and the hidden cursor are stranded on the user's shell. This net covers + // exactly that window, and defers to ink whenever a tree is attached. + removeInterruptNet = + opts.active && suspendPortForSigint !== undefined + ? opts.lifecycle.onInterrupt(() => { + if (suspendPortForSigint.current() !== undefined) return; // ink owns it (mounted, or suspended) + alt.restore(); + opts.write(DISABLE_BRACKETED_PASTE); + try { + opts.lifecycle.setRawMode(false); + } catch { + // best-effort — a non-TTY / already-cooked stdin must not mask the exit + } + opts.lifecycle.exit(130); // conventional 128 + SIGINT(2) + }) + : noop; + removeSignalNet = opts.active + ? opts.lifecycle.onTerminationSignal((signo) => { + alt.restore(); + opts.write(DISABLE_BRACKETED_PASTE); + try { + opts.lifecycle.setRawMode(false); + } catch { + // best-effort — a non-TTY / already-cooked stdin must not mask the exit + } + opts.lifecycle.exit(128 + signo); // conventional 143 (SIGTERM) / 130+ (SIGHUP/SIGQUIT) + }) + : noop; result = await runLoop(alt); } finally { // Exit the alt buffer FIRST (restores the primary buffer + scrollback), THEN emit the summary / error, so BOTH @@ -1779,11 +1947,41 @@ export async function withHoistedAltScreen( alt.restore(); removeExitNet(); removeSignalNet(); + removeInterruptNet(); if (result.summaryText !== undefined) opts.writeOut(`${result.summaryText}\n`); if (result.errorText !== undefined) opts.writeErr(result.errorText); } } +/** + * The ONE alt-screen decision for `relavium chat`. `runReplLoop` needs it for the hoist; `chatCommand` needs it BEFORE + * the loop, to give the view store its transcript bound. Two independent copies of this expression is exactly the + * drift ADR-0068 §c warns about, so there is one. + */ +export function chatAltActive( + deps: Pick, + configAltScreen: boolean | undefined, +): boolean { + return ( + chatIsInteractive(deps.io, deps.global) && + resolveRenderMode({ + outputMode: detectOutputMode({ + stdoutIsTty: deps.io.stdoutIsTty, + json: deps.global.json, + ci: isCiEnv(deps.io.env), + }), + noAltScreenFlag: deps.global.noAltScreen === true, + configAltScreen, + }) === 'alt' + ); +} + +/** The transcript bake bound the renderer can afford (ADR-0068 Decision (c)): none in the full-screen viewport, the + * historical trailing tail inline. */ +export function transcriptBoundFor(altActive: boolean): number { + return altActive ? FULLSCREEN_TRANSCRIPT_BOUND : INLINE_TRANSCRIPT_BOUND; +} + export async function runReplLoop( wiring: ReplWiring, deps: ChatReplDeps, @@ -1794,37 +1992,82 @@ export async function runReplLoop( const opened = wiring.opened; // Resolve alt-mode ONCE from the SAME signals `driveInk` uses, so the hoist and the per-session mount can never // disagree about the mode (2.6.F Step 4b-3, ADR-0068 §c). - const altActive = - chatIsInteractive(deps.io, deps.global) && - resolveRenderMode({ - outputMode: detectOutputMode({ - stdoutIsTty: deps.io.stdoutIsTty, - json: deps.global.json, - ci: isCiEnv(deps.io.env), - }), - noAltScreenFlag: deps.global.noAltScreen === true, - configAltScreen: wiring.altScreen, - }) === 'alt'; + const altActive = chatAltActive(deps, wiring.altScreen); + // Mouse reporting (2.6.F Step 5e, ADR-0068 §e): only inside the alt screen, and only when not opted out. Resolved + // from the SAME signals as the render mode, so the two can never disagree. + const mouseEnabled = resolveMouseMode({ + renderMode: altActive ? 'alt' : 'inline', + noMouseFlag: deps.global.noMouse === true, + configMouse: wiring.mouse, + }); const writeControl = deps.writeControl ?? ((sequence: string): void => { process.stdout.write(sequence); }); + // ONE suspend port for the whole REPL: ink remounts per session (a `/clear` / reseat re-drive), and each mount + // re-attaches. The hatch ports read the terminal facts at CALL time — `alt.isEntered()` rather than the resolved + // `altActive`, because a hatch must reflect the buffer's LIVE state, not the mode we resolved at startup. + const suspendPort = deps.hatchPorts?.suspendPort ?? createSuspendPort(); + const hatchPorts: Omit = deps.hatchPorts ?? { + suspendPort, + writeControl, + // The factory's NAME says which surface it is for; `inkOwnsAltScreen` is decided there, once, never at a call site. + // Both predicates read the hoisted controller LIVE, so a hatch reflects the terminal's real state — not the startup + // decision. `mouseActive` is asked separately from `altActive` because `--no-mouse` decouples them (Step 5e). + terminal: hoistedTerminal( + () => altScreenController?.isEntered() ?? false, + () => altScreenController?.isMouseEnabled() ?? false, + () => process.stdout.columns, + ), + dump: { + writeOut: nodeWriteOut(process.stdout), + waitForContinue: nodeWaitForContinue(process.stdin), + }, + // `/copy` writes OSC 52 through the SAME control sink the alt-buffer and mouse toggles use. + clipboard: (text: string) => copyToClipboard({ writeControl, env: deps.io.env }, text), + editor: { + env: deps.io.env, + spawnEditor: nodeSpawnEditor, + createTempDocument: nodeCreateTempDocument, + onDisposeFailed: (path, error) => { + deps.io.writeErr( + `warning: transcript temp file teardown failed (${path}): ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + }, + }, + }; + // The hoisted controller, captured so `hatchPorts.terminal()` can read the LIVE buffer state (it is created inside + // `withHoistedAltScreen` below, after `hatchPorts` is built — hence the mutable binding read lazily). + let altScreenController: AltScreenController | undefined; + // Copy-on-select (2.6.F Step 6e): a durable preference, resolved from the ALREADY-RESOLVED mouse decision, so + // `--no-mouse` structurally turns it off too. `/copy` is unaffected: it binds the clipboard through `hatchPorts`. + const copyOnSelect = resolveCopyOnSelect({ + mouseEnabled, + configCopyOnSelect: wiring.copyOnSelect, + }); + const replDeps: ChatReplDeps = { ...deps, hatchPorts, copyOnSelect }; + try { await withHoistedAltScreen( { active: altActive, + mouse: mouseEnabled, write: writeControl, lifecycle: deps.lifecycle ?? defaultReplLifecycle, writeOut: (text) => deps.io.writeOut(text), writeErr: (text) => deps.io.writeErr(text), + suspendPort, }, async (alt): Promise => { + altScreenController = alt; // so `hatchPorts.terminal()` reads the LIVE buffer state, not the startup decision let current = wiring; let summaryText: string | undefined; // the final `/exit` summary, printed after the single alt-exit for (;;) { - const outcome = await driveOneSession(current, deps); + const outcome = await driveOneSession(current, replDeps); // The end-of-session summary is LIFTED out of driveInk (ADR-0068 §c): the wrapper prints it after the single // alt-exit, on the PRIMARY buffer. Only a final `'exit'` carries one; a `/clear` / reseat swap carries none. if (outcome.kind === 'exit') summaryText = outcome.summaryText; diff --git a/apps/cli/src/commands/repl-commands.test.ts b/apps/cli/src/commands/repl-commands.test.ts index db79bb98..2d3bfe21 100644 --- a/apps/cli/src/commands/repl-commands.test.ts +++ b/apps/cli/src/commands/repl-commands.test.ts @@ -11,25 +11,14 @@ import { type ReplCommandContext, } from './repl-commands.js'; -interface CapabilityCalls { - readonly exit: number; - readonly cancel: number; - readonly exportSession: number; - readonly help: number; - readonly showWorkflows: number; - readonly showCost: number; - readonly runDoctor: number; - readonly setMode: number; - readonly setReasoningEffort: number; - readonly toggleReasoning: number; - readonly compactHistory: number; - readonly trimHistory: number; - readonly clearSession: number; - readonly openModels: number; -} - +/** + * DERIVED from `ReplCommandContext`, never hand-kept. Both this map and the `total` sum below used to enumerate the + * capabilities by hand, and both silently stopped counting each newly added one — so a command that fired TWO + * capabilities still totalled 1 (found twice: Step 5d, Step 6e). Adding a capability to the context now fails to + * compile until the spy exists. + */ /** A fully-spied REPL context — each capability is a spy so a command's `run` can be asserted to call exactly one. */ -function spyContext(): { ctx: ReplCommandContext; calls: () => CapabilityCalls } { +function spyContext(): { ctx: ReplCommandContext; calls: () => Map } { const spies = { exit: vi.fn(), cancel: vi.fn(), @@ -45,25 +34,15 @@ function spyContext(): { ctx: ReplCommandContext; calls: () => CapabilityCalls } trimHistory: vi.fn(), clearSession: vi.fn(), openModels: vi.fn(), - }; + dumpScrollback: vi.fn(), + editTranscript: vi.fn(), + copyTranscript: vi.fn(), + } satisfies Record; + return { ctx: spies, - calls: () => ({ - exit: spies.exit.mock.calls.length, - cancel: spies.cancel.mock.calls.length, - exportSession: spies.exportSession.mock.calls.length, - help: spies.help.mock.calls.length, - showWorkflows: spies.showWorkflows.mock.calls.length, - showCost: spies.showCost.mock.calls.length, - setMode: spies.setMode.mock.calls.length, - setReasoningEffort: spies.setReasoningEffort.mock.calls.length, - toggleReasoning: spies.toggleReasoning.mock.calls.length, - runDoctor: spies.runDoctor.mock.calls.length, - compactHistory: spies.compactHistory.mock.calls.length, - trimHistory: spies.trimHistory.mock.calls.length, - clearSession: spies.clearSession.mock.calls.length, - openModels: spies.openModels.mock.calls.length, - }), + // A Map built from the spy map itself: no second list to fall out of step with the first, and no cast. + calls: () => new Map(Object.entries(spies).map(([name, spy]) => [name, spy.mock.calls.length])), }; } @@ -87,11 +66,15 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { 'trim', 'clear', 'models', + 'scrollback', + 'edit', + 'copy', ]); }); it('each command run() invokes EXACTLY its one capability', async () => { - const cases: Array<[string, keyof ReturnType['calls']>]> = [ + const cases: Array<[string, string]> = [ + // [command name, the capability it must call] ['help', 'help'], ['exit', 'exit'], ['cancel', 'cancel'], @@ -106,27 +89,21 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { ['trim', 'trimHistory'], ['clear', 'clearSession'], ['models', 'openModels'], + // ADR-0068 §e (Step 5d). Without these two rows the run→capability binding of the two newest commands was + // untested: cross-wiring `/scrollback` to `ctx.editTranscript` left the whole suite green (the name/palette/help + // assertions only check PRESENCE). Adding a command to the pinned lists is not enough — its binding needs a row. + ['scrollback', 'dumpScrollback'], + ['edit', 'editTranscript'], + ['copy', 'copyTranscript'], ]; for (const [name, capability] of cases) { const { ctx, calls } = spyContext(); await REPL_COMMANDS_BY_NAME.get(name)?.run(ctx, []); // run may be async — await so the spy is recorded (+ no unhandled rejection) const counts = calls(); - expect(counts[capability], `${name} → ${capability}`).toBe(1); - const total = - counts.exit + - counts.cancel + - counts.exportSession + - counts.help + - counts.showWorkflows + - counts.showCost + - counts.runDoctor + - counts.setMode + - counts.setReasoningEffort + - counts.toggleReasoning + - counts.compactHistory + - counts.trimHistory + - counts.clearSession + - counts.openModels; + expect(counts.get(capability), `${name} → ${capability}`).toBe(1); + // Sum EVERY capability, not a hand-kept list. The hand-kept version silently stopped counting each newly added + // one — so a command that fired two capabilities would still have totalled 1 (Step-6e, and a Step-5d repeat). + const total = [...counts.values()].reduce((a, b) => a + b, 0); expect(total, `${name} calls exactly one capability`).toBe(1); } }); @@ -142,7 +119,7 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { it('replCommandList renders the slash hint, formatReplHelp lists every command', () => { expect(replCommandList()).toBe( - '/help, /exit, /cancel, /export, /workflows, /cost, /doctor, /mode, /effort, /thinking, /compact, /trim, /clear, /models', + '/help, /exit, /cancel, /export, /workflows, /cost, /doctor, /mode, /effort, /thinking, /compact, /trim, /clear, /models, /scrollback, /edit, /copy', ); const help = formatReplHelp(); for (const command of REPL_COMMANDS) { @@ -167,6 +144,9 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { 'thinking', 'trim', 'models', + 'scrollback', + 'edit', + 'copy', ]) { expect(REPL_COMMANDS_BY_NAME.get(name)?.effect).toBe('read'); } @@ -191,6 +171,9 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { 'trim', 'clear', 'models', + 'scrollback', + 'edit', + 'copy', ]); // /models is availableIn ['home','chat'] (ADR-0059: the chat reseat) — so it appears in BOTH palettes. expect(CHAT_PALETTE_COMMANDS.map((c) => c.name)).toEqual([ @@ -207,6 +190,9 @@ describe('curated REPL command registry (ADR-0056 amendment)', () => { 'trim', 'clear', 'models', + 'scrollback', + 'edit', + 'copy', ]); // The bare Home offers /exit + /doctor (pre-chat diagnostics), /clear (availableIn ['home','chat']; an inert // "nothing to clear" notice — ADR-0062 §7), and /models (availableIn ['home','chat'] — the Home writes the diff --git a/apps/cli/src/commands/repl-commands.ts b/apps/cli/src/commands/repl-commands.ts index f0f2b53d..dd4245e3 100644 --- a/apps/cli/src/commands/repl-commands.ts +++ b/apps/cli/src/commands/repl-commands.ts @@ -69,6 +69,22 @@ export interface ReplCommandContext { * ctx handler runs ONLY on a non-interactive chat driver (plain/`--json`), where it surfaces an actionable hint; * the bare Home wires the real picker. */ readonly openModels: () => void | Promise; + /** + * `/scrollback` (2.6.F Step 5d, [ADR-0068](../../../../docs/decisions/0068-full-screen-tui-renderer-ink7-harness.md) §e) + * — dump the transcript into the terminal's NATIVE scrollback, so the user can scroll, search, select and copy it + * with the emulator's own tools. The alternate screen removes all of those (no scrollback; mouse reporting captures + * click-drag), which is why this exists. Unlike `/models` it opens no overlay — it suspends the renderer via ink's + * `suspendTerminal`, so BOTH interactive surfaces reach it through this ONE capability, with no render-layer + * interception. A plain / `--json` driver (no ink tree) surfaces an actionable hint instead. Chat-only: the bare + * Home has no transcript. + */ + readonly dumpScrollback: () => void | Promise; + /** `/edit` (2.6.F Step 5d, ADR-0068 §e) — open the transcript READ-ONLY in `$EDITOR` for search + copy, via ink's + * `suspendTerminal`. Edits are never read back. Same surface story as {@link dumpScrollback}. */ + readonly editTranscript: () => void | Promise; + /** `/copy` (2.6.F Step 6e) — put the WHOLE transcript on the system clipboard over OSC 52. The UNWRAPPED document, + * unlike a mouse selection's visual rows. Suspends nothing; a single control write. */ + readonly copyTranscript: () => void | Promise; } /** A flag a {@link ReplCommand} accepts after its name (e.g. `/doctor --deep`). Flags only — the curated set has @@ -258,6 +274,38 @@ const RAW_REPL_COMMANDS: readonly ReplCommand[] = [ run: (ctx) => ctx.openModels(), availableIn: ['home', 'chat'], }, + { + name: 'scrollback', + label: 'Scrollback', + description: 'Dump the transcript into the terminal’s native scrollback (to copy or search).', + // `read` in the forward taxonomy: it prints, and changes nothing. + effect: 'read', + // Chat-only — the BARE Home has no transcript to dump. It reaches the live chat on both surfaces (standalone and + // in-Home) through the ONE `ReplCommandContext` capability: unlike `/models` it opens no React overlay, so no + // render-layer interception is needed and the two surfaces cannot drift (ADR-0068 §e). + run: (ctx) => ctx.dumpScrollback(), + availableIn: ['chat'], + }, + { + name: 'edit', + label: 'Edit', + description: 'Open the transcript in $EDITOR (read-only — to search or copy).', + // `read`: the editor gets a throwaway copy; edits are never read back into the session. + effect: 'read', + run: (ctx) => ctx.editTranscript(), + availableIn: ['chat'], + }, + { + name: 'copy', + label: 'Copy', + description: 'Copy the whole transcript to the system clipboard (OSC 52).', + // `read`: it sends bytes to the terminal, and changes nothing in the session. + effect: 'read', + // Chat-only — the BARE Home has no transcript. Like `/scrollback` and `/edit` it opens no overlay, so the ONE + // capability reaches both surfaces and they cannot drift (ADR-0068 §e). + run: (ctx) => ctx.copyTranscript(), + availableIn: ['chat'], + }, ]; /** DEEP-freeze a curated command — the entry, its `args` array + each flag, and its `availableIn` array — so no diff --git a/apps/cli/src/config/resolve.ts b/apps/cli/src/config/resolve.ts index b37c3b78..762a82a8 100644 --- a/apps/cli/src/config/resolve.ts +++ b/apps/cli/src/config/resolve.ts @@ -69,6 +69,17 @@ export interface ResolvedConfig { * preference (no project/workspace layer — it is a per-user UX choice, not a per-repo default), so it reads * straight from the global config. `undefined` ⇒ the phase default in `resolveRenderMode`. */ readonly altScreen: boolean | undefined; + /** `[preferences].mouse` (2.6.F Step 5e, ADR-0068 §e) — terminal mouse reporting inside the full-screen renderer. + * A GLOBAL-only preference for the same reason as {@link altScreen}. `undefined` ⇒ the phase default in + * `resolveMouseMode`. */ + readonly mouse: boolean | undefined; + /** `[preferences].copy_on_select` (2.6.F Step 6e, ADR-0068 §e) — whether a released drag writes the selection to the + * system clipboard. A GLOBAL-only preference for the same reason as {@link mouse}. `undefined` ⇒ the phase default + * in `resolveCopyOnSelect`; meaningless (and ignored) when {@link mouse} is off. */ + readonly copyOnSelect: boolean | undefined; + /** `[preferences].show_banner` (2.6.F Step 5g, ADR-0068) — the branded Home banner. A GLOBAL-only preference. + * `undefined` ⇒ `shouldShowBanner`'s empty-Home rule. */ + readonly showBanner: boolean | undefined; readonly variables: Readonly>; readonly mcpServers: readonly McpServerRegistration[]; } @@ -96,6 +107,9 @@ export function resolveConfig(layers: ConfigLayers): ResolvedConfig { mediaGcGraceMs: resolveGraceMs(project, workspace), chat: resolveChat(project, workspace, global), altScreen: global?.preferences?.alt_screen, + mouse: global?.preferences?.mouse, + copyOnSelect: global?.preferences?.copy_on_select, + showBanner: global?.preferences?.show_banner, variables: { ...workspace?.variables, ...project?.variables }, mcpServers: mergeMcpServers(global?.mcp_servers, workspace?.mcp_servers, project?.mcp_servers), }; diff --git a/apps/cli/src/engine/media-wiring.test.ts b/apps/cli/src/engine/media-wiring.test.ts index 51ec993b..e13c85ec 100644 --- a/apps/cli/src/engine/media-wiring.test.ts +++ b/apps/cli/src/engine/media-wiring.test.ts @@ -37,6 +37,9 @@ const EMPTY_CONFIG: ResolvedConfig = { reasoningEffort: undefined, }, altScreen: undefined, + mouse: undefined, + copyOnSelect: undefined, + showBanner: undefined, variables: {}, mcpServers: [], }; diff --git a/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index cd726243..664ea689 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -16,9 +16,15 @@ import { EXIT_CODES } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import type { RootAppProps } from '../render/tui/home-app.js'; -import { DISABLE_MOUSE } from '../render/alt-screen.js'; +import { DISABLE_MOUSE, ENABLE_MOUSE } from '../render/alt-screen.js'; +import type { SuspendPort } from '../render/suspend.js'; import { DISABLE_BRACKETED_PASTE } from '../render/tui/home-input.js'; -import { driveHome, type HomeDeps } from './drive-home.js'; +import { + defaultSubscribeProcessExit, + defaultSubscribeSignals, + driveHome, + type HomeDeps, +} from './drive-home.js'; // Regression for the `provider_auth` bug: the Home built an ENV-ONLY key resolver, so a key stored in the OS // keychain (the normal `relavium provider add` path) was invisible while `relavium chat` (keychain-wired) worked. @@ -107,7 +113,15 @@ describe('driveHome (2.5.B / ADR-0054)', () => { function makeDeps( capture: (props: RootAppProps) => void, overrides: Partial = {}, - ): { deps: HomeDeps; unmount: ReturnType; writeControl: ReturnType } { + ): { + deps: HomeDeps; + unmount: ReturnType; + writeControl: ReturnType; + signalHandlers: ((signo: number) => void)[]; + exitHandlers: (() => void)[]; + } { + const signalHandlers: ((signo: number) => void)[] = []; + const exitHandlers: (() => void)[] = []; const opened: OpenedSessionStore = { store: createSessionStore(client.db), db: client.db, @@ -129,7 +143,14 @@ describe('driveHome (2.5.B / ADR-0054)', () => { }, getSize: () => ({ cols: 120, rows: 40 }), subscribeResize: () => () => undefined, - subscribeSignals: () => () => undefined, // no real process listeners in the default tests + subscribeSignals: (onSignal) => { + signalHandlers.push(onSignal); + return () => undefined; + }, // no real process listeners in the default tests + subscribeProcessExit: (onExit) => { + exitHandlers.push(onExit); + return () => undefined; + }, writeControl, exit: () => undefined, // A cancel-immediately onboarding prompter by DEFAULT, so a key-less resolver (e.g. the real keychain-backed @@ -138,7 +159,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { onboardingPrompter: CANCEL_ONBOARDING, ...overrides, }; - return { deps, unmount, writeControl }; + return { deps, unmount, writeControl, signalHandlers, exitHandlers }; } it('an init fault after the db is open (a throwing render/mount) still closes the db once', async () => { @@ -194,6 +215,48 @@ describe('driveHome (2.5.B / ADR-0054)', () => { expect(controls).toContain(DISABLE_MOUSE); }); + it('MOUSE: the capture PORT exists by default, and `--no-mouse` withholds it (Step 5e/6g, ADR-0068 §e)', async () => { + // The opt-out IS the safety mechanism this feature exists for. The unit tests pin `resolveMouseMode` in isolation; + // this pins the ASSEMBLY — `deps.global.noMouse` → `resolveMouseMode` → whether `RootApp` gets a capture port at + // all. A mis-threaded field would still be `boolean | undefined`, compile, and leave every test green + // (Step-5e Opus review). + // + // Since Step 6g the port is what arms the mouse, not a mount-time write: capture belongs to the in-Home CHAT, and + // the Home landing keeps the emulator's own click-drag selection. + const exitCleanly = async ( + captured: () => RootAppProps | undefined, + drivePromise: Promise, + ): Promise => { + const props = captured(); + if (props === undefined) throw new Error('the injected render was never invoked'); + props.controller.handleKey('c', CTRL_C); + await drivePromise; + }; + + // (a) the phase default hands `RootApp` a port, and NOTHING is captured until it is called. + let onProps: RootAppProps | undefined; + const on = makeDeps((p) => (onProps = p)); + const onDrive = driveHome(on.deps); + expect(onProps?.setMouseCapture).toBeTypeOf('function'); + expect(on.writeControl.mock.calls.map((c) => c[0] as string)).not.toContain(ENABLE_MOUSE); + + onProps?.setMouseCapture?.(true); // the chat takes the screen + expect(on.writeControl.mock.calls.map((c) => c[0] as string)).toContain(ENABLE_MOUSE); + onProps?.setMouseCapture?.(false); // …and gives it back + expect(on.writeControl.mock.calls.map((c) => c[0] as string)).toContain(DISABLE_MOUSE); + await exitCleanly(() => onProps, onDrive); + + // (b) `--no-mouse` withholds the port entirely — there is nothing to arm, however the Home is driven. + let offProps: RootAppProps | undefined; + const off = makeDeps((p) => (offProps = p), { global: { ...global, noMouse: true } }); + const offDrive = driveHome(off.deps); + expect(offProps?.setMouseCapture).toBeUndefined(); + await exitCleanly(() => offProps, offDrive); + const controls = off.writeControl.mock.calls.map((c) => c[0] as string); + expect(controls).not.toContain(ENABLE_MOUSE); + expect(controls).toContain(DISABLE_MOUSE); // …and the teardown still disables, unconditionally + }); + it('resolves the render mode into ink’s alternateScreen: default ON (4b-3), config opts out, --no-alt-screen wins (ADR-0068 §e)', async () => { // Capture the alt-screen decision driveHome passes to the (injected) render, driving a clean Ctrl-C exit so the // driveHome promise settles between cases. Exercises the resolver → render wiring end-to-end (flag + config). @@ -535,4 +598,325 @@ describe('driveHome (2.5.B / ADR-0054)', () => { await flush(); expect(resolverKeychainArg.value).toBe(keychainSentinel); // the resolver received the keychain, not env-only }); + + /** + * The terminal-restore NETS (2.6.F Step 6f, Opus review). Mouse reporting is a mode we set on the USER'S terminal, + * and every path out of the process must clear it — otherwise the shell they return to echoes an SGR report on every + * click and drag. `relavium chat` has covered SIGTERM/SIGHUP/SIGQUIT plus a `process.on('exit')` net since Step + * 4b-3; the bare Home listened only for SIGINT and SIGTERM, so closing the terminal window (SIGHUP) stranded + * DECSET 1002+1006. + * + * These tests never await `driveHome`: on a signal it hands off to `process.exit`, which the injected `exit` mock + * does not perform, so the drive promise stays pending by design. The restore is SYNCHRONOUS — that is the point — + * so every assertion below reads `writeControl` the moment the handler returns. + */ + /** + * `[preferences].copy_on_select` (2.6.F Step 6e). The switch reaches the ink tree as the PRESENCE of the `clipboard` + * prop: absent ⇒ a released drag still highlights and never touches the system clipboard. `/copy` binds its own + * clipboard through the hatch ports, so it keeps working either way. + */ + /** + * THE CAPS-LIFT, END TO END (2.6.F Step 6g). `session-view-model.test.ts` pins the reducer; a break that makes + * `transcriptBoundFor` always return the inline bound stays GREEN there, because the unit tests inject the bound + * themselves. This drives the REAL `startChat` and asserts what the user's viewport would actually hold. + */ + describe('a long assistant answer survives into the full-screen transcript', () => { + const LONG = 'X'.repeat(10_000); + + const assistantText = async (over: Partial): Promise => { + let captured: RootAppProps | undefined; + const { deps } = makeDeps((p) => (captured = p), { + providers: scriptedResolver([textTurn(LONG)]), + ...over, + }); + const drivePromise = driveHome(deps); + const props = captured; + if (props === undefined) throw new Error('the injected render was never invoked'); + + type(props, 'hello'); + props.controller.handleKey('', ENTER); // submit ⇒ build + first turn + await flush(); + await flush(); + const transcript = + props.controller.getSnapshot().session?.store.getSnapshot().state.transcript ?? []; + const assistant = transcript.find((e) => e.role === 'assistant'); + + props.controller.handleKey('c', CTRL_C); // chat Ctrl-C ⇒ /cancel ⇒ back to Home + await flush(); + props.controller.handleKey('c', CTRL_C); // Home Ctrl-C ⇒ clean exit + await drivePromise; + return assistant?.text ?? ''; + }; + + it('the alt-screen Home keeps all 10 000 characters', async () => { + expect(await assistantText({})).toHaveLength(10_000); + }); + + it('`--no-alt-screen` keeps the historical trailing tail — the inline renderer has no viewport', async () => { + const text = await assistantText({ global: { ...global, noAltScreen: true } }); + expect(text).toHaveLength(4001); + expect(text.startsWith('…')).toBe(true); + }); + }); + + /** + * BUDGET WARNINGS GO INTO THE TRANSCRIPT, NEVER RAW STDERR (2.6.F Step 6g, whole-phase Opus review). + * `relavium chat` learned this in the Step-4b-3 Sonnet fold: on the alt screen a raw `writeErr` is painted over by + * ink's next frame, so the user is told they are near their spending cap on a line that lives for one frame. + * `driveHome` was still writing raw. + */ + describe('a budget warning reaches the in-Home chat’s transcript', () => { + it('routes through the view store, and never to stderr', async () => { + let captured: RootAppProps | undefined; + let warn: ((w: { thresholdPct: number; limitMicrocents: number }) => void) | undefined; + const errs: string[] = []; + const made = makeDeps((p) => (captured = p), { + io: { ...io, writeErr: (t: string) => errs.push(t) }, + buildSession: (async (opts: { onBudgetWarning?: typeof warn }) => { + warn = opts.onBudgetWarning; + return (await buildChatSession(opts as never)) as never; + }) as never, + }); + const drivePromise = driveHome(made.deps); + const props = captured; + if (props === undefined) throw new Error('the injected render was never invoked'); + + type(props, 'hello'); + props.controller.handleKey('', ENTER); + await flush(); + await flush(); + expect(warn).toBeTypeOf('function'); // driveHome really passed one + + warn?.({ thresholdPct: 90, limitMicrocents: 1000 }); + const transcript = + props.controller.getSnapshot().session?.store.getSnapshot().state.transcript ?? []; + expect(transcript.some((e) => (e.text ?? '').includes('budget warning'))).toBe(true); + expect(errs.join('')).not.toContain('budget warning'); + + props.controller.handleKey('c', CTRL_C); + await flush(); + props.controller.handleKey('c', CTRL_C); + await drivePromise; + }); + }); + + describe('copy-on-select', () => { + const captureProps = async (over: Partial): Promise => { + let captured: RootAppProps | undefined; + const made = makeDeps((p) => (captured = p), over); + const drivePromise = driveHome({ ...made.deps, ...over }); + const props = captured; + if (props === undefined) throw new Error('the injected render was never invoked'); + props.controller.handleKey('c', CTRL_C); + await drivePromise; + return props; + }; + + it('is ON by default: the ink tree gets a clipboard', async () => { + const props = await captureProps({}); + expect(props.clipboard).toBeTypeOf('function'); + }); + + it('`copy_on_select = false` withholds the clipboard from the ink tree', async () => { + const cfg = join(cwd, 'copy-off.toml'); + writeFileSync(cfg, '[preferences]\ncopy_on_select = false\n'); + const props = await captureProps({ global: { ...global, configPath: cfg } }); + expect(props.clipboard).toBeUndefined(); + }); + + it('`--no-mouse` withholds it too — there is no selection to copy', async () => { + // Structural, not a second check: `resolveCopyOnSelect` takes the ALREADY-RESOLVED mouse decision. + const props = await captureProps({ global: { ...global, noMouse: true } }); + expect(props.clipboard).toBeUndefined(); + }); + + it('`copy_on_select = true` with `--no-mouse` STILL withholds it', async () => { + const cfg = join(cwd, 'copy-on-nomouse.toml'); + writeFileSync(cfg, '[preferences]\ncopy_on_select = true\n'); + const props = await captureProps({ global: { ...global, configPath: cfg, noMouse: true } }); + expect(props.clipboard).toBeUndefined(); + }); + }); + + /** + * A KEYBOARD Ctrl-C DURING A HATCH (2.6.F Step 6g, whole-phase Opus review — rated critical by three lenses). + * + * A `/scrollback` or `/edit` suspension turns raw mode OFF, so the kernel resumes translating Ctrl-C into a real + * SIGINT. On `relavium chat` that signal is DROPPED (`onSigintGated`, since Step 5d) and the hatch's own listener + * resumes the renderer. The Home never had that gate: the signal tore the whole session down behind the + * suspension's back, whose `reclaim` then re-emitted ENABLE_MOUSE on the way out — leaving DECSET 1002+1006 live + * on the user's shell, where every subsequent click types escape bytes. + */ + describe('a keyboard Ctrl-C during a hatch does not tear the Home down', () => { + const drive = (): ReturnType & { + exitProcess: ReturnType; + port: SuspendPort; + } => { + const exitProcess = vi.fn(); + let captured: RootAppProps | undefined; + const made = makeDeps((p) => (captured = p)); + void driveHome({ ...made.deps, exit: exitProcess as (code: number) => void }).catch( + () => undefined, + ); + const props = captured; + if (props?.suspendPort === undefined) throw new Error('driveHome passed no suspend port'); + // `RootApp` attaches ink's `suspendTerminal` on mount; the INJECTED render does not, so stand in for it. + props.suspendPort.attach((callback) => callback()); + return { ...made, exitProcess, port: props.suspendPort }; + }; + + it('SIGINT while SUSPENDED is dropped — no exit, no terminal restore behind the hatch’s back', async () => { + const d = drive(); + let sawSuspended = false; + await d.port.current()?.(() => { + sawSuspended = d.port.isSuspended(); + d.signalHandlers[0]?.(2); // the keyboard Ctrl-C + return Promise.resolve(); + }); + expect(sawSuspended).toBe(true); // the port really was suspended when the signal arrived + expect(d.exitProcess).not.toHaveBeenCalled(); + expect(d.writeControl.mock.calls.map((c) => c[0] as string)).not.toContain(DISABLE_MOUSE); + }); + + it('SIGINT when NOT suspended still exits 130 — the cooperative teardown is unchanged', () => { + const d = drive(); + d.signalHandlers[0]?.(2); + expect(d.writeControl.mock.calls.map((c) => c[0] as string)).toContain(DISABLE_MOUSE); + }); + + it.each([ + ['SIGTERM', 15], + ['SIGHUP', 1], + ['SIGQUIT', 3], + ])( + 'an EXTERNAL %s tears down even while suspended — only SIGINT is the hatch’s', + async (_n, signo) => { + const d = drive(); + await d.port.current()?.(() => { + d.signalHandlers[0]?.(signo); + return Promise.resolve(); + }); + expect(d.writeControl.mock.calls.map((c) => c[0] as string)).toContain(DISABLE_MOUSE); + }, + ); + }); + + describe('the terminal is restored on EVERY termination path', () => { + const drive = (): ReturnType & { exitProcess: ReturnType } => { + const exitProcess = vi.fn(); + let captured: RootAppProps | undefined; + const made = makeDeps((p) => (captured = p)); + const deps: HomeDeps = { ...made.deps, exit: exitProcess as (code: number) => void }; + void driveHome(deps).catch(() => undefined); // never resolves once a signal fires — see the docstring + if (captured === undefined) throw new Error('the injected render was never invoked'); + return { ...made, exitProcess }; + }; + + const controlsOf = (d: { writeControl: ReturnType }): string[] => + d.writeControl.mock.calls.map((c) => c[0] as string); + + it.each([ + ['SIGINT', 2], + ['SIGTERM', 15], + ['SIGHUP', 1], + ['SIGQUIT', 3], + ])('%s disables mouse reporting before anything else can run', (_name, signo) => { + const d = drive(); + expect(d.signalHandlers).toHaveLength(1); + d.signalHandlers[0]?.(signo); + expect(controlsOf(d)).toContain(DISABLE_MOUSE); + expect(controlsOf(d)).toContain(DISABLE_BRACKETED_PASTE); + }); + + it('the PRODUCTION subscriber registers all four signals, and unsubscribing removes them', () => { + // The `it.each` above drives an INJECTED subscriber, so it would stay green if `defaultSubscribeSignals` forgot + // SIGHUP — which is exactly the bug this step fixes. Pin the real thing against `process` itself. + const before = (['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const).map((s_) => + process.listenerCount(s_), + ); + const seen: number[] = []; + const off = defaultSubscribeSignals((signo) => seen.push(signo)); + try { + const after = (['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const).map((s_) => + process.listenerCount(s_), + ); + expect(after).toEqual(before.map((n) => n + 1)); + process.emit('SIGHUP'); + process.emit('SIGQUIT'); + expect(seen).toEqual([1, 3]); // the conventional signo, so the exit code is 128+signo + } finally { + off(); + } + const restored = (['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const).map((s_) => + process.listenerCount(s_), + ); + expect(restored).toEqual(before); // no listener outlives the drive + }); + + it('the PRODUCTION exit net registers on `process` and is removable', () => { + const before = process.listenerCount('exit'); + let fired = 0; + const off = defaultSubscribeProcessExit(() => (fired += 1)); + try { + expect(process.listenerCount('exit')).toBe(before + 1); + process.emit('exit', 0); + expect(fired).toBe(1); + } finally { + off(); + } + expect(process.listenerCount('exit')).toBe(before); + }); + + it('a `process.exit()` that never unwinds the finally is still caught by the exit net', () => { + const d = drive(); + expect(d.exitHandlers).toHaveLength(1); + d.exitHandlers[0]?.(); // Node's synchronous 'exit' event + expect(controlsOf(d)).toContain(DISABLE_MOUSE); + }); + + it('the restore is IDEMPOTENT — overlapping nets must not write DISABLE_MOUSE twice', () => { + const d = drive(); + d.exitHandlers[0]?.(); + d.exitHandlers[0]?.(); + d.signalHandlers[0]?.(1); + expect(controlsOf(d).filter((c) => c === DISABLE_MOUSE)).toHaveLength(1); + expect(d.unmount).toHaveBeenCalledTimes(1); + }); + + it('a step that THROWS on one net is RETRIED by the next — the latch is per-op, set only on success', () => { + // A transient EIO on a `writeControl` used to trip a single latch and make every later net decline to retry, + // stranding mouse reporting on the shell (Step-6h review). `DISABLE_MOUSE` fails on the first exit-net call and + // succeeds on the second (the signal handler). + const writes: string[] = []; + let failMouseOnce = true; + let captured: RootAppProps | undefined; + const exitHandlers: (() => void)[] = []; + const signalHandlers: ((signo: number) => void)[] = []; + const made = makeDeps((p) => (captured = p), { + writeControl: (seq: string) => { + if (seq === DISABLE_MOUSE && failMouseOnce) { + failMouseOnce = false; + throw new Error('EIO'); + } + writes.push(seq); + }, + subscribeProcessExit: (onExit) => { + exitHandlers.push(onExit); + return () => undefined; + }, + subscribeSignals: (onSignal) => { + signalHandlers.push(onSignal); + return () => undefined; + }, + exit: vi.fn() as unknown as (code: number) => void, + }); + void driveHome(made.deps).catch(() => undefined); + if (captured === undefined) throw new Error('the injected render was never invoked'); + + exitHandlers[0]?.(); // DISABLE_MOUSE throws here — the latch must stay down + expect(writes).not.toContain(DISABLE_MOUSE); + signalHandlers[0]?.(1); // the retry succeeds + expect(writes).toContain(DISABLE_MOUSE); + }); + }); }); diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index c422a06a..9db8379a 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -5,12 +5,18 @@ import type { AgentSessionRecord, ReasoningEffort } from '@relavium/shared'; import { render } from 'ink'; import { createElement } from 'react'; -import { createChatLineHandler, type ReseatTarget } from '../commands/chat.js'; +import { + budgetWarningText, + createChatLineHandler, + transcriptBoundFor, + type ReseatTarget, +} from '../commands/chat.js'; import { buildChatSession, buildResumedChatSession, swapAgentModel, type BuiltChatSession, + type ChatBudgetWarning, } from '../chat/session-host.js'; import { assembleDoctorProbes } from '../chat/doctor-host.js'; import type { DoctorProbes } from '../chat/doctor.js'; @@ -36,7 +42,7 @@ import type { CliIo } from '../process/io.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { GlobalOptions } from '../process/options.js'; import { detectOutputMode, isCiEnv } from '../process/output-mode.js'; -import { resolveRenderMode } from '../render/render-mode.js'; +import { resolveCopyOnSelect, resolveMouseMode, resolveRenderMode } from '../render/render-mode.js'; import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp-secret.js'; import { createOsKeychainStore } from '../secrets/os-keychain.js'; import { createChatStore, type ChatStoreController } from '../render/tui/chat-store.js'; @@ -48,6 +54,11 @@ import { type HomeModelsPort, } from '../render/tui/home-controller.js'; import { DISABLE_MOUSE, ENABLE_MOUSE } from '../render/alt-screen.js'; +import { nodeCreateTempDocument, nodeSpawnEditor } from '../render/editor.js'; +import { inkOwnedTerminal, type HatchDeps } from '../render/hatches.js'; +import { nodeWaitForContinue, nodeWriteOut } from '../render/scrollback.js'; +import { copyToClipboard, type ClipboardOutcome } from '../render/clipboard.js'; +import { createSuspendPort } from '../render/suspend.js'; import { DISABLE_BRACKETED_PASTE } from '../render/tui/home-input.js'; import { RootApp, type RootAppProps } from '../render/tui/home-app.js'; import { FORCE_TEARDOWN_MS, FRAME_MS } from '../render/tui/tui-constants.js'; @@ -60,11 +71,12 @@ import { createHomeStore } from './home-store.js'; * (`startChat`) so the strip shows immediately and a slow/failed build degrades to a loading state / a Home banner. * * Process lifetime lives here (the controller owns the session lifetime): - * - **Signals** — one SIGINT/SIGTERM handler covering the Home, the in-Home chat, and MCP teardown: a clean Home - * exit (Ctrl-C / EOF in Home mode) resolves exit 0; an EXTERNAL signal unmounts ink, tears the live chat down - * (bounded so a stuck MCP teardown can't hang), closes the db, and exits with the conventional `128+signo` - * (`130` SIGINT / `143` SIGTERM) so a shell pipeline still sees the interruption. A chat's own exit-code-4 is - * consumed by the controller loop (a chat ending returns to Home), never leaked. + * - **Signals** — one handler for SIGINT(2)/SIGTERM(15)/SIGHUP(1)/SIGQUIT(3), covering the Home, the in-Home chat, + * and MCP teardown: a clean Home exit (Ctrl-C / EOF in Home mode) resolves exit 0; an EXTERNAL signal unmounts ink, + * tears the live chat down (bounded so a stuck MCP teardown can't hang), closes the db, and exits with the + * conventional `128+signo` (`130` SIGINT / `143` SIGTERM / `129` SIGHUP / `131` SIGQUIT) so a shell pipeline still + * sees the interruption. A chat's own exit-code-4 is consumed by the controller loop, never leaked. Behind all of + * them sits a synchronous `process.on('exit')` net, the last chance to restore the terminal (2.6.F Step 6f). * - **Bracketed paste** — DECSET 2004 is enabled on mount and disabled on every exit path, so a pasted multi-line * block is bracketed literal text (no embedded newline submits early); the controller strips the markers. */ @@ -95,27 +107,50 @@ export interface HomeDeps { ) => { unmount: () => void }; readonly getSize?: () => { cols: number; rows: number }; readonly subscribeResize?: (onResize: () => void) => () => void; - /** Subscribe to SIGINT(2)/SIGTERM(15); returns an unsubscribe. Default registers on `process`. */ + /** Subscribe to SIGINT(2)/SIGTERM(15)/SIGHUP(1)/SIGQUIT(3); returns an unsubscribe. Default registers on `process`. */ readonly subscribeSignals?: (onSignal: (signo: number) => void) => () => void; + /** Register a synchronous `process.on('exit')` net; returns a remover. Default registers on `process`. The LAST + * chance to restore the terminal when something calls `process.exit()` past the `finally` (2.6.F Step 6f). */ + readonly subscribeProcessExit?: (onExit: () => void) => () => void; /** Exit the process (tests inject a capture; production `process.exit`). */ readonly exit?: (code: number) => void; /** Write a terminal control sequence (the bracketed-paste DECSET toggles). Default `process.stdout`. */ readonly writeControl?: (sequence: string) => void; } -/** The default external-signal source: SIGINT(2) + SIGTERM(15) on `process`, registered with `on` (not `once`) - * so ink's signal-exit listener never re-raises while we still hold the cooperative teardown. */ -function defaultSubscribeSignals(onSignal: (signo: number) => void): () => void { +/** + * The default external-signal source, registered with `on` (not `once`) so ink's signal-exit listener never re-raises + * while we still hold the cooperative teardown. + * + * SIGINT(2) + SIGTERM(15) drive the cooperative teardown. SIGHUP(1) + SIGQUIT(3) were MISSING until Step 6f: they are + * catchable kills that terminate WITHOUT firing Node's `'exit'` event, and SIGHUP is what a user gets by closing the + * terminal window. Without them the Home left DECSET 1002+1006 enabled on the primary buffer, and the shell then + * echoed a mouse report on every click. `relavium chat`'s `defaultReplLifecycle` has covered all four since Step 4b-3; + * the two surfaces now agree. + */ +export function defaultSubscribeSignals(onSignal: (signo: number) => void): () => void { const onSigint = (): void => onSignal(2); const onSigterm = (): void => onSignal(15); + const onSighup = (): void => onSignal(1); + const onSigquit = (): void => onSignal(3); process.on('SIGINT', onSigint); process.on('SIGTERM', onSigterm); + process.on('SIGHUP', onSighup); + process.on('SIGQUIT', onSigquit); return () => { process.removeListener('SIGINT', onSigint); process.removeListener('SIGTERM', onSigterm); + process.removeListener('SIGHUP', onSighup); + process.removeListener('SIGQUIT', onSigquit); }; } +/** The default `process.on('exit')` net — synchronous by definition, which is why the restore it runs must be too. */ +export function defaultSubscribeProcessExit(onExit: () => void): () => void { + process.on('exit', onExit); + return () => process.removeListener('exit', onExit); +} + export async function driveHome(deps: HomeDeps): Promise { const now = deps.now ?? Date.now; const uuid = deps.uuid ?? randomUUID; @@ -144,6 +179,7 @@ export async function driveHome(deps: HomeDeps): Promise { let instance: { unmount: () => void } | undefined; let controller: HomeController | undefined; let unsubscribeSignals: (() => void) | undefined; + let unsubscribeProcessExit: (() => void) | undefined; let dbClosed = false; const closeDb = (): void => { if (dbClosed) return; @@ -156,6 +192,96 @@ export async function driveHome(deps: HomeDeps): Promise { process.stdout.write(sequence); }); + /** + * Undo every terminal mode this command turned on, in reverse order: unmount ink FIRST (leaving raw mode and the + * alternate buffer), then disable bracketed paste (DECSET 2004, enabled by ink 7's `usePaste`) and mouse reporting + * (DECSET 1002+1006, ours). Both writes are unconditional — a disable is a no-op when the mode was never enabled. + * BEST-EFFORT by contract: it swallows its own throw so a faulty terminal can never skip the caller's session + * teardown + db close (the `finally`) nor the bounded teardown + exit (the signal handler). Shared by EVERY path + * (the `finally`, the signal handler, and the `process.on('exit')` net), so they can never drift. + * + * IDEMPOTENT: the nets deliberately overlap, and `unmount()` on an already-unmounted tree plus a second `DISABLE` + * write would be harmless but noisy. The latch makes "call it from wherever, as often as you like" the contract. + */ + // Each step latches INDEPENDENTLY, and only after it SUCCEEDS. A single latch set before the writes would let one + // transient fault (an EIO on a half-dead TTY unmounting ink, an EPIPE on a `writeControl`) mark the terminal + // "restored" and every later net (the signal handler, the `process.on('exit')` net, the `finally`) decline to retry + // — stranding mouse reporting / bracketed paste on the user's shell. This is the same discipline `alt-screen.ts`'s + // `restore()` applies (2.6.F Step 6h). Each swallows its own throw so teardown + exit/close still run. + let unmounted = false; + let pasteDisabled = false; + let mouseDisabled = false; + const restoreTerminalControls = (): void => { + if (!unmounted) { + try { + instance?.unmount(); // restore the terminal from raw mode BEFORE anything else + unmounted = true; + } catch { + // a later net retries + } + } + if (!pasteDisabled) { + try { + writeControl(DISABLE_BRACKETED_PASTE); + pasteDisabled = true; + } catch { + // a later net retries + } + } + if (!mouseDisabled) { + try { + writeControl(DISABLE_MOUSE); // restore native mouse text-selection (no-op if never enabled) + mouseDisabled = true; + } catch { + // a later net retries + } + } + }; + + // The ADR-0068 §e hatch ports (2.6.F Step 5d). `RootApp` attaches ink's `suspendTerminal` to the port on mount; + // `wireHomeChatSession` hands these ports to `createChatLineHandler` — the SAME builder `relavium chat` uses — so + // `/scrollback` and `/edit` are literally the same code on both surfaces. Unlike the chat, the bare Home mounts ink + // with `alternateScreen: true`, so ink itself toggles DECSET-1049 across a suspension; only the mouse is ours. + const suspendPort = createSuspendPort(); + let altScreenActive = false; // assigned once the render mode resolves; read LAZILY by `terminal()` below + let mouseActive = false; // ditto — `--no-mouse` / `[preferences].mouse = false` leaves the alt buffer mouse-less + // Whether the mouse is captured RIGHT NOW. Distinct from `mouseActive` (the resolved mode) since Step 6g: the Home + // landing gives the mouse back to the emulator, and a suspension must not "restore" a mode that is not on. + let mouseCaptured = false; + // ONE clipboard closure over the SAME control-write sink as the alt-buffer + mouse toggles (Step 6). `/copy` (via + // `hatchPorts`) and copy-on-select (via `RootApp`'s `clipboard` prop, when enabled) both use it. + const clipboard = (text: string): ClipboardOutcome => + copyToClipboard({ writeControl, env: deps.io.env }, text); + const hatchPorts: Omit = { + suspendPort, + writeControl, + // `inkOwnedTerminal` (not `hoistedTerminal`): this surface mounts ink with `alternateScreen: true`, so ink toggles + // DECSET-1049 across the suspension and only the mouse is ours. Both read lazily — set at mount. `mouseActive` is + // separate from `altActive` because `--no-mouse` decouples them (Step 5e). + terminal: inkOwnedTerminal( + () => altScreenActive, + () => mouseCaptured, + () => process.stdout.columns, + ), + clipboard, + dump: { + writeOut: nodeWriteOut(process.stdout), + waitForContinue: nodeWaitForContinue(process.stdin), + }, + editor: { + env: deps.io.env, + spawnEditor: nodeSpawnEditor, + createTempDocument: nodeCreateTempDocument, + onDisposeFailed: (path, error) => { + deps.io.writeErr( + `warning: transcript temp file teardown failed (${path}): ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + }, + }, + }; + try { const homeStore = createHomeStore({ sessions: opened.store, @@ -282,7 +408,7 @@ export async function driveHome(deps: HomeDeps): Promise { onSetEffort, } = createChatLineHandler( { built, opened, store, persister, doctorProbes: chatDoctorProbes }, - deps, + { ...deps, hatchPorts }, ); // Subscribe the view store BEFORE opening the session so the synchronous session:started is observed. unsubscribe = built.handle.subscribe((event) => store.apply(event)); @@ -346,7 +472,14 @@ export async function driveHome(deps: HomeDeps): Promise { // Build + wire + START a fresh chat session (the controller sends the first message on transition). const startChat = async (): Promise => { - const store = createChatStore(deps.global.color); + // `altScreenActive` is assigned when the render mode resolves, BEFORE the first submit that calls this. The + // full-screen viewport can hold a whole answer; the inline `` path keeps its trailing tail (ADR-0068 + // Decision (c)). Read lazily, like the hatch ports, so the mode is the LIVE one. + const store = createChatStore( + deps.global.color, + undefined, + transcriptBoundFor(altScreenActive), + ); // The ADR-0065 §2 user-pricing overlay (2.5.G S10), read FRESH per chat from the SAME `history.db` (empty map // on a read fault). Static `MODEL_PRICING` still wins. const resolvePrice = readUserPricingOverlay(opened.db); @@ -369,10 +502,10 @@ export async function driveHome(deps: HomeDeps): Promise { mcpSecretResolver, mcpRegistrations: config.mcpServers, ...(resolvePrice.size === 0 ? {} : { resolvePrice }), - onBudgetWarning: (warning) => - deps.io.writeErr( - `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached\n`, - ), + // Into the chat's TRANSCRIPT, never raw stderr. `relavium chat` routes this through `emitLiveNotice` for + // exactly this reason (Step-4b-3 Sonnet fix): a raw write lands on the alt buffer, where ink's next frame + // overwrites it — the user is warned about their spend on a line that survives a single frame. + onBudgetWarning: (warning) => store.notice(budgetWarningText(warning)), }); return wireHomeChatSession(built, store, { open: true }); }; @@ -400,6 +533,16 @@ export async function driveHome(deps: HomeDeps): Promise { ); const record: AgentSessionRecord = { ...loaded.session, agentSnapshot: newAgent }; const resolvePrice = readUserPricingOverlay(opened.db); + // The store's SEED comes from the build, so it cannot be created first — yet `onBudgetWarning` closes over it and + // a pre-egress cap check can fire DURING the build. Hold it in a `let` and fall back to stderr until it exists, + // exactly as `emitLiveNotice` does on the standalone chat. Either way the warning is never written raw onto the + // alt buffer, where ink's next frame would erase it (Step-4b-3 Sonnet fix, carried here by the phase review). + const storeRef: { current?: ChatStoreController } = {}; + const noteBudget = (warning: ChatBudgetWarning): void => { + const text = budgetWarningText(warning); + if (storeRef.current !== undefined) storeRef.current.notice(text); + else deps.io.writeErr(`${text}\n`); + }; const built = await (deps.buildResumedSession ?? buildResumedChatSession)({ chat: config.chat, record, @@ -409,19 +552,21 @@ export async function driveHome(deps: HomeDeps): Promise { mcpSecretResolver, mcpRegistrations: config.mcpServers, ...(resolvePrice.size === 0 ? {} : { resolvePrice }), - onBudgetWarning: (warning) => - deps.io.writeErr( - `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached\n`, - ), + onBudgetWarning: noteBudget, }); // Seed the view store with the carried model + cost/turns — a resumed session never re-emits session:started, // so without this the footer shows nothing until the first new turn (mirrors chatResumeCommand). - const store = createChatStore(deps.global.color, { - agentRef: built.agent.id, - model: built.agent.model, - cumulativeCostMicrocents: built.resumeState.cumulativeCostMicrocents, - turnCount: built.resumeState.turnCount, - }); + const store = createChatStore( + deps.global.color, + { + agentRef: built.agent.id, + model: built.agent.model, + cumulativeCostMicrocents: built.resumeState.cumulativeCostMicrocents, + turnCount: built.resumeState.turnCount, + }, + transcriptBoundFor(altScreenActive), + ); + storeRef.current = store; // from here a budget warning renders in the transcript, not on the alt buffer return wireHomeChatSession(built, store, { open: false, initialSequenceNumber: built.nextSequenceNumber, @@ -458,26 +603,28 @@ export async function driveHome(deps: HomeDeps): Promise { } // Bracketed paste (DECSET 2004) is enabled by ink 7's `usePaste` on mount (home-app.tsx). The defensive - // `DISABLE_BRACKETED_PASTE` writes on the teardown paths below are belt-and-suspenders (usePaste also disables - // on unmount) so an external signal can never leave the terminal in bracketed-paste mode. + // `DISABLE_BRACKETED_PASTE` writes in `restoreTerminalControls` are belt-and-suspenders (usePaste also + // disables on unmount) so an external signal can never leave the terminal in bracketed-paste mode. // One external-signal lifecycle covering the Home, the in-Home chat, and MCP teardown. let signaled = false; const onSignal = (signo: number): void => { + // A KEYBOARD Ctrl-C during a `/scrollback` or `/edit` hatch arrives here as a REAL SIGINT: the suspension turns + // raw mode OFF, so the kernel resumes translating Ctrl-C. The hatch owns the terminal — `nodeWaitForContinue` + // resolves on that same SIGINT, and `$EDITOR` receives it directly. Tearing the Home down here would exit + // BEHIND the suspension's back: its `reclaim` re-emits ENABLE_MOUSE on the way out, and the latched + // `restoreTerminalControls` would never run again, stranding DECSET 1002+1006 on the user's shell. + // `relavium chat` has gated this since Step 5d (`onSigintGated`, chat-ink.tsx); the Home never did. + // Only SIGINT: an EXTERNAL kill (TERM/HUP/QUIT) must still tear down, suspended or not. + if (signo === 2 && suspendPort.isSuspended()) return; if (signaled) { exitProcess(128 + signo); // a second signal forces an immediate exit (a teardown ignoring the abort) return; } signaled = true; - // Best-effort terminal restore — a throw here must NOT skip scheduling the bounded teardown + exit below - // (else an external signal could neither close the db nor exit). - try { - instance?.unmount(); // restore the terminal from raw mode BEFORE anything else - writeControl(DISABLE_BRACKETED_PASTE); - writeControl(DISABLE_MOUSE); // restore native mouse text-selection (no-op if never enabled) - } catch { - // ignore — restoring the terminal is best-effort; the close + exit must still run - } + // Best-effort terminal restore — it swallows its own throw, so it can NOT skip scheduling the bounded + // teardown + exit below (else an external signal could neither close the db nor exit). + restoreTerminalControls(); // The bound is REFERENCED until the race settles so the exit is guaranteed even if teardown hangs; it is // cleared the instant the race resolves so a fast teardown (the common case) neither waits nor dangles. let bound: ReturnType | undefined; @@ -498,6 +645,12 @@ export async function driveHome(deps: HomeDeps): Promise { }); }; unsubscribeSignals = (deps.subscribeSignals ?? defaultSubscribeSignals)(onSignal); + // The LAST net. `onSignal` covers the catchable kills; this covers everything that reaches Node's `'exit'` without + // unwinding our `finally` — a `process.exit()` from a nested command, an uncaught throw, an unhandled rejection. + // It must be synchronous, which `restoreTerminalControls` is; the latch makes the overlap with the other nets free. + unsubscribeProcessExit = (deps.subscribeProcessExit ?? defaultSubscribeProcessExit)( + restoreTerminalControls, + ); // Resolve the effective render mode (2.6.F, ADR-0068 §e). driveHome only runs on a TTY interactive path // (shouldOpenHome-gated), so the output mode is 'tui'; the resolver still short-circuits a 'plain' path to @@ -526,6 +679,19 @@ export async function driveHome(deps: HomeDeps): Promise { onError: (err) => reject(err instanceof Error ? err : new Error(String(err))), }); const alternateScreen = renderMode === 'alt'; + altScreenActive = alternateScreen; // the hatch ports read this lazily (see `terminal()` above) + // Mouse reporting (Step 5e, ADR-0068 §e) — resolved from the SAME render mode, so the two cannot disagree. + mouseActive = resolveMouseMode({ + renderMode, + noMouseFlag: deps.global.noMouse === true, + configMouse: config.mouse, + }); + // Copy-on-select (Step 6e): a durable preference, resolved from the ALREADY-RESOLVED mouse decision, so + // `--no-mouse` turns it off structurally. `/copy` is unaffected — it has its own clipboard binding. + const copyOnSelect = resolveCopyOnSelect({ + mouseEnabled: mouseActive, + configCopyOnSelect: config.copyOnSelect, + }); const props: RootAppProps = { controller, nowMs: now, @@ -534,6 +700,24 @@ export async function driveHome(deps: HomeDeps): Promise { subscribeResize, // The in-Home chat renders its transcript through the scroll viewport when mounted on the alt screen (Step 4b). alternateScreen, + // `RootApp` attaches ink's `suspendTerminal` here while mounted (2.6.F Step 5d, ADR-0068 §e). + suspendPort, + // The branded banner's durable switch (Step 5g); `HomeView` owns the empty-Home rule when it is absent. + showBanner: config.showBanner, + // Armed only while the in-Home chat owns the screen (Step 6g). `mouseActive` is the RESOLVED mode + // (`--no-mouse` / `[preferences].mouse`); when it is off, no port is passed and nothing is ever captured. + ...(mouseActive + ? { + setMouseCapture: (enabled: boolean) => { + mouseCaptured = enabled; // the hatch ports read this LIVE, like `altScreenActive` + writeControl(enabled ? ENABLE_MOUSE : DISABLE_MOUSE); + }, + } + : {}), + // Copy-on-select rides the SAME control-write sink as the alt-buffer + mouse toggles (Step 6). OSC 52 prints + // nothing and moves no cursor, so writing it mid-frame cannot corrupt ink's line accounting. ABSENT when + // `[preferences].copy_on_select = false`: the selection still highlights, and `/copy` still copies. + ...(copyOnSelect ? { clipboard } : {}), }; instance = deps.render === undefined @@ -546,24 +730,19 @@ export async function driveHome(deps: HomeDeps): Promise { alternateScreen, }) : deps.render(props, { alternateScreen }); - // Enable terminal mouse reporting so the in-Home chat's viewport wheel-scrolls (2.6.F Step 5). Only on the alt - // screen (ink owns 1049 on this single mount; mouse is ours). Disabled on EVERY teardown path below — the - // `DISABLE_MOUSE` writes are unconditional there (a no-op when it was never enabled, like DISABLE_BRACKETED_PASTE). - if (alternateScreen) writeControl(ENABLE_MOUSE); + // Mouse reporting is armed by `RootApp` as the in-Home CHAT takes the screen (`setMouseCapture`), not here: + // capturing it for the whole Home stripped the landing of the emulator's native selection and gave nothing back + // (2.6.F Step 6g). Disabled on EVERY teardown path below — the `DISABLE_MOUSE` writes are unconditional there + // (a no-op when it was never enabled, like DISABLE_BRACKETED_PASTE). }); } finally { // The clean-exit / error / INIT-FAULT path (NOT the signal path, which exits the process directly): undo the - // terminal state, reclaim a live session, and close the shared db ONCE. The terminal restore is best-effort — - // a throw there is swallowed so it neither turns a clean exit into a failure nor skips the teardown + close - // below (a faulty terminal can never leak the session or the db handle). Unmount BEFORE disabling paste. + // terminal state, reclaim a live session, and close the shared db ONCE. The terminal restore swallows its own + // throw, so it neither turns a clean exit into a failure nor skips the teardown + close below — a faulty + // terminal can never leak the session or the db handle. unsubscribeSignals?.(); - try { - instance?.unmount(); - writeControl(DISABLE_BRACKETED_PASTE); - writeControl(DISABLE_MOUSE); // restore native mouse text-selection (no-op if never enabled) - } catch { - // ignore — restoring the terminal is best-effort; the session teardown + db close must still run - } + unsubscribeProcessExit?.(); + restoreTerminalControls(); await controller?.teardownActive().catch(() => undefined); // always reclaim a live session closeDb(); // always close the shared db } diff --git a/apps/cli/src/process/options.test.ts b/apps/cli/src/process/options.test.ts index 255ae82d..28a82fc0 100644 --- a/apps/cli/src/process/options.test.ts +++ b/apps/cli/src/process/options.test.ts @@ -43,6 +43,8 @@ describe('extractGlobalOptions', () => { expect(raw.noAltScreen).toBe(true); expect(rest).toEqual(['node', 'relavium', 'chat']); expect(extractGlobalOptions(argv('chat')).raw.noAltScreen).toBeUndefined(); // absent ⇒ unset + expect(extractGlobalOptions(argv('--no-mouse', 'chat')).raw.noMouse).toBe(true); + expect(extractGlobalOptions(argv('chat')).raw.noMouse).toBeUndefined(); // absent ⇒ unset }); it('reports (not throws) invalid_invocation when --cwd / --config has no argument', () => { @@ -86,6 +88,7 @@ describe('resolveGlobalOptions', () => { configPath: undefined, verbosity: 'normal', noAltScreen: false, + noMouse: false, }); }); @@ -99,6 +102,7 @@ describe('resolveGlobalOptions', () => { configPath: '/c.toml', verbosity: 'normal', noAltScreen: false, + noMouse: false, }); }); @@ -107,6 +111,11 @@ describe('resolveGlobalOptions', () => { expect(resolveGlobalOptions({}, '/w').noAltScreen).toBe(false); }); + it('maps --no-mouse to noMouse (absent ⇒ false) — the ADR-0068 §e mouse opt-out, Step 5e', () => { + expect(resolveGlobalOptions({ noMouse: true }, '/w').noMouse).toBe(true); + expect(resolveGlobalOptions({}, '/w').noMouse).toBe(false); + }); + it('maps --verbose / --quiet to verbosity', () => { expect(resolveGlobalOptions({ verbose: true }, '/w').verbosity).toBe('verbose'); expect(resolveGlobalOptions({ quiet: true }, '/w').verbosity).toBe('quiet'); diff --git a/apps/cli/src/process/options.ts b/apps/cli/src/process/options.ts index 8590e9d9..d7a60b7a 100644 --- a/apps/cli/src/process/options.ts +++ b/apps/cli/src/process/options.ts @@ -20,6 +20,11 @@ export interface GlobalOptions { * resolved fields) so the many test fixtures that predate it need no churn — `resolveGlobalOptions` always * populates it in production, and an absent value reads as `false` (alt-screen not force-disabled). */ readonly noAltScreen?: boolean; + /** `true` when `--no-mouse` was passed — a per-invocation opt-out of terminal mouse reporting in the full-screen + * renderer (2.6.F, ADR-0068 §e). Overrides `[preferences].mouse`; the effective decision is resolved by + * `resolveMouseMode` (render-mode.ts), which also gates on the alt screen being active at all. OPTIONAL for the + * same reason as {@link noAltScreen} — an absent value reads as `false` (mouse not force-disabled). */ + readonly noMouse?: boolean; } /** The raw global-flag values harvested from argv (before normalization). */ @@ -34,6 +39,9 @@ export interface RawGlobalOptions { /** `true` for `--no-alt-screen` (the only alt-screen flag — the DISABLE opt-out; enabling is via * `[preferences].alt_screen`, ADR-0068 §e). Absent ⇒ fall to the config key / phase default. */ noAltScreen?: boolean; + /** `true` for `--no-mouse` (the DISABLE opt-out; enabling is via `[preferences].mouse`, ADR-0068 §e). + * Absent ⇒ fall to the config key / phase default. */ + noMouse?: boolean; } export interface ExtractedArgv { @@ -73,6 +81,9 @@ const BOOLEAN_FLAGS: Readonly void>> = '--no-alt-screen': (raw) => { raw.noAltScreen = true; }, + '--no-mouse': (raw) => { + raw.noMouse = true; + }, }; type ValueFlagResult = @@ -214,5 +225,6 @@ export function resolveGlobalOptions( configPath: raw.config, verbosity: resolveVerbosity(raw), noAltScreen: raw.noAltScreen === true, + noMouse: raw.noMouse === true, }; } diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 2ff886e0..bdbee02a 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -31,6 +31,7 @@ Global options (usable anywhere on the command line): --cwd run as if started in --config use an explicit config file --no-alt-screen keep the inline renderer (no full-screen alt screen) + --no-mouse disable mouse reporting (restores native click-drag selection) -v, --verbose print verbose diagnostics to stderr -q, --quiet suppress non-essential output diff --git a/apps/cli/src/render/alt-screen.test.ts b/apps/cli/src/render/alt-screen.test.ts index 9960d24f..a0c1188f 100644 --- a/apps/cli/src/render/alt-screen.test.ts +++ b/apps/cli/src/render/alt-screen.test.ts @@ -84,7 +84,138 @@ describe('createAltScreenController (2.6.F Step 4b-3)', () => { expect(HIDE_CURSOR).toBe('\x1b[?25l'); expect(SHOW_CURSOR).toBe('\x1b[?25h'); expect(CLEAR_ALT_SCREEN).toBe('\x1b[H\x1b[J'); - expect(ENABLE_MOUSE).toBe('\x1b[?1000h\x1b[?1006h'); // X11 button (incl. wheel) + SGR coords - expect(DISABLE_MOUSE).toBe('\x1b[?1006l\x1b[?1000l'); // symmetric off + // Step 6: 1002 (button-EVENT tracking) not 1000 — it adds motion-while-held, i.e. the DRAG the in-app text + // selection is built on. Never 1003 (any-motion), which reports every pointer move with no button held. + expect(ENABLE_MOUSE).toBe('\x1b[?1002h\x1b[?1006h'); + // The disable covers 1000 TOO: a disable of a never-enabled mode is a no-op, and an earlier Relavium — or any + // other program in this terminal — may have left 1000 armed. Stranding DECSET-1000 ruins the user's shell. + expect(DISABLE_MOUSE).toBe('\x1b[?1006l\x1b[?1002l\x1b[?1000l'); + expect(DISABLE_MOUSE).toContain('?1000l'); + }); +}); + +/** + * The `mouse` option (2.6.F Step 5e, ADR-0068 §e). `--no-mouse` / `[preferences].mouse = false` must leave the + * emulator's native click-drag selection working — so `enter()` must not arm DECSET-1000. The DISABLE on `restore()` + * stays unconditional: disabling a mode that was never enabled is a no-op, and an unconditional teardown can never + * strand mouse reporting if the option is ever mis-threaded. + */ +describe('createAltScreenController — the mouse opt-out', () => { + const sink = (): { write: (s: string) => void; out: string[] } => { + const out: string[] = []; + return { write: (s) => out.push(s), out }; + }; + + it('mouse: false ⇒ enters WITHOUT arming mouse reporting; native selection keeps working', () => { + const { write, out } = sink(); + const c = createAltScreenController({ write, active: true, mouse: false }); + c.enter(); + expect(out).toEqual([ENTER_ALT_SCREEN + HIDE_CURSOR]); + expect(out[0]).not.toContain(ENABLE_MOUSE); + expect(c.isEntered()).toBe(true); + expect(c.isMouseEnabled()).toBe(false); // …and a hatch suspension must not "restore" what we never set + }); + + it('mouse: false ⇒ restore STILL disables (a no-op on a mode never enabled, but it can never strand DECSET-1000)', () => { + const { write, out } = sink(); + const c = createAltScreenController({ write, active: true, mouse: false }); + c.enter(); + c.restore(); + expect(out.at(-1)).toBe(DISABLE_MOUSE + EXIT_ALT_SCREEN + SHOW_CURSOR); + }); + + it('mouse defaults to ON when the option is omitted (every pre-Step-5e caller keeps its behaviour)', () => { + const { write, out } = sink(); + const c = createAltScreenController({ write, active: true }); + c.enter(); + expect(out).toEqual([ENTER_ALT_SCREEN + HIDE_CURSOR + ENABLE_MOUSE]); + expect(c.isMouseEnabled()).toBe(true); + }); + + it('isMouseEnabled is false before enter and after restore (it tracks the LIVE terminal, not the option)', () => { + const { write } = sink(); + const c = createAltScreenController({ write, active: true, mouse: true }); + expect(c.isMouseEnabled()).toBe(false); + c.enter(); + expect(c.isMouseEnabled()).toBe(true); + c.restore(); + expect(c.isMouseEnabled()).toBe(false); + }); + + it('inactive (inline / non-TTY) ⇒ mouse is never enabled, whatever the option says', () => { + const { write, out } = sink(); + const c = createAltScreenController({ write, active: false, mouse: true }); + c.enter(); + expect(out).toEqual([]); + expect(c.isMouseEnabled()).toBe(false); + }); +}); + +/** + * The idempotence latch and a FAILING terminal write (2.6.F Step 6h, Sonnet review). `restore()` runs from a + * `finally`, from a `process.on('exit')` listener and from signal handlers, deliberately overlapping. It used to set + * `restored = true` BEFORE the write, so one transient fault (an EIO on a half-dead TTY) marked the terminal restored + * and every later net declined to try — leaving the user on the alt buffer with mouse reporting on, permanently. + */ +describe('createAltScreenController — a failed restore must not disarm the later nets', () => { + /** A write sink that throws on the Nth call (1-based) and records every write that lands. */ + const failOnCall = (n: number): { write: (s: string) => void; writes: string[] } => { + const writes: string[] = []; + let call = 0; + return { + writes, + write: (s: string) => { + call += 1; + if (call === n) throw new Error('EIO'); + writes.push(s); + }, + }; + }; + + it('a THROWING restore leaves the latch down, so the next net retries and the terminal is reclaimed', () => { + // Call 1 is `enter`'s write; call 2 is the first `restore`'s, and it fails. Call 3 is the retry — the + // `process.on('exit')` net, or a signal handler, or the `finally`. + const sink = failOnCall(2); + const alt = createAltScreenController({ write: sink.write, active: true }); + alt.enter(); + expect(sink.writes).toHaveLength(1); + + alt.restore(); // the write throws; swallowed, and the latch stays DOWN + expect(sink.writes).toHaveLength(1); + + alt.restore(); // the retry + expect(sink.writes).toHaveLength(2); + expect(sink.writes[1]).toContain(EXIT_ALT_SCREEN); + expect(sink.writes[1]).toContain(DISABLE_MOUSE); + }); + + it('restore() NEVER throws — it runs from an `exit` listener, where a throw is an uncaught exception', () => { + const alt = createAltScreenController({ + write: () => { + throw new Error('EIO'); + }, + active: true, + }); + expect(() => alt.enter()).toThrow(); // enter may throw: the caller decides + expect(() => alt.restore()).not.toThrow(); // restore may not + }); + + it('a SUCCESSFUL restore still latches — the overlapping nets write exactly once', () => { + const sink = failOnCall(0); // never fails + const alt = createAltScreenController({ write: sink.write, active: true }); + alt.enter(); + alt.restore(); + alt.restore(); + alt.restore(); + expect(sink.writes.filter((w) => w.includes(EXIT_ALT_SCREEN))).toHaveLength(1); + }); + + it('a THROWING enter does not latch, so restore never exits a buffer the terminal is not in', () => { + const flaky = failOnCall(1); + const alt = createAltScreenController({ write: flaky.write, active: true }); + expect(() => alt.enter()).toThrow(); + alt.restore(); + expect(flaky.writes).toEqual([]); // nothing was entered, so nothing is exited + expect(alt.isEntered()).toBe(false); }); }); diff --git a/apps/cli/src/render/alt-screen.ts b/apps/cli/src/render/alt-screen.ts index a01bc997..8204c266 100644 --- a/apps/cli/src/render/alt-screen.ts +++ b/apps/cli/src/render/alt-screen.ts @@ -28,16 +28,29 @@ export const SHOW_CURSOR = '\x1b[?25h'; * mount, so successive sessions never STACK (ink's non-fullscreen unmount `log.done()` does not erase, and a fresh * mount starts at `previousLineCount=0`). */ export const CLEAR_ALT_SCREEN = '\x1b[H\x1b[J'; -/** Enable terminal mouse reporting — X11 button events (DECSET 1000, which INCLUDES the wheel) + SGR extended - * coordinates (1006, so columns past 223 report correctly), 2.6.F Step 5. This is what makes wheel-scroll possible; - * the trade-off is that the terminal's NATIVE mouse text-selection now needs Shift/Option (see accessibility.md). */ -export const ENABLE_MOUSE = '\x1b[?1000h\x1b[?1006h'; -/** Disable mouse reporting — restore native mouse text-selection. Paired with the alt-buffer exit on every path. */ -export const DISABLE_MOUSE = '\x1b[?1006l\x1b[?1000l'; +/** + * Enable terminal mouse reporting — **DECSET 1002** (button-event tracking: press, release, wheel, and motion ONLY + * while a button is held) + SGR extended coordinates (1006, so columns past 223 report correctly). + * + * 1002, not 1000: the drag reports are what let the app implement TEXT SELECTION itself (2.6.F Step 6). A terminal + * either reports mouse events or performs its own click-drag selection — never both — so with reporting on, giving + * selection back to the user means owning it. NOT 1003 (any-motion), which reports every pointer move even with no + * button held and floods the input stream for nothing. + */ +export const ENABLE_MOUSE = '\x1b[?1002h\x1b[?1006h'; +/** + * Disable mouse reporting — restore the emulator's native text-selection. Paired with the alt-buffer exit on every + * path. It disables **1000 as well as 1002**: a disable of a mode that was never enabled is a no-op, and an earlier + * Relavium (or any other program in this terminal) may have left 1000 armed. + */ +export const DISABLE_MOUSE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l'; export interface AltScreenController { /** Enter the alt buffer + hide the cursor, exactly ONCE (a repeat call and the inactive case are no-ops). */ enter(): void; + /** Whether mouse reporting is currently ON — `enter()` ran AND the `mouse` option was set (2.6.F Step 5e, + * ADR-0068 §e). The `/scrollback` + `/edit` suspension asks this so it suspends the mouse only if we enabled it. */ + readonly isMouseEnabled: () => boolean; /** Exit the alt buffer + show the cursor, exactly ONCE — IDEMPOTENT, so the `finally`, the `process.on('exit')` * net, and a signal handler can all call it without a double toggle. A no-op if `enter()` never ran. */ restore(): void; @@ -55,26 +68,50 @@ export interface AltScreenController { export function createAltScreenController(opts: { readonly write: (sequence: string) => void; readonly active: boolean; + /** Enable mouse reporting with the buffer (2.6.F Step 5e, ADR-0068 §e). `false` (`--no-mouse` / + * `[preferences].mouse = false`) keeps the wheel inert and leaves the emulator's native click-drag selection + * working. Defaults to `true` so every existing caller/test keeps the Step-5b behaviour. */ + readonly mouse?: boolean; }): AltScreenController { const { write, active } = opts; + const mouse = opts.mouse ?? true; let entered = false; let restored = false; return { enter: (): void => { if (!active || entered) return; + // Latch AFTER the write, for the same reason as `restore` below: a `write` that throws did not enter anything, + // and pretending it did would make `restore` emit a DECRST-1049 for a buffer the terminal is not in. + write(ENTER_ALT_SCREEN + HIDE_CURSOR + (mouse ? ENABLE_MOUSE : '')); entered = true; - write(ENTER_ALT_SCREEN + HIDE_CURSOR + ENABLE_MOUSE); }, restore: (): void => { if (!entered || restored) return; // never exit a buffer we never entered; never exit twice + // Disable mouse reporting FIRST (restore native selection), then exit the alt buffer + show the cursor. The + // DISABLE is UNCONDITIONAL even when `mouse` is off: a disable of a mode that was never enabled is a no-op, and + // an unconditional teardown can never strand DECSET-1002 if the option is ever mis-threaded. + // + // The idempotence latch is set only AFTER the write SUCCEEDS. It used to be set first, so a single transient + // write fault (an EIO on a half-dead TTY, an EPIPE) marked the terminal "restored" and every later net — the + // `finally`, the `process.on('exit')` net, the signal handlers — silently declined to try again. The user was + // left on the alt buffer with mouse reporting on, permanently (Step-6h Sonnet review). This is the same + // "track what actually changed" discipline `suspend.ts`'s `suspendFullScreen` already applies. + // + // BEST-EFFORT, and it never throws: this runs from a `finally`, from an `'exit'` listener (where a throw is an + // uncaught exception) and from signal handlers. The latch staying DOWN is how a failure is reported — the next + // net retries. + try { + write(DISABLE_MOUSE + EXIT_ALT_SCREEN + SHOW_CURSOR); + } catch { + return; // a later net gets another chance at the terminal + } restored = true; - // Disable mouse reporting FIRST (restore native selection), then exit the alt buffer + show the cursor. - write(DISABLE_MOUSE + EXIT_ALT_SCREEN + SHOW_CURSOR); }, clearBetween: (): void => { if (!entered || restored) return; write(CLEAR_ALT_SCREEN); }, isEntered: (): boolean => entered && !restored, + isMouseEnabled: (): boolean => mouse && entered && !restored, }; } diff --git a/apps/cli/src/render/clipboard.test.ts b/apps/cli/src/render/clipboard.test.ts new file mode 100644 index 00000000..71829ef8 --- /dev/null +++ b/apps/cli/src/render/clipboard.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; + +import { + copyToClipboard, + detectMultiplexer, + encodeOsc52, + OSC52_MAX_BASE64_LENGTH, + type ClipboardDeps, +} from './clipboard.js'; + +/** + * The OSC 52 clipboard writer (2.6.F Step 6). Three properties carry the whole feature: the payload is base64 (so + * transcript text can never terminate the escape), tmux needs a DCS passthrough with DOUBLED inner escapes (without + * it tmux swallows the sequence and nothing reaches the emulator), and an over-long payload is REFUSED rather than + * truncated (a silently half-copied selection is worse than a refusal). + */ + +const harness = ( + env: Record = {}, +): { deps: ClipboardDeps; writes: string[] } => { + const writes: string[] = []; + return { deps: { writeControl: (s) => writes.push(s), env }, writes }; +}; + +const b64 = (s: string): string => Buffer.from(s, 'utf8').toString('base64'); + +describe('detectMultiplexer', () => { + it('reads the environment each multiplexer sets for its children', () => { + expect(detectMultiplexer({ TMUX: '/tmp/tmux-501/default,123,0' })).toBe('tmux'); + expect(detectMultiplexer({ ZELLIJ: '0' })).toBe('zellij'); + expect(detectMultiplexer({})).toBeUndefined(); + }); + + it('an EMPTY variable is not a multiplexer (a shell that exported it blank)', () => { + expect(detectMultiplexer({ TMUX: '' })).toBeUndefined(); + expect(detectMultiplexer({ ZELLIJ: '' })).toBeUndefined(); + }); + + it('tmux wins when both are somehow set (the inner one frames the escape)', () => { + expect(detectMultiplexer({ TMUX: 'x', ZELLIJ: '0' })).toBe('tmux'); + }); +}); + +describe('encodeOsc52', () => { + it('the plain form is `ESC ] 52 ; c ; BEL`', () => { + expect(encodeOsc52('SGk=')).toBe('\x1b]52;c;SGk=\x07'); + }); + + it('writes only the CLIPBOARD selection — never PRIMARY (which would clobber the middle-click buffer)', () => { + expect(encodeOsc52('SGk=')).toContain(';c;'); + expect(encodeOsc52('SGk=')).not.toContain(';p;'); + }); + + it('SECURITY: never emits the `?` read payload — OSC 52 can also EXFILTRATE the clipboard', () => { + expect(encodeOsc52(b64('?'))).not.toContain(';c;?'); + }); + + it('tmux: emits BOTH the plain escape and a DCS passthrough — stock tmux honours neither alone', () => { + // Read from tmux's own source, not folklore. `input_osc_52_parse` bails unless `set-clipboard` == 2 (`on`), and + // the DEFAULT is `external` (1); `input_dcs_dispatch` bails unless `allow-passthrough` is on, and the DEFAULT is + // `off` (0). So a user who set EITHER option gets a working copy, and one who set both sets the clipboard twice + // to the same value. Shipping only the passthrough (as Step 6c did) silently failed for the common + // `set-clipboard on` recipe. + expect(encodeOsc52('SGk=', 'tmux')).toBe( + '\x1b]52;c;SGk=\x07' + // honoured under `set-clipboard on` + '\x1bPtmux;\x1b\x1b]52;c;SGk=\x07\x1b\\', // honoured under `allow-passthrough on` + ); + }); + + it('tmux: the passthrough DOUBLES every inner ESC, and only inside the wrapper', () => { + // tmux's DCS table sends 0x1b to `dcs_escape` WITHOUT appending it; the next byte (if not `\`) is appended alone. + // So ESC ESC collapses to one ESC in the forwarded string, and an undoubled ESC would be eaten, forwarding `]52;…`. + const wrapped = encodeOsc52('SGk=', 'tmux'); + const passthrough = wrapped.slice(wrapped.indexOf('\x1bPtmux;')); + expect(passthrough).toContain('\x1b\x1b]52'); // doubled + const plain = wrapped.slice(0, wrapped.indexOf('\x1bPtmux;')); + expect(plain).toBe('\x1b]52;c;SGk=\x07'); // NOT doubled + }); + + it('zellij: forwards a PLAIN OSC 52 (it does not need the tmux wrapper)', () => { + expect(encodeOsc52('SGk=', 'zellij')).toBe('\x1b]52;c;SGk=\x07'); + }); +}); + +describe('copyToClipboard', () => { + it('base64-encodes the text and writes exactly one escape', () => { + const { deps, writes } = harness(); + expect(copyToClipboard(deps, 'hello')).toEqual({ kind: 'written', characters: 5 }); + expect(writes).toEqual([`\x1b]52;c;${b64('hello')}\x07`]); + }); + + it('SECURITY: an ESC or BEL inside the text cannot terminate the escape — base64 IS the boundary', () => { + const { deps, writes } = harness(); // no multiplexer: exactly one escape goes out, so the counts below are exact + copyToClipboard(deps, 'a\x1b]52;c;evil\x07b'); + const written = writes[0] ?? ''; + // Exactly one BEL (the real terminator) and one ESC (the introducer); the payload's own bytes are encoded away. + expect([...written].filter((c) => c === '\x07')).toHaveLength(1); + expect([...written].filter((c) => c === '\x1b')).toHaveLength(1); + expect(written).toContain(b64('a\x1b]52;c;evil\x07b')); + }); + + it('preserves non-ASCII exactly (UTF-8 in, UTF-8 out)', () => { + const { deps, writes } = harness(); + copyToClipboard(deps, 'merhaba 日本語 👋'); + expect(writes[0]).toContain(b64('merhaba 日本語 👋')); + }); + + it('EMPTY text writes nothing at all — a click that selected nothing must not touch the clipboard', () => { + const { deps, writes } = harness(); + expect(copyToClipboard(deps, '')).toEqual({ kind: 'empty' }); + expect(writes).toEqual([]); + }); + + it('REFUSES an over-long payload rather than truncating it, and writes nothing', () => { + const { deps, writes } = harness(); + // 3 bytes of input → 4 of base64, so this comfortably exceeds the floor. + const huge = 'x'.repeat(OSC52_MAX_BASE64_LENGTH); + const outcome = copyToClipboard(deps, huge); + expect(outcome.kind).toBe('too-large'); + expect(outcome).toMatchObject({ limit: OSC52_MAX_BASE64_LENGTH }); + expect(writes).toEqual([]); // a half-copied selection is worse than a refusal + }); + + it('the bound is INCLUSIVE, exercised on BOTH sides of it', () => { + // base64 length is always 4·ceil(n/3), so no payload lands exactly on 74 994: 56 244 chars encode to 74 992 + // (fits) and one more char pushes it to 74 996 (refused). Testing a rounder number would miss the boundary. + const fits = harness(); + expect(copyToClipboard(fits.deps, 'y'.repeat(56_244)).kind).toBe('written'); + expect(fits.writes).toHaveLength(1); + + const over = harness(); + expect(copyToClipboard(over.deps, 'y'.repeat(56_245))).toMatchObject({ + kind: 'too-large', + base64Length: 74_996, + limit: OSC52_MAX_BASE64_LENGTH, + }); + expect(over.writes).toEqual([]); + }); + + it('inside tmux, both forms go out in ONE write (never a half-sequence between them)', () => { + const { deps, writes } = harness({ TMUX: '/tmp/tmux-501/default,1,0' }); + copyToClipboard(deps, 'hi'); + expect(writes).toEqual([ + `\x1b]52;c;${b64('hi')}\x07\x1bPtmux;\x1b\x1b]52;c;${b64('hi')}\x07\x1b\\`, + ]); + }); + + it('is TOTAL: a throwing writeControl is the caller’s to handle, and no partial escape is emitted before it', () => { + // The three refusal paths (`empty`, `too-large`) must write NOTHING, so a caller can trust that a failed copy + // never left a half-sequence on the terminal. The success path writes exactly once — asserted above. + const boom = new Error('stdout closed'); + const deps = { + writeControl: () => { + throw boom; + }, + env: {}, + }; + expect(copyToClipboard(deps, '')).toEqual({ kind: 'empty' }); // never reaches writeControl + expect(copyToClipboard(deps, 'x'.repeat(56_245)).kind).toBe('too-large'); // …nor here + expect(() => copyToClipboard(deps, 'hi')).toThrow(boom); // …and the ONE write is not swallowed + }); +}); diff --git a/apps/cli/src/render/clipboard.ts b/apps/cli/src/render/clipboard.ts new file mode 100644 index 00000000..dfe2036b --- /dev/null +++ b/apps/cli/src/render/clipboard.ts @@ -0,0 +1,113 @@ +/** + * The system clipboard over **OSC 52** — the output half of copy-on-select (2.6.F Step 6, ADR-0068 §e amendment). + * + * OSC 52 asks the terminal EMULATOR to set the clipboard, so it works wherever the escape reaches: a local terminal, + * a plain SSH session, a container. That is the whole reason to prefer it over shelling out to `pbcopy`/`xclip`/ + * `wl-copy` — no platform branch, no child process, and it is the only mechanism that survives SSH. + * + * SECURITY. The payload is base64, so no byte of transcript text can terminate the escape and inject a sequence of + * its own — the encoding is the boundary. (The text is also already sanitized upstream: it comes from the wrapped + * `DisplayLine`s, which `entryLines` stripped of ANSI/C0/C1 and Trojan-Source bidi controls.) We only ever WRITE: + * OSC 52 can also *read* the clipboard back with a `?` payload, which would let a rogue MCP server or model output + * exfiltrate whatever the user last copied. This module never emits `?`, and nothing else in the CLI emits OSC 52. + * + * KNOWN TERMINAL REALITY, designed for rather than discovered later (each is a live issue against a competing agent + * CLI that shipped copy-on-select first): + * - **tmux** honours NEITHER form out of the box, which is why {@link encodeOsc52} emits both. Read from tmux's own + * source rather than assumed (`input.c`, `options-table.c`, `tty.c` @ tmux/tmux): + * * `input_osc_52_parse()` opens with `if (options_get_number(global_options, "set-clipboard") != 2) return 0;` + * and the choice list is `{off, external, on}` — so a **bare** OSC 52 from an application is honoured only + * under `set-clipboard on`. The DEFAULT is `external` (`default_num = 1`), under which tmux sets the system + * clipboard for its OWN copy-mode yanks but ignores an application's escape. + * * `input_dcs_dispatch()` opens with `if (!allow_passthrough) return 0;`, and `allow-passthrough` defaults to + * `off` (`default_num = 0`, choices `{off, on, all}`) — so the **DCS passthrough** is silently dropped. + * Emitting both means whichever option the user set wins; if they set both, the outer terminal receives the same + * clipboard twice, which is a no-op. If they set neither, nothing can help us. + * - **VS Code Remote SSH** silently drops OSC 52 entirely. There is no reply to detect that — OSC 52 write has no + * acknowledgement — so a copy can never be *confirmed*, only attempted. Callers must not claim success they + * cannot know: {@link ClipboardOutcome} says `'written'`, not `'copied'`. + * - Terminals cap the escape's length. {@link OSC52_MAX_BASE64_LENGTH} is the conservative floor; beyond it we + * refuse rather than truncate, because a silently half-copied selection is worse than a refusal. + */ + +/** The OSC 52 clipboard selection to set. `c` (CLIPBOARD) is the only one macOS terminals implement; X11's `p` + * (PRIMARY) is deliberately not written — it would surprise a Linux user by clobbering their middle-click buffer. */ +const CLIPBOARD_SELECTION = 'c'; + +/** + * The maximum base64 payload we will emit. Several terminal emulators bound the length of an OSC string and TRUNCATE + * past it rather than erroring; taking a conservative floor keeps behaviour identical everywhere instead of "works + * until it silently does not". ~74 KB of base64 ≈ ~56 KB of UTF-8 text — far more than any plausible selection, and + * less than a large transcript, which is what `/scrollback` and `/edit` are for. + * + * NOT a tmux limit, despite the folklore: tmux's `input_buffer_size` defaults to `INPUT_BUF_DEFAULT_SIZE` = 1 MiB + * (`tmux.h`), and both its OSC and DCS collectors grow to it before discarding. + */ +export const OSC52_MAX_BASE64_LENGTH = 74_994; + +/** The terminal multiplexer we are running inside, if any — it changes how the escape must be framed. */ +export type Multiplexer = 'tmux' | 'zellij'; + +/** Detect the multiplexer from the environment it sets for its children. */ +export function detectMultiplexer( + env: Readonly>, +): Multiplexer | undefined { + if (env['TMUX'] !== undefined && env['TMUX'] !== '') return 'tmux'; + if (env['ZELLIJ'] !== undefined && env['ZELLIJ'] !== '') return 'zellij'; + return undefined; +} + +/** + * Encode `text` as an OSC 52 clipboard-set escape. + * + * Inside **tmux** the plain escape is followed by the SAME escape wrapped in a DCS passthrough + * (`ESC P tmux; … ESC \`), because stock tmux honours neither on its own (see the module docstring): the plain form + * needs `set-clipboard on`, the passthrough needs `allow-passthrough on`. Sending both makes either option sufficient. + * + * Every inner `ESC` inside the passthrough is DOUBLED. tmux's DCS state machine (`input.c`, + * `input_state_dcs_handler_table`) sends `0x1b` to `dcs_escape` WITHOUT appending it; from there any byte other than + * `\` is appended alone. So `ESC ESC` collapses to one `ESC` in the forwarded string, and a single `ESC ]` would lose + * its `ESC` and forward a bare `]`. The `BEL` terminator (`0x07`) is in the `0x00-0x1a` range and is appended as-is. + * + * Zellij forwards OSC 52 unwrapped, so it takes the plain form. + */ +export function encodeOsc52(base64: string, multiplexer?: Multiplexer): string { + const osc = `\x1b]${52};${CLIPBOARD_SELECTION};${base64}\x07`; + if (multiplexer !== 'tmux') return osc; + return `${osc}\x1bPtmux;${osc.replaceAll('\x1b', '\x1b\x1b')}\x1b\\`; +} + +/** What a copy attempt did. Never `'copied'`: OSC 52 has no acknowledgement, so a terminal that drops the escape + * (VS Code Remote SSH) is indistinguishable from one that honoured it. We report what we WROTE. */ +export type ClipboardOutcome = + /** The escape was written to the terminal. Whether the emulator honoured it is unknowable from here. */ + | { readonly kind: 'written'; readonly characters: number } + /** Nothing was selected — no escape emitted. */ + | { readonly kind: 'empty' } + /** Past the terminal's escape-length floor. Refused rather than truncated. */ + | { readonly kind: 'too-large'; readonly base64Length: number; readonly limit: number }; + +export interface ClipboardDeps { + /** Write a raw control sequence to the TTY (the same sink the alt-buffer toggles use). */ + readonly writeControl: (sequence: string) => void; + /** The process environment — read for the multiplexer detection only. */ + readonly env: Readonly>; +} + +/** + * Put `text` on the system clipboard. An empty selection (`'empty'`) and an over-long one (`'too-large'`) are REFUSED + * as return values — no escape is written, so a caller can trust a refusal left nothing half-emitted. A THROW from + * `deps.writeControl` (a closed stdout) propagates to the caller: this function never writes a truncated payload, but + * the terminal write itself is the caller's to guard. + */ +export function copyToClipboard(deps: ClipboardDeps, text: string): ClipboardOutcome { + if (text.length === 0) return { kind: 'empty' }; + + const base64 = Buffer.from(text, 'utf8').toString('base64'); + if (base64.length > OSC52_MAX_BASE64_LENGTH) { + return { kind: 'too-large', base64Length: base64.length, limit: OSC52_MAX_BASE64_LENGTH }; + } + + deps.writeControl(encodeOsc52(base64, detectMultiplexer(deps.env))); + return { kind: 'written', characters: text.length }; +} diff --git a/apps/cli/src/render/editor.test.ts b/apps/cli/src/render/editor.test.ts new file mode 100644 index 00000000..1eb9afd8 --- /dev/null +++ b/apps/cli/src/render/editor.test.ts @@ -0,0 +1,445 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; + +/** A switchable `writeFile` fault, so the "a partial write must not strand the conversation" path is driven for real + * (an ENOSPC/EIO cannot be provoked portably). Everything else in `node:fs/promises` stays real. */ +const fsFault = vi.hoisted(() => ({ + writeFileError: undefined as Error | undefined, + rmError: undefined as Error | undefined, +})); +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeFile: async (...args: Parameters) => { + if (fsFault.writeFileError !== undefined) throw fsFault.writeFileError; + return actual.writeFile(...args); + }, + rm: async (...args: Parameters) => { + if (fsFault.rmError !== undefined) throw fsFault.rmError; + return actual.rm(...args); + }, + }; +}); + +import { + nodeCreateTempDocument, + openInEditor, + parseEditorCommand, + resolveEditor, + type EditorExit, + type OpenInEditorDeps, + type TempDocument, + disposePendingTempDirs, + pendingTempDirCount, +} from './editor.js'; + +/** + * The `$EDITOR` hatch (2.6.F Step 5d, ADR-0068 §e). The load-bearing properties: the temp file holding the + * conversation is removed on EVERY path, nothing is spawned (and no file written) when no editor is configured, + * `$EDITOR` is tokenized WITHOUT a shell, and no fault escapes as a raw throw. + */ + +describe('parseEditorCommand — tokenize WITHOUT a shell', () => { + it('splits a bare command and its flags', () => { + expect(parseEditorCommand('vim')).toEqual({ command: 'vim', args: [] }); + expect(parseEditorCommand('code -w')).toEqual({ command: 'code', args: ['-w'] }); + expect(parseEditorCommand(' subl --wait ')).toEqual({ command: 'subl', args: ['--wait'] }); + }); + + it('honours quoting so a path with spaces survives', () => { + expect(parseEditorCommand('"/Applications/My Editor/bin/ed" -n')).toEqual({ + command: '/Applications/My Editor/bin/ed', + args: ['-n'], + }); + expect(parseEditorCommand("'/opt/my ed' --wait")).toEqual({ + command: '/opt/my ed', + args: ['--wait'], + }); + }); + + it('SECURITY: shell metacharacters are inert literal TOKENS, never operators (spawn runs with shell:false)', () => { + // If this were handed to a shell, `; rm -rf ~` would execute. Tokenized, `;` is just an argv string that the + // editor will treat as a filename — and the editor is the user's own $EDITOR anyway. + expect(parseEditorCommand('vim; rm -rf ~')).toEqual({ + command: 'vim;', + args: ['rm', '-rf', '~'], + }); + expect(parseEditorCommand('vim $(whoami)')).toEqual({ command: 'vim', args: ['$(whoami)'] }); + expect(parseEditorCommand('vim | tee /etc/passwd')).toEqual({ + command: 'vim', + args: ['|', 'tee', '/etc/passwd'], + }); + }); + + it('returns undefined for a blank / whitespace-only / empty-quoted value', () => { + expect(parseEditorCommand('')).toBeUndefined(); + expect(parseEditorCommand(' ')).toBeUndefined(); + expect(parseEditorCommand('""')).toBeUndefined(); // an empty command is not a command + }); +}); + +describe('resolveEditor — $VISUAL wins over $EDITOR', () => { + it('prefers VISUAL (the full-screen editor — we are handing over a full screen)', () => { + expect(resolveEditor({ VISUAL: 'code -w', EDITOR: 'vi' })).toEqual({ + command: 'code', + args: ['-w'], + }); + }); + + it('falls back to EDITOR when VISUAL is unset or blank', () => { + expect(resolveEditor({ EDITOR: 'nano' })).toEqual({ command: 'nano', args: [] }); + expect(resolveEditor({ VISUAL: ' ', EDITOR: 'nano' })).toEqual({ + command: 'nano', + args: [], + }); + }); + + it('returns undefined when NEITHER is set — never falls back to `vi` (an unexitable trap for a novice)', () => { + expect(resolveEditor({})).toBeUndefined(); + expect(resolveEditor({ VISUAL: '', EDITOR: '' })).toBeUndefined(); + }); +}); + +/** A recording harness: every disposal + spawn is traced so the cleanup contract is asserted on each path. */ +const harness = ( + over: Partial & { exit?: EditorExit; spawnThrows?: Error } = {}, +): { deps: OpenInEditorDeps; trace: string[] } => { + const trace: string[] = []; + const document: TempDocument = { + path: '/tmp/relavium-transcript-xyz/transcript.md', + dispose: () => { + trace.push('dispose'); + return Promise.resolve(); + }, + }; + const deps: OpenInEditorDeps = { + env: { EDITOR: 'vim' }, + createTempDocument: (contents) => { + trace.push(`temp:${contents.length}`); + return Promise.resolve(document); + }, + spawnEditor: (command, args, file) => { + trace.push(`spawn:${command} ${[...args, file].join(' ')}`); + if (over.spawnThrows !== undefined) return Promise.reject(over.spawnThrows); + return Promise.resolve(over.exit ?? { code: 0, signal: null }); + }, + ...over, + }; + return { deps, trace }; +}; + +describe('openInEditor', () => { + it('writes the transcript, spawns the editor with the FILE appended, and disposes the temp file', async () => { + const { deps, trace } = harness(); + await expect(openInEditor(deps, 'hello')).resolves.toEqual({ kind: 'closed', exitCode: 0 }); + expect(trace).toEqual([ + 'temp:5', + 'spawn:vim /tmp/relavium-transcript-xyz/transcript.md', + 'dispose', + ]); + }); + + it('passes the configured FLAGS before the file (so `code -w ` waits)', async () => { + const { deps, trace } = harness({ env: { VISUAL: 'code -w' } }); + await openInEditor(deps, 'x'); + expect(trace[1]).toBe('spawn:code -w /tmp/relavium-transcript-xyz/transcript.md'); + }); + + it('NO editor configured ⇒ `unavailable`, and NOTHING is spawned or written to disk', async () => { + const { deps, trace } = harness({ env: {} }); + await expect(openInEditor(deps, 'hello')).resolves.toEqual({ kind: 'unavailable' }); + expect(trace).toEqual([]); // the conversation never touched the filesystem + }); + + it('a non-zero editor exit is still `closed` (the user’s editor failed, not us)', async () => { + const { deps, trace } = harness({ exit: { code: 1, signal: null } }); + await expect(openInEditor(deps, 'x')).resolves.toEqual({ kind: 'closed', exitCode: 1 }); + expect(trace).toContain('dispose'); + }); + + it('the editor cannot be STARTED ⇒ `failed` (never a raw throw), and the temp file is STILL disposed', async () => { + const { deps, trace } = harness({ spawnThrows: new Error('ENOENT') }); + await expect(openInEditor(deps, 'x')).resolves.toEqual({ + kind: 'failed', + message: 'could not start vim', + }); + expect(trace).toContain('dispose'); // the conversation is not left on disk + }); + + it('the editor is KILLED by a signal ⇒ `failed`, and the temp file is STILL disposed', async () => { + const { deps, trace } = harness({ exit: { code: null, signal: 'SIGKILL' } }); + await expect(openInEditor(deps, 'x')).resolves.toEqual({ + kind: 'failed', + message: 'vim was terminated by SIGKILL', + }); + expect(trace).toContain('dispose'); + }); + + it('a temp-file creation fault ⇒ `failed`, nothing spawned, nothing to dispose', async () => { + const { deps, trace } = harness({ + createTempDocument: () => Promise.reject(new Error('EACCES')), + }); + await expect(openInEditor(deps, 'x')).resolves.toEqual({ + kind: 'failed', + message: 'could not create a temporary file for the transcript', + }); + expect(trace).toEqual([]); + }); + + it('a THROWING disposer cannot turn a successful edit into a failure', async () => { + const { deps } = harness({ + createTempDocument: () => + Promise.resolve({ + path: '/tmp/x/transcript.md', + dispose: () => Promise.reject(new Error('EBUSY')), + }), + }); + await expect(openInEditor(deps, 'x')).resolves.toEqual({ kind: 'closed', exitCode: 0 }); + }); + + it('SECURITY: a failed disposal is REPORTED with its path — a leaked transcript is never silently retained', async () => { + // The Step-5d-2 Sonnet review: `.catch(() => undefined)` was the one teardown in the codebase that swallowed its + // own failure, and the one whose whole job is keeping the conversation off disk (Windows EBUSY/EPERM). + const reported: { path: string; error: unknown }[] = []; + const boom = new Error('EBUSY'); + const { deps } = harness({ + onDisposeFailed: (path, error) => reported.push({ path, error }), + createTempDocument: () => + Promise.resolve({ + path: '/tmp/relavium-transcript-abc/transcript.md', + dispose: () => Promise.reject(boom), + }), + }); + await expect(openInEditor(deps, 'x')).resolves.toEqual({ kind: 'closed', exitCode: 0 }); + expect(reported).toEqual([{ path: '/tmp/relavium-transcript-abc/transcript.md', error: boom }]); + }); + + it('reports a failed disposal even when the EDITOR itself failed (both faults surface, neither masks the other)', async () => { + const reported: string[] = []; + const { deps } = harness({ + spawnThrows: new Error('ENOENT'), + onDisposeFailed: (path) => reported.push(path), + createTempDocument: () => + Promise.resolve({ path: '/tmp/x/t.md', dispose: () => Promise.reject(new Error('EBUSY')) }), + }); + await expect(openInEditor(deps, 'x')).resolves.toEqual({ + kind: 'failed', + message: 'could not start vim', + }); + expect(reported).toEqual(['/tmp/x/t.md']); + }); +}); + +/** + * `nodeCreateTempDocument` — the real filesystem adapter. It is tested against the real disk because the properties + * that matter (permissions, and the hard-exit net) are properties of the OS, not of our orchestration. + */ +/** The transcript directories currently in the OS temp dir — compared as a BEFORE/AFTER diff, never absolutely + * (the temp dir is shared with other test files, other vitest workers, and stale runs). */ +const transcriptDirs = (): string[] => + readdirSync(tmpdir()).filter((name) => name.startsWith('relavium-transcript-')); + +describe('nodeCreateTempDocument — the private temp document + its hard-exit net', () => { + it('writes a 0600 file inside a 0700 private directory', async () => { + const doc = await nodeCreateTempDocument('the conversation'); + try { + expect(existsSync(doc.path)).toBe(true); + expect(statSync(doc.path).mode & 0o777).toBe(0o600); // owner-only: it holds the conversation + expect(statSync(dirname(doc.path)).mode & 0o777).toBe(0o700); + } finally { + await doc.dispose(); + } + }); + + it('dispose removes the WHOLE directory (any editor swap/backup file with it) and drops it from the pending set', async () => { + const pendingBefore = pendingTempDirCount(); + const doc = await nodeCreateTempDocument('x'); + expect(pendingTempDirCount()).toBe(pendingBefore + 1); // pending while the file lives + const dir = dirname(doc.path); + await doc.dispose(); + expect(existsSync(dir)).toBe(false); + expect(pendingTempDirCount()).toBe(pendingBefore); // …and reclaimed after + }); + + it('the exit net is ONE process listener, however many documents are open', async () => { + // A listener PER document only ever came off on a SUCCESSFUL `rm` — which it must, since a failing `rm` is + // exactly when the last-ditch net is needed. On a host where cleanup persistently fails (an AV scanner holding + // every new file), that accumulated one listener per `/edit` until Node printed a `MaxListenersExceededWarning` + // onto the alt buffer (Step-6h Sonnet review). Reproduced at five. + const before = process.listenerCount('exit'); + const docs = await Promise.all([ + nodeCreateTempDocument('a'), + nodeCreateTempDocument('b'), + nodeCreateTempDocument('c'), + ]); + expect(process.listenerCount('exit')).toBeLessThanOrEqual(before + 1); + await Promise.all(docs.map((d) => d.dispose())); + expect(process.listenerCount('exit')).toBeLessThanOrEqual(before + 1); + }); + + it('a FAILING dispose keeps the directory PENDING, and the one exit net reclaims every one of them', async () => { + const doc1 = await nodeCreateTempDocument('conversation one'); + const doc2 = await nodeCreateTempDocument('conversation two'); + const dirs = [dirname(doc1.path), dirname(doc2.path)]; + const pendingBefore = pendingTempDirCount(); + + fsFault.rmError = new Error('EBUSY: resource busy or locked'); + try { + await expect(doc1.dispose()).rejects.toThrow('EBUSY'); + await expect(doc2.dispose()).rejects.toThrow('EBUSY'); + } finally { + fsFault.rmError = undefined; + } + expect(pendingTempDirCount()).toBe(pendingBefore); // both still pending + for (const dir of dirs) expect(existsSync(dir)).toBe(true); + + disposePendingTempDirs(); // what the single `'exit'` listener runs + for (const dir of dirs) expect(existsSync(dir)).toBe(false); + expect(pendingTempDirCount()).toBe(0); + }); + + it('SECURITY: a FAILED write whose reclaim SUCCEEDS leaves no directory and nothing pending', async () => { + // The Step-5d-3 Opus review: `mkdtemp` created the private dir and `writeFile` flushed the transcript, and an + // ENOSPC/EIO mid-write must not leave that directory — holding part of the conversation — on disk. + const dirsBefore = transcriptDirs(); + const pendingBefore = pendingTempDirCount(); + fsFault.writeFileError = new Error('ENOSPC: no space left on device'); + try { + await expect(nodeCreateTempDocument('the whole conversation')).rejects.toThrow('ENOSPC'); + } finally { + fsFault.writeFileError = undefined; + } + // No NEW directory, and nothing left in the pending set. A before/after DIFF, never an absolute scan: the OS temp + // dir is shared with other test files, other vitest workers, and stale runs. + expect(transcriptDirs().filter((name) => !dirsBefore.includes(name))).toEqual([]); + expect(pendingTempDirCount()).toBe(pendingBefore); + }); + + it('SECURITY: a FAILED write whose reclaim ALSO fails keeps the dir PENDING and rethrows the WRITE error', async () => { + // The Step-6h Sonnet finding: registering only AFTER the write meant a write-then-failed-rmSync left the directory + // with nothing to reclaim it, and a throwing rmSync would MASK the write error. Now the dir is registered first, so + // the exit net still covers it, and the write error — not the removal error — is what the caller classifies. + const pendingBefore = pendingTempDirCount(); + fsFault.writeFileError = new Error('EIO: i/o error'); + fsFault.rmError = new Error('EBUSY: resource busy or locked'); + try { + await expect(nodeCreateTempDocument('the whole conversation')).rejects.toThrow('EIO'); // NOT 'EBUSY' + expect(pendingTempDirCount()).toBe(pendingBefore + 1); // still registered for the exit net + } finally { + fsFault.writeFileError = undefined; + fsFault.rmError = undefined; + } + disposePendingTempDirs(); // the exit net's reclaim — now that rm works again + expect(pendingTempDirCount()).toBe(0); + }); + + it('SECURITY: the exit net reclaims the transcript on a HARD process.exit() — the path the async finally never runs on', async () => { + // The Step-5d-2 Sonnet review's critical finding: during a suspension ink has raw mode OFF, so a keyboard Ctrl-C + // is delivered as a REAL SIGINT to the foreground group; the surface's second-press `process.exit()` halts the + // event loop while `openInEditor` still awaits the child, so its `async finally` never disposes. Only a + // synchronous `'exit'` listener can still reclaim the directory. Here we invoke exactly that listener. + const doc = await nodeCreateTempDocument('the whole conversation'); + const dir = dirname(doc.path); + expect(existsSync(dir)).toBe(true); + + disposePendingTempDirs(); // exactly what the single `'exit'` listener runs + + expect(existsSync(dir)).toBe(false); // the conversation is NOT left in the OS temp directory + await doc.dispose(); // idempotent (`force: true`), and it clears the (already-empty) pending entry + expect(pendingTempDirCount()).toBe(0); + }); +}); + +/** + * The temp document's LAST-DITCH cleanup (2.6.F Step 6g, whole-phase Opus review). The file holds the whole + * conversation. `dispose()` used to remove the `process.on('exit')` net in a `finally`, so a failing `rm` — a Windows + * `EBUSY` from an AV scanner, an editor that has not released its handle — disarmed the very net that existed for + * that case, and the transcript survived the process. + */ +describe('nodeCreateTempDocument — the pending set outlives a failing dispose', () => { + it('a SUCCESSFUL dispose reclaims the directory and clears it from the pending set', async () => { + const before = pendingTempDirCount(); + const doc = await nodeCreateTempDocument('hello'); + expect(pendingTempDirCount()).toBe(before + 1); + expect(existsSync(doc.path)).toBe(true); + + await doc.dispose(); + expect(pendingTempDirCount()).toBe(before); + expect(existsSync(doc.path)).toBe(false); + }); + + it('a FAILING dispose rethrows and keeps it PENDING — the conversation gets one more chance', async () => { + const before = pendingTempDirCount(); + const doc = await nodeCreateTempDocument('secret conversation'); + fsFault.rmError = new Error('EBUSY'); + try { + await expect(doc.dispose()).rejects.toThrow('EBUSY'); + expect(pendingTempDirCount()).toBe(before + 1); // still pending + expect(existsSync(doc.path)).toBe(true); // …and the file is still there, which is exactly why + } finally { + fsFault.rmError = undefined; + } + + await doc.dispose(); // the retry succeeds + expect(pendingTempDirCount()).toBe(before); + expect(existsSync(doc.path)).toBe(false); + }); + + it('the file is 0600 inside a 0700 directory', async () => { + const doc = await nodeCreateTempDocument('hello'); + try { + expect(statSync(doc.path).mode & 0o777).toBe(0o600); + expect(statSync(dirname(doc.path)).mode & 0o777).toBe(0o700); + } finally { + await doc.dispose(); + } + }); +}); + +/** + * `openInEditor` NEVER THROWS — its whole contract is to classify every fault into an `EditorOutcome`, because the + * caller runs it inside a terminal suspension where a rejection strands the terminal (2.6.F Step 6h, Sonnet review). + */ +describe('openInEditor — the cleanup block cannot break the never-throws contract', () => { + it('a THROWING onDisposeFailed does not override the classified outcome', async () => { + const outcome = await openInEditor( + { + env: { EDITOR: 'true' }, + spawnEditor: () => Promise.resolve({ code: 0, signal: null }), + createTempDocument: () => + Promise.resolve({ + path: '/tmp/relavium-x/transcript.md', + dispose: () => Promise.reject(new Error('EBUSY')), + }), + onDisposeFailed: () => { + throw new Error('the reporter itself is broken'); + }, + }, + 'the whole conversation', + ); + // Without the guard, the `finally`'s rejection replaces this and `openInEditor` rejects — inside a suspension. + expect(outcome).toEqual({ kind: 'closed', exitCode: 0 }); + }); + + it('a failing dispose is still REPORTED when the reporter behaves', async () => { + const reported: unknown[] = []; + const outcome = await openInEditor( + { + env: { EDITOR: 'true' }, + spawnEditor: () => Promise.resolve({ code: 0, signal: null }), + createTempDocument: () => + Promise.resolve({ + path: '/tmp/relavium-x/transcript.md', + dispose: () => Promise.reject(new Error('EBUSY')), + }), + onDisposeFailed: (path, error) => reported.push([path, String(error)]), + }, + 'the whole conversation', + ); + expect(outcome.kind).toBe('closed'); + expect(reported).toHaveLength(1); + }); +}); diff --git a/apps/cli/src/render/editor.ts b/apps/cli/src/render/editor.ts new file mode 100644 index 00000000..bec1d904 --- /dev/null +++ b/apps/cli/src/render/editor.ts @@ -0,0 +1,282 @@ +import { spawn } from 'node:child_process'; +import { rmSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** + * The `$EDITOR` half of the ADR-0068 §e copy-and-search escape hatches (2.6.F Step 5d): write the transcript to a + * private temp file and hand the terminal to the user's editor, so they can search, select, and copy it — the + * capability the alternate screen takes away (it has no native scrollback, and mouse reporting captures click-drag). + * + * It is **read-only by contract**: the file is a throwaway VIEW of the conversation. Edits are never read back — the + * transcript is the session's persisted history, not a document. The temp file is removed on every path. + * + * This is the repo's FIRST TTY-inheriting child process. Every other spawn (`run_command`, `git_*`, the `!`-shell) + * runs behind the tool sandbox with piped stdio (`apps/cli/src/engine/tool-host/process.ts`) and therefore never + * touches the terminal. `$EDITOR` must inherit the TTY to be usable at all, which is exactly why it may only run + * inside `suspendFullScreen` — see `suspend.ts` for which terminal modes must be off first (mouse reporting above + * all: DECSET 1002 left on floods the editor with `\x1b[<…M` reports). + * + * SECURITY. `$EDITOR` is the user's own environment, at the same trust level as `$PATH` — not untrusted input. Even + * so it is spawned with **`shell: false`**, so every shell metacharacter in its value (`;`, `|`, `` ` ``, `$`) is an + * inert literal argv token rather than a command: there is no shell for an injection to reach. There is deliberately + * **no fallback to `vi`** — dropping a user who never set `$EDITOR` into a modal editor they cannot exit, inside a + * suspended full-screen app, is a worse failure than an actionable "set $EDITOR" notice. + */ + +/** The classified result of an editor session — a discriminated union so the surface renders each case explicitly + * and no raw error escapes (mirrors `UserCommandOutcome`, ADR-0061). */ +export type EditorOutcome = + /** The editor ran and exited; `exitCode` may be non-zero (the user's editor failed, not us). */ + | { readonly kind: 'closed'; readonly exitCode: number } + /** Neither `$VISUAL` nor `$EDITOR` is set (or both are blank) — nothing was spawned, no temp file created. */ + | { readonly kind: 'unavailable' } + /** The editor could not be started, or died on a signal. `message` is secret-free and user-facing. */ + | { readonly kind: 'failed'; readonly message: string }; + +/** How the editor process ended. `code` is `null` when it was killed by `signal`. */ +export interface EditorExit { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; +} + +/** A temp file holding the transcript, plus the disposer that removes it (and its private directory). */ +export interface TempDocument { + readonly path: string; + readonly dispose: () => Promise; +} + +export interface OpenInEditorDeps { + /** The process environment — read for `$VISUAL` / `$EDITOR` (in that precedence, as `git` and `less` use). */ + readonly env: Readonly>; + /** + * Report a temp-file disposal failure. This is the ONE cleanup in the module whose purpose is to keep the user's + * conversation off disk, so a failure (a Windows `EBUSY` from an AV scanner or a not-yet-released editor handle) + * must never vanish into a bare `.catch()`. Mirrors `chat.ts`'s `warnTeardown`: warn, never throw — a cleanup + * fault must not turn a successful edit into a failure. Absent ⇒ the failure is dropped (a test/driver default). + */ + readonly onDisposeFailed?: ((path: string, error: unknown) => void) | undefined; + /** Spawn the editor with the TTY INHERITED; resolves when it exits, rejects if it could not be started. */ + readonly spawnEditor: ( + command: string, + args: readonly string[], + file: string, + ) => Promise; + /** Create the private temp document. Injected so the orchestration is unit-testable without touching disk. */ + readonly createTempDocument: (contents: string) => Promise; +} + +/** + * Split an `$EDITOR` value into `command + args`, honouring simple single/double quoting so `"code -w"`, + * `"subl --wait"`, and `"'/Applications/My Editor/bin/ed' -n"` all work. NOT a shell parser: no variable expansion, + * no globbing, no escape sequences, no operators — the value is spawned with `shell: false`, so anything it does not + * understand stays an inert literal argument instead of becoming executable. Returns `undefined` for a blank value. + */ +/** Split `value` into whitespace-separated tokens, honouring `"…"` / `'…'` quoting (an empty quoted token `""` is a + * real, kept token; an unterminated quote runs to the end). NOT a shell parser — no expansion/globbing/operators. */ +function tokenizeEditorCommand(value: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | undefined; + let started = false; // distinguishes a real empty token (`""`) from "no token yet" + for (const char of value) { + if (quote !== undefined) { + if (char === quote) quote = undefined; + else current += char; + continue; + } + if (char === '"' || char === "'") { + quote = char; + started = true; + continue; + } + if (/\s/.test(char)) { + if (started) tokens.push(current); + current = ''; + started = false; + continue; + } + current += char; + started = true; + } + if (started) tokens.push(current); + return tokens; +} + +export function parseEditorCommand( + value: string, +): { readonly command: string; readonly args: readonly string[] } | undefined { + const [command, ...args] = tokenizeEditorCommand(value); + if (command === undefined || command.length === 0) return undefined; + return { command, args }; +} + +/** Resolve the editor from the environment: `$VISUAL` wins over `$EDITOR` (the POSIX convention — `VISUAL` is the + * full-screen editor, `EDITOR` the line-mode fallback, and we are handing over a full screen). */ +export function resolveEditor( + env: Readonly>, +): { readonly command: string; readonly args: readonly string[] } | undefined { + for (const key of ['VISUAL', 'EDITOR'] as const) { + const raw = env[key]; + if (raw === undefined) continue; + const parsed = parseEditorCommand(raw); + if (parsed !== undefined) return parsed; + } + return undefined; +} + +/** + * Write `contents` to a private temp file and open it in the user's editor, removing the file on EVERY path + * (success, non-zero exit, spawn failure, or a throw). Never throws: every fault is classified into + * {@link EditorOutcome} for the surface to render as a notice. + * + * Must be called inside `suspendFullScreen` — it inherits the TTY. + */ +export async function openInEditor( + deps: OpenInEditorDeps, + contents: string, +): Promise { + const editor = resolveEditor(deps.env); + if (editor === undefined) return { kind: 'unavailable' }; // nothing spawned, nothing written to disk + + let document: TempDocument; + try { + document = await deps.createTempDocument(contents); + } catch { + return { kind: 'failed', message: 'could not create a temporary file for the transcript' }; + } + + try { + const exit = await deps.spawnEditor(editor.command, editor.args, document.path); + if (exit.signal !== null) { + return { kind: 'failed', message: `${editor.command} was terminated by ${exit.signal}` }; + } + return { kind: 'closed', exitCode: exit.code ?? 0 }; + } catch { + // The editor could not be started (ENOENT / EACCES). The command NAME is the user's own env value, so echoing + // it is safe and is the only actionable part of the message. + return { kind: 'failed', message: `could not start ${editor.command}` }; + } finally { + // Best-effort: a leftover temp file must never turn a successful edit into a failure — but it also must not be + // silently retained, because it holds the conversation. Report, never throw (`warnTeardown`'s contract). + await document.dispose().catch((error: unknown) => { + // The reporter is injected, and a throwing one would replace the classified `EditorOutcome` with its own + // rejection — breaking this function's "never throws" contract from inside the very block meant to uphold it. + try { + deps.onDisposeFailed?.(document.path, error); + } catch { + // a faulty disposal reporter must not turn a successful edit into a failure + } + }); + } +} + +/** + * The production {@link OpenInEditorDeps.createTempDocument}: a `0700` private directory holding one `0600` file, + * both removed by `dispose`. `mkdtemp` (not a predictable name) closes the classic shared-`/tmp` symlink race, and + * the directory means `dispose` reclaims any sidecar/swap file the editor left behind (`.swp`, `~`). + * + * It also registers a SYNCHRONOUS `process.on('exit')` net, mirroring the alt-screen exit-safety pattern in + * `alt-screen.ts` / `chat.ts`. This is not belt-and-braces — it closes a real hole found by the Step-5d-2 Sonnet + * review: during a suspension ink has turned raw mode OFF, so a keyboard **Ctrl-C reaches the kernel as a real + * SIGINT** to the whole foreground process group (the editor shares ours — it is not `detached`). A second press + * runs the surface's `process.exit(…)`, which halts the event loop while `openInEditor` is still awaiting the + * child — so its `async finally` NEVER runs and the directory holding the full conversation survives on disk. + * `rmSync` in an `'exit'` listener is the only cleanup that can still run there. + * + * ONE listener, for the whole process, over a SET of directories still awaiting cleanup — not one listener per + * document. A per-document listener was only removed when its `rm` SUCCEEDED (it must be: a failing `rm` is exactly + * when the last-ditch net is needed), so a host where cleanup persistently fails — a Windows AV scanner holding every + * newly-written file, an NFS delete race — accumulated one listener per `/edit` until Node printed a + * `MaxListenersExceededWarning` straight onto the alt buffer (2.6.F Step 6h, Sonnet review). Reproduced: five failing + * disposes left five listeners and five directories holding the conversation. + */ + +/** Directories awaiting cleanup. A document is removed on a SUCCESSFUL `dispose`; whatever remains at process exit is + * reclaimed synchronously by the single net below. */ +const pendingTempDirs = new Set(); +let exitNetArmed = false; + +/** Arm the process-wide `'exit'` net once. Idempotent, so `nodeCreateTempDocument` can call it every time. */ +function armTempDirExitNet(): void { + if (exitNetArmed) return; + exitNetArmed = true; + process.on('exit', () => { + for (const dir of pendingTempDirs) { + try { + rmSync(dir, { recursive: true, force: true }); // the only cleanup that survives a hard `process.exit()` + } catch { + // an 'exit' listener may not throw — and there is nowhere left to report to + } + } + pendingTempDirs.clear(); + }); +} + +/** Reclaim every temp directory still pending. Exposed for the test that cannot call `process.exit()`. */ +export function disposePendingTempDirs(): void { + for (const dir of pendingTempDirs) rmSync(dir, { recursive: true, force: true }); + pendingTempDirs.clear(); +} + +/** How many documents are still awaiting cleanup. Exposed for the accumulation test. */ +export function pendingTempDirCount(): number { + return pendingTempDirs.size; +} +export const nodeCreateTempDocument = async (contents: string): Promise => { + const dir = await mkdtemp(join(tmpdir(), 'relavium-transcript-')); + const path = join(dir, 'transcript.md'); + // Register + arm the exit net BEFORE the write: the write flushes (part of) the conversation, so a write that fails + // has still put transcript bytes in the directory, and the exit net must be able to reclaim it (Step-6h review). + pendingTempDirs.add(dir); + armTempDirExitNet(); + try { + await writeFile(path, contents, { encoding: 'utf8', mode: 0o600 }); + } catch (error) { + // Reclaim the partial transcript. If THAT fails (a locked dir), leave it registered so the exit net retries — and + // never let the removal's error MASK the write error the caller classifies as `{ kind: 'failed' }` (Step-5d-3 Opus + // review; the masking + never-registered gap is the Step-6h Sonnet finding). Async `rm` here (not `rmSync`) so this + // is the SAME reclaim `dispose` uses — the sync `rmSync` is reserved for the `'exit'` net, which cannot await. + try { + await rm(dir, { recursive: true, force: true }); + pendingTempDirs.delete(dir); + } catch { + // keep it in the pending set — the exit net gets one more chance + } + throw error; + } + + return { + path, + dispose: async () => { + // `force: true` ⇒ an already-reclaimed dir is a silent no-op, so the exit net can never provoke a spurious + // disposal warning if both run. + // + // If this THROWS (a Windows `EBUSY` from an AV scanner, an editor that has not released its handle, `EPERM`), + // the directory stays in `pendingTempDirs` and the exit net gets one more chance. It used to be removed in a + // `finally`, which disarmed the last-ditch cleanup at exactly the moment it was needed — and the temp file + // holding the WHOLE conversation survived the process (whole-phase Opus review). `openInEditor` still reports + // the failure through `onDisposeFailed`; this only decides whether we keep trying. + await rm(dir, { recursive: true, force: true }); + pendingTempDirs.delete(dir); + }, + }; +}; + +/** The production {@link OpenInEditorDeps.spawnEditor}: inherit the TTY (the editor IS the foreground app while it + * runs) and resolve on exit. `shell: false` — see the module note. Rejects only when the child cannot be started. */ +export const nodeSpawnEditor = ( + command: string, + args: readonly string[], + file: string, +): Promise => + new Promise((resolve, reject) => { + const child = spawn(command, [...args, file], { + stdio: 'inherit', // the editor owns the terminal; suspendFullScreen already handed it over + shell: false, // SECURITY: no shell — a metacharacter in $EDITOR is an inert argv token, never a command + windowsHide: false, + }); + child.once('error', reject); // ENOENT / EACCES — the command does not exist or is not executable + child.once('close', (code, signal) => resolve({ code, signal })); + }); diff --git a/apps/cli/src/render/hatches.test.ts b/apps/cli/src/render/hatches.test.ts new file mode 100644 index 00000000..3d19c5fb --- /dev/null +++ b/apps/cli/src/render/hatches.test.ts @@ -0,0 +1,412 @@ +import { describe, expect, it } from 'vitest'; + +import { DISABLE_MOUSE, ENABLE_MOUSE, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from './alt-screen.js'; +import { + EMPTY_TRANSCRIPT_NOTICE, + createHatches, + DEFAULT_COLUMNS, + hoistedTerminal, + inertHatchPorts, + inkOwnedTerminal, + type HatchDeps, +} from './hatches.js'; +import { createSuspendPort, type SuspendTerminal } from './suspend.js'; +import type { TranscriptEntry } from './tui/session-view-model.js'; + +/** + * The `/scrollback` + `/edit` hatches (2.6.F Step 5d, ADR-0068 §e). These pin what the USER experiences: the terminal + * is always given back, no fault ever crashes the REPL, the transcript is dumped at the live width but handed to + * `$EDITOR` unwrapped, and a driver with no full-screen renderer says so instead of failing. + */ + +const userEntry = (text: string): TranscriptEntry => ({ role: 'user', text }); + +/** A real (recording) `suspendTerminal`, so the terminal-mode writes are asserted through the real primitive. */ +const inkSuspend = + (trace: string[]): SuspendTerminal => + async (callback) => { + trace.push('ink:begin'); + try { + await callback(); + } finally { + trace.push('ink:end'); + } + }; + +const harness = ( + over: Partial & { + transcriptEntries?: readonly TranscriptEntry[]; + noSuspend?: boolean; + columns?: number; + } = {}, +): { + deps: HatchDeps; + trace: string[]; + notes: string[]; + edited: string[]; + copied: string[]; +} => { + const trace: string[] = []; + const notes: string[] = []; + const edited: string[] = []; + const copied: string[] = []; + const port = createSuspendPort(); + if (over.noSuspend !== true) port.attach(inkSuspend(trace)); + + const deps: HatchDeps = { + suspendPort: port, + clipboard: (text) => { + copied.push(text); + trace.push('clipboard'); + return { kind: 'written', characters: text.length }; + }, + transcript: () => over.transcriptEntries ?? [userEntry('hello')], + // Notes go into the SAME trace as the terminal writes: their ORDER relative to `ink:begin`/`ink:end` is the + // property under test (a notice pushed mid-suspension is never painted — ink's frame is erased there). + note: (text) => { + notes.push(text); + trace.push(`NOTE:${text}`); + }, + terminal: () => ({ + columns: over.columns ?? 80, + altActive: true, + mouseActive: true, + inkOwnsAltScreen: false, // the `relavium chat` shape + }), + writeControl: (sequence) => trace.push(sequence), + dump: { + writeOut: (text) => trace.push(`OUT:${text.split('\n').length}L`), + waitForContinue: () => { + trace.push('wait'); + return Promise.resolve(); + }, + }, + editor: { + env: { EDITOR: 'vim' }, + createTempDocument: (contents) => { + edited.push(contents); + return Promise.resolve({ path: '/tmp/t.md', dispose: () => Promise.resolve() }); + }, + spawnEditor: () => { + trace.push('editor'); + return Promise.resolve({ code: 0, signal: null }); + }, + }, + ...over, + }; + return { deps, trace, notes, edited, copied }; +}; + +describe('/scrollback', () => { + it('suspends the renderer, dumps, waits, and restores every terminal mode — in order', async () => { + const { deps, trace, notes } = harness(); + await createHatches(deps).dumpScrollback(); + expect(trace).toEqual([ + 'ink:begin', + DISABLE_MOUSE, // native selection back, and no mouse reports into the dump + expect.stringContaining(EXIT_ALT_SCREEN), // the chat surface owns 1049 + 'OUT:5L', + 'wait', // the dump is useless if the frame repaints before the user looks + expect.stringContaining(ENTER_ALT_SCREEN), + ENABLE_MOUSE, + 'ink:end', + ]); + expect(notes).toEqual([]); // a clean run says nothing + }); + + it('wraps to the LIVE terminal width (it is printed to THAT terminal)', async () => { + const { deps, trace } = harness({ + transcriptEntries: [userEntry('x'.repeat(50))], + columns: 20, + }); + await createHatches(deps).dumpScrollback(); + // 52 chars (`> ` + 50) char-wrap to 3 rows at width 20. The single write is header + 3 rows + footer + prompt, + // each newline-terminated ⇒ `split('\n')` yields 7 (the trailing newline leaves an empty last element). + expect(trace).toContain('OUT:7L'); + }); + + it('an EMPTY transcript notices instead of flipping the screen for nothing', async () => { + const { deps, trace, notes } = harness({ transcriptEntries: [] }); + await createHatches(deps).dumpScrollback(); + expect(trace).toEqual(['NOTE:/scrollback: the transcript is empty.']); // the terminal is never touched + expect(notes).toEqual(['/scrollback: the transcript is empty.']); + }); +}); + +describe('/edit', () => { + it('hands $EDITOR the UNWRAPPED document (the editor re-flows at its own width)', async () => { + const { deps, edited } = harness({ + transcriptEntries: [userEntry('y'.repeat(500))], + columns: 20, + }); + await createHatches(deps).editTranscript(); + expect(edited).toHaveLength(1); + expect(edited[0]?.split('\n')).toHaveLength(1); // NOT wrapped to 20 columns + }); + + it('a missing $EDITOR notices AFTER the suspension, never mid-suspension (ink’s frame is erased there)', async () => { + const { deps, trace, notes } = harness({ + editor: { + env: {}, // neither VISUAL nor EDITOR + createTempDocument: () => Promise.reject(new Error('should not be reached')), + spawnEditor: () => Promise.reject(new Error('should not be reached')), + }, + }); + await createHatches(deps).editTranscript(); + const notice = '/edit: set $EDITOR (or $VISUAL) to open the transcript in your editor.'; + expect(notes).toEqual([notice]); + // The ORDER is the point: the whole suspension completed and the terminal was restored BEFORE the notice. + expect(trace.indexOf(`NOTE:${notice}`)).toBeGreaterThan(trace.indexOf('ink:end')); + }); + + it('a clean editor session says nothing (the user knows they just closed their editor)', async () => { + const { deps, notes } = harness(); + await createHatches(deps).editTranscript(); + expect(notes).toEqual([]); + }); + + it('a dead editor is a notice, not a crash — and the terminal is restored first', async () => { + const { deps, trace, notes } = harness({ + editor: { + env: { EDITOR: 'vim' }, + createTempDocument: () => + Promise.resolve({ path: '/t.md', dispose: () => Promise.resolve() }), + spawnEditor: () => Promise.reject(new Error('ENOENT')), + }, + }); + await createHatches(deps).editTranscript(); + expect(trace).toContain(ENABLE_MOUSE); + expect(notes).toEqual(['/edit: could not start vim']); + expect(trace.indexOf('NOTE:/edit: could not start vim')).toBeGreaterThan( + trace.indexOf('ink:end'), + ); + }); + + it('an EMPTY transcript notices without spawning anything', async () => { + const { deps, trace, notes } = harness({ transcriptEntries: [] }); + await createHatches(deps).editTranscript(); + expect(trace).toEqual(['NOTE:/edit: the transcript is empty.']); // nothing spawned, nothing written + expect(notes).toEqual(['/edit: the transcript is empty.']); + }); +}); + +describe('the hatches on a driver with NO full-screen renderer (plain / --json)', () => { + it('both notice honestly instead of failing — the port is empty, so there is no terminal to suspend', async () => { + const { deps, trace, notes } = harness({ noSuspend: true }); + const hatches = createHatches(deps); + await hatches.dumpScrollback(); + await hatches.editTranscript(); + expect(trace.filter((t) => !t.startsWith('NOTE:'))).toEqual([]); // no terminal mode was ever touched + expect(notes).toEqual([ + '/scrollback: needs an interactive terminal.', + '/edit: needs an interactive terminal.', + ]); + }); + + it('reads the port at CALL time, so a hatch works the moment a renderer mounts', async () => { + const { deps, trace, notes } = harness({ noSuspend: true }); + const hatches = createHatches(deps); + await hatches.dumpScrollback(); + expect(notes).toHaveLength(1); + + deps.suspendPort.attach(inkSuspend(trace)); // ink mounted + await hatches.dumpScrollback(); + expect(trace).toContain('ink:begin'); + expect(notes).toHaveLength(1); // no second "needs an interactive terminal" + }); +}); + +describe('a rejected suspension never crashes the REPL', () => { + it('surfaces ink’s error as a notice (the terminal is already restored by suspendFullScreen)', async () => { + const { deps, notes } = harness(); + deps.suspendPort.attach(() => Promise.reject(new Error('The terminal is already suspended.'))); + await createHatches(deps).dumpScrollback(); + expect(notes).toEqual(['/scrollback: The terminal is already suspended.']); + }); + + it('the busy latch makes a CONCURRENT hatch a no-op rather than ink’s "already suspended" throw', async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const trace: string[] = []; + const { deps, notes } = harness(); + deps.suspendPort.attach(async (callback) => { + trace.push('ink:begin'); + await gate; // hold the suspension open + await callback(); + trace.push('ink:end'); + }); + const hatches = createHatches(deps); + const first = hatches.dumpScrollback(); + await hatches.editTranscript(); // arrives while the first suspension is still open + release?.(); + await first; + expect(trace.filter((t) => t === 'ink:begin')).toHaveLength(1); // never a second beginSuspend + expect(notes).toEqual([]); // and the dropped hatch is silent, not an error + }); +}); + +/** + * The per-surface terminal-fact factories (Step-5d-3 Opus review). `inkOwnsAltScreen` is the single most dangerous + * boolean in this feature: get it backwards and `/scrollback` either paints into the invisible alt buffer or + * double-toggles DECSET-1049 — a stranded or garbled terminal. It is therefore decided ONCE per surface, by a factory + * whose NAME says which surface it is for, and pinned here. Before this, both were bare booleans at call sites in + * `chat.ts` / `drive-home.tsx` with no test at all. + */ +describe('the per-surface terminal facts', () => { + it('hoistedTerminal (`relavium chat`): WE own 1049 — ink mounts with alternateScreen:false', () => { + const term = hoistedTerminal( + () => true, + () => true, + () => 120, + )(); + expect(term).toEqual({ + columns: 120, + altActive: true, + mouseActive: true, + inkOwnsAltScreen: false, + }); + }); + + it('inkOwnedTerminal (the bare Home): INK owns 1049 — it mounts with alternateScreen:true', () => { + const term = inkOwnedTerminal( + () => true, + () => true, + () => 120, + )(); + expect(term).toEqual({ + columns: 120, + altActive: true, + mouseActive: true, + inkOwnsAltScreen: true, + }); + }); + + it('both read their predicate LIVE, so a hatch reflects the buffer’s real state, not the startup mode', () => { + let entered = false; + const chat = hoistedTerminal( + () => entered, + () => entered, + () => 80, + ); + const home = inkOwnedTerminal( + () => entered, + () => entered, + () => 80, + ); + expect(chat().altActive).toBe(false); + expect(home().mouseActive).toBe(false); + entered = true; // the alt buffer is entered AFTER the ports were built + expect(chat().altActive).toBe(true); + expect(chat().mouseActive).toBe(true); + expect(home().altActive).toBe(true); + }); + + it('mouseActive is INDEPENDENT of altActive — the `--no-mouse` shape (alt buffer on, mouse off)', () => { + // Step 5e decoupled them: `--no-mouse` / `[preferences].mouse = false` leaves the alt buffer entered with mouse + // reporting never armed. A suspension must then NOT "restore" DECSET-1000 on the way back — it was never set. + const chat = hoistedTerminal( + () => true, + () => false, + () => 80, + )(); + expect(chat.altActive).toBe(true); + expect(chat.mouseActive).toBe(false); + + const home = inkOwnedTerminal( + () => true, + () => false, + () => 80, + )(); + expect(home.altActive).toBe(true); + expect(home.mouseActive).toBe(false); + }); + + it('falls back to a sane width when the terminal reports no column count', () => { + expect( + hoistedTerminal( + () => true, + () => true, + () => undefined, + )().columns, + ).toBe(DEFAULT_COLUMNS); + expect( + inkOwnedTerminal( + () => true, + () => true, + () => undefined, + )().columns, + ).toBe(DEFAULT_COLUMNS); + }); +}); + +describe('inertHatchPorts — a driver with no renderer (plain / --json, or a unit test)', () => { + it('short-circuits on the ONE "needs an interactive terminal" notice, never touching the dump/editor ports', async () => { + const notes: string[] = []; + const hatches = createHatches({ + ...inertHatchPorts(), + transcript: () => [userEntry('hello')], + note: (text) => notes.push(text), + }); + await hatches.dumpScrollback(); + await hatches.editTranscript(); + expect(notes).toEqual([ + '/scrollback: needs an interactive terminal.', + '/edit: needs an interactive terminal.', + ]); + // The editor port would REJECT if reached — proving the short-circuit is what produced the notices. + await expect(inertHatchPorts().editor.spawnEditor('x', [], 'f')).rejects.toThrow( + 'no full-screen renderer is attached', + ); + }); +}); + +/** + * `/copy` (2.6.F Step 6e). The third hatch, and the only one that suspends NOTHING: OSC 52 is a single control write, + * so the renderer never gives up the terminal. It copies the UNWRAPPED document — a paragraph the viewport folded + * across four rows comes back as one line, which is what a user pasting into a bug report wants. The mouse selection + * copies the VISUAL rows instead; they are different jobs. + */ +describe('createHatches — /copy', () => { + it('copies the unwrapped transcript document and never suspends the renderer', () => { + const { deps, trace, copied, notes } = harness({ columns: 20 }); + createHatches(deps).copyTranscript(); + + expect(copied).toHaveLength(1); + expect(copied[0]).toContain('hello'); + // The clipboard write happens, then the notice — and no `ink:begin` / `ink:end` between them. + expect(trace[0]).toBe('clipboard'); + expect(trace.filter((t) => t.startsWith('ink:'))).toEqual([]); + expect(notes[0]).toMatch(/^\/copy: sent \d+ characters to the clipboard\.$/); + }); + + it('an EMPTY transcript is a notice, and the clipboard is never touched', () => { + const { deps, copied, notes } = harness({ transcriptEntries: [] }); + createHatches(deps).copyTranscript(); + expect(copied).toEqual([]); + expect(notes).toEqual([`/copy: ${EMPTY_TRANSCRIPT_NOTICE}`]); + }); + + it('a transcript past the terminal’s OSC 52 floor is REFUSED, and points at the hatches that scale', () => { + const { deps, notes } = harness({ + clipboard: () => ({ kind: 'too-large', base64Length: 120_000, limit: 74_994 }), + }); + createHatches(deps).copyTranscript(); + expect(notes[0]).toContain('too large'); + expect(notes[0]).toContain('/scrollback or /edit'); + }); + + it('reports what it WROTE, never that it was copied — OSC 52 has no acknowledgement', () => { + const { deps, notes } = harness(); + createHatches(deps).copyTranscript(); + expect(notes[0]).toContain('sent'); + expect(notes[0]).not.toContain('copied'); + }); + + it('works with NO full-screen renderer attached — unlike /scrollback and /edit, it needs no suspension', () => { + const { deps, copied } = harness({ noSuspend: true }); + createHatches(deps).copyTranscript(); + expect(copied).toHaveLength(1); // a plain / `--json` chat can still `/copy` + }); +}); diff --git a/apps/cli/src/render/hatches.ts b/apps/cli/src/render/hatches.ts new file mode 100644 index 00000000..983b7d91 --- /dev/null +++ b/apps/cli/src/render/hatches.ts @@ -0,0 +1,245 @@ +import type { ClipboardOutcome } from './clipboard.js'; +import { openInEditor, type EditorOutcome, type OpenInEditorDeps } from './editor.js'; +import { dumpToScrollback, type DumpToScrollbackDeps } from './scrollback.js'; +import { createSuspendPort, suspendFullScreen, type SuspendPort } from './suspend.js'; +import { transcriptDocument, wrapTranscript } from './tui/chat-projection.js'; +import type { TranscriptEntry } from './tui/session-view-model.js'; + +/** + * The two ADR-0068 §e **copy-and-search escape hatches**, shared verbatim by both interactive surfaces (2.6.F Step 5d): + * + * - **`/scrollback`** — dump the transcript into the terminal's native scrollback, where every tool the user already + * has (scroll, search, click-drag select, copy) works on it. + * - **`/edit`** — open the transcript in `$EDITOR`, read-only, for search and copy. + * + * They exist because the alternate screen structurally removes those affordances: it has no scrollback, and mouse + * reporting captures the click-drag the emulator would use for selection. + * + * WHY THIS IS SHARED, and why neither surface intercepts them. `/models` and `/effort` must be intercepted at the + * render layer on BOTH surfaces (four call sites) because they open a React overlay. These two open nothing — they + * need a *function*, `suspendTerminal`, which the {@link SuspendPort} carries out of the ink tree. So they are plain + * registry commands whose `run` calls a `ReplCommandContext` capability, dispatched through the one existing slash + * path. The standalone chat and the in-Home chat therefore cannot drift. + * + * NEVER CRASH THE REPL: every fault (no live renderer, an empty transcript, a rejected suspension, a missing + * `$EDITOR`, a dead editor) becomes a one-line transcript notice. The terminal is restored by `suspendFullScreen` + * regardless — see `suspend.ts` for the exit-safety contract. + */ + +/** The live terminal facts, read at CALL time — a resize, or a future `--no-mouse` toggle, must never be captured. */ +export interface HatchTerminal { + /** The current column count, for wrapping the scrollback dump to the terminal the user is looking at. */ + readonly columns: number; + /** Whether the alt buffer is currently entered. */ + readonly altActive: boolean; + /** Whether mouse reporting (DECSET 1002+1006) is currently on. */ + readonly mouseActive: boolean; + /** `true` on the bare Home (ink's `alternateScreen` render option owns 1049); `false` on `relavium chat`. */ + readonly inkOwnsAltScreen: boolean; + // NOTE `mouseActive` is INDEPENDENT of `altActive` since Step 5e: `--no-mouse` / `[preferences].mouse = false` + // leaves the alt buffer entered with mouse reporting off, so a suspension must not "restore" a mode we never set. +} + +export interface HatchDeps { + /** The React→core bridge carrying ink's `suspendTerminal`. `undefined` ⇒ no live full-screen renderer. */ + readonly suspendPort: SuspendPort; + /** The transcript at call time (the store's CURRENT snapshot — never a stale capture). */ + readonly transcript: () => readonly TranscriptEntry[]; + /** Surface a one-line result in the transcript (the store's sanitized notice channel). */ + readonly note: (text: string) => void; + /** The live terminal facts, read at call time. */ + readonly terminal: () => HatchTerminal; + /** Write a raw control sequence (the alt-buffer / mouse toggles). */ + readonly writeControl: (sequence: string) => void; + /** The scrollback dump's I/O (production: `nodeWriteOut` + `nodeWaitForContinue`). */ + readonly dump: DumpToScrollbackDeps; + /** The `$EDITOR` ports (production: `nodeSpawnEditor` + `nodeCreateTempDocument`). */ + readonly editor: OpenInEditorDeps; + /** Put text on the system clipboard over OSC 52 (production: `copyToClipboard` bound to `writeControl` + `env`). + * Used by `/copy`; the mouse selection has its own binding inside the ink tree. */ + readonly clipboard: (text: string) => ClipboardOutcome; +} + +/** The notice a hatch surfaces when no ink tree is mounted — a plain / `--json` driver has no terminal to suspend. */ +export const NO_RENDERER_NOTICE = 'needs an interactive terminal.'; +/** The assumed width when the terminal reports no column count (a detached / zero-sized TTY). The dump is printed to + * a real terminal, so a sane fallback beats refusing to print. */ +export const DEFAULT_COLUMNS = 80; + +/** + * The terminal facts for **`relavium chat`**: ink mounts with `alternateScreen: false`, so the HOISTED + * `AltScreenController` owns DECSET-1049 and the suspension must toggle it itself. Mouse reporting is enabled with + * the buffer (alt-screen.ts bundles them), so both read the SAME live predicate — never the mode resolved at startup. + */ +export const hoistedTerminal = + (altEntered: () => boolean, mouseEnabled: () => boolean, columns: () => number | undefined) => + (): HatchTerminal => ({ + columns: columns() ?? DEFAULT_COLUMNS, + altActive: altEntered(), + mouseActive: mouseEnabled(), + inkOwnsAltScreen: false, + }); + +/** + * The terminal facts for the **bare Home**: ink mounts with `alternateScreen: true`, so ink's own begin/endSuspend + * exit and re-enter DECSET-1049 — the suspension must NOT touch it. Only the mouse is ours. + * + * The two factories exist so `inkOwnsAltScreen` is chosen ONCE per surface, by a name that says which surface it is + * for. Inverting it strands or garbles the terminal, and a bare boolean at a call site is exactly the kind of thing + * a future edit gets backwards (Step-5d-3 Opus review). + */ +export const inkOwnedTerminal = + (altActive: () => boolean, mouseEnabled: () => boolean, columns: () => number | undefined) => + (): HatchTerminal => ({ + columns: columns() ?? DEFAULT_COLUMNS, + altActive: altActive(), + mouseActive: mouseEnabled(), + inkOwnsAltScreen: true, + }); + +/** + * Ports for a caller with NO full-screen renderer (a plain / `--json` driver, or a unit test). The suspend port is + * empty, so {@link createHatches} short-circuits on {@link NO_RENDERER_NOTICE} and never reaches the dump/editor + * ports. They exist to satisfy the type — and this is why there is no second, drifting "unavailable" string anywhere. + */ +export function inertHatchPorts(): Omit { + const unreachable = (): Promise => + Promise.reject(new Error('no full-screen renderer is attached')); + return { + suspendPort: createSuspendPort(), + writeControl: () => undefined, + terminal: () => ({ + columns: DEFAULT_COLUMNS, + altActive: false, + mouseActive: false, + inkOwnsAltScreen: false, + }), + dump: { writeOut: () => undefined, waitForContinue: () => Promise.resolve() }, + editor: { env: {}, spawnEditor: unreachable, createTempDocument: unreachable }, + clipboard: () => ({ kind: 'empty' }), + }; +} +/** The notice a hatch surfaces before a single turn has completed — nothing to dump or edit yet. */ +export const EMPTY_TRANSCRIPT_NOTICE = 'the transcript is empty.'; + +/** Render an {@link EditorOutcome} as the one line the user sees. `closed` is silent: the user just came back from + * their editor and does not need to be told that they did. */ +function editorNotice(outcome: EditorOutcome): string | undefined { + switch (outcome.kind) { + case 'closed': + return undefined; + case 'unavailable': + return '/edit: set $EDITOR (or $VISUAL) to open the transcript in your editor.'; + case 'failed': + return `/edit: ${outcome.message}`; + } +} + +export interface Hatches { + readonly dumpScrollback: () => Promise; + readonly editTranscript: () => Promise; + readonly copyTranscript: () => void; +} + +/** + * Build the two hatches over a surface's ports. The `busy` latch makes ink's `beginSuspend()` "already suspended" + * throw unreachable: input is paused for the whole suspension, so a second invocation should be impossible — but the + * cost of being sure is one boolean, and the failure it prevents is a rejected promise mid-terminal-handover. + */ +export function createHatches(deps: HatchDeps): Hatches { + let busy = false; + + /** Run `body` with the full-screen renderer suspended, funnelling every fault into a notice. */ + const withSuspension = async (label: string, body: () => Promise): Promise => { + const suspend = deps.suspendPort.current(); + if (suspend === undefined) { + deps.note(`${label}: ${NO_RENDERER_NOTICE}`); + return false; + } + if (busy) return false; // a suspension is already in flight; ink would throw + busy = true; + const term = deps.terminal(); + try { + await suspendFullScreen( + { + suspendTerminal: suspend, + writeControl: deps.writeControl, + inkOwnsAltScreen: term.inkOwnsAltScreen, + altActive: term.altActive, + mouseActive: term.mouseActive, + }, + body, + ); + return true; + } catch (error) { + // The terminal is already restored (suspend.ts's contract). Report the ROOT cause, never crash the REPL. + deps.note(`${label}: ${error instanceof Error ? error.message : String(error)}`); + return false; + } finally { + busy = false; + } + }; + + return { + dumpScrollback: async () => { + const transcript = deps.transcript(); + if (transcript.length === 0) { + deps.note(`/scrollback: ${EMPTY_TRANSCRIPT_NOTICE}`); + return; + } + // Wrapped to the LIVE width, because it is printed to that terminal (unlike `/edit`, which the editor re-flows). + const lines = wrapTranscript(transcript, deps.terminal().columns).map((line) => line.text); + await withSuspension('/scrollback', () => dumpToScrollback(deps.dump, lines)); + }, + + /** + * `/copy` — put the WHOLE transcript on the system clipboard (2.6.F Step 6e). Unlike its two siblings it suspends + * nothing: OSC 52 is one control write, and the renderer never has to give up the terminal. + * + * It copies the UNWRAPPED document (`transcriptDocument`), not the wrapped display rows a mouse selection yields: + * a paragraph the viewport folded across four rows comes back as one line, which is what a user pasting into a + * bug report or a chat wants. The visual fidelity of a selection is the selection's job. + */ + copyTranscript: () => { + const transcript = deps.transcript(); + if (transcript.length === 0) { + deps.note(`/copy: ${EMPTY_TRANSCRIPT_NOTICE}`); + return; + } + const outcome = deps.clipboard(transcriptDocument(transcript)); + switch (outcome.kind) { + case 'written': + // `'written'`, never `'copied'`: OSC 52 has no acknowledgement, so a terminal that drops it (VS Code Remote + // SSH) is indistinguishable from one that honoured it. Say what we did, not what we cannot know. + deps.note(`/copy: sent ${String(outcome.characters)} characters to the clipboard.`); + return; + case 'too-large': + deps.note( + `/copy: the transcript is too large for the terminal's clipboard escape (${String(Math.ceil(outcome.base64Length / 1024))} KB) — use /scrollback or /edit.`, + ); + return; + case 'empty': + deps.note(`/copy: ${EMPTY_TRANSCRIPT_NOTICE}`); + return; + } + }, + + editTranscript: async () => { + const transcript = deps.transcript(); + if (transcript.length === 0) { + deps.note(`/edit: ${EMPTY_TRANSCRIPT_NOTICE}`); + return; + } + const contents = transcriptDocument(transcript); + let outcome: EditorOutcome | undefined; + // The outcome is noted AFTER the suspension: ink's frame is erased and its render loop paused for the whole + // window, so a notice pushed mid-suspension would never be painted (and the editor owns the screen anyway). + const ran = await withSuspension('/edit', async () => { + outcome = await openInEditor(deps.editor, contents); + }); + if (!ran || outcome === undefined) return; + const notice = editorNotice(outcome); + if (notice !== undefined) deps.note(notice); + }, + }; +} diff --git a/apps/cli/src/render/render-mode.test.ts b/apps/cli/src/render/render-mode.test.ts index 0282876a..0340ff8c 100644 --- a/apps/cli/src/render/render-mode.test.ts +++ b/apps/cli/src/render/render-mode.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { DEFAULT_ALT_SCREEN, resolveRenderMode, type RenderModeInput } from './render-mode.js'; +import { + DEFAULT_COPY_ON_SELECT, + resolveCopyOnSelect, + DEFAULT_ALT_SCREEN, + DEFAULT_MOUSE, + resolveMouseMode, + resolveRenderMode, + type RenderModeInput, +} from './render-mode.js'; const input = (over: Partial = {}): RenderModeInput => ({ outputMode: 'tui', @@ -60,3 +68,71 @@ describe('resolveRenderMode (2.6.F / ADR-0068 §e)', () => { ).toBe('inline'); // plain beats all }); }); + +/** + * `resolveMouseMode` (2.6.F Step 5e, ADR-0068 §e). Precedence mirrors `resolveRenderMode`, with one extra structural + * guarantee: the INLINE renderer can never enable mouse reporting, whatever the flag or the config key say — capturing + * the mouse there would break the emulator's native scrollback selection, which is the whole reason inline exists. + */ +describe('resolveMouseMode', () => { + const base = { renderMode: 'alt', noMouseFlag: false, configMouse: undefined } as const; + + it('defaults to ON inside the alt screen (a maintainer deviation from §e, recorded in the ADR)', () => { + expect(resolveMouseMode(base)).toBe(true); + expect(DEFAULT_MOUSE).toBe(true); + }); + + it('the INLINE renderer never enables the mouse — not by config, not by the phase default', () => { + expect(resolveMouseMode({ ...base, renderMode: 'inline' })).toBe(false); + expect(resolveMouseMode({ ...base, renderMode: 'inline', configMouse: true })).toBe(false); + expect(resolveMouseMode({ ...base, renderMode: 'inline', defaultMouse: true })).toBe(false); + }); + + it('`--no-mouse` overrides the config key (the flag is the per-invocation opt-out)', () => { + expect(resolveMouseMode({ ...base, noMouseFlag: true, configMouse: true })).toBe(false); + }); + + it('`[preferences].mouse` is the durable opt-out / opt-in when no flag is passed', () => { + expect(resolveMouseMode({ ...base, configMouse: false })).toBe(false); + expect(resolveMouseMode({ ...base, configMouse: true, defaultMouse: false })).toBe(true); + }); + + it('falls to the injected phase default when neither flag nor key decides', () => { + expect(resolveMouseMode({ ...base, defaultMouse: false })).toBe(false); + }); +}); + +/** + * `[preferences].copy_on_select` (2.6.F Step 6e). Deliberately has NO flag: it is a durable preference, and + * `--no-mouse` already removes the gesture that produces a copy. + */ +describe('resolveCopyOnSelect', () => { + it('defaults ON when the mouse is on', () => { + expect(resolveCopyOnSelect({ mouseEnabled: true, configCopyOnSelect: undefined })).toBe( + DEFAULT_COPY_ON_SELECT, + ); + expect(DEFAULT_COPY_ON_SELECT).toBe(true); + }); + + it('the config key opts out durably, and can also opt IN explicitly', () => { + expect(resolveCopyOnSelect({ mouseEnabled: true, configCopyOnSelect: false })).toBe(false); + expect(resolveCopyOnSelect({ mouseEnabled: true, configCopyOnSelect: true })).toBe(true); + }); + + it('is STRUCTURALLY off without the mouse — no selection can exist, so nothing can be copied', () => { + // Taking the already-resolved mouse decision (not the raw flag/key) is what makes this unbypassable: an unmoused + // caller cannot ask for copy-on-select even by setting the key. Same trick as `resolveMouseMode(renderMode)`. + expect(resolveCopyOnSelect({ mouseEnabled: false, configCopyOnSelect: true })).toBe(false); + expect(resolveCopyOnSelect({ mouseEnabled: false, configCopyOnSelect: undefined })).toBe(false); + }); + + it('honours an injected phase default (so the default can move without touching call sites)', () => { + expect( + resolveCopyOnSelect({ + mouseEnabled: true, + configCopyOnSelect: undefined, + defaultCopyOnSelect: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/cli/src/render/render-mode.ts b/apps/cli/src/render/render-mode.ts index fa91dc31..da15a440 100644 --- a/apps/cli/src/render/render-mode.ts +++ b/apps/cli/src/render/render-mode.ts @@ -44,3 +44,71 @@ export function resolveRenderMode(input: RenderModeInput): RenderMode { const enabled = input.configAltScreen ?? input.defaultAltScreen ?? DEFAULT_ALT_SCREEN; return enabled ? 'alt' : 'inline'; } + +/** + * The phase default for terminal MOUSE reporting inside the full-screen renderer (2.6.F Step 5e, ADR-0068 §e). + * + * `true` — a maintainer decision that DEVIATES from ADR-0068 §e's "the first release defaults OFF (opt-in)". The + * wheel is what most users expect of a full-screen TUI, and PgUp/PgDn alone surprised them. The ADR's reason for + * defaulting off — mouse reporting disables the emulator's native click-drag SELECTION, worst over SSH/tmux — was + * ANSWERED in Step 6: Relavium now runs its own selection and copy-on-select. The opt-out remains, for a user whose + * terminal drops OSC 52 or who simply prefers the emulator's own selection: `--no-mouse` / `[preferences].mouse`, + * plus the `/scrollback`, `/edit` and `/copy` hatches. + */ +export const DEFAULT_MOUSE = true; + +/** + * The phase default for COPY-ON-SELECT (2.6.F Step 6e). `true`, matching every competing agent CLI: a released drag + * puts the selection on the system clipboard. Nothing about it is silent-but-harmful — the highlight shows exactly + * what will be copied — but it does overwrite whatever the user last copied elsewhere, so the opt-out exists. + */ +export const DEFAULT_COPY_ON_SELECT = true; + +export interface MouseModeInput { + /** The already-resolved render mode. Mouse reporting exists ONLY in the alt screen — the inline renderer must never + * enable it (it would break the native scrollback capture the inline mode is chosen for). Taking the RESOLVED mode + * (not the raw signals) makes that structural: an `inline` caller cannot accidentally ask for the mouse. */ + readonly renderMode: RenderMode; + /** `true` when `--no-mouse` was passed — the per-invocation opt-out, overriding the config key. */ + readonly noMouseFlag: boolean; + /** `[preferences].mouse`: `true` opts in, `false` opts out, `undefined` falls to {@link defaultMouse}. */ + readonly configMouse: boolean | undefined; + /** The phase default when neither flag nor config decides; {@link DEFAULT_MOUSE} when omitted. */ + readonly defaultMouse?: boolean; +} + +/** + * Resolve whether to enable mouse reporting (DECSET 1002 + 1006). Precedence mirrors {@link resolveRenderMode}: + * inline short-circuits FIRST → `--no-mouse` flag → config key → phase default. + */ +export function resolveMouseMode(input: MouseModeInput): boolean { + if (input.renderMode === 'inline') return false; // never in the inline renderer, whatever the flag or key says + if (input.noMouseFlag) return false; // the explicit flag opt-out overrides the config key + return input.configMouse ?? input.defaultMouse ?? DEFAULT_MOUSE; +} + +export interface CopyOnSelectInput { + /** The already-RESOLVED mouse decision. Copy-on-select is a property of a selection, and there are no selections + * without mouse reporting — so an unmoused caller cannot accidentally ask for it. Same structural trick as + * {@link MouseModeInput.renderMode}. */ + readonly mouseEnabled: boolean; + /** `[preferences].copy_on_select`: `true` opts in, `false` opts out, `undefined` falls to {@link defaultCopyOnSelect}. */ + readonly configCopyOnSelect: boolean | undefined; + /** The phase default when the config does not decide; {@link DEFAULT_COPY_ON_SELECT} when omitted. */ + readonly defaultCopyOnSelect?: boolean; +} + +/** + * Resolve whether a released drag writes to the system clipboard. There is deliberately NO flag: it is a durable + * preference, not a per-invocation one, and `--no-mouse` already turns off the gesture that produces it. + * + * NOT auto-disabled inside tmux/zellij, though the first design said it should be. tmux honours an application's + * OSC 52 only under `set-clipboard on` or `allow-passthrough on` (read from its source; see `clipboard.ts`), so a copy + * there may silently do nothing — but that is indistinguishable from VS Code Remote SSH dropping the escape, which we + * already accept and report honestly as `'written'` rather than `'copied'`. Guessing at a multiplexer's configuration + * and silently disabling a feature is worse than attempting it. + */ +export function resolveCopyOnSelect(input: CopyOnSelectInput): boolean { + if (!input.mouseEnabled) return false; // no mouse ⇒ no selection ⇒ nothing to copy + return input.configCopyOnSelect ?? input.defaultCopyOnSelect ?? DEFAULT_COPY_ON_SELECT; +} diff --git a/apps/cli/src/render/scrollback.test.ts b/apps/cli/src/render/scrollback.test.ts new file mode 100644 index 00000000..5aa79226 --- /dev/null +++ b/apps/cli/src/render/scrollback.test.ts @@ -0,0 +1,224 @@ +import { Writable } from 'node:stream'; + +import { describe, expect, it } from 'vitest'; + +import { PassThrough } from 'node:stream'; + +import { + DUMP_FOOTER, + DUMP_HEADER, + DUMP_PROMPT, + dumpToScrollback, + nodeWaitForContinue, + type DumpToScrollbackDeps, + type InterruptSource, + nodeWriteOut, +} from './scrollback.js'; + +/** + * The `/scrollback` dump (2.6.F Step 5d, ADR-0068 §e). The load-bearing properties: the transcript is sanitized AT + * THIS boundary (it is written as raw bytes to a terminal), the whole dump is ONE write (no interleaving), and the + * caller waits for the user before the full-screen view repaints over it. + */ + +const harness = ( + over: Partial = {}, +): { deps: DumpToScrollbackDeps; trace: string[] } => { + const trace: string[] = []; + const deps: DumpToScrollbackDeps = { + writeOut: (text) => trace.push(text), + waitForContinue: () => { + trace.push('waited'); + return Promise.resolve(); + }, + ...over, + }; + return { deps, trace }; +}; + +describe('dumpToScrollback', () => { + it('prints the banners around the transcript, then WAITS before the caller repaints', async () => { + const { deps, trace } = harness(); + await dumpToScrollback(deps, ['> hi', 'hello there']); + expect(trace).toEqual([ + `${DUMP_HEADER}\n> hi\nhello there\n${DUMP_FOOTER}\n${DUMP_PROMPT}\n`, + 'waited', // the dump is useless if the full-screen frame repaints over it first + ]); + }); + + it('emits the whole dump in ONE write, so another stdout writer cannot interleave mid-transcript', async () => { + const { deps, trace } = harness(); + await dumpToScrollback(deps, ['a', 'b', 'c', 'd']); + expect(trace.filter((t) => t !== 'waited')).toHaveLength(1); + }); + + it('an EMPTY transcript still prints the banners (a silent no-op would read as a broken command)', async () => { + const { deps, trace } = harness(); + await dumpToScrollback(deps, []); + expect(trace[0]).toBe(`${DUMP_HEADER}\n${DUMP_FOOTER}\n${DUMP_PROMPT}\n`); + expect(trace).toContain('waited'); + }); + + it('SECURITY: sanitizes at ITS OWN boundary — an ANSI escape in a line can never reach the terminal', async () => { + const { deps, trace } = harness(); + // A model that emitted a cursor jump + a colour + a bidi override: all stripped before the terminal sees them. + // `\u202e` is written as an ESCAPE, never as a literal character: a source file asserting Trojan-Source protection + // must not itself contain a bidi override, which would reorder what a human reviewer reads (SonarCloud S6389). + const RLO = '\u202e'; // RIGHT-TO-LEFT OVERRIDE + await dumpToScrollback(deps, ['\x1b[31mred\x1b[0m', 'jump\x1b[2Jhere', `rtl${RLO}override`]); + const written = trace[0] ?? ''; + expect(written).toContain('red'); + expect(written).toContain('jumphere'); + expect(written).toContain('rtloverride'); + expect(written).not.toContain('\x1b'); // no escape byte survives + expect(written).not.toContain(RLO); // no Trojan-Source reordering survives + }); + + it('keeps newlines inside a single entry (a multi-line assistant answer stays multi-line)', async () => { + const { deps, trace } = harness(); + await dumpToScrollback(deps, ['line one\nline two']); + expect(trace[0]).toBe(`${DUMP_HEADER}\nline one\nline two\n${DUMP_FOOTER}\n${DUMP_PROMPT}\n`); + }); + + it('propagates a waitForContinue rejection (the caller’s suspension still restores the terminal)', async () => { + const boom = new Error('stdin closed'); + const { deps } = harness({ waitForContinue: () => Promise.reject(boom) }); + await expect(dumpToScrollback(deps, ['x'])).rejects.toBe(boom); + }); +}); + +/** A fake SIGINT source: a suite must never raise a real signal at its own runner. */ +const fakeInterrupts = (): InterruptSource & { raise: () => void; listeners: number } => { + const handlers = new Set<() => void>(); + return { + once: (_event, listener) => void handlers.add(listener), + removeListener: (_event, listener) => void handlers.delete(listener), + raise: () => { + for (const h of [...handlers]) h(); + }, + get listeners() { + return handlers.size; + }, + }; +}; + +/** + * `nodeWaitForContinue` — the "Press Enter to return" wait. It runs INSIDE the suspension, where ink has detached its + * own stdin listeners and turned raw mode off, so it owns stdin for the duration and must hand it back clean. + */ +describe('nodeWaitForContinue', () => { + it('resolves on the first keypress and removes every listener it added', async () => { + const stdin = new PassThrough(); + const interrupts = fakeInterrupts(); + const wait = nodeWaitForContinue(stdin, interrupts)(); + stdin.write('\n'); + await wait; + expect(stdin.listenerCount('data')).toBe(0); + expect(stdin.listenerCount('end')).toBe(0); + expect(stdin.listenerCount('error')).toBe(0); + expect(interrupts.listeners).toBe(0); // ink's resumeInput must find a quiet stream + no stray SIGINT handler + }); + + it('resolves on a real SIGINT — Ctrl-C at the prompt RETURNS to Relavium instead of hanging', async () => { + // Raw mode is off for the whole suspension, so Ctrl-C is a signal, not a `\x03` byte on `data`. The surface's own + // SIGINT handler is deliberately inert while suspended, so if this wait ignored the signal there would be no way + // back but Enter (Step-5d-3 Sonnet review). + const stdin = new PassThrough(); + const interrupts = fakeInterrupts(); + const wait = nodeWaitForContinue(stdin, interrupts)(); + interrupts.raise(); + await expect(wait).resolves.toBeUndefined(); + expect(interrupts.listeners).toBe(0); + }); + + it('resolves on `end` (a piped stdin) rather than hanging — the dump is already in the scrollback', async () => { + const stdin = new PassThrough(); + const wait = nodeWaitForContinue(stdin, fakeInterrupts())(); + stdin.end(); + await expect(wait).resolves.toBeUndefined(); + }); +}); + +/** + * `nodeWriteOut` (2.6.F Step 6g, whole-phase Opus review). It is the only thing standing between a dying TTY and a + * dead process: `process.stdout` surfaces an OS write fault as an ASYNCHRONOUS `'error'` event, and Node throws an + * unhandled `'error'` as an uncaught exception — mid-suspension, with the terminal handed away. It had zero coverage. + */ +describe('nodeWriteOut — an async stdout error must not kill the suspension', () => { + /** A Writable whose flush is deferred, so a test can fire `'error'` while the write is still in flight. */ + const deferredStream = (): Writable & { flush: () => void; written: string[] } => { + let done: (() => void) | undefined; + const written: string[] = []; + const stream = new Writable({ + write(chunk: unknown, _enc: unknown, callback: () => void) { + written.push(String(chunk)); + done = () => callback(); + }, + }) as Writable & { flush: () => void; written: string[] }; + stream.flush = () => done?.(); + stream.written = written; + return stream; + }; + + it('an async `error` DURING the write is swallowed — an unhandled one would be an uncaught exception', () => { + const stream = deferredStream(); + nodeWriteOut(stream)('hello'); + expect(stream.written).toEqual(['hello']); + // No listener ⇒ Node throws. With ours attached, this is inert. + expect(() => stream.emit('error', new Error('EPIPE'))).not.toThrow(); + }); + + it('a write that FAILS does not escape — the case the test above could not see', async () => { + // A real OS write fault reaches the completion callback WITH the error, and Node emits `'error'` AFTER it. The + // first version of this guard detached the listener in that callback, so the emit was unhandled and Node threw an + // uncaught exception — mid-suspension, terminal handed away. The test above emits `'error'` while the write is + // still PENDING, which is a different (and already-safe) shape (Step-6h Sonnet review). + const failing = new Writable({ + write(_chunk: unknown, _enc: unknown, callback: (error?: Error) => void) { + callback(new Error('EPIPE')); + }, + }); + let escaped: unknown; + const onUncaught = (error: unknown): void => { + escaped = error; + }; + process.on('uncaughtException', onUncaught); + try { + nodeWriteOut(failing)('hello'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } finally { + process.off('uncaughtException', onUncaught); + } + expect(escaped).toBeUndefined(); // the suspension survives a dying TTY + expect(failing.listenerCount('error')).toBe(0); // …and the `once` guard was consumed, not leaked + }); + + it('the listener is REMOVED once the write flushes — it must not swallow another writer’s errors forever', () => { + const stream = deferredStream(); + const before = stream.listenerCount('error'); + nodeWriteOut(stream)('hello'); + expect(stream.listenerCount('error')).toBe(before + 1); + stream.flush(); + expect(stream.listenerCount('error')).toBe(before); + }); + + it('a SYNCHRONOUS throw (an already-destroyed stream) removes the listener too, and does not escape', () => { + const stream = new Writable({ write() {} }); + stream.write = () => { + throw new Error('write after end'); + }; + const before = stream.listenerCount('error'); + expect(() => nodeWriteOut(stream)('hello')).not.toThrow(); + expect(stream.listenerCount('error')).toBe(before); + }); + + it('does not leak a listener per write across a long dump', () => { + const stream = deferredStream(); + const write = nodeWriteOut(stream); + for (let i = 0; i < 5; i += 1) { + write(`line ${String(i)}`); + stream.flush(); + } + expect(stream.listenerCount('error')).toBe(0); + }); +}); diff --git a/apps/cli/src/render/scrollback.ts b/apps/cli/src/render/scrollback.ts new file mode 100644 index 00000000..450eac69 --- /dev/null +++ b/apps/cli/src/render/scrollback.ts @@ -0,0 +1,119 @@ +import type { Readable, Writable } from 'node:stream'; + +import { stripTerminalControls } from './tui/chat-projection.js'; + +/** + * The `/scrollback` half of the ADR-0068 §e copy-and-search escape hatches (2.6.F Step 5d): print the transcript to + * the **primary** buffer, so it lands in the terminal emulator's own scrollback where the user can scroll, search, + * select, and copy it with every native tool they already have. + * + * This exists because the alternate screen structurally removes those affordances — it has no scrollback at all, and + * mouse reporting (DECSET 1002) captures the click-drag the emulator would otherwise use for selection. The dump is + * the escape hatch, not a workaround: it is the ONLY path that puts the whole conversation, not just the visible + * rows, into the emulator's hands. + * + * It runs inside `suspendFullScreen`, which has already put the terminal back on the primary buffer with the mouse + * off. After the user acknowledges, the caller's suspension restores the full-screen view — and the dumped text + * stays in the scrollback above it, reachable for the rest of the terminal session. + * + * SANITIZATION is applied here, again. The lines a caller hands us were already sanitized by `entryLines` at the + * projection boundary, and `stripTerminalControls` is idempotent — but this function writes raw bytes to a terminal, + * so it sanitizes at its own boundary rather than trusting a caller to have done it. A model- or MCP-authored + * escape sequence reaching the primary buffer could forge output, move the cursor, or (via bidi overrides) spoof the + * reading order of the transcript the user opened this hatch to inspect. + */ + +/** The banner printed above the dump, so a scrollback search lands on an unambiguous boundary. */ +export const DUMP_HEADER = '───── relavium transcript ─────'; +/** The banner printed below the dump. */ +export const DUMP_FOOTER = '───── end of transcript ─────'; +/** The acknowledgement line. The dump is useless if the full-screen view repaints over it before the user looks. */ +export const DUMP_PROMPT = 'Press Enter to return to Relavium.'; + +/** The SIGINT source {@link nodeWaitForContinue} listens on. `process` in production; a fake in tests, so a suite + * never has to raise a real signal at its own runner. */ +export interface InterruptSource { + once: (event: 'SIGINT', listener: () => void) => void; + removeListener: (event: 'SIGINT', listener: () => void) => void; +} + +export interface DumpToScrollbackDeps { + /** Write to the PRIMARY buffer. MUST NOT throw and MUST NOT let the stream's async `'error'` event escape — see + * {@link nodeWriteOut}, the production adapter. */ + readonly writeOut: (text: string) => void; + /** Resolve when the user acknowledges (production: one keypress/line on stdin). Injected: it is pure terminal I/O. */ + readonly waitForContinue: () => Promise; +} + +/** + * Print `lines` between the banners, then wait for the user before returning (the caller then restores the + * full-screen view). An empty transcript still prints the banners — a silent no-op would read as a broken command. + */ +export async function dumpToScrollback( + deps: DumpToScrollbackDeps, + lines: readonly string[], +): Promise { + const body = lines.map((line) => stripTerminalControls(line)).join('\n'); + // One write: a single syscall cannot be interleaved by another stdout writer mid-transcript. + deps.writeOut( + `${DUMP_HEADER}\n${body}${body.length > 0 ? '\n' : ''}${DUMP_FOOTER}\n${DUMP_PROMPT}\n`, + ); + await deps.waitForContinue(); +} + +/** + * The production {@link DumpToScrollbackDeps.writeOut}. `process.stdout` surfaces an OS write fault (EPIPE on a + * closed pipe, EIO on a half-dead TTY) as an **asynchronous `'error'` event**, and Node throws an unhandled `'error'` + * as an uncaught exception — which, mid-suspension, would kill the process with the terminal still handed away + * (Step-5d-2 Sonnet review). The listener is attached for the write's lifetime and removed once it flushes, so we + * neither crash nor permanently swallow errors on a stream other code shares. + */ +export const nodeWriteOut = + (stdout: Writable) => + (text: string): void => { + const swallow = (): void => undefined; // a dying TTY must not crash a suspension; there is nowhere to report to + stdout.once('error', swallow); + try { + stdout.write(text, (error) => { + // KEEP the guard when the write FAILED. Node hands the fault to this callback and THEN emits `'error'` on the + // stream; detaching here left that emit unhandled, and Node throws an unhandled `'error'` as an uncaught + // exception — mid-suspension, with the terminal handed away. Exactly the crash this function exists to + // prevent, and the original test missed it by emitting `'error'` while the write was still pending rather + // than completing the write WITH one (Step-6h Sonnet review). `once` consumes the listener on that emit. + if (error !== null && error !== undefined) return; + stdout.removeListener('error', swallow); + }); + } catch { + stdout.removeListener('error', swallow); // a SYNCHRONOUS throw (an already-destroyed stream) + } + }; + +/** + * The production {@link DumpToScrollbackDeps.waitForContinue}: one line (or any keypress) on stdin. It runs INSIDE the + * suspension, where ink has already turned raw mode off and detached its own listeners — so we own stdin for the + * duration and hand it back untouched. `ref()` keeps the event loop alive while we wait (ink's `pauseInput` `unref`s + * it); the stream is re-paused on the way out so ink's `resumeInput` re-attaches to a quiet stream. An `end`/`error` + * (a piped or closed stdin) resolves rather than hangs: the dump is already in the scrollback, which is the point. + */ +export const nodeWaitForContinue = + (stdin: Readable & { ref?: () => void }, interrupts: InterruptSource = process) => + (): Promise => + new Promise((resolve) => { + const done = (): void => { + stdin.removeListener('data', done); + stdin.removeListener('end', done); + stdin.removeListener('error', done); + interrupts.removeListener('SIGINT', done); + stdin.pause(); + resolve(); + }; + stdin.ref?.(); + stdin.resume(); + stdin.once('data', done); + stdin.once('end', done); + stdin.once('error', done); + // Raw mode is OFF for the whole suspension, so Ctrl-C at this prompt arrives as a real SIGINT rather than a + // `\x03` byte on `data`. Treat it as "return to Relavium": without this the wait would hang, the surface's own + // SIGINT handler is deliberately inert here (chat-ink.tsx), and the user would have no way back but Enter. + interrupts.once('SIGINT', done); + }); diff --git a/apps/cli/src/render/suspend.test.ts b/apps/cli/src/render/suspend.test.ts new file mode 100644 index 00000000..009e2787 --- /dev/null +++ b/apps/cli/src/render/suspend.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from 'vitest'; + +import { + DISABLE_MOUSE, + ENABLE_MOUSE, + ENTER_ALT_SCREEN, + EXIT_ALT_SCREEN, + HIDE_CURSOR, + SHOW_CURSOR, +} from './alt-screen.js'; +import { createSuspendPort, suspendFullScreen, type SuspendFullScreenOptions } from './suspend.js'; + +/** + * The suspend-full-screen primitive (2.6.F Step 5d, ADR-0068 §e). These pin the contract the `/scrollback` and + * `/edit` hatches rest on: the writes land INSIDE ink's suspension (never before/after), the alt-buffer toggle is + * SURFACE-DIVERGENT (ink owns 1049 on the Home, we own it on `relavium chat`), the mouse is always ours, and no + * failure path can strand the terminal in a half-restored state. + */ + +/** A recording fake of ink's `suspendTerminal`: it stamps the callback's boundaries into the SAME trace the control + * writes go to, so a test can assert that every write happened between `begin` and `end` (ink's frame-erase / + * input-pause window) rather than merely that it happened. */ +const harness = ( + over: Partial = {}, +): { opts: SuspendFullScreenOptions; trace: string[] } => { + const trace: string[] = []; + const opts: SuspendFullScreenOptions = { + suspendTerminal: async (callback) => { + trace.push('ink:begin'); + try { + await callback(); + } finally { + trace.push('ink:end'); + } + }, + writeControl: (sequence) => trace.push(sequence), + inkOwnsAltScreen: false, // the `relavium chat` default (the hoist owns 1049) + altActive: true, + mouseActive: true, + ...over, + }; + return { opts, trace }; +}; + +const body = (trace: string[]) => (): Promise => { + trace.push('body'); + return Promise.resolve(); +}; + +describe('suspendFullScreen — `relavium chat` (ink does NOT own the alt screen)', () => { + it('exits + re-enters the alt buffer ITSELF, and does so INSIDE ink’s suspension window', async () => { + const { opts, trace } = harness({ inkOwnsAltScreen: false }); + await suspendFullScreen(opts, body(trace)); + expect(trace).toEqual([ + 'ink:begin', // ink erased its frame + paused input (raw mode + bracketed paste OFF) + DISABLE_MOUSE, + EXIT_ALT_SCREEN + SHOW_CURSOR, // ours: ink's render option is false on this surface + 'body', + ENTER_ALT_SCREEN + HIDE_CURSOR, + ENABLE_MOUSE, + 'ink:end', // ink resumes input, then force-redraws + ]); + }); + + it('restores BOTH modes when the body throws, and rethrows the body’s error', async () => { + const { opts, trace } = harness({ inkOwnsAltScreen: false }); + const boom = new Error('editor failed'); + await expect( + suspendFullScreen(opts, () => { + trace.push('body'); + return Promise.reject(boom); + }), + ).rejects.toBe(boom); + expect(trace).toEqual([ + 'ink:begin', + DISABLE_MOUSE, + EXIT_ALT_SCREEN + SHOW_CURSOR, + 'body', + ENTER_ALT_SCREEN + HIDE_CURSOR, // the terminal is given back even on the failure path + ENABLE_MOUSE, + 'ink:end', + ]); + }); +}); + +describe('suspendFullScreen — the bare Home (ink OWNS the alt screen)', () => { + it('never touches 1049 (ink’s begin/endSuspend do it) but still suspends the mouse — which ink never writes', async () => { + const { opts, trace } = harness({ inkOwnsAltScreen: true }); + await suspendFullScreen(opts, body(trace)); + expect(trace).toEqual(['ink:begin', DISABLE_MOUSE, 'body', ENABLE_MOUSE, 'ink:end']); + // The load-bearing negative: a 1049 write here would DOUBLE-toggle against ink and lose the frame. + expect(trace).not.toContain(EXIT_ALT_SCREEN + SHOW_CURSOR); + expect(trace).not.toContain(ENTER_ALT_SCREEN + HIDE_CURSOR); + }); +}); + +describe('suspendFullScreen — the inline renderer and the mouse-off case', () => { + it('inline (no alt buffer, no mouse): writes NOTHING — it is purely ink handing back raw mode (what `/edit` needs)', async () => { + const { opts, trace } = harness({ altActive: false, mouseActive: false }); + await suspendFullScreen(opts, body(trace)); + expect(trace).toEqual(['ink:begin', 'body', 'ink:end']); + }); + + it('alt-on with the mouse OFF (the `--no-mouse` shape): toggles 1049 only', async () => { + const { opts, trace } = harness({ + altActive: true, + mouseActive: false, + inkOwnsAltScreen: false, + }); + await suspendFullScreen(opts, body(trace)); + expect(trace).toEqual([ + 'ink:begin', + EXIT_ALT_SCREEN + SHOW_CURSOR, + 'body', + ENTER_ALT_SCREEN + HIDE_CURSOR, + 'ink:end', + ]); + }); + + it('mouse-on with the alt buffer OFF: suspends the mouse, never the buffer', async () => { + const { opts, trace } = harness({ altActive: false, mouseActive: true }); + await suspendFullScreen(opts, body(trace)); + expect(trace).toEqual(['ink:begin', DISABLE_MOUSE, 'body', ENABLE_MOUSE, 'ink:end']); + }); +}); + +describe('suspendFullScreen — a write that throws can never leave a HALF-restored terminal', () => { + it('the FIRST release write throws ⇒ nothing was changed ⇒ nothing is restored (no phantom alt re-enter)', async () => { + const trace: string[] = []; + const boom = new Error('stdout closed'); + const opts: SuspendFullScreenOptions = { + suspendTerminal: async (callback) => { + trace.push('ink:begin'); + try { + await callback(); + } finally { + trace.push('ink:end'); + } + }, + writeControl: (sequence) => { + if (sequence === DISABLE_MOUSE && !trace.includes(DISABLE_MOUSE)) throw boom; + trace.push(sequence); + }, + inkOwnsAltScreen: false, + altActive: true, + mouseActive: true, + }; + await expect(suspendFullScreen(opts, body(trace))).rejects.toBe(boom); + // The body never ran, the buffer was never exited — so restoring anything would corrupt a terminal that is + // still exactly as ink left it. + expect(trace).toEqual(['ink:begin', 'ink:end']); + }); + + it('the alt-EXIT write throws ⇒ the mouse (already suspended) is still restored', async () => { + const trace: string[] = []; + const boom = new Error('stdout closed'); + const opts: SuspendFullScreenOptions = { + suspendTerminal: async (callback) => { + trace.push('ink:begin'); + try { + await callback(); + } finally { + trace.push('ink:end'); + } + }, + writeControl: (sequence) => { + if (sequence === EXIT_ALT_SCREEN + SHOW_CURSOR) throw boom; + trace.push(sequence); + }, + inkOwnsAltScreen: false, + altActive: true, + mouseActive: true, + }; + await expect(suspendFullScreen(opts, body(trace))).rejects.toBe(boom); + expect(trace).toEqual(['ink:begin', DISABLE_MOUSE, ENABLE_MOUSE, 'ink:end']); + // Never re-enters a buffer it failed to exit. + expect(trace).not.toContain(ENTER_ALT_SCREEN + HIDE_CURSOR); + }); + + it('the alt RE-ENTER write throws ⇒ the mouse is STILL restored (a stranded DECSET-1000 is the worst outcome)', async () => { + const trace: string[] = []; + const boom = new Error('stdout closed'); + const opts: SuspendFullScreenOptions = { + suspendTerminal: async (callback) => { + trace.push('ink:begin'); + try { + await callback(); + } finally { + trace.push('ink:end'); + } + }, + writeControl: (sequence) => { + if (sequence === ENTER_ALT_SCREEN + HIDE_CURSOR) throw boom; + trace.push(sequence); + }, + inkOwnsAltScreen: false, + altActive: true, + mouseActive: true, + }; + await expect(suspendFullScreen(opts, body(trace))).rejects.toBe(boom); + expect(trace).toEqual([ + 'ink:begin', + DISABLE_MOUSE, + EXIT_ALT_SCREEN + SHOW_CURSOR, + 'body', + ENABLE_MOUSE, // the isolated reclaim — reached despite the throw above it + 'ink:end', + ]); + }); + + it('DOUBLE FAULT: the body throws AND the re-enter write throws ⇒ the BODY’s error survives, mouse still restored', async () => { + // The Step-5d-1 Opus review's one surviving finding. A `finally` would let the restore-write error REPLACE the + // body's, so a failing `/edit` on a closed stdout would tell the user "stdout closed" instead of "could not + // start $EDITOR". The root cause must win (error-handling.md), and the mouse must come back regardless. + const trace: string[] = []; + const bodyError = new Error('could not start $EDITOR'); + const writeError = new Error('stdout closed'); + const opts: SuspendFullScreenOptions = { + suspendTerminal: async (callback) => { + trace.push('ink:begin'); + try { + await callback(); + } finally { + trace.push('ink:end'); + } + }, + writeControl: (sequence) => { + if (sequence === ENTER_ALT_SCREEN + HIDE_CURSOR) throw writeError; + trace.push(sequence); + }, + inkOwnsAltScreen: false, + altActive: true, + mouseActive: true, + }; + await expect( + suspendFullScreen(opts, () => { + trace.push('body'); + return Promise.reject(bodyError); + }), + ).rejects.toBe(bodyError); // NOT writeError — the secondary failure is dropped, never the root cause + expect(trace).toContain(ENABLE_MOUSE); // and the worst-to-strand mode is restored despite the double fault + }); +}); + +describe('suspendFullScreen — re-entrancy is the SURFACE’s job to gate', () => { + it('propagates ink’s "already suspended" throw rather than swallowing it (beginSuspend throws by design)', async () => { + const already = new Error('The terminal is already suspended.'); + const trace: string[] = []; + const opts: SuspendFullScreenOptions = { + suspendTerminal: () => Promise.reject(already), // beginSuspend threw before the callback ran + writeControl: (sequence) => trace.push(sequence), + inkOwnsAltScreen: false, + altActive: true, + mouseActive: true, + }; + await expect(suspendFullScreen(opts, body(trace))).rejects.toBe(already); + expect(trace).toEqual([]); // no write escaped the failed suspension + }); +}); + +/** + * `createSuspendPort().isSuspended()` (Step-5d-3 Sonnet review). Not diagnostic — LOAD-BEARING. During a suspension + * ink has raw mode OFF, so a keyboard Ctrl-C reaches the process as a real SIGINT. The chat's SIGINT handler must + * yield while a hatch owns the terminal, or it tears the session down behind the suspension's back and its pending + * reclaim later re-enters the alt buffer on the user's SHELL. The flag is owned by the PORT, wrapped around the ink + * call it hands out, so no caller can forget to maintain it. + */ +describe('createSuspendPort — the suspension window', () => { + it('is false before, TRUE for exactly the callback, and false after', async () => { + const port = createSuspendPort(); + const seen: boolean[] = []; + port.attach(async (callback) => { + seen.push(port.isSuspended()); // ink has begun: the window is open + await callback(); + }); + expect(port.isSuspended()).toBe(false); + await port.current()?.(() => { + seen.push(port.isSuspended()); // inside the body: still open + return Promise.resolve(); + }); + expect(port.isSuspended()).toBe(false); + expect(seen).toEqual([true, true]); + }); + + it('CLOSES the window when the suspension throws (a stuck flag would deafen the surface to SIGINT forever)', async () => { + const port = createSuspendPort(); + const boom = new Error('editor failed'); + port.attach(() => Promise.reject(boom)); + await expect(port.current()?.(() => Promise.resolve())).rejects.toBe(boom); + expect(port.isSuspended()).toBe(false); + }); + + it('is false when nothing is attached (a plain / --json driver never suspends)', () => { + const port = createSuspendPort(); + expect(port.isSuspended()).toBe(false); + expect(port.current()).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/render/suspend.ts b/apps/cli/src/render/suspend.ts new file mode 100644 index 00000000..524e6492 --- /dev/null +++ b/apps/cli/src/render/suspend.ts @@ -0,0 +1,185 @@ +import { + DISABLE_MOUSE, + ENABLE_MOUSE, + ENTER_ALT_SCREEN, + EXIT_ALT_SCREEN, + HIDE_CURSOR, + SHOW_CURSOR, +} from './alt-screen.js'; + +/** + * **Suspend the full-screen renderer** and hand the raw terminal to something else — the substrate for the ADR-0068 §e + * copy-and-search escape hatches (`/scrollback` dumps the transcript into native scrollback; `/edit` opens it in + * `$EDITOR`). 2.6.F Step 5d. + * + * It wraps ink 7's `useApp().suspendTerminal(cb)`, whose contract (read from `ink@7.1.0/build/ink.js` + * `beginSuspend`/`endSuspend`, since none of this is documented) decides everything below: + * + * 1. `beginSuspend()` flushes + **erases ink's current frame** (`log.clear()` + `log.done()`), then `pauseInput()` — + * which turns OFF raw mode and bracketed paste (DECSET 2004) and detaches ink's stdin listeners. So we must NOT + * touch raw mode or bracketed paste ourselves: ink owns both, symmetrically. + * 2. It toggles the alternate screen (DECSET 1049) **only `if (this.alternateScreen)`** — ink's *render option*, not + * whether the terminal happens to be in the alt buffer. That option is `true` for the bare Home but HARD `false` + * for `relavium chat`, whose hoisted `AltScreenController` owns 1049 (Step 4b-3). Hence {@link + * SuspendFullScreenOptions.inkOwnsAltScreen}: on the chat surface we exit/re-enter 1049 ourselves, or `$EDITOR` + * would paint into the alt buffer and vanish on resume. Both halves ALSO early-return under + * `!interactive || isUnmounted || isUnmounting`, so ink's half of the work is skipped once the instance is torn + * down — harmless, because `unmount()` itself writes `exitAlternativeScreen + showCursor` and clears the option, + * and because `beginSuspend()` runs synchronously at the head of `suspendTerminal` (a mounted instance cannot + * become unmounted between the check and our writes). + * 3. ink writes **no mouse escapes at all** (verified: its whole build contains no `?1000`/`?1006`). Mouse reporting + * is entirely ours, so we suspend and restore it on both surfaces — leaving DECSET 1002 on while a child owns the + * TTY floods that child with `\x1b[<…M` reports. + * 4. `endSuspend()` calls `resumeInput()` **before** re-entering the alt buffer, then forces a full redraw. So every + * write we make must land inside the callback, while input is still paused — never after it returns. + * + * ORDERING, therefore: exit the alt buffer INSIDE the callback (after ink erased its frame). Doing it earlier would + * make ink's `log.clear()` erase the *primary* buffer — scrolling the user's shell history away. + * + * EXIT SAFETY, in the spirit of `withHoistedAltScreen`. Three rules, and NOT a `finally` — see below: + * - **Restore only what was changed.** A release write that throws part-way must not be "undone" (re-entering an + * alt buffer we never exited would corrupt a terminal ink left intact). + * - **The restores are isolated.** Each reclaim write has its own `try`, so a failing alt-buffer re-enter can + * never skip the mouse restore after it — a stranded DECSET-1000 is the worst state we can leave. + * - **The first error wins.** A `finally` cannot express this: a throw from a `finally` REPLACES the throw already + * unwinding out of its `try`, so a failing restore write would mask the real cause and tell the user "stdout + * closed" instead of "could not start $EDITOR" (`error-handling.md`). The body's error is captured into + * `pending` and rethrown after the reclaim; a secondary write failure is dropped. + */ + +/** ink 7's `useApp().suspendTerminal` in its callback form. Rejects if the terminal is ALREADY suspended + * (`beginSuspend` throws), so a surface must gate re-entrancy before calling {@link suspendFullScreen}. */ +export type SuspendTerminal = (callback: () => Promise) => Promise; + +export interface SuspendFullScreenOptions { + /** ink's `useApp().suspendTerminal` — the only way to make ink release raw mode, bracketed paste, and its frame. */ + readonly suspendTerminal: SuspendTerminal; + /** Write a raw control sequence to the TTY (production: `process.stdout.write`; tests: a capture). */ + readonly writeControl: (sequence: string) => void; + /** + * `true` when ink's `alternateScreen` RENDER OPTION is on — the bare Home, where ink's own `beginSuspend`/ + * `endSuspend` exit and re-enter DECSET-1049 for us. `false` for `relavium chat` (the option is hard-`false`; the + * hoisted controller owns 1049), where WE must toggle it. Getting this backwards either strands `$EDITOR` inside + * the invisible alt buffer, or double-toggles 1049 and loses the frame. + */ + readonly inkOwnsAltScreen: boolean; + /** `true` when the alt buffer is currently entered. `false` on the inline renderer — there is no buffer to leave, + * and `suspendFullScreen` degrades to "ink hands over raw mode" (which `/edit` still needs). */ + readonly altActive: boolean; + /** `true` when mouse reporting (DECSET 1002+1006) is currently on. Independent of {@link altActive} on purpose: + * once `--no-mouse` lands, the alt screen can be active with the mouse off. */ + readonly mouseActive: boolean; +} + +/** + * Run `body` with the terminal handed back to the user (or to a TTY-inheriting child), then restore the full-screen + * renderer exactly as it was. Rejects with whatever `body` (or ink) threw, AFTER restoring. + */ +export async function suspendFullScreen( + opts: SuspendFullScreenOptions, + body: () => Promise, +): Promise { + const { suspendTerminal, writeControl, inkOwnsAltScreen, altActive, mouseActive } = opts; + const weOwnAltScreen = altActive && !inkOwnsAltScreen; + + await suspendTerminal(async () => { + // Track what we ACTUALLY changed, so a write that throws part-way cannot leave a half-restored terminal (a + // blind symmetric restore would, say, re-enter an alt buffer we never exited). + let mouseSuspended = false; + let altExited = false; + // The FIRST error seen — the root cause. A later restore-write failure must never replace it: the user needs to + // read "could not start $EDITOR", not "stdout closed" (error-handling.md — never swallow a root cause to + // re-throw a vaguer one). A `finally` cannot express this: a throw from a `finally` REPLACES the pending throw. + let pending: { readonly error: unknown } | undefined; + + // RELEASE — hand the terminal over. + try { + if (mouseActive) { + writeControl(DISABLE_MOUSE); // restore native selection + stop flooding the child with mouse reports + mouseSuspended = true; + } + if (weOwnAltScreen) { + writeControl(EXIT_ALT_SCREEN + SHOW_CURSOR); // ink did not: its render option is false on this surface + altExited = true; + } + await body(); + } catch (error) { + pending = { error }; + } + + // RECLAIM — mirror the release, innermost-first. Each write is isolated, so one failing sequence can never skip + // the next: a stranded DECSET-1000 (mouse reporting left on) is the worst terminal state we can leave, and it is + // restored even when re-entering the alt buffer throws. + const reclaim = (sequence: string): void => { + try { + writeControl(sequence); + } catch (error) { + pending ??= { error }; // only the root cause survives; a secondary write failure on a dead stdout is noise + } + }; + if (altExited) reclaim(ENTER_ALT_SCREEN + HIDE_CURSOR); + if (mouseSuspended) reclaim(ENABLE_MOUSE); + + if (pending !== undefined) throw pending.error; + }); +} + +/** + * The **suspend port** — the repo's first React→core capability bridge, and the reason the hatches need no + * surface-specific interception at all. + * + * `suspendTerminal` exists ONLY inside a mounted ink tree (`useApp()`), but the slash-command dispatch that must call + * it lives outside React: `relavium chat`'s `ReplCommandContext` is built before `driveInk` mounts, and the Home's + * `createHomeController` is built before `RootApp` mounts. Every existing port (`runShellCommand`, `modelPicker`, + * `mentionReader`) flows the other way — built outside React, consumed inside. This one inverts that: the non-React + * layer creates an empty port, hands it to both the command context and the component; the component `attach`es its + * `suspendTerminal` on mount and detaches on unmount. + * + * `current()` is therefore the honest answer to "is there a live full-screen renderer right now?" — `undefined` on a + * plain / `--json` driver (no ink at all), and between a session's unmount and the next mount. A hatch that reads + * `undefined` surfaces an actionable notice instead of failing. + */ +export interface SuspendPort { + /** Called by the ink tree: the live `suspendTerminal` on mount, `undefined` on unmount. */ + readonly attach: (suspend: SuspendTerminal | undefined) => void; + /** The live `suspendTerminal`, or `undefined` when no ink tree is mounted. Read at CALL time, never captured. */ + readonly current: () => SuspendTerminal | undefined; + /** + * `true` for exactly as long as a suspension obtained from {@link current} is in flight. + * + * LOAD-BEARING, not diagnostic. During a suspension ink has turned raw mode OFF, so a keyboard **Ctrl-C is no + * longer swallowed by `useInput`** — the tty line discipline delivers it as a REAL process SIGINT. The chat's + * `process.on('SIGINT')` handler would then run its cooperative `/cancel`, unmount ink, and exit the hoisted alt + * buffer *behind the suspension's back* — while the suspension is still awaiting `$EDITOR` or the "press Enter" + * prompt. Its reclaim would later re-enter the alt buffer and re-enable the mouse on the user's SHELL. A signal + * handler must therefore ask this before acting (Step-5d-3 Sonnet review). + */ + readonly isSuspended: () => boolean; +} + +/** + * The flag is maintained by the PORT, wrapped around the ink call it hands out — not by the caller. A caller cannot + * forget to set it, and it is impossible for `isSuspended()` to disagree with what the terminal is actually doing. + */ +export function createSuspendPort(): SuspendPort { + let suspend: SuspendTerminal | undefined; + let suspended = false; + return { + attach: (next) => { + suspend = next; + }, + current: () => { + const live = suspend; + if (live === undefined) return undefined; + return async (callback) => { + suspended = true; + try { + await live(callback); + } finally { + suspended = false; + } + }; + }, + isSuspended: () => suspended, + }; +} diff --git a/apps/cli/src/render/tui/banner.test.ts b/apps/cli/src/render/tui/banner.test.ts new file mode 100644 index 00000000..6c2c931f --- /dev/null +++ b/apps/cli/src/render/tui/banner.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; + +import { BANNER_EXTRA_ROWS, bannerLines, shouldShowBanner } from './banner.js'; +import { HOME_MIN_COLS, HOME_MIN_ROWS } from './home-projection.js'; +import { displayWidth } from './viewport.js'; + +/** + * The branded Home banner (2.6.F Step 5g, ADR-0068). It gates no feature, so the bar here is not "does it work" but + * "can it ever look broken": a plaque with a ragged right edge, one that overflows the terminal and wraps, or one that + * prints `╭` into a terminal that cannot draw it. + */ + +/** Printable ASCII only — no box-drawing glyph, no control character. */ +const ASCII_ONLY = /^[\x20-\x7e]*$/; + +describe('bannerLines', () => { + it('every line is EXACTLY the same display width — a ragged plaque looks broken, not branded', () => { + for (const cols of [80, 81, 100, 120, 200]) { + const widths = bannerLines(cols, false).map((l) => displayWidth(l.text)); + expect(new Set(widths).size, `cols=${cols}`).toBe(1); + } + }); + + it('never exceeds the terminal width — an overflowing line WRAPS and destroys the box', () => { + // Down to cols=1: the plaque's chrome floors at 7 cells, so a narrow terminal is where a naive builder overflows. + for (let cols = 1; cols <= 200; cols += 1) { + for (const ascii of [false, true]) { + const widest = Math.max(...bannerLines(cols, ascii).map((l) => displayWidth(l.text))); + expect(widest, `cols=${cols} ascii=${String(ascii)}`).toBeLessThanOrEqual(cols); + } + } + }); + + it('at every supported width it draws the full plaque: border, wordmark, tagline, border', () => { + const lines = bannerLines(HOME_MIN_COLS, false); + expect(lines.map((l) => l.kind)).toEqual(['border', 'wordmark', 'tagline', 'border']); + expect(lines[1]?.text).toContain('R E L A V I U M'); + expect(lines[2]?.text).toContain('Own every run.'); + }); + + it('drops the TAGLINE before the wordmark when the terminal is too narrow', () => { + // The brand survives; the sentence does not. (Below HOME_MIN_COLS the Home is in its too-small mode anyway, so + // this is defensive — but `bannerLines` is exported and must be total.) + const lines = bannerLines(30, false); + expect(lines.map((l) => l.kind)).toEqual(['border', 'wordmark', 'border']); + expect(lines[1]?.text).toContain('R E L A V I U M'); + }); + + it('truncates the wordmark rather than overflow, at an absurd width', () => { + const lines = bannerLines(8, false); + expect(Math.max(...lines.map((l) => displayWidth(l.text)))).toBeLessThanOrEqual(8); + }); + + it('NO_COLOR / --no-color degrades to PLAIN ASCII — no box-drawing glyph survives', () => { + const ascii = bannerLines(HOME_MIN_COLS, true); + for (const line of ascii) expect(line.text, line.text).toMatch(ASCII_ONLY); + // …and the coloured form really does use box-drawing, so the test above is not vacuous. + const unicode = bannerLines(HOME_MIN_COLS, false); + expect(unicode.some((l) => /[╭╮╰╯─│]/.test(l.text))).toBe(true); + }); + + it('the ASCII and Unicode plaques are the same SHAPE — only the glyphs change', () => { + const a = bannerLines(HOME_MIN_COLS, true); + const u = bannerLines(HOME_MIN_COLS, false); + expect(a.map((l) => l.kind)).toEqual(u.map((l) => l.kind)); + expect(a.map((l) => displayWidth(l.text))).toEqual(u.map((l) => displayWidth(l.text))); + }); + + it('every line carries a UNIQUE, stable id — the two ASCII borders are byte-identical', () => { + // Keying the React children by `text` gave the top and bottom borders the same key under `NO_COLOR` + // (`+---…---+` both), and React printed "Encountered two children with the same key" onto the alt buffer, + // because the Home mounts ink with `patchConsole: false`. Verified against the real renderer before fixing. + for (const ascii of [true, false]) { + const lines = bannerLines(HOME_MIN_COLS, ascii); + expect(new Set(lines.map((l) => l.id)).size).toBe(lines.length); + } + const ascii = bannerLines(HOME_MIN_COLS, true); + expect(ascii[0]?.text).toBe(ascii.at(-1)?.text); // …and this is exactly why an id is needed + expect(ascii[0]?.id).not.toBe(ascii.at(-1)?.id); + }); + + it('the plaque costs exactly BANNER_EXTRA_ROWS more than the one-line heading it replaces', () => { + expect(bannerLines(HOME_MIN_COLS, false)).toHaveLength(1 + BANNER_EXTRA_ROWS); + }); +}); + +describe('shouldShowBanner', () => { + const at = (over: Partial[0]> = {}): boolean => + shouldShowBanner({ + configShowBanner: undefined, + isEmpty: true, + rows: HOME_MIN_ROWS + BANNER_EXTRA_ROWS, + ...over, + }); + + it('`false` never shows it, whatever else is true', () => { + expect(at({ configShowBanner: false })).toBe(false); + expect(at({ configShowBanner: false, isEmpty: true, rows: 200 })).toBe(false); + }); + + it('absent ⇒ shown while the Home is EMPTY, and auto-dismissed once there is anything to continue', () => { + // The ADR asked for "the first five opens", which needs a durable counter. An empty Home IS that signal, and it + // stops the instant the user's first chat gives them something to continue. + expect(at({ isEmpty: true })).toBe(true); + expect(at({ isEmpty: false })).toBe(false); + }); + + it('`true` shows it even on a busy Home — the user asked for it', () => { + expect(at({ configShowBanner: true, isEmpty: false })).toBe(true); + }); + + it('never below HOME_MIN_ROWS — the Home is already in its too-small mode there', () => { + expect(at({ rows: HOME_MIN_ROWS - 1 })).toBe(false); + expect(at({ configShowBanner: true, rows: HOME_MIN_ROWS - 1 })).toBe(false); + }); + + it('a FORCED banner also needs room for the strip it would otherwise push off the screen', () => { + // An empty Home has almost nothing below the banner, so exactly HOME_MIN_ROWS is fine there. A forced one on a + // busy Home is not: the plaque would eat the rows the strip and prompt need on an 80x24 terminal. + expect(at({ isEmpty: true, rows: HOME_MIN_ROWS })).toBe(true); + expect(at({ configShowBanner: true, isEmpty: false, rows: HOME_MIN_ROWS })).toBe(false); + expect( + at({ configShowBanner: true, isEmpty: false, rows: HOME_MIN_ROWS + BANNER_EXTRA_ROWS }), + ).toBe(true); + }); +}); diff --git a/apps/cli/src/render/tui/banner.ts b/apps/cli/src/render/tui/banner.ts new file mode 100644 index 00000000..07d0b7a0 --- /dev/null +++ b/apps/cli/src/render/tui/banner.ts @@ -0,0 +1,141 @@ +import { HOME_MIN_ROWS } from './home-projection.js'; +import { displayWidth, sliceDisplayColumns } from './viewport.js'; + +/** + * The branded Home banner (2.6.F Step 5g, ADR-0068). + * + * PURE: it computes lines and says what each one IS; `home-view.tsx` decides how to paint them. Everything a terminal + * can get wrong about a decorative plaque — width, glyph support, colour — is decided here, once, and tested here. + * + * It is a **cosmetic substrate element**: it gates no feature, and the Home renders identically without it. + */ + +/** The wordmark, letter-spaced. Not ASCII art: a five-row block font eats a fifth of an 80x24 terminal, and it looks + * dated next to the box-drawn strip below it. */ +const WORDMARK = 'R E L A V I U M'; + +/** Relavium's positioning line, verbatim from the README — one place it can drift from, and it is a doc. */ +const TAGLINE = 'Start as an agent. Ship the workflow. Own every run.'; + +/** Rows the banner adds over the plain one-line heading it replaces (border, wordmark, tagline, border ⇒ 4, less 1). */ +export const BANNER_EXTRA_ROWS = 3; + +/** Horizontal padding inside the plaque, each side. */ +const PADDING = 2; + +/** What a banner line is, so the renderer can style it without parsing it back. */ +export interface BannerLine { + /** A STABLE, unique React key. Not the text: under `NO_COLOR` the two ASCII borders are byte-identical + * (`+---…---+`), and keying by text gave React two children with the same key — a runtime error printed straight + * onto the alt buffer, because the Home mounts ink with `patchConsole: false`. */ + readonly id: 'top' | 'wordmark' | 'tagline' | 'bottom'; + readonly text: string; + readonly kind: 'border' | 'wordmark' | 'tagline'; +} + +interface Glyphs { + readonly topLeft: string; + readonly topRight: string; + readonly bottomLeft: string; + readonly bottomRight: string; + readonly horizontal: string; + readonly vertical: string; +} + +/** Box-drawing when colour is on, plain ASCII when it is off. ADR-0068 ties the two: a terminal told `NO_COLOR` is a + * terminal we should assume renders conservatively, and a mis-rendered `╭` is worse than a `+`. */ +const UNICODE: Glyphs = { + topLeft: '╭', + topRight: '╮', + bottomLeft: '╰', + bottomRight: '╯', + horizontal: '─', + vertical: '│', +}; +const ASCII: Glyphs = { + topLeft: '+', + topRight: '+', + bottomLeft: '+', + bottomRight: '+', + horizontal: '-', + vertical: '|', +}; + +/** Pad `text` with spaces to exactly `width` DISPLAY columns, truncating if it does not fit. */ +function fit(text: string, width: number): string { + const w = displayWidth(text); + if (w > width) return sliceDisplayColumns(text, 0, width); + return text + ' '.repeat(width - w); +} + +/** + * Build the banner for a terminal `cols` wide. + * + * The plaque is as wide as its widest line needs, never wider than the terminal. When even the wordmark cannot fit, + * the tagline is dropped first — the brand survives, the sentence does not. In production the caller does not reach the + * degenerate case: the Home renders the banner only inside `homeFitsTerminal` (cols ≥ `HOME_MIN_COLS` = 80). But + * `bannerLines` is exported and must be TOTAL, so every returned line is clamped to `cols` — below the minimum chrome + * (2 borders + 4 padding = 6 cells) the plaque would otherwise floor at 7 and overflow a 1–6 column terminal. + */ +export function bannerLines(cols: number, ascii: boolean): readonly BannerLine[] { + const g = ascii ? ASCII : UNICODE; + // Two border glyphs + padding on each side. + const chrome = 2 + PADDING * 2; + const available = Math.max(cols - chrome, 1); + + const withTagline = displayWidth(TAGLINE) <= available; + const inner = withTagline + ? Math.max(displayWidth(WORDMARK), displayWidth(TAGLINE)) + : Math.min(displayWidth(WORDMARK), available); + + const pad = ' '.repeat(PADDING); + const rule = g.horizontal.repeat(inner + PADDING * 2); + // Clamp every line to the terminal width. A no-op at any real width (the plaque fits `cols` for cols ≥ 7); it only + // trims the fixed 7-cell chrome on a 1–6 column terminal, where the Home is in its too-small mode and the banner is + // never actually shown — but the returned lines still never exceed `cols`. + const clamp = (text: string): string => + displayWidth(text) > cols ? sliceDisplayColumns(text, 0, Math.max(cols, 0)) : text; + const row = (text: string): string => + clamp(`${g.vertical}${pad}${fit(text, inner)}${pad}${g.vertical}`); + + const lines: BannerLine[] = [ + { id: 'top', text: clamp(`${g.topLeft}${rule}${g.topRight}`), kind: 'border' }, + { id: 'wordmark', text: row(WORDMARK), kind: 'wordmark' }, + ]; + if (withTagline) lines.push({ id: 'tagline', text: row(TAGLINE), kind: 'tagline' }); + lines.push({ + id: 'bottom', + text: clamp(`${g.bottomLeft}${rule}${g.bottomRight}`), + kind: 'border', + }); + return lines; +} + +export interface BannerVisibility { + /** `[preferences].show_banner`. `true` ⇒ always, `false` ⇒ never, `undefined` ⇒ the rule below. */ + readonly configShowBanner: boolean | undefined; + /** Whether the Home has nothing to continue — no sessions, no runs, no agents. */ + readonly isEmpty: boolean; + /** The terminal's row count. */ + readonly rows: number; +} + +/** + * Should the Home draw the banner? + * + * `undefined` ⇒ **only while the Home is empty**. ADR-0068 asked for "the first five Home opens, then auto-dismissed", + * which needs a durable counter. The two places to keep one are both wrong for a cosmetic element: a `history.db` + * migration, or auto-writing the user's `config.toml` on startup — mutating a file they may hand-author and commit, + * every time they open the Home. An empty Home IS the first-opens signal, costs nothing to read, and stops the instant + * the user's first chat gives them something to continue. `show_banner = true` brings it back for good. Recorded as a + * deliberate deviation in ADR-0068's Step-5g amendment. + * + * Two guards, both about not crowding a small terminal: never below `HOME_MIN_ROWS` (the Home is already in its + * too-small mode there), and a FORCED banner also needs room for the strip it would otherwise push off the screen. + */ +export function shouldShowBanner(v: BannerVisibility): boolean { + if (v.configShowBanner === false) return false; + if (v.rows < HOME_MIN_ROWS) return false; + if (v.configShowBanner === true) return v.rows >= HOME_MIN_ROWS + BANNER_EXTRA_ROWS; + return v.isEmpty; +} diff --git a/apps/cli/src/render/tui/chat-app.test.tsx b/apps/cli/src/render/tui/chat-app.test.tsx index c2bd99f3..26ac55de 100644 --- a/apps/cli/src/render/tui/chat-app.test.tsx +++ b/apps/cli/src/render/tui/chat-app.test.tsx @@ -4,7 +4,10 @@ import type { ReactElement } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ApprovalAnswer } from '../../chat/chat-mode.js'; +import { createSuspendPort } from '../suspend.js'; import { ChatApp } from './chat-ink.js'; +import { liveAnswerRowBudget } from './chat-projection.js'; +import { COPIED_TOAST_MS } from './tui-constants.js'; import { createChatStore, type ChatStoreController } from './chat-store.js'; import { bracketed, settleFrames, waitFor } from './harness-util.js'; @@ -436,4 +439,395 @@ describe('ChatApp alt-screen transcript viewport (2.6.F Step 4b, ADR-0068 §c)', expect(frame()).not.toContain('FIRSTLINE'); // the top of the entry scrolled off — no scrollback expect(frame().split('\n').length).toBeLessThanOrEqual(24); }); + + /** + * THE STREAMING ANSWER MUST NOT OVERFLOW THE FIXED-HEIGHT FRAME (2.6.F Step 6h, Sonnet review). + * + * The alt screen's root Box is `height: rows`, and ink clips the frame there. An unbounded busy line therefore does + * not scroll — it COLLIDES with its siblings inside the box. Reproduced at 80x24 with a 900-character answer, well + * under `MAX_LIVE_TOKEN_CHARS = 4000`, i.e. an ordinary response: the "Esc to stop" hint and the streamed text landed + * on the SAME frame row, overwriting each other. + */ + describe('ChatApp (alt screen) — the live streaming region is bounded', () => { + const streaming = (store: ChatStoreController, chars: number): void => { + store.apply({ + type: 'session:turn_started', + sessionId: 's', + sequenceNumber: 1, + timestamp: '2026-01-01T00:00:00.000Z', + }); + store.apply({ + type: 'agent:token', + sessionId: 's', + sequenceNumber: 2, + timestamp: '2026-01-01T00:00:01.000Z', + token: 'y'.repeat(chars), + model: 'm', + nodeId: 'n', + }); + store.flush(); + }; + + it('the "Esc to stop" hint never shares a row with the streamed text', async () => { + const store = seed(30); + const h = render(chatApp(store)); + setWindowSize(h.stdout, 80, 24); + await settleFrames(); + streaming(store, 2000); + await settleFrames(); + + const rows = (h.lastFrame() ?? '').split('\n'); + const hintRow = rows.find((r) => r.includes('Esc to stop')); + expect(hintRow).toBeDefined(); + expect(hintRow?.trim()).toBe('Esc to stop'); // …and nothing else on it + expect(rows.length).toBeLessThanOrEqual(24); + }); + + it('the streamed content occupies at most a THIRD of the terminal, and shows the newest text', async () => { + const store = seed(30); + const h = render(chatApp(store)); + setWindowSize(h.stdout, 80, 24); + await settleFrames(); + streaming(store, 2000); + await settleFrames(); + + const rows = (h.lastFrame() ?? '').split('\n'); + const contentRows = rows.filter((r) => r.includes('yyyy')); + expect(contentRows.length).toBeLessThanOrEqual(liveAnswerRowBudget(24)); + expect(rows.some((r) => r.includes('…'))).toBe(true); // the tail is marked + }); + + it('the transcript viewport still renders — the live region does not swallow the whole frame', async () => { + const store = seed(30); + const h = render(chatApp(store)); + setWindowSize(h.stdout, 80, 24); + await settleFrames(); + streaming(store, 2000); + await settleFrames(); + expect(h.lastFrame() ?? '').toContain('MSG29'); // the newest transcript entry is still visible + }); + }); +}); + +/** + * The ADR-0068 §e suspend PORT (2.6.F Step 5d) — the repo's first React→core capability bridge. `suspendTerminal` + * exists only inside a mounted ink tree, while the slash dispatch that runs `/scrollback` and `/edit` lives outside + * it. These pin both halves: the port is filled while mounted and EMPTIED on unmount — the latter is what makes a + * hatch say "needs an interactive terminal" between a `/clear` swap's unmount and the next mount, instead of calling + * into a dead ink instance. + */ +describe('ChatApp — the suspend port (ADR-0068 §e)', () => { + it('attaches a WORKING suspendTerminal while mounted, and detaches on unmount', async () => { + const port = createSuspendPort(); + expect(port.current()).toBeUndefined(); + + const h = render( + {}} + shouldStop={() => false} + onExit={() => {}} + onError={() => {}} + onModeChange={() => {}} + suspendPort={port} + />, + ); + await waitFor(() => port.current() !== undefined); + const suspend = port.current(); + expect(suspend).toBeDefined(); + + // It must be ink's REAL suspendTerminal, not a stub: drive a callback through it. ink 7 hands the method out + // UNBOUND off its prototype, so this also pins that our method-call form never loses `this`. + let ran = false; + await suspend?.(() => { + ran = true; + return Promise.resolve(); + }); + expect(ran).toBe(true); + + h.unmount(); + await settleFrames(); + expect(port.current()).toBeUndefined(); // a dead ink instance is never left reachable + }); + + it('mounts fine with NO port (a driver/test that wires none) — the hatches degrade, nothing throws', async () => { + const h = render( + {}} + shouldStop={() => false} + onExit={() => {}} + onError={() => {}} + onModeChange={() => {}} + />, + ); + await settleFrames(); + expect(h.lastFrame()).toBeDefined(); + }); +}); + +/** + * Mouse SELECTION + copy-on-select (2.6.F Step 6), driven through REAL SGR bytes. The unit tests pin the parser, the + * reducer, `cellAt` and the row splitting in isolation; this pins the WIRING — that a press/drag/release on the alt + * screen reaches the reducer with the viewport's measured geometry, and that the release hands the clipboard exactly + * the text the highlight covered. + */ +describe('ChatApp — mouse selection (ADR-0068 §e Step 6)', () => { + /** Three one-row transcript lines: notices render as their bare text, so the wrapped rows are exactly these. */ + const seedThree = (): ChatStoreController => { + const store = createChatStore(false); + store.notice('AAAA'); + store.notice('BBBB'); + store.notice('CCCC'); + return store; + }; + + const mountWithClipboard = ( + store: ChatStoreController, + copied: string[], + ): ReturnType => + render( + {}} + shouldStop={() => false} + onExit={() => {}} + onError={() => {}} + onModeChange={() => {}} + clipboard={(text) => { + copied.push(text); + return { kind: 'written', characters: text.length }; + }} + />, + ); + + it('a DRAG across the first row copies exactly the cells it covered', async () => { + const copied: string[] = []; + const h = mountWithClipboard(seedThree(), copied); + await waitFor(() => (h.lastFrame() ?? '').includes('AAAA')); + + h.stdin.write('\x1b[<0;1;1M'); // press at terminal row 1, column 1 ⇒ line 0, column 0 + await settleFrames(); + h.stdin.write('\x1b[<32;3;1M'); // drag to column 3 ⇒ column 2 (inclusive) + await settleFrames(); + h.stdin.write('\x1b[<0;3;1m'); // release ⇒ copy + await settleFrames(); + + expect(copied).toEqual(['AAA']); // columns 0..2 of 'AAAA' + }); + + it('a MULTI-ROW drag copies first-partial + last-partial, newline-joined', async () => { + const copied: string[] = []; + const h = mountWithClipboard(seedThree(), copied); + await waitFor(() => (h.lastFrame() ?? '').includes('CCCC')); + + h.stdin.write('\x1b[<0;3;1M'); // press line 0, column 2 + await settleFrames(); + h.stdin.write('\x1b[<32;2;3M'); // drag to line 2 ('CCCC'), column 1 + await settleFrames(); + h.stdin.write('\x1b[<0;2;3m'); + await settleFrames(); + + // First row from column 2 to its end, the middle row whole, the last row to its INCLUSIVE end column. + expect(copied).toEqual(['AA\nBBBB\nCC']); + }); + + it('a plain CLICK copies NOTHING — it only clears any prior highlight', async () => { + const copied: string[] = []; + const h = mountWithClipboard(seedThree(), copied); + await waitFor(() => (h.lastFrame() ?? '').includes('AAAA')); + + h.stdin.write('\x1b[<0;2;2M'); + await settleFrames(); + h.stdin.write('\x1b[<0;2;2m'); // release at the same cell + await settleFrames(); + + expect(copied).toEqual([]); + }); + + it('the WHEEL still scrolls while drag reporting is on, and never starts a selection', async () => { + const copied: string[] = []; + const store = createChatStore(false); + for (let i = 0; i < 60; i += 1) store.notice(`row-${i}`); + const h = mountWithClipboard(store, copied); + await waitFor(() => (h.lastFrame() ?? '').includes('row-59')); + + h.stdin.write('\x1b[<64;5;5M'); // wheel up + await settleFrames(); + expect(h.lastFrame() ?? '').not.toContain('row-59'); // the tail scrolled away + h.stdin.write('\x1b[<64;5;5m'); // a wheel "release" is `other`/release — must not copy + await settleFrames(); + expect(copied).toEqual([]); + }); + + it('after SCROLLING, a drag copies the line now shown on that row — not line 0', async () => { + // The reason `offset` is in the viewport facts at all. A break that hardcodes `offset: 0` passes every test that + // never scrolls first, and then silently copies the wrong lines for any user who did. The drag stays on an INNER + // row: row 1 is the edge-scroll zone (see the auto-scroll tests below), and this test is about `offset`, not that. + const copied: string[] = []; + const store = createChatStore(false); + for (let i = 0; i < 60; i += 1) store.notice(`row-${String(i).padStart(2, '0')}`); + const h = mountWithClipboard(store, copied); + await waitFor(() => (h.lastFrame() ?? '').includes('row-59')); + + for (let i = 0; i < 4; i += 1) { + h.stdin.write('\x1b[<64;5;5M'); // wheel up: leave the tail + await settleFrames(); + } + const thirdRow = (h.lastFrame() ?? '').split('\n')[2]?.trim(); + expect(thirdRow).toMatch(/^row-\d\d$/); + expect(thirdRow).not.toBe('row-02'); // we really did scroll away from the head + + h.stdin.write('\x1b[<0;1;3M'); // press the THIRD viewport row (terminal row 3) + await settleFrames(); + h.stdin.write('\x1b[<32;99;3M'); // drag past its right edge ⇒ the whole row + await settleFrames(); + h.stdin.write('\x1b[<0;99;3m'); + await settleFrames(); + + expect(copied).toEqual([thirdRow]); // exactly the line the user could SEE on that row + }); + + it('reduces against the LIVE transcript, not the last measured one (an append between commits)', async () => { + // `onMeasure` lags by up to a commit. A drag on a row that only exists because of a just-appended line must still + // select it — otherwise the reducer clamps to the stale last line and copies the row above. + const copied: string[] = []; + const store = seedThree(); + const h = mountWithClipboard(store, copied); + await waitFor(() => (h.lastFrame() ?? '').includes('CCCC')); + + store.notice('DDDD'); // the ref still says 3 lines; the store says 4 + h.stdin.write('\x1b[<0;1;4M'); // press terminal row 4 ⇒ the new line + h.stdin.write('\x1b[<32;4;4M'); + h.stdin.write('\x1b[<0;4;4m'); + await settleFrames(); + + expect(copied).toEqual(['DDDD']); // NOT 'CCCC' — the stale clamp would have landed one row up + }); + + it('selects the WRAPPED visual rows, not the raw entries — a dragged second row is the line’s second half', async () => { + // The viewport shows WRAPPED rows; the clipboard must index the same array. Copying from the raw transcript + // entries instead passes every test whose lines are short enough never to wrap — and then mis-selects for anyone + // whose model wrote a paragraph. + const copied: string[] = []; + const store = createChatStore(false); + const long = 'x'.repeat(100) + 'TAIL'; // cols = 100 in the harness ⇒ wraps to two rows + store.notice(long); + const h = mountWithClipboard(store, copied); + await waitFor(() => (h.lastFrame() ?? '').includes('TAIL')); + + h.stdin.write('\x1b[<0;1;2M'); // press the SECOND wrapped row + await settleFrames(); + h.stdin.write('\x1b[<32;99;2M'); // drag to its end + await settleFrames(); + h.stdin.write('\x1b[<0;99;2m'); + await settleFrames(); + + expect(copied).toEqual(['TAIL']); // the continuation row, not the (nonexistent) second entry + }); + + it('mounts without a clipboard port: selection still highlights, copy is inert (no throw)', async () => { + const h = render( + {}} + shouldStop={() => false} + onExit={() => {}} + onError={() => {}} + onModeChange={() => {}} + />, + ); + await waitFor(() => (h.lastFrame() ?? '').includes('AAAA')); + h.stdin.write('\x1b[<0;1;1M'); + await settleFrames(); + h.stdin.write('\x1b[<32;3;1M'); + await settleFrames(); + h.stdin.write('\x1b[<0;3;1m'); + await settleFrames(); + expect(h.lastFrame()).toBeDefined(); + }); + + /** + * THE COPY-ON-SELECT CONFIRMATION TOAST (2.6.F Step 6i). Success was silent because the only notice channel — + * `store.note` — appends a transcript entry that re-wraps and SHIFTS the lines just selected. The toast renders + * OUTSIDE the transcript, so it confirms the copy without disturbing the selection. + */ + describe('the "Copied" toast', () => { + /** Drive a press-drag-release that copies, and return the frame right after. */ + const copyOnce = async (h: ReturnType): Promise => { + await waitFor(() => (h.lastFrame() ?? '').includes('AAAA')); + h.stdin.write('\x1b[<0;1;1M'); + await settleFrames(); + h.stdin.write('\x1b[<32;3;1M'); + await settleFrames(); + h.stdin.write('\x1b[<0;3;1m'); + await settleFrames(); + }; + + it('appears after a copy, ABOVE the footer, and does not touch the transcript', async () => { + const copied: string[] = []; + const h = mountWithClipboard(seedThree(), copied); + await copyOnce(h); + expect(copied).toHaveLength(1); // the write happened + const rows = (h.lastFrame() ?? '').split('\n'); + const toastRow = rows.findIndex((r) => r.includes('Copied')); + const footerRow = rows.findIndex((r) => r.includes('turns')); + expect(toastRow).toBeGreaterThanOrEqual(0); + expect(toastRow).toBeLessThan(footerRow); // the toast sits just above the status footer + // The transcript entries are unchanged — the toast is not a transcript line. + expect(rows.some((r) => r.includes('AAAA'))).toBe(true); + }); + + it('auto-dismisses after COPIED_TOAST_MS', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const copied: string[] = []; + const h = mountWithClipboard(seedThree(), copied); + await copyOnce(h); + expect(h.lastFrame() ?? '').toContain('Copied'); + await vi.advanceTimersByTimeAsync(COPIED_TOAST_MS + 50); + await settleFrames(); + expect(h.lastFrame() ?? '').not.toContain('Copied'); + } finally { + vi.useRealTimers(); + } + }); + + it('a TOO-LARGE selection shows the transcript note, NOT the toast', async () => { + const h = render( + {}} + shouldStop={() => false} + onExit={() => {}} + onError={() => {}} + onModeChange={() => {}} + clipboard={() => ({ kind: 'too-large', base64Length: 120_000, limit: 74_994 })} + />, + ); + await copyOnce(h); + const frame = h.lastFrame() ?? ''; + expect(frame).toContain('too large'); // the note + expect(frame).not.toContain('✓ Copied'); // …not the success toast + }); + + it('WITHOUT a clipboard port (copy-on-select off) there is no toast', async () => { + const h = render( + {}} + shouldStop={() => false} + onExit={() => {}} + onError={() => {}} + onModeChange={() => {}} + />, + ); + await copyOnce(h); + expect(h.lastFrame() ?? '').not.toContain('Copied'); + }); + }); }); diff --git a/apps/cli/src/render/tui/chat-ink.test.ts b/apps/cli/src/render/tui/chat-ink.test.ts index 80d1cc99..4a99b60a 100644 --- a/apps/cli/src/render/tui/chat-ink.test.ts +++ b/apps/cli/src/render/tui/chat-ink.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { finalizeInkExit } from './chat-ink.js'; +import { finalizeInkExit, emitIntro } from './chat-ink.js'; /** * `finalizeInkExit` unit tests (2.6.F Step 4a, refined at Step 4b-3, ADR-0068 §c) — the teardown-then-outcome @@ -48,3 +48,51 @@ describe('finalizeInkExit — teardown-before-outcome order (ADR-0068 §c)', () expect(outcome).toEqual({ kind: 'clear' }); }); }); + +/** + * WHERE the intro goes (2.6.F Step 6g, whole-phase Opus review). The alt buffer has no scrollback and ink's first + * frame is `height: rows`, so a pre-mount `writeOut` is painted over and gone. `/clear`'s notice carries + * `relavium chat-resume ` — the ONLY pointer back to the conversation it just ended — and the default renderer + * was throwing it away. + */ +describe('emitIntro — the renderer decides where the intro survives', () => { + const ports = (): { + notices: string[]; + outs: string[]; + notice: (t: string) => void; + writeOut: (t: string) => void; + } => { + const notices: string[] = []; + const outs: string[] = []; + return { notices, outs, notice: (t) => notices.push(t), writeOut: (t) => outs.push(t) }; + }; + + it('FULL-SCREEN: into the transcript, where the viewport keeps it', () => { + const p = ports(); + emitIntro( + 'Started a fresh conversation. Resume the old one with `relavium chat-resume id-0`.', + true, + p, + ); + expect(p.notices).toEqual([ + 'Started a fresh conversation. Resume the old one with `relavium chat-resume id-0`.', + ]); + expect(p.outs).toEqual([]); // never a pre-mount write: the alt buffer erases it + }); + + it('INLINE: printed above the live region, with its newline — byte-identical to before', () => { + const p = ports(); + emitIntro('Resumed session id-0 (3 turns).', false, p); + expect(p.outs).toEqual(['Resumed session id-0 (3 turns).\n']); + expect(p.notices).toEqual([]); + }); + + it('a FRESH session has no intro, and neither sink is touched', () => { + for (const alt of [true, false]) { + const p = ports(); + emitIntro(undefined, alt, p); + expect(p.notices).toEqual([]); + expect(p.outs).toEqual([]); + } + }); +}); diff --git a/apps/cli/src/render/tui/chat-ink.tsx b/apps/cli/src/render/tui/chat-ink.tsx index 4d24bc7b..18c0b044 100644 --- a/apps/cli/src/render/tui/chat-ink.tsx +++ b/apps/cli/src/render/tui/chat-ink.tsx @@ -1,6 +1,8 @@ -import { Box, Static, Text, render, useInput, usePaste, useWindowSize } from 'ink'; +import { Box, Static, Text, render, useApp, useInput, usePaste, useWindowSize } from 'ink'; import { createElement, + useCallback, + useEffect, useMemo, useRef, useState, @@ -8,6 +10,7 @@ import { type ReactElement, } from 'react'; +import type { SuspendPort } from '../suspend.js'; import { driveJson, drivePlain, @@ -38,7 +41,7 @@ import { EXIT_CODES } from '../../process/exit-codes.js'; import { detectOutputMode, isCiEnv } from '../../process/output-mode.js'; import { resolveRenderMode } from '../render-mode.js'; import { colorProps, dimProps } from './projection.js'; -import { FORCE_TEARDOWN_MS, FRAME_MS } from './tui-constants.js'; +import { COPIED_TOAST_MS, FORCE_TEARDOWN_MS, FRAME_MS } from './tui-constants.js'; import { applyEditorAction, editorFromText, @@ -110,20 +113,31 @@ import { streamingAbortHint, stripTerminalControls, wrapTranscript, + liveAnswerRowBudget, } from './chat-projection.js'; import { TranscriptViewport } from './transcript-viewport.js'; +import { createMouseReportReader, type MouseEvent as TerminalMouseEvent } from './mouse.js'; import { + normalizeSelection, + selectionText, + type SelectionRange, + type SelectionState, + routeMouseSelection, +} from './selection.js'; +import { + effectiveOffset, INITIAL_SCROLL, - parseMouseScroll, reduceScroll, scrollMotionForKey, WHEEL_LINES, type ScrollGeometry, + type ViewportGeometry, type ScrollState, } from './scroll.js'; import type { ReasoningEffort } from '@relavium/shared'; import { nextMode, type ChatMode } from '../../chat/chat-mode.js'; +import type { ClipboardOutcome } from '../clipboard.js'; import type { ChatStoreController, PendingApproval } from './chat-store.js'; import type { SessionViewState, TranscriptEntry } from './session-view-model.js'; @@ -215,6 +229,13 @@ interface ChatAppProps { readonly runShellCommand?: | ((command: string, args: readonly string[]) => Promise) | undefined; + /** The ADR-0068 §e suspend port (2.6.F Step 5d). `ChatApp` attaches ink's `useApp().suspendTerminal` to it while + * mounted, which is the ONLY way the non-React slash dispatch can reach `/scrollback` and `/edit`. Absent ⇒ the + * hatches surface an honest "needs an interactive terminal" notice. */ + readonly suspendPort?: SuspendPort | undefined; + /** Write the mouse selection to the system clipboard (OSC 52, 2.6.F Step 6). Absent ⇒ selection still highlights + * but copy-on-select is inert (a driver/test that wires no terminal). */ + readonly clipboard?: ((text: string) => ClipboardOutcome) | undefined; } interface ChatViewProps { @@ -240,6 +261,9 @@ interface ChatViewProps { readonly approval?: PendingApproval | undefined; /** When the `/` palette is open it owns the bottom of the view, so the idle prompt + footer are suppressed (2.5.C S3b). */ readonly paletteOpen?: boolean; + /** `true` for ~2s after a copy-on-select (2.6.F Step 6i) — renders a transient "✓ Copied" toast above the footer, + * confirming the copy WITHOUT appending to the transcript (which would re-wrap the lines just selected). */ + readonly copied?: boolean; /** Pending `@`/`!` attachments (2.5.D chip redesign) — rendered as a compact chip bar above the idle prompt. */ readonly attachments?: readonly PendingAttachment[]; /** The in-flight `!`-shell command line (2.5.D) — when set, the busy indicator labels WHAT is running (a `!`- @@ -261,9 +285,11 @@ interface ChatViewProps { readonly viewport?: | { readonly rows: number; + /** The active mouse selection in WRAPPED-transcript coordinates (Step 6), already document-ordered. */ + readonly selection?: SelectionRange | undefined; readonly cols: number; readonly scroll: ScrollState; - readonly onMeasure: (geom: ScrollGeometry) => void; + readonly onMeasure: (geom: ViewportGeometry) => void; } | undefined; } @@ -275,6 +301,31 @@ interface ChatViewProps { * and every model/transcript string are sanitized at this display boundary so a pasted/streamed control * sequence cannot corrupt the terminal or inject ANSI/OSC. */ +/** + * The transient "✓ Copied" toast state (2.6.F Step 6i). Copy-on-select was silent on success because the only notice + * channel — `store.note` — appends a TRANSCRIPT entry, which re-wraps and shifts the very lines the user just selected + * (whole-phase review). This flag drives a toast rendered OUTSIDE the transcript, so it confirms the copy without + * touching the selection. Shared by both surfaces (`ChatApp` and the Home's `RootApp`), each of which calls + * `flashCopied()` on a `written` outcome. + */ +export function useCopiedToast(): { readonly copied: boolean; readonly flashCopied: () => void } { + const [copied, setCopied] = useState(false); + const timer = useRef | undefined>(undefined); + // Clear a pending timer on unmount so a late `setCopied(false)` never fires into an unmounted tree. + useEffect( + () => () => { + if (timer.current !== undefined) clearTimeout(timer.current); + }, + [], + ); + const flashCopied = useCallback(() => { + setCopied(true); + if (timer.current !== undefined) clearTimeout(timer.current); // a re-copy RESTARTS the 2s, never stacks timers + timer.current = setTimeout(() => setCopied(false), COPIED_TOAST_MS); + }, []); + return { copied, flashCopied }; +} + export function ChatView(props: Readonly): ReactElement { const { state, @@ -324,6 +375,12 @@ export function ChatView(props: Readonly): ReactElement { liveTokensTruncated: state.liveTokensTruncated, elapsedMs, reasoningActive, + // `props.viewport` is present exactly on the ALT screen, and it carries the terminal's size. Only that surface + // has a FIXED-HEIGHT frame to overflow; the inline renderer's live region grows and the terminal scrolls it, so + // it passes no bound and its output stays byte-identical. + ...(props.viewport === undefined + ? {} + : { columns: props.viewport.cols, maxRows: liveAnswerRowBudget(props.viewport.rows) }), }); // A STATUS line (compaction / shell / pre-token) already carries its inline "· Esc to …" hint; a streaming // CONTENT line has no room for it, so surface the abort affordance on a compact dim line beneath it — `Esc` @@ -374,6 +431,7 @@ export function ChatView(props: Readonly): ReactElement { lines={wrappedTranscript} color={color} scroll={viewport.scroll} + selection={viewport.selection} onMeasure={viewport.onMeasure} /> )} @@ -469,6 +527,18 @@ export function ChatView(props: Readonly): ReactElement { )} + {/* The copy-on-select confirmation (2.6.F Step 6i) — a transient pill above the footer, auto-dismissed by the + owner's timer. It lives OUTSIDE the transcript, so confirming a copy never re-wraps the selected lines. A + green pill when colour is on; a plain `[Copied]` under `NO_COLOR` / `--no-color` (parity with the banner). */} + {props.copied === true && + (color ? ( + + {' ✓ Copied '} + + ) : ( + [Copied] + ))} + {formatSessionFooterWithMode(state, mode, reasoningEffort)} @@ -493,13 +563,40 @@ export function ChatApp(props: Readonly): ReactElement { setEditor(value); }; const cancelFired = useRef(false); + // The copy-on-select confirmation toast (2.6.F Step 6i) — `flashCopied` on a `written` outcome, rendered as a + // transient pill by `ChatView` (not a transcript entry, so the selection never shifts). + const { copied, flashCopied } = useCopiedToast(); // The alt-screen transcript SCROLL state (2.6.F Step 4b-2) — React-local here (the Home keeps it in the // controller), ref-shadowed like the editor so a coalesced stdin chunk reduces off the latest. The live geometry // (total wrapped lines + measured viewport rows) is lifted from `TranscriptViewport.onMeasure` into `scrollGeomRef` // so a scroll key reduces against the SAME geometry the viewport windows with. Inert in the inline renderer. const [scroll, setScroll] = useState(INITIAL_SCROLL); const scrollRef = useRef(INITIAL_SCROLL); - const scrollGeomRef = useRef({ totalLines: 0, height: 0 }); + // Survives an SGR mouse report SPLIT across two `useInput` calls (Step 6f). One reader per mount: it holds the + // fragment, so it must not be re-created on every render. + const mouseReaderRef = useRef(createMouseReportReader()); + // Whether the transcript was following the tail when the current gesture began — a plain click restores it. + const followedBeforeSelectionRef = useRef(false); + // Whether a left-button gesture is in flight — set by a press inside the viewport, cleared on release. Distinct from + // a retained highlight, so a stray click after a copy cannot re-copy it (Step 6h review). + const gestureActiveRef = useRef(false); + // The mouse selection (2.6.F Step 6), held exactly like `scroll`: React state for the render, a ref so a coalesced + // stdin chunk (a drag burst arrives as several reports in ONE read) reduces off the latest, not the render closure. + const [selection, setSelection] = useState(undefined); + const selectionRef = useRef(undefined); + // Seeded at zero: the post-commit measure fills it on the first frame. `top`/`left`/`width` are the box's position + // in ink's frame — the mouse handler's half of the row→line mapping (Step 6). + const scrollGeomRef = useRef({ + totalLines: 0, + height: 0, + width: 0, + top: 0, + left: 0, + }); + const applySelection = (next: SelectionState | undefined): void => { + selectionRef.current = next; + setSelection(next); + }; const applyScroll = (next: ScrollState): void => { scrollRef.current = next; setScroll(next); @@ -895,6 +992,20 @@ export function ChatApp(props: Readonly): ReactElement { } }; + // Attach ink's `suspendTerminal` to the ADR-0068 §e port while this tree is mounted (2.6.F Step 5d). `useApp()` is + // the only place it exists, and the slash dispatch that runs `/scrollback` / `/edit` lives outside React — so the + // port is the bridge. Detaching on unmount is what makes the hatches report "needs an interactive terminal" between + // a `/clear`-swap's unmount and the next mount, rather than calling into a dead ink instance. + // Invoked as a METHOD (`app.suspendTerminal(cb)`), never a bare destructured reference: ink 7 hands it out unbound + // off the prototype, so this form is immune to how the context object is shaped. + const app = useApp(); + const suspendPort = props.suspendPort; + useEffect(() => { + if (suspendPort === undefined) return; + suspendPort.attach((callback) => app.suspendTerminal(callback)); + return () => suspendPort.attach(undefined); + }, [app, suspendPort]); + const submit = (message: string, display?: string): void => { // A typed `/models` opens the reseat picker overlay (ADR-0059) instead of sending — interactive only (the port // is wired). Covers a directly-typed `/models` AND a chat-palette selection (both route through `submit`). @@ -965,14 +1076,82 @@ export function ChatApp(props: Readonly): ReactElement { // Ctrl-C reaches us (not the kernel) in raw mode — `reduceChatKey` maps it to `cancel` even mid-turn. Dispatch // /cancel at most once: cancelOnce() is idempotent, but a held Ctrl-C would otherwise fire redundant turns. + /** + * Reduce one non-wheel mouse report into the selection (2.6.F Step 6). The viewport facts come from the measured + * geometry (`top`/`left` — where the box sits in ink's frame) plus the LIVE wrap (`totalLines`/`height`), so a + * drag during a streaming turn reduces against the transcript as it is now, not as it was at the last commit. + */ + const chatLiveGeom = (): ScrollGeometry => + liveScrollGeometry( + props.store.getSnapshot().state.transcript, + windowSize.columns, + scrollGeomRef.current.height, + ); + + const routeSelection = (event: TerminalMouseEvent): void => { + routeMouseSelection(event, { + geometry: () => { + const measured = scrollGeomRef.current; + const live = chatLiveGeom(); + return { + top: measured.top, + left: measured.left, + height: live.height, + totalLines: live.totalLines, + offset: effectiveOffset(scrollRef.current, live), + }; + }, + current: () => selectionRef.current, + setSelection: applySelection, + copy: copySelection, + scrollBy: (motion: 'line-up' | 'line-down') => + applyScroll(reduceScroll(scrollRef.current, motion, chatLiveGeom())), + pauseFollow: () => { + const scroll = scrollRef.current; + followedBeforeSelectionRef.current = scroll.following; + if (!scroll.following) return; + applyScroll({ offset: effectiveOffset(scroll, chatLiveGeom()), following: false }); + }, + restoreFollow: () => { + if (!followedBeforeSelectionRef.current) return; + applyScroll({ ...scrollRef.current, following: true }); + }, + gestureActive: () => gestureActiveRef.current, + setGestureActive: (active: boolean) => { + gestureActiveRef.current = active; + }, + }); + }; + + /** + * Copy the selection to the system clipboard on release. SILENT on success: `store.notice` appends a transcript + * entry, which would re-wrap and SHIFT the very lines the user just selected — the highlight would jump out from + * under their pointer. Only a refusal (a selection past the terminal's OSC 52 length floor) is worth a notice. + */ + const copySelection = (state: SelectionState): void => { + const clipboard = props.clipboard; + if (clipboard === undefined) return; + const rows = wrapTranscript(props.store.getSnapshot().state.transcript, windowSize.columns).map( + (line) => line.text, + ); + const outcome = clipboard(selectionText(rows, normalizeSelection(state))); + if (outcome.kind === 'written') flashCopied(); + else if (outcome.kind === 'too-large') { + props.store.note( + `selection too large to copy (${Math.ceil(outcome.base64Length / 1024)} KB) — use /scrollback or /edit`, + ); + } + }; + useInput((char, key) => { // Mouse reports (Step 5): the alt screen enables mouse reporting, so a wheel/click arrives in EVERY state — // including while an overlay owns the keyboard. CONSUME every report HERE, ahead of the overlay routing below, // so its raw bytes can never type into the prompt, the `/` palette filter, or the `[c]` reason capture. The wheel // only SCROLLS when no overlay owns the keyboard (parity with the Home + the Step-4b-2 overlay gate). if (props.alternateScreen === true) { - const mouse = parseMouseScroll(char); - if (mouse !== undefined) { + const read = mouseReaderRef.current.read(char); + if (read.kind !== 'none') { + const mouse = read.kind === 'event' ? read.event : undefined; const overlayOwnsKeyboard = reasonDraftRef.current !== undefined || paletteRef.current !== undefined || @@ -980,17 +1159,18 @@ export function ChatApp(props: Readonly): ReactElement { mentionRef.current !== undefined || modelPickerRef.current !== undefined || effortPickerRef.current !== undefined; - if (mouse !== 'ignore' && !overlayOwnsKeyboard) { - const geom = liveScrollGeometry( - props.store.getSnapshot().state.transcript, - windowSize.columns, - scrollGeomRef.current.height, - ); - let next = scrollRef.current; - for (let i = 0; i < WHEEL_LINES; i += 1) next = reduceScroll(next, mouse, geom); - applyScroll(next); + if (mouse !== undefined && !overlayOwnsKeyboard) { + if (mouse.kind === 'wheel') { + const geom = chatLiveGeom(); + const motion = mouse.direction === 'up' ? 'line-up' : 'line-down'; + let next = scrollRef.current; + for (let i = 0; i < WHEEL_LINES; i += 1) next = reduceScroll(next, motion, geom); + applyScroll(next); + } else { + routeSelection(mouse); + } } - return; + return; // CONSUMED in every state — a mouse report's raw bytes must never type into the prompt } } // Read `running` FRESH from the store (not the render closure) so a coalesced same-chunk event after a turn @@ -1145,6 +1325,18 @@ export function ChatApp(props: Readonly): ReactElement { openMention(); return; } + // Esc DISMISSES a live selection first — it is the most recent thing the user did and the only one they can see. + // Gated on `!isRunning` on purpose: while a turn streams, Esc is the mid-turn ABORT, and shadowing an abort with a + // cosmetic clear would be a bad trade. A click still clears the highlight mid-turn. + if ( + key.escape === true && + !isRunning && + !approvalPending && + selectionRef.current !== undefined + ) { + applySelection(undefined); + return; + } // Esc at an IDLE prompt with pending `@`/`!` attachments discards them (a clean cancel affordance — parity with // home-controller.ts; when a turn is running Esc is the mid-turn abort, reduced below). if ( @@ -1164,20 +1356,12 @@ export function ChatApp(props: Readonly): ReactElement { // reduceChatKey below), which is safe because scroll keys never overlap the [y]/[a]/[n] answer set. Read the REF // for coalesced-chunk safety. Not gated on `isRunning` — you can scroll history WHILE a turn streams. if (props.alternateScreen === true) { - const liveGeom = (): ScrollGeometry => - // Reduce against LIVE geometry: wrap the store's CURRENT transcript at the keypress (rare, user-driven) for - // a fresh `totalLines`, not the `onMeasure` ref which lags by up to a commit — else a mid-stream burst makes - // `settle` resume-follow against a stale bottom (Step-4b-2 Sonnet review). `props.store` is a stable prop, so - // its snapshot is read fresh here regardless of any coalesced-chunk closure staleness. - liveScrollGeometry( - props.store.getSnapshot().state.transcript, - windowSize.columns, - scrollGeomRef.current.height, - ); - // (Mouse reports are consumed at the TOP of this handler, ahead of the overlay routing — see above.) + // (Mouse reports are consumed at the TOP of this handler, ahead of the overlay routing — see above.) The scroll + // reduces against LIVE geometry via `chatLiveGeom()` — the store's CURRENT transcript, not the `onMeasure` ref + // which lags by up to a commit (Step-4b-2 Sonnet review). const motion = scrollMotionForKey(key); if (motion !== undefined) { - applyScroll(reduceScroll(scrollRef.current, motion, liveGeom())); + applyScroll(reduceScroll(scrollRef.current, motion, chatLiveGeom())); return; } } @@ -1295,6 +1479,12 @@ export function ChatApp(props: Readonly): ReactElement { // width, and the viewport's re-measure all track a resize — parity with the Home's `subscribeResize`. It falls // back to 80×24 off a TTY (a harness), moot on a real TTY (the only place alt mounts, via the driveInk gate). const windowSize = useWindowSize(); + + // A resize re-wraps the transcript, so every display-line index the live selection holds moves. Drop it rather than + // highlight — and copy — the wrong text (2.6.F Step 6). + useEffect(() => { + applySelection(undefined); + }, [windowSize.columns]); // Alt-screen (Step 4b, ADR-0068 §c): the outer container is bounded to the terminal `rows` so `ChatView`'s // flex-grow viewport has a height to fill BELOW any keyboard-owning overlay (palette / search / …), and the // transcript renders through the scroll {@link TranscriptViewport} instead of ``. Absent ⇒ the inline @@ -1305,8 +1495,10 @@ export function ChatApp(props: Readonly): ReactElement { rows: windowSize.rows, cols: windowSize.columns, scroll, + // Document-ordered here, once: the viewport draws it, `copySelection` re-derives it for the clipboard. + ...(selection === undefined ? {} : { selection: normalizeSelection(selection) }), // Lift the viewport's live geometry into the ref the scroll keymap reduces against (no re-render). - onMeasure: (g: ScrollGeometry): void => { + onMeasure: (g: ViewportGeometry): void => { scrollGeomRef.current = g; }, } @@ -1331,6 +1523,7 @@ export function ChatApp(props: Readonly): ReactElement { columns={windowSize.columns} viewport={viewport} reasonDraft={reasonDraft} + copied={copied} paletteOpen={ palette !== undefined || search !== undefined || @@ -1386,13 +1579,46 @@ export function finalizeInkExit( return exited.finally(ops.teardown).then((): ChatDriveOutcome => ops.outcome()); } +/** + * Where a session's INTRO goes — a resume banner (2.N), the `/clear` notice carrying `relavium chat-resume `, or + * a `/models` reseat line. The renderer decides, and getting it wrong loses the line: + * + * - INLINE: printed before ink mounts, so it scrolls into the terminal's history above the live region (the TTY + * counterpart of what `drivePlain` writes). + * - FULL-SCREEN: the alt buffer has no scrollback, and ink's first frame is `height: rows` — it paints straight over + * a pre-mount write. So the intro goes into the fresh session's TRANSCRIPT, where the viewport keeps it and the + * user can scroll back to it. Without this, `/clear` in the DEFAULT renderer silently discarded the only pointer + * back to the conversation it had just ended (whole-phase Opus review). The MCP-skipped diagnostic already took + * this route (chat.ts); the intro did not. + * + * `driveInk` mounts real ink and cannot be unit-tested, so the decision lives here, where it can be. + */ +export function emitIntro( + intro: string | undefined, + alternateScreen: boolean, + ports: { readonly notice: (text: string) => void; readonly writeOut: (text: string) => void }, +): void { + if (intro === undefined) return; // a fresh session has no intro + if (alternateScreen) ports.notice(intro); + else ports.writeOut(`${intro}\n`); +} + export function driveInk(ctx: ChatDriveContext): Promise { - // The resume banner (2.N): print it once before mounting ink so it scrolls into the terminal history above - // the live region — the TTY counterpart of the line drivePlain writes, so a resumed session is visibly a - // resume (not just an N-turn footer). A fresh session has no intro and prints nothing here. - if (ctx.intro !== undefined) { - ctx.io.writeOut(`${ctx.intro}\n`); - } + // Resolved here, BEFORE the intro: where the intro goes depends on the renderer (see `emitIntro`). + const alternateScreen = + resolveRenderMode({ + outputMode: detectOutputMode({ + stdoutIsTty: ctx.io.stdoutIsTty, + json: ctx.global.json, + ci: isCiEnv(ctx.io.env), + }), + noAltScreenFlag: ctx.global.noAltScreen === true, + configAltScreen: ctx.altScreen, + }) === 'alt'; + emitIntro(ctx.intro, alternateScreen, { + notice: (text) => ctx.store.notice(text), + writeOut: (text) => ctx.io.writeOut(text), + }); // Mirror the live stream into the view store the component projects. const unsubscribe = ctx.handle.subscribe((event) => ctx.store.apply(event)); // Open the session ONLY now — the store is subscribed, so the synchronous session:started (which carries @@ -1408,8 +1634,13 @@ export function driveInk(ctx: ChatDriveContext): Promise { rejectExit = reject; }); - // An EXTERNAL SIGINT (kill -INT / a parent's signal) — a keyboard Ctrl-C is intercepted by useInput in raw - // mode and never reaches the kernel as SIGINT, so this covers only the out-of-band case. Register with + // An EXTERNAL SIGINT (kill -INT / a parent's signal). A keyboard Ctrl-C is normally intercepted by useInput in raw + // mode and never reaches the kernel as SIGINT — EXCEPT while a `/scrollback` / `/edit` suspension owns the terminal, + // where ink's `pauseInput()` has turned raw mode OFF and the tty line discipline delivers a real SIGINT. Running the + // cooperative `/cancel` there would end the session, unmount ink, and exit the hoisted alt buffer behind the + // suspension's back — whose pending reclaim would later re-enter the alt buffer and re-enable the mouse on the + // user's SHELL (Step-5d-3 Sonnet review). So the handler yields while a hatch is suspended: the hatch's own wait + // resolves on SIGINT, and `$EDITOR` (same foreground process group) receives the signal directly. Register with // process.on (NOT once): ink registers a signal-exit SIGINT listener that RE-RAISES SIGINT (→ exit 130) when // it is the SOLE remaining listener, which would skip our finally and leave the row 'active'. Staying // registered keeps signal-exit from re-raising, so the cooperative /cancel (→ session:cancelled → persister @@ -1423,16 +1654,6 @@ export function driveInk(ctx: ChatDriveContext): Promise { // value drives ONLY the `ChatApp` component prop (the transcript viewport vs ``); ink's render OPTION is a // hard `false` (Step 4b-3), so ink toggles NO DECSET-1049 per session — the hoisted `runReplLoop` owns the single // alt-buffer enter/exit, and the end-of-session summary rides on the outcome + prints after that exit (ADR-0068 §c). - const alternateScreen = - resolveRenderMode({ - outputMode: detectOutputMode({ - stdoutIsTty: ctx.io.stdoutIsTty, - json: ctx.global.json, - ci: isCiEnv(ctx.io.env), - }), - noAltScreenFlag: ctx.global.noAltScreen === true, - configAltScreen: ctx.altScreen, - }) === 'alt'; let cancelRequested = false; const onSigint = (): void => { if (cancelRequested) { @@ -1462,7 +1683,13 @@ export function driveInk(ctx: ChatDriveContext): Promise { cancelRequested = true; void ctx.processLine('/cancel').then(() => resolveExit(), rejectExit); }; - process.on('SIGINT', onSigint); + /** Drop a SIGINT that arrives while a hatch owns the terminal — see the note above. Wraps `onSigint` so the guard + * can never be forgotten by a later edit to the handler body. */ + const onSigintGated = (): void => { + if (ctx.suspendPort?.isSuspended() === true) return; + onSigint(); + }; + process.on('SIGINT', onSigintGated); try { instance = render( @@ -1494,6 +1721,8 @@ export function driveInk(ctx: ChatDriveContext): Promise { mentionReader: ctx.mentionReader, // `!`-shell runner (2.5.D, ADR-0061) — interactive-only; absent ⇒ a leading `!` is a literal message. runShellCommand: ctx.runShellCommand, + suspendPort: ctx.suspendPort, + clipboard: ctx.clipboard, }), { // OUR /cancel (Ctrl-C) handler drives the cooperative cancel — never ink's process.exit. @@ -1518,7 +1747,7 @@ export function driveInk(ctx: ChatDriveContext): Promise { } catch { // swallow — never mask the outcome nor skip the SIGINT-listener removal below. } - process.removeListener('SIGINT', onSigint); + process.removeListener('SIGINT', onSigintGated); }, // The end-of-session summary rides on the outcome (Step 4b-3): the hoisted runReplLoop prints it AFTER the // single alt-buffer exit, on the primary buffer (ADR-0068 §c). Only a real end (`/exit`) carries one; a `/clear` @@ -1533,7 +1762,7 @@ export function driveInk(ctx: ChatDriveContext): Promise { // none leaks past the throw (the finally above is never reached when render() throws). clearInterval(frame); unsubscribe(); - process.removeListener('SIGINT', onSigint); + process.removeListener('SIGINT', onSigintGated); throw err; } } diff --git a/apps/cli/src/render/tui/chat-projection.test.ts b/apps/cli/src/render/tui/chat-projection.test.ts index f6fac612..f52be7fb 100644 --- a/apps/cli/src/render/tui/chat-projection.test.ts +++ b/apps/cli/src/render/tui/chat-projection.test.ts @@ -4,6 +4,7 @@ import type { ToolApprovalRequest } from '@relavium/core'; import { ERROR_CODES } from '@relavium/shared'; import { + entryLines, errorRecoveryHint, formatApprovalTarget, formatBusyLine, @@ -18,9 +19,11 @@ import { reasoningLabelActive, sanitizeApprovalReason, sanitizeInline, + transcriptDocument, streamingAbortHint, stripTerminalControls, wrapTranscript, + liveAnswerRowBudget, } from './chat-projection.js'; import { formatDuration, formatTokens } from './format.js'; import { initialSessionViewState, type TranscriptEntry } from './session-view-model.js'; @@ -876,3 +879,177 @@ describe('chat-projection', () => { }); }); }); + +/** + * `entryLines` + `transcriptDocument` (2.6.F Step 5d) — the UNWRAPPED projection. Until now these were exercised only + * transitively through `wrapEntry`/`wrapTranscript`, which proves the WRAPPING, not the document shape `/edit` hands + * to `$EDITOR` (the Step-5d-2 Sonnet review). Tested directly here, because `transcriptDocument` is what the user + * reads, searches, and copies out of their editor. + */ +describe('entryLines — the shared, unwrapped per-entry projection', () => { + it('a user entry is one `> `-prefixed line', () => { + expect(entryLines({ role: 'user', text: 'hello' })).toEqual([ + { text: '> hello', style: 'user' }, + ]); + }); + + it('a notice entry is its bare text', () => { + expect(entryLines({ role: 'notice', text: 'session resumed' })).toEqual([ + { text: 'session resumed', style: 'notice' }, + ]); + }); + + it('an assistant entry is text, THEN the summary line (leading space) — in that order', () => { + const lines = entryLines({ + role: 'assistant', + text: 'the answer', + summary: { stopReason: 'stop', tokensUsed: { input: 10, output: 5 } }, + }); + expect(lines).toHaveLength(2); + expect(lines[0]).toEqual({ text: 'the answer', style: 'assistant' }); + expect(lines[1]?.style).toBe('summary'); + expect(lines[1]?.text.startsWith(' ')).toBe(true); + }); + + it('an assistant entry with an actionable error code appends the hint line LAST', () => { + const lines = entryLines({ + role: 'assistant', + text: 'sorry', + summary: { + stopReason: 'stop', + tokensUsed: { input: 0, output: 0 }, + errorCode: 'provider_auth', + }, + }); + expect(lines).toHaveLength(3); + expect(lines[2]?.style).toBe('hint'); + expect(lines[2]?.text.startsWith(' \u2192 ')).toBe(true); + }); + + it('does NOT wrap: a long line stays one line, and embedded newlines stay embedded', () => { + const long = 'x'.repeat(500); + expect(entryLines({ role: 'notice', text: long })).toEqual([{ text: long, style: 'notice' }]); + expect(entryLines({ role: 'user', text: 'a\nb' })).toEqual([{ text: '> a\nb', style: 'user' }]); + }); + + it('sanitizes at the projection boundary (every consumer inherits it)', () => { + expect(entryLines({ role: 'notice', text: '\x1b[31mred\x1b[0m' })).toEqual([ + { text: 'red', style: 'notice' }, + ]); + }); +}); + +describe('transcriptDocument — what `/edit` hands to $EDITOR', () => { + it('joins every entry’s lines with a newline, in transcript order', () => { + expect( + transcriptDocument([ + { role: 'user', text: 'hi' }, + { role: 'notice', text: 'note' }, + ]), + ).toBe('> hi\nnote'); + }); + + it('an EMPTY transcript is the empty document (never `undefined`, never a stray newline)', () => { + expect(transcriptDocument([])).toBe(''); + }); + + it('a multi-line assistant answer round-trips its internal newlines UNWRAPPED', () => { + const doc = transcriptDocument([ + { + role: 'assistant', + text: 'line one\nline two', + summary: { stopReason: 'stop', tokensUsed: { input: 1, output: 1 } }, + }, + ]); + const [first, second, summary] = doc.split('\n'); + expect(first).toBe('line one'); + expect(second).toBe('line two'); + expect(summary?.startsWith(' ')).toBe(true); // the summary line follows, not a re-wrap of the answer + }); + + it('is width-INDEPENDENT — a 500-char answer is one line, so the editor re-flows at ITS width', () => { + const long = 'y'.repeat(500); + expect(transcriptDocument([{ role: 'user', text: long }]).split('\n')).toHaveLength(1); + }); + + it('SECURITY: strips a Trojan-Source bidi OVERRIDE but leaves legitimate RTL text intact', () => { + // An editor renders bidi controls, so an RLO in model output would spoof the reading order of the very + // transcript the user opened `/edit` to inspect. But Arabic/Hebrew/Persian letters carry their direction + // IMPLICITLY (the Unicode bidi algorithm) — stripping the explicit overrides never touches them. Relavium + // ships `tr` today and may ship RTL locales; this must not mangle a legitimate conversation. + const rlo = '\u202E'; // RIGHT-TO-LEFT OVERRIDE + const doc = transcriptDocument([ + { role: 'user', text: `safe${rlo}gnp.exe` }, + { role: 'notice', text: 'مرحبا بالعالم' }, // Arabic: implicit RTL, no control characters at all + ]); + expect(doc).toBe('> safegnp.exe\nمرحبا بالعالم'); + expect(doc).not.toContain(rlo); + }); +}); + +/** + * THE STREAMING ANSWER'S ROW BUDGET (2.6.F Step 6h, Sonnet review). + * + * The alt screen's frame is `height: rows` and ink clips it there, so an unbounded busy line does not scroll — it + * COLLIDES with its siblings. Reproduced at 80x24 with a 900-character answer, well under `MAX_LIVE_TOKEN_CHARS`: + * the "Esc to stop" hint and the streamed text landed on the SAME frame row, overwriting each other. + */ +describe('liveAnswerRowBudget', () => { + it('is a third of the terminal, so the viewport, prompt and footer all survive', () => { + expect(liveAnswerRowBudget(24)).toBe(8); + expect(liveAnswerRowBudget(60)).toBe(20); + }); + + it('never returns zero — a one-row terminal still shows one row of the answer', () => { + expect(liveAnswerRowBudget(1)).toBe(1); + expect(liveAnswerRowBudget(2)).toBe(1); + }); + + it('falls back for a detached / zero-sized TTY rather than dividing by nothing', () => { + expect(liveAnswerRowBudget(undefined)).toBe(8); + expect(liveAnswerRowBudget(0)).toBe(8); + expect(liveAnswerRowBudget(-5)).toBe(8); + }); +}); + +describe('formatBusyLine — the streaming content is bounded on the alt screen only', () => { + const busy = ( + liveTokens: string, + over: Record = {}, + ): { text: string; dim: boolean } => + formatBusyLine({ + spinner: '*', + compacting: false, + liveTokens, + liveTokensTruncated: false, + ...over, + }); + + it('WITHOUT a row budget the content is untouched — the inline renderer stays byte-identical', () => { + const long = 'y'.repeat(2000); + expect(busy(long).text).toBe(`* ${long}`); + }); + + it('WITH a row budget it keeps the TAIL and marks the elision', () => { + const long = 'y'.repeat(2000); + const line = busy(long, { columns: 80, maxRows: 8 }); + expect(line.text.startsWith('* …')).toBe(true); + expect(line.text.length).toBeLessThan(long.length); // …and it is a tail, not the whole thing + expect(line.text.endsWith('y')).toBe(true); // the NEWEST characters survive + }); + + it('short content is not marked, budget or no budget', () => { + expect(busy('hello', { columns: 80, maxRows: 8 }).text).toBe('* hello'); + }); + + it('the character-cap marker still shows even when the row budget did not trigger', () => { + expect(busy('hello', { columns: 80, maxRows: 8, liveTokensTruncated: true }).text).toBe( + '* …hello', + ); + }); + + it('a STATUS line (pre-token, compacting, shell) is never tailed — it has no content', () => { + expect(busy('', { columns: 80, maxRows: 8 }).dim).toBe(true); + expect(busy('x', { compacting: true, columns: 80, maxRows: 1 }).dim).toBe(true); + }); +}); diff --git a/apps/cli/src/render/tui/chat-projection.ts b/apps/cli/src/render/tui/chat-projection.ts index c7d35d38..cbfd109c 100644 --- a/apps/cli/src/render/tui/chat-projection.ts +++ b/apps/cli/src/render/tui/chat-projection.ts @@ -262,25 +262,53 @@ export function errorRecoveryHint(code: string | undefined, message?: string): s } } -/** Wrap ONE transcript entry to its width-wrapped display lines — the per-entry unit {@link wrapTranscript} caches. */ -function wrapEntry(entry: TranscriptEntry, cols: number): DisplayLine[] { - const lines: DisplayLine[] = []; - const push = (text: string, style: DisplayLine['style']): void => { - for (const row of wrapText(text, cols)) lines.push({ text: row, style }); - }; +/** + * Project ONE transcript entry to its LOGICAL display lines — prefixes, styles, and display-boundary sanitization, + * but NO width-wrapping (a returned `text` may still contain `\n`). The single source of the transcript's rendered + * CONTENT: {@link wrapEntry} wraps these to terminal rows for the viewport, and {@link transcriptDocument} joins them + * unwrapped for the `/edit` hatch (2.6.F Step 5d) — so the on-screen transcript and the one handed to `$EDITOR` can + * never disagree about what the conversation said. + * + * Sanitization happens HERE, once, for every consumer: a `user` entry becomes `> {text}`; a `notice` its text; an + * `assistant` entry its text, then the one-line summary (a leading space, as `TranscriptLine`), then the optional + * recovery-hint line. + */ +export function entryLines(entry: TranscriptEntry): DisplayLine[] { if (entry.role === 'user') { - push(`> ${stripTerminalControls(entry.text)}`, 'user'); - } else if (entry.role === 'notice') { - push(stripTerminalControls(entry.text), 'notice'); - } else { - push(stripTerminalControls(entry.text), 'assistant'); - push(` ${formatTurnSummary(entry.summary)}`, 'summary'); - const hint = errorRecoveryHint(entry.summary.errorCode, entry.summary.errorMessage); - if (hint !== undefined) push(` → ${hint}`, 'hint'); + return [{ text: `> ${stripTerminalControls(entry.text)}`, style: 'user' }]; } + if (entry.role === 'notice') { + return [{ text: stripTerminalControls(entry.text), style: 'notice' }]; + } + const lines: DisplayLine[] = [ + { text: stripTerminalControls(entry.text), style: 'assistant' }, + { text: ` ${formatTurnSummary(entry.summary)}`, style: 'summary' }, + ]; + const hint = errorRecoveryHint(entry.summary.errorCode, entry.summary.errorMessage); + if (hint !== undefined) lines.push({ text: ` → ${hint}`, style: 'hint' }); return lines; } +/** Wrap ONE transcript entry to its width-wrapped display lines — the per-entry unit {@link wrapTranscript} caches. */ +function wrapEntry(entry: TranscriptEntry, cols: number): DisplayLine[] { + const wrapped: DisplayLine[] = []; + for (const line of entryLines(entry)) { + for (const row of wrapText(line.text, cols)) wrapped.push({ text: row, style: line.style }); + } + return wrapped; +} + +/** + * The whole transcript as ONE plain-text document for the `/edit` hatch (2.6.F Step 5d, ADR-0068 §e) — the same + * sanitized content the viewport shows ({@link entryLines}), joined UNWRAPPED so the user's editor re-flows it at its + * own width instead of inheriting the terminal's column count. Sanitized like every other display boundary: an editor + * renders bidi/RTL overrides, so a Trojan-Source reordering (CVE-2021-42574) in model output would spoof the reading + * order of the very transcript the user opened it to inspect. + */ +export function transcriptDocument(transcript: readonly TranscriptEntry[]): string { + return transcript.flatMap((entry) => entryLines(entry).map((line) => line.text)).join('\n'); +} + /** * PER-ENTRY wrap cache (2.6.F Step 4b-3, ADR-0068 §c). A `WeakMap` keyed on the IMMUTABLE, append-only transcript * ENTRY object → its last wrap `{ cols, lines }`. `wrapTranscript` re-wraps the whole transcript on each append (a new @@ -379,6 +407,11 @@ export function formatBusyLine(input: { * streamed this turn AND no tool call is currently executing), NOT the raw "any reasoning streamed" flag, so a * tool round shows "Working…". Absent/false ⇒ a plain (or tool-running) turn shows "Working…". */ readonly reasoningActive?: boolean | undefined; + /** The terminal's width, for the row estimate. Absent ⇒ the projection's fallback. */ + readonly columns?: number | undefined; + /** How many rendered rows the streaming content may occupy ({@link liveAnswerRowBudget}). Absent ⇒ unbounded, the + * INLINE renderer's behaviour: it has no fixed-height frame to overflow. */ + readonly maxRows?: number | undefined; }): BusyLine { const { spinner } = input; if (input.compacting) { @@ -390,13 +423,21 @@ export function formatBusyLine(input: { dim: true, }; } - const content = stripTerminalControls(input.liveTokens); - if (content.length === 0) { + const sanitized = stripTerminalControls(input.liveTokens); + if (sanitized.length === 0) { const label = input.reasoningActive === true ? 'Thinking…' : 'Working…'; const elapsed = input.elapsedMs === undefined ? '' : ` ${formatElapsed(input.elapsedMs)}`; return { text: `${spinner} ${label}${elapsed} · Esc to stop`, dim: true }; } - return { text: `${spinner} ${input.liveTokensTruncated ? '…' : ''}${content}`, dim: false }; + // The alt screen's live region is a FIXED-HEIGHT box: an unbounded busy line does not scroll, it collides with its + // siblings and overwrites them. Bound it to the caller's row budget, the same discipline the reasoning panel uses. + // The inline renderer passes none — it has no frame to overflow. + const { body, tailed } = + input.maxRows === undefined + ? { body: sanitized, tailed: false } + : tailToRenderedRows(sanitized, input.columns, input.maxRows); + const elided = input.liveTokensTruncated || tailed; + return { text: `${spinner} ${elided ? '…' : ''}${body}`, dim: false }; } /** @@ -445,6 +486,29 @@ export interface ReasoningPanel { */ export const MAX_REASONING_PANEL_LINES = 12; +/** The fallback terminal height when the caller has none (a detached / zero-sized TTY). */ +const LIVE_ANSWER_FALLBACK_ROWS = 24; + +/** + * How many rendered rows the STREAMING answer may occupy in the alt screen's fixed-height live region. + * + * The frame is `height: rows` and ink clips it there, so an unbounded busy line does not scroll — it COLLIDES with + * its siblings. Reproduced at 80×24 with a 900-character answer (well under {@link MAX_LIVE_TOKEN_CHARS}): the + * "Esc to stop" hint and the streamed text landed on the SAME row, overwriting each other, and the transcript + * viewport was squeezed from 22 rows to 13 (2.6.F Step 6h, Sonnet review). + * + * A third of the terminal keeps the viewport, the prompt and the footer intact at every supported size. Nothing is + * lost: the live region shows a TAIL with the same `…` marker the character cap uses, and the completed turn lands + * in the transcript whole — since Step 6g's caps-lift, all of it. + */ +export function liveAnswerRowBudget(terminalRows: number | undefined): number { + const rows = + terminalRows !== undefined && Math.floor(terminalRows) >= 1 + ? Math.floor(terminalRows) + : LIVE_ANSWER_FALLBACK_ROWS; + return Math.max(1, Math.floor(rows / 3)); +} + /** The assumed width when the caller passes no live column count (a headless/test render, or a non-TTY stdout with * no `.columns`). 80 is the conventional terminal width + the 80×24 degrade floor the harness pins. */ const REASONING_PANEL_FALLBACK_COLUMNS = 80; @@ -458,12 +522,13 @@ const REASONING_PANEL_FALLBACK_COLUMNS = 80; * The row count is APPROXIMATE, not exact: `.length` (UTF-16 units) stands in for ink's display-width wrap, so a * wide-glyph (CJK/emoji) line under-counts and a combining-mark line over-counts — the panel can render up to ~2× * on wide text; the prepended `…` marker in {@link formatReasoningPanel} can add one more row. This matches the - * store's existing `.length`-based 4000-char cap and avoids a `string-width` runtime dependency; the bound is a + * store's existing `.length`-based cap; the bound is a * cosmetic anti-flicker guard, so an off-by-a-row on unusual scripts is acceptable. */ function tailToRenderedRows( text: string, columns: number | undefined, + maxRows: number = MAX_REASONING_PANEL_LINES, ): { body: string; tailed: boolean } { // `>= 1` (not `> 0`): a fractional 0= 0; i -= 1) { const line = lines[i] ?? ''; - if (rows + rowsOf(line) > MAX_REASONING_PANEL_LINES) { + if (rows + rowsOf(line) > maxRows) { // The tail is full. If we have kept nothing yet, this single (oldest-included) line is itself taller than the // whole budget — keep only its last budget×width chars so the most recent reasoning still shows. Otherwise // stop: the already-kept newer lines fill the budget and older ones are dropped. if (kept.length === 0) { - kept.unshift(line.slice(line.length - MAX_REASONING_PANEL_LINES * width)); + kept.unshift(line.slice(line.length - maxRows * width)); } return { body: kept.join('\n'), tailed: true }; } diff --git a/apps/cli/src/render/tui/chat-store.ts b/apps/cli/src/render/tui/chat-store.ts index da921e03..d77e79bb 100644 --- a/apps/cli/src/render/tui/chat-store.ts +++ b/apps/cli/src/render/tui/chat-store.ts @@ -17,6 +17,7 @@ import { reduceSessionEvent, type SessionViewSeed, type SessionViewState, + INLINE_TRANSCRIPT_BOUND, } from './session-view-model.js'; /** @@ -125,9 +126,19 @@ const HIGH_FREQUENCY_EVENTS: ReadonlySet = new 'cost:updated', ]); -export function createChatStore(color: boolean, seed?: SessionViewSeed): ChatStoreController { +/** + * @param transcriptBound the RENDERER-injected bound on the text a completed turn bakes into the transcript + * (ADR-0068 Decision (c)). `FULLSCREEN_TRANSCRIPT_BOUND` in the alt-screen viewport, `INLINE_TRANSCRIPT_BOUND` + * otherwise. Defaults to the inline bound so a caller that forgets keeps today's behaviour rather than an + * unbounded live buffer. + */ +export function createChatStore( + color: boolean, + seed?: SessionViewSeed, + transcriptBound: number = INLINE_TRANSCRIPT_BOUND, +): ChatStoreController { const listeners = new Set<() => void>(); - let state = initialSessionViewState(seed); + let state = initialSessionViewState(seed, transcriptBound); let mode: ChatMode = DEFAULT_CHAT_MODE; let reasoningEffort: ReasoningEffort | undefined; let reasoningVisible = false; // the "thinking" panel is collapsed by default (2.5.H); `/thinking` / Ctrl+T flips it diff --git a/apps/cli/src/render/tui/force-color.ts b/apps/cli/src/render/tui/force-color.ts new file mode 100644 index 00000000..b1dbf004 --- /dev/null +++ b/apps/cli/src/render/tui/force-color.ts @@ -0,0 +1,12 @@ +/** + * Test-only side effect: make chalk emit real ANSI so a frame snapshot can SEE styling. + * + * ink 7 renders `` through the chalk singleton, whose `level` is resolved once, at chalk's import, from + * `supports-color`. Under vitest stdout is not a TTY, so the level is 0 and every style attribute vanishes from + * `lastFrame()` — a selection highlight that never renders would ship green. Importing this module BEFORE ink (the + * import order is the mechanism) sets the level to truecolor for that test file. + * + * It must be a separate module: `import` statements are hoisted, so an assignment at the top of the test file would + * run after ink — and chalk — had already been evaluated. + */ +process.env['FORCE_COLOR'] = '3'; diff --git a/apps/cli/src/render/tui/home-app.test.tsx b/apps/cli/src/render/tui/home-app.test.tsx index f4815207..597e4b31 100644 --- a/apps/cli/src/render/tui/home-app.test.tsx +++ b/apps/cli/src/render/tui/home-app.test.tsx @@ -7,6 +7,7 @@ import type { ReseatTarget } from '../../commands/chat.js'; import type { ApprovalAnswer } from '../../chat/chat-mode.js'; import type { DoctorProbes } from '../../chat/doctor.js'; import type { HomeSnapshot, HomeStore } from '../../home/home-store.js'; +import { createSuspendPort } from '../suspend.js'; import { createChatStore, type ChatStoreController } from './chat-store.js'; import { bracketed, settleFrames, waitFor } from './harness-util.js'; import { RootApp } from './home-app.js'; @@ -16,6 +17,7 @@ import { type HomeController, type HomeModelsPort, } from './home-controller.js'; +import { COPIED_TOAST_MS } from './tui-constants.js'; /** * Mounted-Home component tests (2.6.F Step 3, ADR-0068 part f) — the second surface (after `chat-app.test.tsx`) @@ -127,16 +129,29 @@ function mountHome( startChat?: () => Promise; reseatChat?: (sessionId: string, target: ReseatTarget) => Promise; models?: HomeModelsPort; + /** Capture what copy-on-select would put on the clipboard (2.6.F Step 6). */ + clipboard?: (text: string) => { kind: 'written'; characters: number }; + /** `[preferences].show_banner` (2.6.F Step 5g). */ + showBanner?: boolean; + /** A non-empty Home strip, so `isEmpty` is false (2.6.F Step 5g). */ + snapshot?: HomeSnapshot; + /** The initial terminal size (`rows` decides whether the banner fits). */ + size?: { cols: number; rows: number }; + /** `false` ⇒ the `NO_COLOR` / `--no-color` path (plain-ASCII banner). */ + color?: boolean; + /** Record the mouse-capture toggles `RootApp` requests (2.6.F Step 6g). */ + setMouseCapture?: (enabled: boolean) => void; } = {}, ): MountedHome { let onResize: () => void = () => {}; - let size = { cols: 100, rows: 30 }; + let size = opts.size ?? { cols: 100, rows: 30 }; + const snapshot = opts.snapshot; // captured so `read: () => snapshot` narrows without an `as` cast const c = createHomeController({ doctorProbes: STUB_DOCTOR_PROBES, startChat: opts.startChat ?? (() => Promise.resolve(makeSession(store))), ...(opts.reseatChat !== undefined ? { reseatChat: opts.reseatChat } : {}), ...(opts.models !== undefined ? { models: opts.models } : {}), - homeStore, + homeStore: snapshot === undefined ? homeStore : { read: () => snapshot }, onExit: vi.fn(), onError: vi.fn(), }); @@ -144,13 +159,16 @@ function mountHome( Date.now()} - color={false} + color={opts.color ?? false} getSize={() => size} subscribeResize={(cb) => { onResize = cb; return () => {}; }} {...(opts.alternateScreen === true ? { alternateScreen: true } : {})} + {...(opts.clipboard === undefined ? {} : { clipboard: opts.clipboard })} + {...(opts.showBanner === undefined ? {} : { showBanner: opts.showBanner })} + {...(opts.setMouseCapture === undefined ? {} : { setMouseCapture: opts.setMouseCapture })} />, ); return { @@ -412,3 +430,469 @@ describe('RootApp (Home) alt-screen transcript viewport (2.6.F Step 4b, ADR-0068 expect(frame()).toContain('HMSG59'); // the view RE-FOLLOWED the tail after the swap (object-identity reset fired) }); }); + +/** + * The ADR-0068 §e suspend PORT on the HOME surface (2.6.F Step 5d). Unlike `relavium chat`, `createHomeController` is + * built BEFORE this tree mounts and every existing Home port flows core→React — so this bridge is the inversion, and + * the in-Home chat's `/scrollback` and `/edit` depend entirely on it. + */ +describe('RootApp — the suspend port (ADR-0068 §e)', () => { + it('attaches a WORKING suspendTerminal while mounted, and detaches on unmount', async () => { + const port = createSuspendPort(); + const c = createHomeController({ + doctorProbes: STUB_DOCTOR_PROBES, + startChat: () => Promise.resolve(makeSession(createChatStore(false))), + homeStore, + onExit: vi.fn(), + onError: vi.fn(), + }); + const harness = render( + Date.now()} + color={false} + getSize={() => ({ cols: 80, rows: 24 })} + subscribeResize={() => () => {}} + suspendPort={port} + />, + ); + await waitFor(() => port.current() !== undefined); + + let ran = false; + await port.current()?.(() => { + ran = true; + return Promise.resolve(); + }); + expect(ran).toBe(true); // ink's REAL suspendTerminal, driven through the port + + harness.unmount(); + await settleFrames(); + expect(port.current()).toBeUndefined(); + }); +}); + +/** + * Mouse SELECTION on the HOME surface (2.6.F Step 6). The in-Home chat and `relavium chat` share `reduceSelection`, + * `cellAt` and the highlight split, so what needs pinning here is the WIRING: that the Home's own `useInput` routes + * press/drag/release into the reducer with its own viewport geometry, and that the release reaches the clipboard. + */ +describe('RootApp — mouse selection in the in-Home chat', () => { + const seedThree = (): ChatStoreController => { + const store = createChatStore(false); + store.notice('AAAA'); + store.notice('BBBB'); + store.notice('CCCC'); + return store; + }; + + it('a DRAG in the in-Home chat copies exactly the cells it covered', async () => { + const copied: string[] = []; + const store = seedThree(); + const m = mountHome(store, { + alternateScreen: true, + clipboard: (text) => { + copied.push(text); + return { kind: 'written', characters: text.length }; + }, + }); + await enterChat(m.c); + await waitFor(() => (m.harness.lastFrame() ?? '').includes('AAAA')); + + m.harness.stdin.write('\x1b[<0;1;1M'); // press line 0, column 0 + await settleFrames(); + m.harness.stdin.write('\x1b[<32;3;1M'); // drag to column 2 (inclusive) + await settleFrames(); + m.harness.stdin.write('\x1b[<0;3;1m'); // release ⇒ copy + await settleFrames(); + + expect(copied).toEqual(['AAA']); + }); + + it('the BARE Home (no chat) consumes a mouse report and copies nothing — there is no transcript', async () => { + const copied: string[] = []; + const m = mountHome(seedThree(), { + alternateScreen: true, + clipboard: (text) => { + copied.push(text); + return { kind: 'written', characters: text.length }; + }, + }); + await settleFrames(); + + m.harness.stdin.write('\x1b[<0;1;1M'); + m.harness.stdin.write('\x1b[<32;5;1M'); + m.harness.stdin.write('\x1b[<0;5;1m'); + await settleFrames(); + + expect(copied).toEqual([]); + expect(m.c.getSnapshot().input.text).toBe(''); // …and no raw bytes typed into the Home prompt + }); + + it('a plain CLICK copies nothing; the WHEEL still scrolls and never copies', async () => { + const copied: string[] = []; + const store = createChatStore(false); + for (let i = 0; i < 60; i += 1) store.notice(`row-${String(i).padStart(2, '0')}`); + const m = mountHome(store, { + alternateScreen: true, + clipboard: (text) => { + copied.push(text); + return { kind: 'written', characters: text.length }; + }, + }); + await enterChat(m.c); + await waitFor(() => (m.harness.lastFrame() ?? '').includes('row-59')); + + m.harness.stdin.write('\x1b[<0;2;2M'); + await settleFrames(); + m.harness.stdin.write('\x1b[<0;2;2m'); // release at the same cell ⇒ a click + await settleFrames(); + expect(copied).toEqual([]); + + m.harness.stdin.write('\x1b[<64;5;5M'); // wheel up + await settleFrames(); + expect(m.harness.lastFrame() ?? '').not.toContain('row-59'); + expect(copied).toEqual([]); + }); + + it('after SCROLLING, a drag copies the line now shown on that row — not line 0', async () => { + // The Home builds its own viewport facts, so `chat-app.test.tsx`'s equivalent proves nothing here: an `offset: 0` + // break in `home-app.tsx` alone would ship green (Step-6 Opus review). + const copied: string[] = []; + const store = createChatStore(false); + for (let i = 0; i < 60; i += 1) store.notice(`row-${String(i).padStart(2, '0')}`); + const m = mountHome(store, { + alternateScreen: true, + clipboard: (text) => { + copied.push(text); + return { kind: 'written', characters: text.length }; + }, + }); + await enterChat(m.c); + await waitFor(() => (m.harness.lastFrame() ?? '').includes('row-59')); + + for (let i = 0; i < 4; i += 1) { + m.harness.stdin.write('\x1b[<64;5;5M'); // wheel up: leave the tail + await settleFrames(); + } + const thirdRow = (m.harness.lastFrame() ?? '').split('\n')[2]?.trim(); + expect(thirdRow).toMatch(/^row-\d\d$/); + expect(thirdRow).not.toBe('row-02'); + + m.harness.stdin.write('\x1b[<0;1;3M'); // press the third row (an INNER row: row 1 is the edge-scroll zone) + await settleFrames(); + m.harness.stdin.write('\x1b[<32;99;3M'); // drag past its right edge ⇒ the whole row + await settleFrames(); + m.harness.stdin.write('\x1b[<0;99;3m'); + await settleFrames(); + + expect(copied).toEqual([thirdRow]); + }); + + it('a drag in the SAME tick as an append reduces against the LIVE wrap, not the last measured one', async () => { + // `onMeasure` fires after a render; a mouse report that arrives before the next one sees a stale `totalLines`, + // and while following the tail that shifts `effectiveOffset` by exactly the number of new lines. Substituting the + // measured count for the live one is invisible to every test that settles a frame in between (break-verified). + const copied: string[] = []; + const store = createChatStore(false); + for (let i = 0; i < 60; i += 1) store.notice(`row-${String(i).padStart(2, '0')}`); + const m = mountHome(store, { + alternateScreen: true, + clipboard: (text) => { + copied.push(text); + return { kind: 'written', characters: text.length }; + }, + }); + await enterChat(m.c); + await waitFor(() => (m.harness.lastFrame() ?? '').includes('row-59')); + + // No `settleFrames` between the append and the gesture: `scrollGeomRef` still says 60 lines. + store.notice('row-60'); + m.harness.stdin.write('\x1b[<0;1;3M'); + m.harness.stdin.write('\x1b[<32;99;3M'); + m.harness.stdin.write('\x1b[<0;99;3m'); + await settleFrames(); + + const thirdRow = (m.harness.lastFrame() ?? '').split('\n')[2]?.trim(); + expect(copied).toEqual([thirdRow]); // the row the append pushed there, not the one that was there before + }); + + it('a WRAPPED entry copies the VISUAL row under the pointer, not the whole logical line', async () => { + // The transcript the selection indexes is the WRAPPED one. Copying raw entries instead of wrapped rows stays green + // for as long as no line is wider than the terminal — so make one that is. + const copied: string[] = []; + const store = createChatStore(false); + store.notice('A'.repeat(140)); // at 100 columns this wraps into two display rows + const m = mountHome(store, { + alternateScreen: true, + clipboard: (text) => { + copied.push(text); + return { kind: 'written', characters: text.length }; + }, + }); + await enterChat(m.c); + await waitFor(() => (m.harness.lastFrame() ?? '').includes('AAAA')); + + const frame = (m.harness.lastFrame() ?? '').split('\n'); + const firstRow = frame.findIndex((l) => l.startsWith('AAAA')); + expect(firstRow).toBeGreaterThanOrEqual(0); + expect(frame[firstRow + 1]?.startsWith('AAAA')).toBe(true); // it really wrapped + + // Drag the SECOND visual row only. Terminal rows are 1-based. + const row = String(firstRow + 2); + m.harness.stdin.write(`\x1b[<0;1;${row}M`); + await settleFrames(); + m.harness.stdin.write(`\x1b[<32;200;${row}M`); + await settleFrames(); + m.harness.stdin.write(`\x1b[<0;200;${row}m`); + await settleFrames(); + + expect(copied).toHaveLength(1); + expect(copied[0]).toBe('A'.repeat(40)); // the 40-char remainder, not all 140 + }); + + it('a RESIZE drops the live selection — re-wrapping moves every display-line index it holds', async () => { + const copied: string[] = []; + const store = seedThree(); + const m = mountHome(store, { + alternateScreen: true, + clipboard: (text) => { + copied.push(text); + return { kind: 'written', characters: text.length }; + }, + }); + await enterChat(m.c); + await waitFor(() => (m.harness.lastFrame() ?? '').includes('AAAA')); + + m.harness.stdin.write('\x1b[<0;1;1M'); // press… + await settleFrames(); + m.harness.stdin.write('\x1b[<32;3;1M'); // …drag… + await settleFrames(); + + m.setSize({ cols: 60, rows: 30 }); + m.fireResize(); + await settleFrames(); + + m.harness.stdin.write('\x1b[<0;3;1m'); // …release AFTER the resize + await settleFrames(); + expect(copied).toEqual([]); // the anchor was dropped, so there is nothing to copy + }); +}); + +/** + * THE COPY-ON-SELECT CONFIRMATION TOAST on the HOME surface (2.6.F Step 6i). The in-Home chat threads its OWN + * `useCopiedToast` through `ChatRegion` (a separate mount from `relavium chat`'s `ChatApp`), so `chat-app.test.tsx`'s + * toast tests prove nothing here — a `copied` prop dropped between `RootApp` and `ChatRegion`, or a `flashCopied()` + * left off the Home's `copySelection`, would ship green there and dark here. Colour is off by default (`mountHome`), + * so the toast renders as the plain `[Copied]` pill — `.toContain('Copied')` matches either rendering. + */ +describe('RootApp — the copy-on-select "Copied" toast', () => { + const seedThree = (): ChatStoreController => { + const store = createChatStore(false); + store.notice('AAAA'); + store.notice('BBBB'); + store.notice('CCCC'); + return store; + }; + + /** Enter the in-Home chat, then drive a press-drag-release that copies one row. */ + const copyOnce = async (m: MountedHome): Promise => { + await enterChat(m.c); + await waitFor(() => (m.harness.lastFrame() ?? '').includes('AAAA')); + m.harness.stdin.write('\x1b[<0;1;1M'); // press line 0, column 0 + await settleFrames(); + m.harness.stdin.write('\x1b[<32;3;1M'); // drag to column 2 (inclusive) + await settleFrames(); + m.harness.stdin.write('\x1b[<0;3;1m'); // release ⇒ copy + await settleFrames(); + }; + + it('appears after a copy, ABOVE the footer, and leaves the transcript intact', async () => { + const copied: string[] = []; + const m = mountHome(seedThree(), { + alternateScreen: true, + clipboard: (text) => { + copied.push(text); + return { kind: 'written', characters: text.length }; + }, + }); + await copyOnce(m); + expect(copied).toEqual(['AAA']); // the write happened + const rows = (m.harness.lastFrame() ?? '').split('\n'); + const toastRow = rows.findIndex((r) => r.includes('Copied')); + const footerRow = rows.findIndex((r) => r.includes('turns')); + expect(toastRow).toBeGreaterThanOrEqual(0); + expect(toastRow).toBeLessThan(footerRow); // the toast sits just above the status footer + expect(rows.some((r) => r.includes('AAAA'))).toBe(true); // the transcript is untouched — the toast is not a line + }); + + it('auto-dismisses after COPIED_TOAST_MS', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const copied: string[] = []; + const m = mountHome(seedThree(), { + alternateScreen: true, + clipboard: (text) => { + copied.push(text); + return { kind: 'written', characters: text.length }; + }, + }); + await copyOnce(m); + expect(m.harness.lastFrame() ?? '').toContain('Copied'); + await vi.advanceTimersByTimeAsync(COPIED_TOAST_MS + 50); + await settleFrames(); + expect(m.harness.lastFrame() ?? '').not.toContain('Copied'); + } finally { + vi.useRealTimers(); + } + }); + + it('WITHOUT a clipboard port (copy-on-select off) there is no toast', async () => { + const m = mountHome(seedThree(), { alternateScreen: true }); // no `clipboard` ⇒ copy is inert + await copyOnce(m); + expect(m.harness.lastFrame() ?? '').not.toContain('Copied'); + }); +}); + +/** + * The branded Home banner ON SCREEN (2.6.F Step 5g). `banner.test.ts` pins the plaque itself; this pins that it + * REPLACES the plain heading, obeys `[preferences].show_banner`, and never pushes the prompt off an 80x24 terminal. + */ +describe('RootApp — the branded Home banner', () => { + const BUSY: HomeSnapshot = { + attention: { gates: [], failedRuns: [] }, + recentSessions: [ + { + sessionId: 'sess-9', + title: 'a chat', + agentSlug: 'default', + modelId: 'anthropic/claude-opus-4-8', + status: 'active', + updatedAt: '2026-07-10T00:00:00.000Z', + totalCostMicrocents: 0, + }, + ], + recentRuns: [], + recentAgents: [], + isEmpty: false, + }; + + const frameOf = async (opts: Parameters[1]): Promise => { + const m = mountHome(createChatStore(false), opts); + await settleFrames(); + return m.harness.lastFrame() ?? ''; + }; + + /** The frame's lines, trimmed — so "is the plain heading anywhere on screen" is one question, not a position. */ + const rows = (frame: string): string[] => frame.split('\n').map((l) => l.trim()); + + it('an EMPTY Home shows the plaque, and the plain heading is GONE (it is replaced, not stacked)', async () => { + const frame = await frameOf({}); + expect(frame).toContain('R E L A V I U M'); + expect(frame).toContain('Own every run.'); + // Checking only row 0 would pass while the heading sat just BELOW the plaque (break-verified). + expect(rows(frame)).not.toContain('relavium'); + }); + + it('a BUSY Home falls back to the plain heading — the banner auto-dismisses', async () => { + const frame = await frameOf({ snapshot: BUSY }); + expect(frame).not.toContain('R E L A V I U M'); + expect(rows(frame)).toContain('relavium'); + }); + + it('`show_banner = false` hides it even on an empty Home', async () => { + const frame = await frameOf({ showBanner: false }); + expect(frame).not.toContain('R E L A V I U M'); + }); + + it('`show_banner = true` brings it back on a busy Home', async () => { + const frame = await frameOf({ showBanner: true, snapshot: BUSY }); + expect(frame).toContain('R E L A V I U M'); + }); + + it('on an 80x24 terminal the plaque never obscures the prompt', async () => { + const frame = await frameOf({ size: { cols: 80, rows: 24 } }); + expect(frame).toContain('R E L A V I U M'); + // The prompt marker still renders, and no line wrapped past 80 columns. + expect(frame).toContain('>'); + for (const line of frame.split('\n')) expect(line.length).toBeLessThanOrEqual(80); + }); + + it('renders WITHOUT a React duplicate-key error under NO_COLOR — the two ASCII borders are byte-identical', async () => { + // The Home mounts ink with `patchConsole: false`, so a React runtime error goes straight to stderr — printed onto + // the alt buffer, over the frame. Keying the plaque's rows by their TEXT did exactly that (whole-phase review). + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const m = mountHome(createChatStore(false), { color: false }); + await settleFrames(); + expect(m.harness.lastFrame() ?? '').toContain('R E L A V I U M'); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + + it('a FORCED banner on a busy 80x24 Home stands down rather than crowd the strip', async () => { + const frame = await frameOf({ showBanner: true, snapshot: BUSY, size: { cols: 80, rows: 24 } }); + expect(frame).not.toContain('R E L A V I U M'); + expect(frame).toContain('a chat'); // the strip the banner would have pushed away + }); +}); + +/** + * MOUSE CAPTURE FOLLOWS THE CHAT (2.6.F Step 6g, whole-phase Opus review). Capturing the mouse for the whole Home was + * a regression: the landing has no viewport to wheel-scroll and no in-app selection, so a user lost the emulator's own + * click-drag there and got nothing back — they could not even copy a session id off the strip. + */ +describe('RootApp — mouse capture follows the in-Home chat', () => { + it('the bare Home does NOT capture; entering the chat does', async () => { + // The release side (`setMouseCapture(false)` ⇒ DISABLE_MOUSE) is pinned at the port, in `drive-home.test.ts`. + // Here the claim is that the effect tracks `state.mode`: hardcoding `alternateScreen` would capture the landing. + const toggles: boolean[] = []; + const m = mountHome(createChatStore(false), { + alternateScreen: true, + setMouseCapture: (enabled) => toggles.push(enabled), + }); + await settleFrames(); + expect(toggles).toEqual([false]); // the landing keeps the emulator's own click-drag selection + + await enterChat(m.c); + await settleFrames(); + expect(toggles.at(-1)).toBe(true); + }); + + it('an OVERLAY over the chat does not release the mouse — that would be DECSET churn', async () => { + const toggles: boolean[] = []; + const m = mountHome(createChatStore(false), { + alternateScreen: true, + setMouseCapture: (enabled) => toggles.push(enabled), + }); + await enterChat(m.c); + await settleFrames(); + const before = toggles.length; + + m.harness.stdin.write('/'); // open the palette over the chat + await settleFrames(); + expect(toggles).toHaveLength(before); // no new toggle + }); + + it('the INLINE Home does not CONSUME mouse-report bytes either — nothing enables the mouse there', async () => { + // The `alternateScreen` guard in `consumeMouseReport` is what keeps the reader (and its partial-report buffer) + // out of a renderer that never receives a report. Without it, a user typing `[<0;1;1M` would have it swallowed. + const m = mountHome(createChatStore(false), {}); // no `alternateScreen` + await settleFrames(); + m.harness.stdin.write('[<0;1;1M'); + await settleFrames(); + expect(m.c.getSnapshot().input.text).toBe('[<0;1;1M'); // typed, not eaten + }); + + it('the INLINE Home never captures, whatever the mode', async () => { + const toggles: boolean[] = []; + const m = mountHome(createChatStore(false), { + setMouseCapture: (enabled) => toggles.push(enabled), + }); + await settleFrames(); + await enterChat(m.c); + await settleFrames(); + expect(toggles.every((t) => t === false)).toBe(true); + }); +}); diff --git a/apps/cli/src/render/tui/home-app.tsx b/apps/cli/src/render/tui/home-app.tsx index 817b0ff7..19bcc1a8 100644 --- a/apps/cli/src/render/tui/home-app.tsx +++ b/apps/cli/src/render/tui/home-app.tsx @@ -1,11 +1,11 @@ -import { Box, Text, useInput, usePaste } from 'ink'; +import { Box, Text, useApp, useInput, usePaste } from 'ink'; import { useEffect, useRef, useState, useSyncExternalStore, type ReactElement } from 'react'; import { CHAT_PALETTE_COMMANDS, HOME_PALETTE_COMMANDS } from '../../commands/repl-commands.js'; import type { PendingAttachment } from './attachments.js'; -import { ChatView } from './chat-ink.js'; +import { ChatView, useCopiedToast } from './chat-ink.js'; import type { EditorState } from './chat-input.js'; -import { liveScrollGeometry, sanitizeInline } from './chat-projection.js'; +import { liveScrollGeometry, sanitizeInline, wrapTranscript } from './chat-projection.js'; import type { ChatStoreController } from './chat-store.js'; import type { HomeController } from './home-controller.js'; import { HomeView } from './home-view.js'; @@ -20,13 +20,22 @@ import { PaletteView } from './palette-view.js'; import type { PaletteState } from './palette-reducer.js'; import { colorProps, dimProps } from './projection.js'; import { ReverseSearchView } from './reverse-search-view.js'; +import { createMouseReportReader, type MouseEvent as TerminalMouseEvent } from './mouse.js'; import { + normalizeSelection, + selectionText, + type SelectionRange, + type SelectionState, + routeMouseSelection, +} from './selection.js'; +import { + effectiveOffset, INITIAL_SCROLL, - parseMouseScroll, reduceScroll, scrollMotionForKey, WHEEL_LINES, type ScrollGeometry, + type ViewportGeometry, type ScrollState, } from './scroll.js'; @@ -40,6 +49,9 @@ import { export type { HomeChatSession } from './home-controller.js'; +import type { ClipboardOutcome } from '../clipboard.js'; +import type { SuspendPort } from '../suspend.js'; + export interface RootAppProps { readonly controller: HomeController; readonly nowMs: () => number; @@ -51,6 +63,23 @@ export interface RootAppProps { * renders through the scroll viewport (bounded to the resize-tracked size) instead of ``. Resolved by * `driveHome` (`resolveRenderMode`); absent/false ⇒ the inline renderer. */ readonly alternateScreen?: boolean; + /** The ADR-0068 §e suspend port (2.6.F Step 5d). `RootApp` attaches ink's `useApp().suspendTerminal` to it while + * mounted — the ONLY way the non-React slash dispatch (`createHomeController` is built before this tree exists) + * can reach `/scrollback` and `/edit`. Absent (a test) ⇒ the hatches notice "needs an interactive terminal". */ + readonly suspendPort?: SuspendPort | undefined; + /** Write the in-Home chat's mouse selection to the system clipboard over OSC 52 (2.6.F Step 6). Absent ⇒ the + * selection still highlights but copy-on-select is inert (a test that wires no terminal). */ + readonly clipboard?: ((text: string) => ClipboardOutcome) | undefined; + /** `[preferences].show_banner` (2.6.F Step 5g) — forwarded verbatim to `HomeView`, which owns the visibility rule. */ + readonly showBanner?: boolean | undefined; + /** + * Turn terminal mouse reporting on/off as the in-Home CHAT takes and gives up the screen (2.6.F Step 6g). + * + * Capturing the mouse for the whole Home was a regression: the landing has no viewport to wheel-scroll and no in-app + * selection, so the user lost the emulator's native click-drag there and got nothing back — they could not even copy + * a session id off the strip. Absent (or `--no-mouse` / `[preferences].mouse = false`) ⇒ never captured. + */ + readonly setMouseCapture?: ((enabled: boolean) => void) | undefined; } /** The chat region: subscribes to the chat store (re-render on stream events) and renders the pure {@link ChatView}, @@ -80,7 +109,9 @@ function ChatRegion( readonly rows: number; readonly cols: number; readonly scroll: ScrollState; - readonly onMeasure: (geom: ScrollGeometry) => void; + /** The active mouse selection, document-ordered (2.6.F Step 6). */ + readonly selection?: SelectionRange | undefined; + readonly onMeasure: (geom: ViewportGeometry) => void; } | undefined; shellBusy: boolean; @@ -90,6 +121,8 @@ function ChatRegion( attachments: readonly PendingAttachment[]; /** The in-flight `[c]` typed-reason capture buffer (Step 14) — shows the reason input in the approval prompt. */ reasonDraft: EditorState | undefined; + /** `true` for ~2s after a copy-on-select (2.6.F Step 6i) — forwarded to `ChatView`'s toast. */ + copied: boolean; }>, ): ReactElement { const { state, tick, color, mode, reasoningEffort, reasoningVisible, approval } = @@ -117,6 +150,7 @@ function ChatRegion( columns={props.cols} viewport={viewport} reasonDraft={props.reasonDraft} + copied={props.copied} paletteOpen={ props.palette !== undefined || props.search !== undefined || @@ -153,13 +187,50 @@ function ChatRegion( export function RootApp(props: Readonly): ReactElement { const { controller, getSize, subscribeResize, color } = props; const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot); + // Attach ink's `suspendTerminal` to the ADR-0068 §e port while this tree is mounted (2.6.F Step 5d). `useApp()` is + // the only place it exists, and the in-Home chat's slash dispatch runs outside React — so the port is the bridge. + // Invoked as a METHOD (`app.suspendTerminal(cb)`), never as a bare destructured reference: ink 7 hands it out + // unbound off the prototype, so this form is immune to how the context object is shaped. + const app = useApp(); + const suspendPort = props.suspendPort; + useEffect(() => { + if (suspendPort === undefined) return; + suspendPort.attach((callback) => app.suspendTerminal(callback)); + return () => suspendPort.attach(undefined); + }, [app, suspendPort]); const [size, setSize] = useState(getSize); // The alt-screen transcript SCROLL state (2.6.F Step 4b-2) — RootApp-local (a pure-render concern, like `size`; // NOT session state), ref-shadowed for coalesced-chunk safety, and the viewport's live geometry lifted into // `scrollGeomRef` via `onMeasure` so a scroll key reduces against the SAME geometry the viewport windows with. const [scroll, setScroll] = useState(INITIAL_SCROLL); const scrollRef = useRef(INITIAL_SCROLL); - const scrollGeomRef = useRef({ totalLines: 0, height: 0 }); + // Survives an SGR mouse report SPLIT across two `useInput` calls (Step 6f). One reader per mount: it holds the + // fragment, so it must not be re-created on every render. + const mouseReaderRef = useRef(createMouseReportReader()); + // Whether the transcript was following the tail when the current gesture began — a plain click restores it. + const followedBeforeSelectionRef = useRef(false); + // Whether a left-button gesture is in flight — set by a press inside the viewport, cleared on release. Distinct from + // a retained highlight, so a stray click after a copy cannot re-copy it (Step 6h review). + const gestureActiveRef = useRef(false); + // The copy-on-select confirmation toast (2.6.F Step 6i) — same as `relavium chat`. + const { copied, flashCopied } = useCopiedToast(); + // The mouse selection (2.6.F Step 6) — held exactly like `scroll`: state for the render, a ref so a coalesced drag + // burst (several SGR reports in ONE stdin read) reduces off the latest rather than the render closure. + const [selection, setSelection] = useState(undefined); + const selectionRef = useRef(undefined); + // Seeded at zero: the post-commit measure fills it on the first frame. `top`/`left`/`width` are the box's position + // in ink's frame — the mouse handler's half of the row→line mapping (Step 6). + const scrollGeomRef = useRef({ + totalLines: 0, + height: 0, + width: 0, + top: 0, + left: 0, + }); + const applySelection = (next: SelectionState | undefined): void => { + selectionRef.current = next; + setSelection(next); + }; const applyScroll = (next: ScrollState): void => { scrollRef.current = next; setScroll(next); @@ -195,38 +266,128 @@ export function RootApp(props: Readonly): ReactElement { state.effortPicker === undefined && state.reasonDraft === undefined; const altChat = props.alternateScreen === true && state.mode === 'chat' && noOverlay; + // Capture the mouse exactly while the CHAT owns the screen — not while an overlay is open over it (a transient + // state; toggling DECSET per overlay would be churn, and a report there is consumed and ignored anyway). + const mouseCaptured = props.alternateScreen === true && state.mode === 'chat'; + const setMouseCapture = props.setMouseCapture; + useEffect(() => { + setMouseCapture?.(mouseCaptured); + }, [mouseCaptured, setMouseCapture]); + + // Reduce against LIVE geometry (parity with `ChatApp`): wrap the session store's CURRENT transcript at the keypress + // for a fresh `totalLines`, not the `onMeasure` ref which lags by up to a commit — else a mid-stream burst makes + // `settle` resume-follow against a stale bottom (Step-4b-2 Sonnet review). `getSnapshot()` reads the store fresh + // regardless of closure staleness; no session (bare Home) ⇒ the lifted geometry, nothing to move. Hoisted out of + // `useInput` at Step 6 so the scroll keymap and the selection reducer share ONE definition. + const liveGeom = (): ScrollGeometry => { + const store = state.session?.store; + return store === undefined + ? scrollGeomRef.current + : liveScrollGeometry( + store.getSnapshot().state.transcript, + size.cols, + scrollGeomRef.current.height, + ); + }; + + /** Reduce one non-wheel mouse report into the in-Home chat's selection (2.6.F Step 6) — the same reducer, the same + * viewport facts, and therefore the same behaviour as `relavium chat`. */ + const routeSelection = (event: TerminalMouseEvent): void => { + routeMouseSelection(event, { + geometry: () => { + const measured = scrollGeomRef.current; + const live = liveGeom(); + return { + top: measured.top, + left: measured.left, + height: live.height, + totalLines: live.totalLines, + offset: effectiveOffset(scrollRef.current, live), + }; + }, + current: () => selectionRef.current, + setSelection: applySelection, + copy: copySelection, + scrollBy: (motion: 'line-up' | 'line-down') => + applyScroll(reduceScroll(scrollRef.current, motion, liveGeom())), + pauseFollow: () => { + const scroll = scrollRef.current; + followedBeforeSelectionRef.current = scroll.following; + if (!scroll.following) return; + applyScroll({ offset: effectiveOffset(scroll, liveGeom()), following: false }); + }, + restoreFollow: () => { + if (!followedBeforeSelectionRef.current) return; + applyScroll({ ...scrollRef.current, following: true }); + }, + gestureActive: () => gestureActiveRef.current, + setGestureActive: (active: boolean) => { + gestureActiveRef.current = active; + }, + }); + }; + + /** Copy on release. SILENT on success: a notice would append a transcript entry and shift the very lines the user + * just selected. Only a refusal (past the terminal's OSC 52 length floor) is worth telling them about. */ + const copySelection = (state_: SelectionState): void => { + const clipboard = props.clipboard; + const store = state.session?.store; + if (clipboard === undefined || store === undefined) return; + const rows = wrapTranscript(store.getSnapshot().state.transcript, size.cols).map( + (line) => line.text, + ); + const outcome = clipboard(selectionText(rows, normalizeSelection(state_))); + if (outcome.kind === 'written') flashCopied(); + else if (outcome.kind === 'too-large') { + store.note( + `selection too large to copy (${Math.ceil(outcome.base64Length / 1024)} KB) — use /scrollback or /edit`, + ); + } + }; + + // A resize re-wraps the transcript, and a session swap (`/clear`, a reseat) replaces it — either way every + // display-line index the selection holds moves. Drop it rather than highlight, and copy, the wrong text. + useEffect(() => { + applySelection(undefined); + }, [size.cols, state.session]); + + /** One wheel notch. Kept beside `routeSelection` so the two mouse verbs read alike. */ + const routeWheel = (direction: 'up' | 'down'): void => { + const geom = liveGeom(); + const motion = direction === 'up' ? 'line-up' : 'line-down'; + let next = scrollRef.current; + for (let i = 0; i < WHEEL_LINES; i += 1) next = reduceScroll(next, motion, geom); + applyScroll(next); + }; + + /** + * Consume a mouse report, if `input` is one. Returns `true` when it was — the caller must then type NOTHING: a + * report's raw bytes must never reach the Home prompt, the `/` palette filter, or the `[c]` reason capture. That is + * true in EVERY mode and behind EVERY overlay; only the ROUTING is gated on the chat owning the screen. + */ + const consumeMouseReport = (input: string): boolean => { + if (props.alternateScreen !== true) return false; + const read = mouseReaderRef.current.read(input); + if (read.kind === 'none') return false; + const mouse = read.kind === 'event' ? read.event : undefined; + if (mouse !== undefined && altChat) { + if (mouse.kind === 'wheel') routeWheel(mouse.direction); + else routeSelection(mouse); + } + return true; + }; + useInput((input, key) => { - // Reduce against LIVE geometry (parity with `ChatApp`): wrap the session store's CURRENT transcript at the - // keypress for a fresh `totalLines`, not the `onMeasure` ref which lags by up to a commit — else a mid-stream - // burst makes `settle` resume-follow against a stale bottom (Step-4b-2 Sonnet review). `getSnapshot()` reads the - // store fresh here regardless of closure staleness; no session (bare Home) ⇒ the lifted geometry, nothing to move. - const liveGeom = (): ScrollGeometry => { - const store = state.session?.store; - return store === undefined - ? scrollGeomRef.current - : liveScrollGeometry( - store.getSnapshot().state.transcript, - size.cols, - scrollGeomRef.current.height, - ); - }; - // Mouse reports (Step 5): `driveHome` enables mouse reporting for the WHOLE alt-screen Home, so a wheel/click - // arrives in EVERY mode and behind EVERY overlay. CONSUME it here — ahead of all routing — so its raw bytes can - // never type into the Home prompt, the `/` palette filter, or the `[c]` reason capture. A wheel only SCROLLS - // when the chat transcript owns the screen (`altChat`); elsewhere there is no viewport to move. - if (props.alternateScreen === true) { - const mouse = parseMouseScroll(input); - if (mouse !== undefined) { - if (mouse !== 'ignore' && altChat) { - const geom = liveGeom(); - let next = scrollRef.current; - for (let i = 0; i < WHEEL_LINES; i += 1) next = reduceScroll(next, mouse, geom); - applyScroll(next); - } + if (consumeMouseReport(input)) return; + if (altChat) { + // Esc DISMISSES a live selection. Gated on an IDLE chat: while a turn streams, Esc is the mid-turn ABORT that + // `controller.handleKey` reduces, and shadowing an abort with a cosmetic clear would be a bad trade. Mirrors + // ChatApp exactly — a click still clears the highlight mid-turn. + const chatRunning = state.session?.store.getSnapshot().state.status === 'running'; + if (key.escape === true && !chatRunning && selectionRef.current !== undefined) { + applySelection(undefined); return; } - } - if (altChat) { const motion = scrollMotionForKey(key); if (motion !== undefined) { applyScroll(reduceScroll(scrollRef.current, motion, liveGeom())); @@ -260,7 +421,9 @@ export function RootApp(props: Readonly): ReactElement { rows: size.rows, cols: size.cols, scroll, - onMeasure: (g: ScrollGeometry): void => { + // Document-ordered here, once: the viewport draws it, `copySelection` re-derives it for the clipboard. + ...(selection === undefined ? {} : { selection: normalizeSelection(selection) }), + onMeasure: (g: ViewportGeometry): void => { scrollGeomRef.current = g; }, } @@ -272,6 +435,7 @@ export function RootApp(props: Readonly): ReactElement { shellCommand={state.shellCommand} historyEntries={state.historyEntries} attachments={state.attachments} + copied={copied} /> ); } @@ -300,6 +464,7 @@ export function RootApp(props: Readonly): ReactElement { rows={size.rows} color={color} paletteOpen={state.palette !== undefined || state.modelPicker !== undefined} + showBanner={props.showBanner} /> {state.palette !== undefined && ( diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index 33dec529..d238e610 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -945,6 +945,15 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { // over the merged catalog. Unlike the inert chat-only noops above, this wires the live picker. openModels: () => openModelPicker(), + // `/scrollback` + `/edit` (ADR-0068 §e) are chat-only (`availableIn: ['chat']`), so they never appear in + // HOME_PALETTE_COMMANDS and are unreachable from the bare Home — there is no transcript to dump or edit. An + // ACTIVE in-Home chat routes them through the chat handler's REAL capabilities (sendChatLine → the slash + // dispatch → `createChatLineHandler`'s hatches), never through this ctx. Inert here, like the other chat-only + // capabilities above. + dumpScrollback: () => undefined, + editTranscript: () => undefined, + copyTranscript: () => undefined, + runDoctor: async (deep) => { if (exiting) return; const runId = (doctorRunId += 1); // a new run; a prompt edit/submit or a later run bumps this, invalidating us diff --git a/apps/cli/src/render/tui/home-view.tsx b/apps/cli/src/render/tui/home-view.tsx index 09605f7b..7e853c2c 100644 --- a/apps/cli/src/render/tui/home-view.tsx +++ b/apps/cli/src/render/tui/home-view.tsx @@ -2,6 +2,7 @@ import { Box, Text } from 'ink'; import { type ReactElement } from 'react'; import type { HomeSnapshot } from '../../home/home-store.js'; +import { bannerLines, shouldShowBanner } from './banner.js'; import type { EditorState } from './chat-input.js'; import { sanitizeInline } from './chat-projection.js'; import type { StatusColor } from './format.js'; @@ -48,6 +49,8 @@ interface HomeViewProps { readonly color: boolean; /** When the `/` palette is open it owns the bottom of the view, so the prompt + footer hint are suppressed (2.5.C S3c). */ readonly paletteOpen?: boolean; + /** `[preferences].show_banner` (2.6.F Step 5g). `undefined` ⇒ `shouldShowBanner`'s empty-Home rule. */ + readonly showBanner?: boolean | undefined; } /** A glanceable strip row — a stable `key` (the row's durable id, not its array index) + the rendered line. */ @@ -127,9 +130,30 @@ export function HomeView(props: Readonly): ReactElement { return ( - - relavium - + {shouldShowBanner({ + configShowBanner: props.showBanner, + isEmpty: snapshot.isEmpty, + rows, + }) ? ( + // The banner REPLACES the plain heading rather than sitting above it, so nothing shifts when it is off. + // `color === false` covers both `NO_COLOR` and `--no-color`, and takes the ASCII glyphs with it (ADR-0068). + + {bannerLines(cols, !color).map((line) => ( + + {line.text} + + ))} + + ) : ( + + relavium + + )} {snapshot.isEmpty ? ( diff --git a/apps/cli/src/render/tui/mouse.test.ts b/apps/cli/src/render/tui/mouse.test.ts new file mode 100644 index 00000000..e990d961 --- /dev/null +++ b/apps/cli/src/render/tui/mouse.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; + +import { parseMouseEvent, createMouseReportReader } from './mouse.js'; + +/** + * The SGR mouse-report parser (2.6.F Step 6). The bit field is the whole contract: mistake the wheel bit for a button + * and the wheel starts a text selection; mistake the motion bit and a drag is read as a fresh press, collapsing the + * selection on every pointer move. + * + * Encoding: `ESC [ < Cb ; Cx ; Cy M|m` — `Cx` COLUMN and `Cy` ROW, both 1-based; `Cb` = button | 4 shift | 8 alt | + * 16 ctrl | 32 motion | 64 wheel. + */ + +const NO_MODS = { shift: false, alt: false, ctrl: false }; + +describe('parseMouseEvent — not a mouse report', () => { + it('returns undefined for ordinary keys, so they fall through to the editor', () => { + expect(parseMouseEvent('q')).toBeUndefined(); + expect(parseMouseEvent('')).toBeUndefined(); + expect(parseMouseEvent('\x1b[5~')).toBeUndefined(); // PgUp — a key, not a mouse report + expect(parseMouseEvent('[<0;1;1X')).toBeUndefined(); // malformed terminator + expect(parseMouseEvent('[<0;1M')).toBeUndefined(); // missing a coordinate + }); + + it('parses with OR without the leading ESC (ink may hand the CSI to `input` either way)', () => { + expect(parseMouseEvent('\x1b[<0;3;7M')?.kind).toBe('press'); + expect(parseMouseEvent('[<0;3;7M')?.kind).toBe('press'); + }); +}); + +describe('parseMouseEvent — buttons and motion', () => { + it('button 0/1/2 with the final `M` is a PRESS, carrying 1-based column + row', () => { + expect(parseMouseEvent('[<0;12;5M')).toEqual({ + kind: 'press', + button: 'left', + column: 12, + row: 5, + modifiers: NO_MODS, + }); + expect(parseMouseEvent('[<1;1;1M')).toMatchObject({ kind: 'press', button: 'middle' }); + expect(parseMouseEvent('[<2;1;1M')).toMatchObject({ kind: 'press', button: 'right' }); + }); + + it('the MOTION bit (+32) turns a press into a DRAG — not a second press', () => { + // 32 = left button held + motion. Reading this as a press would restart the selection on every pointer move. + expect(parseMouseEvent('[<32;20;9M')).toEqual({ + kind: 'drag', + button: 'left', + column: 20, + row: 9, + modifiers: NO_MODS, + }); + expect(parseMouseEvent('[<33;1;1M')).toMatchObject({ kind: 'drag', button: 'middle' }); + }); + + it('the final `m` is a RELEASE, and it carries WHICH button came up', () => { + // xterm's ctlseqs on the SGR (1006) `m` byte: "A different final character is used for button release to resolve + // the X10 ambiguity regarding which button was released." Step 6a's comment claimed the opposite, which is why a + // right-click used to re-copy the live selection. + expect(parseMouseEvent('[<0;12;5m')).toEqual({ + kind: 'release', + button: 'left', + column: 12, + row: 5, + modifiers: NO_MODS, + }); + expect(parseMouseEvent('[<2;12;5m')).toMatchObject({ kind: 'release', button: 'right' }); + expect(parseMouseEvent('[<1;12;5m')).toMatchObject({ kind: 'release', button: 'middle' }); + expect(parseMouseEvent('[<32;12;5m')).toMatchObject({ kind: 'release', button: 'left' }); // after a drag + }); + + it('a release reporting the legacy X10 "no button" code 3 leaves the button UNKNOWN, not `other`', () => { + // A press with code 3 is meaningless and becomes `other`; a RELEASE with code 3 is the X10 encoding and still + // means "a button came up". Dropping it would strand a live drag on such a terminal — the selection would never + // copy and never clear. + expect(parseMouseEvent('[<3;12;5m')).toEqual({ + kind: 'release', + button: undefined, + column: 12, + row: 5, + modifiers: NO_MODS, + }); + expect(parseMouseEvent('[<3;12;5M')).toEqual({ kind: 'other' }); + }); + + it('decodes the modifier bits (shift 4, alt 8, ctrl 16)', () => { + // Read through `toMatchObject` on the EVENT, not `?.modifiers`: the `other` variant deliberately carries no + // modifiers, so the union has none in common — the type system is right and the test must go through a variant. + expect(parseMouseEvent('[<4;1;1M')).toMatchObject({ + modifiers: { shift: true, alt: false, ctrl: false }, + }); + expect(parseMouseEvent('[<8;1;1M')).toMatchObject({ + modifiers: { shift: false, alt: true, ctrl: false }, + }); + expect(parseMouseEvent('[<16;1;1M')).toMatchObject({ + modifiers: { shift: false, alt: false, ctrl: true }, + }); + // 28 = 16|8|4 with button 0. + expect(parseMouseEvent('[<28;1;1M')).toMatchObject({ + kind: 'press', + button: 'left', + modifiers: { shift: true, alt: true, ctrl: true }, + }); + }); +}); + +describe('parseMouseEvent — the wheel', () => { + it('the WHEEL bit (+64) re-purposes the low bits as a direction, NOT as a button', () => { + // 64 read as a button would be "left press" and would start a selection on every wheel notch. + expect(parseMouseEvent('[<64;10;5M')).toEqual({ + kind: 'wheel', + direction: 'up', + column: 10, + row: 5, + modifiers: NO_MODS, + }); + expect(parseMouseEvent('[<65;10;5M')).toMatchObject({ kind: 'wheel', direction: 'down' }); + }); + + it('a HORIZONTAL wheel (66/67) is `other` — consumed, never mistaken for a middle/right button', () => { + expect(parseMouseEvent('[<66;1;1M')).toEqual({ kind: 'other' }); + expect(parseMouseEvent('[<67;1;1M')).toEqual({ kind: 'other' }); + }); + + it('a modified wheel still scrolls (Ctrl+wheel is a zoom in some terminals; we treat it as a notch)', () => { + expect(parseMouseEvent('[<80;1;1M')).toMatchObject({ + kind: 'wheel', + direction: 'up', + modifiers: { ctrl: true }, + }); // 64|16 + }); + + it('a wheel report with the motion bit set is still a wheel, never a drag', () => { + expect(parseMouseEvent('[<96;1;1M')).toMatchObject({ kind: 'wheel', direction: 'up' }); // 64|32 + }); +}); + +describe('parseMouseEvent — the “no button” encoding', () => { + it('button code 3 (no button) is `other`, not a right-click', () => { + expect(parseMouseEvent('[<3;1;1M')).toEqual({ kind: 'other' }); + expect(parseMouseEvent('[<35;1;1M')).toEqual({ kind: 'other' }); // 3|32 — motion with no button (1003 mode) + }); +}); + +/** + * A report SPLIT across two `useInput` calls (2.6.F Step 6f). Verified against a real ink 7.1.0 mount: three reports + * written as ONE chunk arrive as three separate calls (so the anchored regex is right), but a report written as + * `'\x1b[<0;1;'` then `'1M'` arrives as TWO calls. Before the reader, neither matched and both fell through to the + * editor — typing `[<0;1;` into the user's prompt on any laggy link during a drag. + */ +describe('createMouseReportReader — a report split across chunks', () => { + it('holds the fragment, CONSUMES it, and emits the event when the rest arrives', () => { + const reader = createMouseReportReader(); + expect(reader.read('[<0;1;')).toEqual({ kind: 'partial' }); + expect(reader.read('1M')).toEqual({ + kind: 'event', + event: { kind: 'press', button: 'left', row: 1, column: 1, modifiers: NO_MODS }, + }); + }); + + it('a complete report in one chunk never touches the buffer', () => { + const reader = createMouseReportReader(); + expect(reader.read('[<64;5;5M')).toMatchObject({ kind: 'event' }); + expect(reader.read('a')).toEqual({ kind: 'none' }); // no stale fragment prepended + }); + + it('an ordinary keystroke is never mistaken for a fragment — the buffer needs a leading `[<`', () => { + const reader = createMouseReportReader(); + for (const key of ['[', '<', ';', '0', 'a', '', '\x1b']) { + expect(reader.read(key), key).toEqual({ kind: 'none' }); + } + }); + + it('a fragment that never completes is DROPPED, and the keystroke after it still types', () => { + const reader = createMouseReportReader(); + expect(reader.read('[<32;9;')).toEqual({ kind: 'partial' }); + // `a` does not complete the report. The fragment is discarded, and `a` is judged on its own — never swallowed. + expect(reader.read('a')).toEqual({ kind: 'none' }); + expect(reader.read('[<0;1;1M')).toMatchObject({ kind: 'event' }); // the reader is not wedged + }); + + it('a fragment that grows past the cap is not held forever', () => { + const reader = createMouseReportReader(); + const long = `[<${'9'.repeat(30)}`; + expect(reader.read(long)).toEqual({ kind: 'none' }); // too long to be a report prefix + }); + + it('a SECOND fragment can follow a dropped one (a burst of split reports)', () => { + const reader = createMouseReportReader(); + expect(reader.read('[<32;9;')).toEqual({ kind: 'partial' }); + expect(reader.read('[<32;')).toEqual({ kind: 'partial' }); // the first is dropped, this one starts fresh + expect(reader.read('8;2M')).toMatchObject({ + kind: 'event', + event: { kind: 'drag', button: 'left', row: 2, column: 8 }, + }); + }); +}); diff --git a/apps/cli/src/render/tui/mouse.ts b/apps/cli/src/render/tui/mouse.ts new file mode 100644 index 00000000..75ab617e --- /dev/null +++ b/apps/cli/src/render/tui/mouse.ts @@ -0,0 +1,205 @@ +/** + * The SGR (DECSET 1006) terminal mouse-report parser — the input half of in-app text selection + copy-on-select + * (2.6.F Step 6, ADR-0068 §e amendment). + * + * WHY THE APP MUST OWN THE MOUSE. A terminal either reports mouse events to the application or performs its own + * click-drag selection — never both. With reporting on, the emulator hands us the clicks and stops selecting; with it + * off, the wheel never reaches us. There is no in-between (DECSET 1007 "alternate scroll" turns the wheel into arrow + * keys, which collide irrecoverably with the prompt's history keys). So a full-screen TUI that wants BOTH a scrolling + * wheel and text selection must implement selection itself. This module is where that starts. + * + * WHAT WE ENABLE. **DECSET 1002** (button-event tracking): press, release, wheel, and motion **only while a button is + * held** — i.e. drag. NOT 1003 (any-motion), which reports every pointer move and floods the input stream for nothing. + * + * THE ENCODING. `ESC [ < Cb ; Cx ; Cy M` (press or drag) / `… m` (release), 1-based column `Cx` and row `Cy`. `Cb` is + * a bit field: the low two bits are the button (0 left, 1 middle, 2 right), then `+4` shift, `+8` meta/alt, `+16` + * ctrl, `+32` motion, `+64` wheel — and when the wheel bit is set, the low two bits select the wheel direction + * (0 up, 1 down, 2 left, 3 right) rather than a button. + */ + +/** The physical button of a press/drag. Wheel "buttons" are reported separately — see {@link MouseEvent}. */ +export type MouseButton = 'left' | 'middle' | 'right'; + +/** Modifier keys held during the report. A terminal that uses Shift as its own selection-bypass modifier will not + * report the event at all, so `shift` is usually false in practice — it is parsed, not relied upon. */ +export interface MouseModifiers { + readonly shift: boolean; + readonly alt: boolean; + readonly ctrl: boolean; +} + +/** A parsed mouse report. `row`/`column` are the terminal's own 1-based cell coordinates. */ +export type MouseEvent = + /** A wheel notch. Horizontal wheels (buttons 66/67) are reported as {@link MouseOther}. */ + | { + readonly kind: 'wheel'; + readonly direction: 'up' | 'down'; + readonly row: number; + readonly column: number; + readonly modifiers: MouseModifiers; + } + /** A button went down — the selection ANCHOR. */ + | { + readonly kind: 'press'; + readonly button: MouseButton; + readonly row: number; + readonly column: number; + readonly modifiers: MouseModifiers; + } + /** The pointer moved with a button held — the selection FOCUS. */ + | { + readonly kind: 'drag'; + readonly button: MouseButton; + readonly row: number; + readonly column: number; + readonly modifiers: MouseModifiers; + } + /** + * A button came up — where copy-on-select fires. + * + * SGR **does** encode which button was released, and that is the whole reason the mode exists: xterm's `ctlseqs` + * says of the `m` final byte, *"A different final character is used for button release to resolve the X10 ambiguity + * regarding which button was released."* A terminal that still reports the X10 "no button" code 3 leaves + * {@link button} `undefined`, which the reducer treats as "some button came up" — the legacy meaning. + */ + | { + readonly kind: 'release'; + readonly button: MouseButton | undefined; + readonly row: number; + readonly column: number; + readonly modifiers: MouseModifiers; + } + /** A mouse report we do not act on (a horizontal wheel, an exotic button). CONSUMED, never typed. */ + | MouseOther; + +export interface MouseOther { + readonly kind: 'other'; +} + +const BUTTON_MASK = 0b11; +const SHIFT_BIT = 4; +const ALT_BIT = 8; +const CTRL_BIT = 16; +const MOTION_BIT = 32; +const WHEEL_BIT = 64; + +/** The leading ESC is OPTIONAL: ink's `parse-keypress` hands an unrecognized CSI to `input` with or without it. */ +// eslint-disable-next-line no-control-regex -- the SGR mouse report is introduced by ESC (U+001B) +const SGR_MOUSE = /^\x1b?\[<(\d+);(\d+);(\d+)([Mm])$/; + +function buttonOf(code: number): MouseButton | undefined { + switch (code & BUTTON_MASK) { + case 0: + return 'left'; + case 1: + return 'middle'; + case 2: + return 'right'; + default: + return undefined; // 3 = "no button" — only meaningful in the legacy X10 release encoding + } +} + +/** + * Parse one SGR mouse report. Returns `undefined` when `input` is not a mouse report at all (a normal key), so the + * caller can fall through to the editor. Every other result must be CONSUMED — a mouse report's raw bytes must never + * reach the prompt. + */ +export function parseMouseEvent(input: string): MouseEvent | undefined { + const match = SGR_MOUSE.exec(input); + if (match === null) return undefined; + + const code = Number(match[1]); + const column = Number(match[2]); + const row = Number(match[3]); + const released = match[4] === 'm'; + const modifiers: MouseModifiers = { + shift: (code & SHIFT_BIT) !== 0, + alt: (code & ALT_BIT) !== 0, + ctrl: (code & CTRL_BIT) !== 0, + }; + + if ((code & WHEEL_BIT) !== 0) { + // The wheel bit re-purposes the low two bits as a direction. Vertical only: a horizontal wheel (2/3) scrolls + // nothing here and must not be mistaken for a middle/right button. + const direction = code & BUTTON_MASK; + if (direction === 0) return { kind: 'wheel', direction: 'up', row, column, modifiers }; + if (direction === 1) return { kind: 'wheel', direction: 'down', row, column, modifiers }; + return { kind: 'other' }; + } + + const button = buttonOf(code); + // A release keeps its button (`undefined` only from a terminal still emitting the X10 code 3). Copy-on-select needs + // it: without it, a right-click while a selection is live reads as "the drag ended" and re-writes the clipboard. + if (released) return { kind: 'release', button, row, column, modifiers }; + + if (button === undefined) return { kind: 'other' }; + return (code & MOTION_BIT) !== 0 + ? { kind: 'drag', button, row, column, modifiers } + : { kind: 'press', button, row, column, modifiers }; +} + +/** + * The longest fragment we will hold waiting for the rest of a report. A complete report is at most + * `ESC [ < 3d ; 4d ; 4d M` — comfortably under this. The cap is what stops a malformed stream from growing a buffer. + */ +const MAX_PARTIAL_LENGTH = 24; + +/** A strict PREFIX of an SGR mouse report: at least `[<`, then digits and up to two semicolons, and no final byte. + * Requiring `[<` is what keeps a user's keystroke out of the buffer — ink delivers one keypress per `useInput` call, + * and no single keypress is `[<`. */ +// eslint-disable-next-line no-control-regex -- the SGR mouse report is introduced by ESC (U+001B) +const SGR_MOUSE_PREFIX = /^\x1b?\[<\d*(?:;\d*){0,2}$/; + +/** What {@link MouseReportReader.read} decided about one `useInput` payload. */ +export type MouseRead = + /** A complete report. Act on it, and CONSUME it. */ + | { readonly kind: 'event'; readonly event: MouseEvent } + /** The leading bytes of a report; the rest has not arrived. CONSUME it and wait. */ + | { readonly kind: 'partial' } + /** Not a mouse report. Hand it to the editor. */ + | { readonly kind: 'none' }; + +export interface MouseReportReader { + read: (input: string) => MouseRead; +} + +/** + * A stateful reader that survives an SGR report SPLIT across two `useInput` calls (2.6.F Step 6f). + * + * ink coalesces nothing and splits nothing on purpose — it hands over whatever the stream gave it. Verified against + * ink 7.1.0 with a real mount: three reports written as one chunk arrive as three separate `useInput` calls (so the + * anchored regex is right), but a report written as `'\x1b[<0;1;'` + `'1M'` arrives as TWO calls, and neither matched. + * Both fell through to the editor, typing `[<0;1;` into the user's prompt — reachable whenever a pty read lands + * mid-report, i.e. on any laggy SSH link during a drag. + * + * The buffer only ever starts on something that already looks like a report (`[<`…), is length-capped, and — when the + * next payload does not complete it — is DISCARDED while that payload is processed normally. So a stray fragment can + * never swallow the keystroke that follows it. + */ +export function createMouseReportReader(): MouseReportReader { + let pending = ''; + + const classify = (input: string): MouseRead | undefined => { + const event = parseMouseEvent(input); + if (event !== undefined) return { kind: 'event', event }; + if (input.length <= MAX_PARTIAL_LENGTH && SGR_MOUSE_PREFIX.test(input)) { + pending = input; + return { kind: 'partial' }; + } + return undefined; + }; + + return { + read: (input: string): MouseRead => { + if (pending !== '') { + const joined = pending + input; + pending = ''; + const resolved = classify(joined); + if (resolved !== undefined) return resolved; + // The fragment did not grow into a report. Drop it (it was never a keystroke) and judge `input` on its own. + } + return classify(input) ?? { kind: 'none' }; + }, + }; +} diff --git a/apps/cli/src/render/tui/scroll.test.ts b/apps/cli/src/render/tui/scroll.test.ts index bc3db959..bf150314 100644 --- a/apps/cli/src/render/tui/scroll.test.ts +++ b/apps/cli/src/render/tui/scroll.test.ts @@ -4,7 +4,6 @@ import type { ChatKey } from './chat-input.js'; import { effectiveOffset, INITIAL_SCROLL, - parseMouseScroll, reduceScroll, scrollMotionForKey, type ScrollGeometry, @@ -124,31 +123,3 @@ describe('scrollMotionForKey', () => { expect(scrollMotionForKey(arrowUp)).toBeUndefined(); }); }); - -describe('parseMouseScroll (2.6.F Step 5 — mouse-wheel)', () => { - const wheelUp = '\x1b[<64;10;5M'; // SGR mouse: button 64 = wheel up - const wheelDown = '\x1b[<65;10;5M'; // button 65 = wheel down - const click = '\x1b[<0;10;5M'; // button 0 = left click (a non-wheel report) - - it('maps a wheel notch to a line motion (up reveals older, down toward the tail)', () => { - expect(parseMouseScroll(wheelUp)).toBe('line-up'); - expect(parseMouseScroll(wheelDown)).toBe('line-down'); - }); - - it('parses with OR without the leading ESC (ink may hand the CSI to `input` either way)', () => { - expect(parseMouseScroll('[<64;10;5M')).toBe('line-up'); // no ESC - expect(parseMouseScroll('[<65;120;40m')).toBe('line-down'); // release form `m` too - }); - - it('returns `ignore` for a non-wheel mouse report (a click/drag) — CONSUMED, never typed, never scrolls', () => { - expect(parseMouseScroll(click)).toBe('ignore'); - expect(parseMouseScroll('\x1b[<32;5;5M')).toBe('ignore'); // button 32 = drag - }); - - it('returns undefined for input that is not a mouse report (a normal key / typed text)', () => { - expect(parseMouseScroll('q')).toBeUndefined(); - expect(parseMouseScroll('\x1b[5~')).toBeUndefined(); // PgUp — a key, not a mouse report - expect(parseMouseScroll('')).toBeUndefined(); - expect(parseMouseScroll('[<64;10;5X')).toBeUndefined(); // malformed terminator - }); -}); diff --git a/apps/cli/src/render/tui/scroll.ts b/apps/cli/src/render/tui/scroll.ts index 53b1965a..41f206b1 100644 --- a/apps/cli/src/render/tui/scroll.ts +++ b/apps/cli/src/render/tui/scroll.ts @@ -29,6 +29,21 @@ export interface ScrollGeometry { readonly height: number; } +/** + * What the viewport reports after each commit (2.6.F Step 6): the scroll geometry PLUS where the box actually sits in + * ink's frame. A terminal mouse report carries an absolute 1-based row; turning it into a wrapped-transcript line + * needs `top`. Both surfaces bind their ink root to `height: terminal rows` and ink writes a frame without a trailing + * newline, so frame row 0 IS terminal row 1 — hence `line = scrollOffset + (mouseRow - 1 - top)`. + */ +export interface ViewportGeometry extends ScrollGeometry { + /** The viewport's first rendered row, as a 0-based row in ink's frame. */ + readonly top: number; + /** The viewport's left edge, as a 0-based column in ink's frame. */ + readonly left: number; + /** The viewport's width in cells — the column a drag past the right edge clamps to. */ + readonly width: number; +} + /** The scroll motions the keymap produces: PgUp/PgDn, line up/down, and jump to top/bottom (Ctrl+Home / Ctrl+End). */ export type ScrollMotion = 'line-up' | 'line-down' | 'page-up' | 'page-down' | 'top' | 'bottom'; @@ -106,24 +121,3 @@ export function scrollMotionForKey(key: ScrollKey): ScrollMotion | undefined { /** How many display lines one mouse-wheel notch scrolls (the conventional 3-line step). */ export const WHEEL_LINES = 3; - -/** - * Classify a terminal SGR-mouse (DECSET 1006) escape (2.6.F Step 5): the alt screen enables mouse reporting - * (1000 + 1006), so a report arrives on `useInput`'s `input` as `ESC [ < b ; col ; row (M|m)`. Returns the wheel - * scroll motion (button **64** ⇒ wheel-up `line-up`, **65** ⇒ wheel-down `line-down`), `'ignore'` for ANY OTHER mouse - * report (a click / drag / non-wheel button), or `undefined` when the input is not a mouse report at all. The caller - * CONSUMES any non-`undefined` result — even `'ignore'` — so a mouse report's raw bytes never reach the editor; the - * coordinates are dropped (scroll is not position-sensitive). - */ -export type MouseScroll = ScrollMotion | 'ignore'; - -export function parseMouseScroll(input: string): MouseScroll | undefined { - // The leading ESC is OPTIONAL — ink parse-keypress may hand the CSI to `input` with or without it. - // eslint-disable-next-line no-control-regex -- the SGR mouse report is introduced by ESC (U+001B) - const match = /^\x1b?\[<(\d+);\d+;\d+[Mm]$/.exec(input); - if (match === null) return undefined; // not a mouse report - const button = Number(match[1]); - if (button === 64) return 'line-up'; // wheel up ⇒ reveal older lines - if (button === 65) return 'line-down'; // wheel down ⇒ toward the tail - return 'ignore'; // a mouse report but not a wheel — consume it (don't scroll, don't type) -} diff --git a/apps/cli/src/render/tui/selection.test.ts b/apps/cli/src/render/tui/selection.test.ts new file mode 100644 index 00000000..1161c5e3 --- /dev/null +++ b/apps/cli/src/render/tui/selection.test.ts @@ -0,0 +1,609 @@ +import { describe, expect, it } from 'vitest'; + +import { parseMouseEvent, type MouseEvent } from './mouse.js'; +import { + cellAt, + isCollapsed, + lineSpan, + normalizeSelection, + reduceSelection, + selectionText, + splitRow, + type SelectionRange, + type SelectionViewport, + routeMouseSelection, + type SelectionRouterPorts, + type SelectionState, +} from './selection.js'; +import { sliceDisplayColumns } from './viewport.js'; + +/** + * The pure selection state machine (2.6.F Step 6). Columns are DISPLAY CELLS, not character indices — the terminal + * reports the cell a click landed on, and a CJK glyph or emoji occupies two of them. Getting that wrong silently + * copies the wrong text, which is worse than copying nothing. + */ + +const cell = (line: number, column: number): { line: number; column: number } => ({ line, column }); +const range = (a: [number, number], b: [number, number]): SelectionRange => + normalizeSelection({ anchor: cell(...a), focus: cell(...b) }); + +describe('normalizeSelection + isCollapsed', () => { + it('a plain CLICK is collapsed — it selects nothing and copies nothing (but still clears a prior selection)', () => { + expect(isCollapsed({ anchor: cell(3, 5), focus: cell(3, 5) })).toBe(true); + expect(isCollapsed({ anchor: cell(3, 5), focus: cell(3, 6) })).toBe(false); + }); + + it('puts a BACKWARD drag (up, or leftward on one line) into document order', () => { + expect(normalizeSelection({ anchor: cell(5, 2), focus: cell(1, 9) })).toEqual({ + start: cell(1, 9), + end: cell(5, 2), + }); + expect(normalizeSelection({ anchor: cell(2, 9), focus: cell(2, 3) })).toEqual({ + start: cell(2, 3), + end: cell(2, 9), + }); + }); + + it('leaves a forward drag alone', () => { + expect(normalizeSelection({ anchor: cell(1, 0), focus: cell(4, 7) })).toEqual({ + start: cell(1, 0), + end: cell(4, 7), + }); + }); +}); + +describe('lineSpan — what is highlighted on each row', () => { + it('a line outside the selection has no span', () => { + const r = range([2, 1], [4, 3]); + expect(lineSpan(1, r)).toBeUndefined(); + expect(lineSpan(5, r)).toBeUndefined(); + }); + + it('a single-line selection is [from, end+1) — the end cell is INCLUSIVE, as in every terminal', () => { + expect(lineSpan(2, range([2, 3], [2, 6]))).toEqual({ from: 3, to: 7 }); + }); + + it('the FIRST line runs to the end of the row (open-ended), the LAST from column 0', () => { + const r = range([2, 4], [4, 2]); + expect(lineSpan(2, r)).toEqual({ from: 4, to: undefined }); // to end of row + expect(lineSpan(3, r)).toEqual({ from: 0, to: undefined }); // whole row + expect(lineSpan(4, r)).toEqual({ from: 0, to: 3 }); + }); +}); + +describe('sliceDisplayColumns — width-aware, the reason columns are cells', () => { + it('slices ASCII like String.slice', () => { + expect(sliceDisplayColumns('hello world', 0, 5)).toBe('hello'); + expect(sliceDisplayColumns('hello world', 6, 11)).toBe('world'); + expect(sliceDisplayColumns('hello', 3, 3)).toBe(''); + }); + + it('takes the WHOLE wide character when either of its two cells is selected', () => { + // 日 and 本 are 2 cells each: columns 0-1 and 2-3. + expect(sliceDisplayColumns('日本語', 0, 1)).toBe('日'); // clicked its left half + expect(sliceDisplayColumns('日本語', 1, 2)).toBe('日'); // clicked its right half + expect(sliceDisplayColumns('日本語', 0, 4)).toBe('日本'); + expect(sliceDisplayColumns('日本語', 2, 6)).toBe('本語'); + }); + + it('an emoji cluster (ZWJ / flag / keycap) is atomic — never split down the middle', () => { + expect(sliceDisplayColumns('a👍b', 1, 2)).toBe('👍'); + expect(sliceDisplayColumns('a👍b', 0, 3)).toBe('a👍'); + expect(sliceDisplayColumns('👩‍👩‍👧b', 0, 1)).toBe('👩‍👩‍👧'); // one grapheme, 2 cells + }); + + it('a zero-width combining mark rides its base — it is never orphaned onto a base it does not modify', () => { + const eAcute = 'é'; // e + COMBINING ACUTE + expect(sliceDisplayColumns(`x${eAcute}y`, 1, 2)).toBe(eAcute); // the mark comes with its `e` + expect(sliceDisplayColumns(`x${eAcute}y`, 0, 1)).toBe('x'); // …and never with the `x` before it + }); + + it('truncates past the end of the row rather than throwing (a drag beyond a short line)', () => { + expect(sliceDisplayColumns('hi', 0, 999)).toBe('hi'); + expect(sliceDisplayColumns('hi', 5, 999)).toBe(''); + }); +}); + +describe('selectionText — what lands on the clipboard', () => { + const lines = ['first line', 'second line', 'third line', '日本語です']; + + it('a single-line selection copies exactly the highlighted cells', () => { + expect(selectionText(lines, range([0, 0], [0, 4]))).toBe('first'); + expect(selectionText(lines, range([1, 7], [1, 10]))).toBe('line'); + }); + + it('a multi-line selection copies first-partial, whole-middle, last-partial — `\\n`-joined', () => { + expect(selectionText(lines, range([0, 6], [2, 4]))).toBe('line\nsecond line\nthird'); + }); + + it('a backward drag copies the same text as the forward one', () => { + expect(selectionText(lines, range([2, 4], [0, 6]))).toBe('line\nsecond line\nthird'); + }); + + it('copies wide characters whole, by CELL', () => { + expect(selectionText(lines, range([3, 0], [3, 3]))).toBe('日本'); // cells 0..3 ⇒ two glyphs + }); + + it('a selection that outruns the transcript copies what still exists (never throws)', () => { + expect(selectionText(lines, range([2, 0], [9, 0]))).toBe('third line\n日本語です'); + expect(selectionText([], range([0, 0], [3, 3]))).toBe(''); + }); + + it('a collapsed selection copies a single cell — the caller decides not to copy at all', () => { + // `isCollapsed` is the guard; `selectionText` is total, so it must still behave on the degenerate range. + expect(selectionText(lines, range([0, 0], [0, 0]))).toBe('f'); + }); +}); + +/** + * `splitRow` — what the viewport actually draws. The highlight is an ANSI `inverse` attribute, which a frame snapshot + * cannot see, so the SPLIT is where correctness has to be pinned: get it wrong and the user sees the wrong characters + * highlighted, then copies exactly what was highlighted, and never learns why. + */ +describe('splitRow — the three pieces the viewport renders', () => { + it('an open-ended span highlights to the end of the row (an inner row of a multi-line selection)', () => { + expect(splitRow('hello world', { from: 6, to: undefined })).toEqual({ + before: 'hello ', + selected: 'world', + after: '', + }); + expect(splitRow('hello', { from: 0, to: undefined })).toEqual({ + before: '', + selected: 'hello', + after: '', + }); + }); + + it('a bounded span leaves a tail (a single-line selection, or the last row)', () => { + expect(splitRow('hello world', { from: 0, to: 5 })).toEqual({ + before: '', + selected: 'hello', + after: ' world', + }); + expect(splitRow('hello world', { from: 2, to: 4 })).toEqual({ + before: 'he', + selected: 'll', + after: 'o world', + }); + }); + + it('a span that starts PAST the row selects nothing and leaves the row whole (a drag over a short line)', () => { + expect(splitRow('hi', { from: 40, to: undefined })).toEqual({ + before: 'hi', + selected: '', + after: '', + }); + }); + + it('reassembles losslessly — before + selected + after is always the original row', () => { + // The degenerate rows are the point. A LEADING zero-width cluster (a combining mark, a ZWJ, a lone variation + // selector) has no cell of its own and no cluster before it to ride. Until the Step-6 review it matched neither + // membership test and fell into `after`, physically moving it PAST its base — `'\u0301ab'` came back as + // `'ab\u0301'` — and dropping it from the copy. A row that is ENTIRELY zero-width has no cell at all. + const rows = [ + 'hello world', + '日本語です', + 'a👍b', + 'x', + '\u0301ab', + '\u200dab', + '\ufe0fab', + 'aéb', + '\u0301', + 'a\u0001b', + ]; + const spans = [ + { from: 0, to: undefined }, + { from: 1, to: 3 }, + { from: 2, to: undefined }, + { from: 0, to: 1 }, + { from: 99, to: undefined }, + { from: 1, to: 1 }, // degenerate: selects nothing, and must still not lose or move a cluster + ]; + for (const row of rows) { + for (const span of spans) { + const { before, selected, after } = splitRow(row, span); + expect(before + selected + after, `${row} @ ${span.from}..${String(span.to)}`).toBe(row); + } + } + }); + + it('a MID-ROW zero-width cluster rides the cluster before it (the defensive control-character path)', () => { + // UAX#29 (GB9) absorbs every combining mark / ZWJ into the preceding cluster, so the only way to get a width-0 + // cluster that is NOT the first one is a C0/C1 control — which `sanitizeInline` strips upstream, making this the + // walker's DEFENSIVE branch. Pinned anyway: without it the control is emitted into the tail and the row no longer + // reassembles (`'a\u0001b'` comes back as `'ab\u0001'`), which a break-verify proved nothing else catches. + expect(splitRow('a\u0001b', { from: 1, to: 2 })).toEqual({ + before: 'a\u0001', + selected: 'b', + after: '', + }); + }); + + it('a DEGENERATE span selects nothing — and the wide glyph it straddles is not silently highlighted', () => { + // `sliceDisplayColumns` guards `endColumn <= startColumn` and copies ''. `partitionDisplayColumns` did not, so the + // intersect rule highlighted `日` (cells 0-1 straddle column 1) while the clipboard got '' — the one thing + // copy-on-select must never do (Step-6 Opus review). Unreachable through `lineSpan` today; structural now. + expect(splitRow('日本語です', { from: 1, to: 1 })).toEqual({ + before: '日', + selected: '', + after: '本語です', + }); + }); + + it('a LEADING zero-width cluster stays with the base it precedes, in both the highlight and the copy', () => { + expect(splitRow('\u0301ab', { from: 0, to: 1 })).toEqual({ + before: '', + selected: '\u0301a', // the mark has no cell; it rides the first cluster that does + after: 'b', + }); + expect(splitRow('\u0301ab', { from: 1, to: 2 })).toEqual({ + before: '\u0301a', + selected: 'b', + after: '', + }); + }); + + it('never splits a wide character or an emoji cluster down the middle', () => { + expect(splitRow('日本語', { from: 1, to: 3 })).toEqual({ + before: '', + selected: '日本', // both glyphs — cells 0-1 and 2-3 each intersect [1,3) + after: '語', + }); + expect(splitRow('a👍b', { from: 1, to: 2 })).toEqual({ + before: 'a', + selected: '👍', + after: 'b', + }); + }); +}); + +/** + * THE invariant of copy-on-select: what the user sees highlighted is EXACTLY what lands on their clipboard. The + * highlight comes from `splitRow` (a partition), the clipboard from `selectionText` (`sliceDisplayColumns`). They are + * different functions and could drift; a user would never discover it, because both look right in isolation. + */ +describe('the highlight and the clipboard agree, character for character', () => { + const rows = [ + 'hello world', + '日本語です', + 'a👍b', + '', + 'x', + 'aéb', + '\u0301ab', + '\u200dab', + '\u0301', + ]; + + it('splitRow().selected === the text selectionText would copy for that row', () => { + for (const row of rows) { + for (const from of [0, 1, 2, 3]) { + for (const to of [undefined, 1, 2, 4, 99]) { + if (to !== undefined && to <= from) continue; + const highlighted = splitRow(row, { from, to }).selected; + // `selectionText` indexes by ABSOLUTE line, so hand it a one-row transcript. `end` is inclusive. + const copied = selectionText([row], { + start: cell(0, from), + end: cell(0, (to ?? Number.MAX_SAFE_INTEGER) - 1), + }); + expect(copied, `"${row}" @ ${from}..${String(to)}`).toBe(highlighted); + } + } + } + }); +}); + +/** A viewport starting at frame row 3 (a Home-style header above it), 10 rows tall, scrolled to line 100. */ +const VP: SelectionViewport = { top: 3, left: 0, height: 10, totalLines: 500, offset: 100 }; + +/** Parse a real SGR report, so the reducer is exercised through the same bytes a terminal sends. Throws rather than + * asserting non-null: a typo in a test's escape would otherwise silently reduce `undefined` and pass. */ +const ev = (sgr: string): MouseEvent => { + const parsed = parseMouseEvent(sgr); + if (parsed === undefined) throw new Error(`not a mouse report: ${JSON.stringify(sgr)}`); + return parsed; +}; + +describe('cellAt — terminal cell → wrapped-transcript cell', () => { + it('frame row 0 is TERMINAL row 1: the viewport’s first row maps to the scroll offset', () => { + expect(cellAt(4, 1, VP)).toEqual(cell(100, 0)); // row 4 = frame row 3 = the viewport's first + expect(cellAt(5, 7, VP)).toEqual(cell(101, 6)); + }); + + it('CLAMPS a drag above the viewport to its first line — not to a negative index', () => { + expect(cellAt(1, 1, VP)).toEqual(cell(100, 0)); // the header rows + expect(cellAt(-5, 1, VP)).toEqual(cell(100, 0)); + }); + + it('CLAMPS a drag below the viewport to its last visible line', () => { + expect(cellAt(13, 1, VP)).toEqual(cell(109, 0)); // top 3 + height 10 ⇒ last visible frame row 12 + expect(cellAt(99, 1, VP)).toEqual(cell(109, 0)); + }); + + it('CLAMPS a drag left of the viewport to column 0', () => { + expect(cellAt(4, 0, { ...VP, left: 2 })).toEqual(cell(100, 0)); + }); + + it('never indexes past the transcript (a short transcript in a tall viewport)', () => { + const short: SelectionViewport = { top: 0, left: 0, height: 10, totalLines: 3, offset: 0 }; + expect(cellAt(9, 1, short)).toEqual(cell(2, 0)); // row 9 ⇒ visible row 8, but only 3 lines exist + }); + + it('an EMPTY transcript maps everything to line 0 (never -1)', () => { + const empty: SelectionViewport = { top: 0, left: 0, height: 10, totalLines: 0, offset: 0 }; + expect(cellAt(5, 5, empty)).toEqual(cell(0, 4)); + }); +}); + +describe('reduceSelection — the shared gesture, so the two surfaces cannot drift', () => { + it('a LEFT press starts a collapsed selection (a click alone highlights nothing)', () => { + const action = reduceSelection(undefined, ev('[<0;5;5M'), VP); + expect(action).toEqual({ kind: 'set', state: { anchor: cell(101, 4), focus: cell(101, 4) } }); + expect(isCollapsed({ anchor: cell(101, 4), focus: cell(101, 4) })).toBe(true); + }); + + it('MIDDLE and RIGHT presses leave a live selection alone (they paste / open a menu in emulators)', () => { + const live = { anchor: cell(100, 0), focus: cell(102, 3) }; + expect(reduceSelection(live, ev('[<1;5;5M'), VP)).toEqual({ kind: 'none' }); + expect(reduceSelection(live, ev('[<2;5;5M'), VP)).toEqual({ kind: 'none' }); + }); + + it('a DRAG moves the focus and keeps the anchor', () => { + const started = { anchor: cell(100, 0), focus: cell(100, 0) }; + expect(reduceSelection(started, ev('[<32;9;7M'), VP)).toEqual({ + kind: 'set', + state: { anchor: cell(100, 0), focus: cell(103, 8) }, + }); + }); + + it('a drag with NO press before it does nothing (a stray report after a re-render)', () => { + expect(reduceSelection(undefined, ev('[<32;9;7M'), VP)).toEqual({ kind: 'none' }); + }); + + it('RELEASE after a real drag COPIES, and keeps the highlight (as every terminal does)', () => { + const dragged = { anchor: cell(100, 0), focus: cell(102, 5) }; + expect(reduceSelection(dragged, ev('[<0;6;8m'), VP)).toEqual({ kind: 'copy', state: dragged }); + }); + + it('RELEASE after a plain click CLEARS — it must not copy a single character', () => { + const clicked = { anchor: cell(101, 4), focus: cell(101, 4) }; + expect(reduceSelection(clicked, ev('[<0;5;5m'), VP)).toEqual({ kind: 'clear' }); + }); + + it('a MIDDLE or RIGHT release leaves a live selection alone — it must NOT re-copy it', () => { + // SGR encodes the released button (xterm ctlseqs: the `m` byte exists "to resolve the X10 ambiguity regarding + // which button was released"). Without reading it, every right-click while a selection was live re-emitted the + // whole selection over OSC 52 (Step-6 Opus review). + const dragged = { anchor: cell(2, 3), focus: cell(2, 8) }; + expect(reduceSelection(dragged, ev('[<2;9;7m'), VP)).toEqual({ kind: 'none' }); // right + expect(reduceSelection(dragged, ev('[<1;9;7m'), VP)).toEqual({ kind: 'none' }); // middle + }); + + it('a release from a terminal reporting the X10 "no button" code 3 still ENDS the gesture', () => { + // Honour the legacy meaning: "some button came up". Treating it as `none` would strand the drag — the selection + // would neither copy nor clear, and the next press would look like a drag continuation. + const dragged = { anchor: cell(2, 3), focus: cell(2, 8) }; + expect(reduceSelection(dragged, ev('[<3;9;7m'), VP)).toEqual({ kind: 'copy', state: dragged }); + const clicked = { anchor: cell(2, 3), focus: cell(2, 3) }; + expect(reduceSelection(clicked, ev('[<3;4;5m'), VP)).toEqual({ kind: 'clear' }); + }); + + it('the WHEEL never touches the selection — it belongs to reduceScroll', () => { + const live = { anchor: cell(100, 0), focus: cell(102, 3) }; + expect(reduceSelection(live, ev('[<64;5;5M'), VP)).toEqual({ kind: 'none' }); + expect(reduceSelection(live, ev('[<65;5;5M'), VP)).toEqual({ kind: 'none' }); + }); + + it('a horizontal wheel / exotic button is inert (still CONSUMED by the caller)', () => { + expect(reduceSelection(undefined, ev('[<66;1;1M'), VP)).toEqual({ kind: 'none' }); + }); + + it('a BACKWARD drag (up-left) produces a selection that copies the same text', () => { + const started = { anchor: cell(105, 5), focus: cell(105, 5) }; + const dragged = reduceSelection(started, ev('[<32;2;5M'), VP); // up and to the left + expect(dragged).toEqual({ kind: 'set', state: { anchor: cell(105, 5), focus: cell(101, 1) } }); + if (dragged.kind !== 'set') throw new Error('unreachable'); + expect(normalizeSelection(dragged.state)).toEqual({ start: cell(101, 1), end: cell(105, 5) }); + }); +}); + +/** + * The three things the pure reducer cannot own, because they touch SCROLL state (2.6.F Step 6f, Opus review). Pinned + * against a fake port set rather than a mounted ink tree, so each rule is readable on its own; `chat-app.test.tsx` + * and `home-app.test.tsx` then pin the assembly. + */ +describe('routeMouseSelection — the scroll-aware half of a gesture', () => { + const VIEWPORT: SelectionViewport = { top: 2, left: 0, height: 5, totalLines: 100, offset: 40 }; + + /** + * The fake ports MODEL the scroll: `scrollBy` moves `offset`, and `geometry()` reads it. Without that a break that + * maps the focus BEFORE the scroll instead of after stays green — the whole point of the ordering is that the + * second `geometry()` call sees a different offset. `pauseFollow` likewise records the follow flag exactly as the + * surfaces do, so a double-`pauseFollow` loses the memory here too. + */ + const ports = ( + overrides: Partial = {}, + ): SelectionRouterPorts & { + log: string[]; + selection: () => SelectionState | undefined; + following: () => boolean; + } => { + const log: string[] = []; + let selection: SelectionState | undefined; + let offset = VIEWPORT.offset; + let following = true; + let followedBefore = false; + let gesture = false; + const base: SelectionRouterPorts = { + geometry: () => ({ ...VIEWPORT, offset }), + current: () => selection, + setSelection: (s_) => { + selection = s_; + log.push(s_ === undefined ? 'clear' : `set ${s_.anchor.line}->${s_.focus.line}`); + }, + copy: () => log.push('copy'), + scrollBy: (m) => { + offset += m === 'line-down' ? 1 : -1; + log.push(`scroll ${m}`); + }, + pauseFollow: () => { + followedBefore = following; + following = false; + log.push('pauseFollow'); + }, + restoreFollow: () => { + if (followedBefore) following = true; + log.push('restoreFollow'); + }, + gestureActive: () => gesture, + setGestureActive: (active) => { + gesture = active; + }, + ...overrides, + }; + return { ...base, log, selection: () => selection, following: () => following }; + }; + + it('a PRESS below the viewport (the prompt) starts nothing — it must not anchor on the last visible line', () => { + // `cellAt` clamps by design, for drags. Clamping a PRESS anchors it to the viewport's last line, so the user drags + // across, and copies, text they never pressed on (Step-6 completeness critic). + const p = ports(); + routeMouseSelection(ev('[<0;5;9M'), p); // terminal row 9 = frame row 8; the viewport ends at frame row 6 + expect(p.log).toEqual([]); + expect(p.selection()).toBeUndefined(); + }); + + it('a PRESS above the viewport (the Home’s management strip) starts nothing', () => { + const p = ports(); + routeMouseSelection(ev('[<0;5;1M'), p); // terminal row 1 = frame row 0; the viewport starts at frame row 2 + expect(p.log).toEqual([]); + }); + + it('a PRESS on the viewport’s first and last rows DOES start a selection (they are inside it)', () => { + const first = ports(); + routeMouseSelection(ev('[<0;5;3M'), first); // frame row 2 === top + expect(first.selection()).toBeDefined(); + const last = ports(); + routeMouseSelection(ev('[<0;5;7M'), last); // frame row 6 === top + height - 1 + expect(last.selection()).toBeDefined(); + }); + + it('a DRAG on the viewport’s LAST row scrolls down BEFORE the focus is mapped', () => { + // Without this a selection can never exceed one screenful: `cellAt` clamps the focus to the last visible line, so + // dragging further down just re-selects the same row. And the ORDER is load-bearing: the focus must be mapped + // against the offset the scroll just produced, or the selection lags a line behind the pointer forever. + const p = ports(); + routeMouseSelection(ev('[<0;5;5M'), p); // press, inner row (frame row 4 ⇒ line 40 + 2 = 42) + p.log.length = 0; + routeMouseSelection(ev('[<32;5;7M'), p); // drag to the last row (frame row 6) + expect(p.log).toEqual(['scroll line-down', 'set 42->45']); + // offset 41 + visibleRow 4 = 45. Mapping before the scroll would give 44 — a line the pointer has left behind. + }); + + it('a sustained DRAG down the edge extends the selection one line per report', () => { + const p = ports(); + routeMouseSelection(ev('[<0;5;5M'), p); + for (let i = 0; i < 3; i += 1) routeMouseSelection(ev('[<32;5;7M'), p); + expect(p.selection()?.focus.line).toBe(47); // 45, 46, 47 — it really keeps growing + expect(p.selection()?.anchor.line).toBe(42); // …and the anchor never moves + }); + + it('a DRAG on the viewport’s FIRST row scrolls up — the only signal there is, since nothing is above it', () => { + const p = ports(); + routeMouseSelection(ev('[<0;5;5M'), p); + p.log.length = 0; + routeMouseSelection(ev('[<32;5;3M'), p); // drag to the first row + expect(p.log[0]).toBe('scroll line-up'); + }); + + it('a DRAG on an INNER row never scrolls', () => { + const p = ports(); + routeMouseSelection(ev('[<0;5;4M'), p); + p.log.length = 0; + routeMouseSelection(ev('[<32;9;5M'), p); + expect(p.log.filter((l) => l.startsWith('scroll'))).toEqual([]); + }); + + it('a DRAG with no press before it neither scrolls nor selects', () => { + const p = ports(); + routeMouseSelection(ev('[<32;5;7M'), p); // last row, but no gesture in flight + expect(p.log).toEqual([]); + }); + + it('a PRESS freezes auto-follow, so a completing turn cannot slide the transcript under the pointer', () => { + const p = ports(); + routeMouseSelection(ev('[<0;5;5M'), p); + expect(p.log).toContain('pauseFollow'); + }); + + it('a plain CLICK restores auto-follow — pausing it for a click would silently stop the stream', () => { + const p = ports(); + routeMouseSelection(ev('[<0;5;5M'), p); // press + routeMouseSelection(ev('[<0;5;5m'), p); // release at the same cell ⇒ collapsed ⇒ clear + expect(p.log).toEqual(['set 42->42', 'pauseFollow', 'clear', 'restoreFollow']); + }); + + it('a drag that RETURNS to its anchor still restores auto-follow — pauseFollow must run once, on the press', () => { + // A second `pauseFollow` overwrites the remembered flag with the already-false `following`, so the `clear` that + // follows silently fails to restore it. The user presses, wiggles, lets go on the same cell — and the transcript + // has quietly stopped following the stream, with nothing on screen to say why. + const p = ports(); + routeMouseSelection(ev('[<0;5;5M'), p); // press + routeMouseSelection(ev('[<32;9;5M'), p); // drag away + routeMouseSelection(ev('[<32;5;5M'), p); // …and back to the anchor cell + routeMouseSelection(ev('[<0;5;5m'), p); // release ⇒ collapsed ⇒ clear + expect(p.log.filter((l) => l === 'pauseFollow')).toHaveLength(1); + expect(p.following()).toBe(true); + }); + + it('a real DRAG keeps auto-follow frozen after the copy', () => { + const p = ports(); + routeMouseSelection(ev('[<0;5;5M'), p); + routeMouseSelection(ev('[<32;9;5M'), p); + routeMouseSelection(ev('[<0;9;5m'), p); + expect(p.log).toContain('copy'); + expect(p.log).not.toContain('restoreFollow'); + }); + + it('a MIDDLE/RIGHT press neither freezes follow nor disturbs the selection', () => { + const p = ports(); + routeMouseSelection(ev('[<0;5;5M'), p); + p.log.length = 0; + routeMouseSelection(ev('[<2;9;5M'), p); + expect(p.log).toEqual([]); + }); + + it('a stray click OUTSIDE the viewport after a copy does not re-copy the retained highlight', () => { + // The gesture-gating bug (Step-6h review): after a drag-copy the highlight is RETAINED, so `current` is a real + // non-collapsed selection. A left press on the prompt returns `none` (outside the viewport), leaving that + // selection — and the following release used to see a non-collapsed `current` and re-emit it over OSC 52. + const p = ports(); + routeMouseSelection(ev('[<0;5;5M'), p); // press inside… + routeMouseSelection(ev('[<32;9;5M'), p); // …drag… + routeMouseSelection(ev('[<0;9;5m'), p); // …release ⇒ copy + expect(p.log.filter((l) => l === 'copy')).toHaveLength(1); + const held = p.selection(); + p.log.length = 0; + + // The prompt is at terminal row 9 = frame row 8; the viewport spans frame rows 2..6. Both press and release miss. + routeMouseSelection(ev('[<0;5;9M'), p); // press on the prompt ⇒ no gesture + routeMouseSelection(ev('[<0;5;9m'), p); // release ⇒ must NOT copy + expect(p.log).toEqual([]); // no copy, no clear, no mutation + expect(p.selection()).toBe(held); // the highlight is preserved, byte-for-byte + }); + + it('a DRAG whose press missed the viewport cannot resurrect a retained selection', () => { + const p = ports(); + routeMouseSelection(ev('[<0;5;5M'), p); // a real gesture… + routeMouseSelection(ev('[<32;9;5M'), p); + routeMouseSelection(ev('[<0;9;5m'), p); // …copied, gesture closed + const held = p.selection(); + p.log.length = 0; + + routeMouseSelection(ev('[<0;5;9M'), p); // press on the prompt ⇒ no gesture + routeMouseSelection(ev('[<32;9;5M'), p); // a drag with the button held, back into the viewport + expect(p.log).toEqual([]); // no scroll, no set + expect(p.selection()).toBe(held); + }); +}); diff --git a/apps/cli/src/render/tui/selection.ts b/apps/cli/src/render/tui/selection.ts new file mode 100644 index 00000000..965d4e25 --- /dev/null +++ b/apps/cli/src/render/tui/selection.ts @@ -0,0 +1,323 @@ +import type { MouseEvent } from './mouse.js'; +import { partitionDisplayColumns, sliceDisplayColumns } from './viewport.js'; + +/** + * The pure text-selection state machine for the full-screen transcript viewport (2.6.F Step 6, ADR-0068 §e amendment) + * — the counterpart of `scroll.ts`. Mouse reporting takes the emulator's own click-drag selection away from the user + * (that is the price of a scrolling wheel), so the app gives it back: drag to select, release to copy. + * + * COORDINATES. A {@link Cell} is `{ line, column }` where `line` indexes the WRAPPED transcript (the same + * `DisplayLine[]` the viewport windows) and `column` is a DISPLAY COLUMN, not a character index — a CJK glyph or an + * emoji occupies two, a combining mark none. Both are 0-based. The surface converts a terminal's 1-based mouse row + * into a line index; that mapping lives at the render boundary, not here. + * + * PURE + geometry-free: no ink, no terminal, no scroll offset. The reducer never clamps to the viewport, because a + * selection legitimately extends past it — the user drags, scrolls, and drags again. + */ + +/** A position in the wrapped transcript: a display-line index and a display column, both 0-based. */ +export interface Cell { + readonly line: number; + readonly column: number; +} + +/** A live selection: where the drag began, and where the pointer is now. Both endpoints are real, ordered by the + * user's gesture — {@link normalizeSelection} puts them in document order. */ +export interface SelectionState { + readonly anchor: Cell; + readonly focus: Cell; +} + +/** A selection in document order, INCLUSIVE of both endpoint cells (as every terminal's own selection is). */ +export interface SelectionRange { + readonly start: Cell; + readonly end: Cell; +} + +/** Order two cells: earlier line first, then earlier column. */ +function before(a: Cell, b: Cell): boolean { + return a.line !== b.line ? a.line < b.line : a.column < b.column; +} + +/** `true` when anchor and focus are the same cell — a plain CLICK, which selects nothing and copies nothing. It still + * CLEARS any prior selection, which is why a click is not simply ignored. */ +export function isCollapsed(state: SelectionState): boolean { + return state.anchor.line === state.focus.line && state.anchor.column === state.focus.column; +} + +/** Put the gesture into document order. A drag upward or leftward is as valid as one downward. */ +export function normalizeSelection(state: SelectionState): SelectionRange { + return before(state.focus, state.anchor) + ? { start: state.focus, end: state.anchor } + : { start: state.anchor, end: state.focus }; +} + +/** + * The half-open display-column span `[from, to)` selected on one wrapped line, or `undefined` when the line is outside + * the selection. `to === undefined` means "to the end of the line" — the row is fully selected from `from` onward, + * whatever its width, so a caller never needs to know how long the row is. + * + * The `end` cell is INCLUSIVE, so the last (or only) line extends one column past it. + */ +export function lineSpan( + line: number, + range: SelectionRange, +): { readonly from: number; readonly to: number | undefined } | undefined { + if (line < range.start.line || line > range.end.line) return undefined; + const from = line === range.start.line ? range.start.column : 0; + const to = line === range.end.line ? range.end.column + 1 : undefined; + return { from, to }; +} + +/** The maximum display column any real transcript row can reach — a stand-in for "to the end of the line" when a span + * is open-ended. Any value at least as large as the widest row works; `sliceDisplayColumns` truncates. */ +const OPEN_END = Number.MAX_SAFE_INTEGER; + +/** + * Extract the selected text from the wrapped transcript, `\n`-joined. + * + * It copies the VISUAL rows the user actually selected, so a paragraph that the viewport wrapped comes back with those + * wraps as newlines — precisely what the terminal's own selection would have given, and what the highlight showed. + * (`/edit` and `/copy` hand over the UNWRAPPED document when fidelity matters more than the visual.) + */ +export function selectionText(lines: readonly string[], range: SelectionRange): string { + const out: string[] = []; + for (let line = range.start.line; line <= range.end.line; line += 1) { + const row = lines[line]; + if (row === undefined) continue; // a selection anchored before a transcript rebuild — copy what still exists + const span = lineSpan(line, range); + if (span === undefined) continue; + out.push(sliceDisplayColumns(row, span.from, span.to ?? OPEN_END)); + } + return out.join('\n'); +} + +/** One wrapped row, split by the selection into the three pieces the viewport renders: unselected head, highlighted + * middle, unselected tail. Kept PURE (and exhaustively tested) so the component stays a three-`` arrangement — + * an ANSI inverse attribute is invisible to a frame snapshot, so the splitting is where correctness must be pinned. */ +export interface RowSegments { + readonly before: string; + readonly selected: string; + readonly after: string; +} + +/** + * Split `text` at the display-column span the selection covers on this row. An open-ended span (`to === undefined`) + * highlights to the end of the row, whatever its width — that is what a multi-line selection does to its inner rows. + * A span that starts past the row's width selects nothing and leaves the row whole, which is what a drag over a short + * line does. + */ +export function splitRow( + text: string, + span: { readonly from: number; readonly to: number | undefined }, +): RowSegments { + return partitionDisplayColumns(text, span.from, span.to ?? OPEN_END); +} + +/** + * What the reducer needs to know about the viewport at the instant a mouse event arrives: where it sits in ink's + * frame, how big it is, how far the transcript is scrolled, and how long the transcript is. Everything here is + * measured (`ViewportGeometry`) or derived (`effectiveOffset`) — the reducer computes none of it. + */ +export interface SelectionViewport { + /** The viewport's first row, as a 0-based row in ink's frame. Frame row 0 IS terminal row 1. */ + readonly top: number; + /** The viewport's left edge, as a 0-based column in ink's frame. */ + readonly left: number; + /** Visible rows. */ + readonly height: number; + /** Total wrapped display lines in the transcript. */ + readonly totalLines: number; + /** The top display-line index currently shown (`effectiveOffset` of the scroll state). */ + readonly offset: number; +} + +/** + * Translate a terminal's 1-based `row`/`column` into a wrapped-transcript {@link Cell}. + * + * Clamped on BOTH axes, because a drag legitimately leaves the viewport: pulling above the top row anchors to the + * first visible line, below the bottom row to the last, and past the left edge to column 0. Without the clamp a drag + * off the top would index a negative line and select nothing — the single most likely way to make selection feel + * broken. + */ +export function cellAt(row: number, column: number, viewport: SelectionViewport): Cell { + const visibleRow = Math.min( + Math.max(row - 1 - viewport.top, 0), + Math.max(viewport.height - 1, 0), + ); + const lastLine = Math.max(viewport.totalLines - 1, 0); + return { + line: Math.min(viewport.offset + visibleRow, lastLine), + column: Math.max(column - 1 - viewport.left, 0), + }; +} + +/** + * Is a terminal's 1-based `row` inside the viewport? + * + * {@link cellAt} deliberately CLAMPS, which is what a drag needs — the pointer leaves the viewport all the time. A + * PRESS is different: clamping one that landed on the prompt, the status strip, or the live streaming region anchors + * the selection to the viewport's last visible line, so the user drags across text they never touched and copies it. + * A press outside the viewport starts nothing. + */ +export function containsRow(row: number, viewport: SelectionViewport): boolean { + const frameRow = row - 1; // frame row 0 IS terminal row 1 + return frameRow >= viewport.top && frameRow < viewport.top + viewport.height; +} + +/** + * The scroll motion a DRAG at `row` should trigger before its focus is mapped, or `undefined` when the pointer is + * comfortably inside the viewport. + * + * Without this a selection can never exceed one screenful: `cellAt` clamps the focus to the last visible line, so + * dragging further down just re-selects the same last row. Dragging to the top or bottom EDGE now scrolls a line and + * the focus is mapped against the new offset, exactly as a text editor does. + * + * KNOWN LIMIT, and it is the terminal's: DECSET 1002 reports motion only when the pointer enters a NEW CELL. Holding + * the pointer still at the edge sends nothing, so the scroll advances per movement rather than on a timer. Moving the + * pointer even one cell resumes it. + */ +export function dragScrollMotion( + row: number, + viewport: SelectionViewport, +): 'line-up' | 'line-down' | undefined { + if (viewport.height <= 0) return undefined; + const frameRow = row - 1; + if (frameRow <= viewport.top) return 'line-up'; + if (frameRow >= viewport.top + viewport.height - 1) return 'line-down'; + return undefined; +} + +/** What the surface should do with a mouse event. `'none'` ⇒ nothing changed (the event is still CONSUMED — a mouse + * report's raw bytes must never reach the prompt). */ +export type SelectionAction = + | { readonly kind: 'none' } + /** Replace the live selection (a press starts one collapsed; a drag extends it). */ + | { readonly kind: 'set'; readonly state: SelectionState } + /** Drop the highlight — a plain click, or a release with nothing selected. */ + | { readonly kind: 'clear' } + /** The drag ended on a real selection: copy `text`, and KEEP the highlight (as every terminal does). */ + | { readonly kind: 'copy'; readonly state: SelectionState }; + +/** + * Reduce one mouse event into the next selection. PURE, and shared by both surfaces so `relavium chat` and the + * in-Home chat can never disagree about what a drag does. + * + * The WHEEL is not handled here — it belongs to `reduceScroll`, and routing it through the selection would start a + * highlight on every notch. The caller checks `event.kind === 'wheel'` first. + */ +export function reduceSelection( + current: SelectionState | undefined, + event: MouseEvent, + viewport: SelectionViewport, +): SelectionAction { + switch (event.kind) { + case 'press': { + // Only the LEFT button selects. Middle pastes and right opens a menu in most emulators; neither should disturb + // a selection the user is about to copy. + if (event.button !== 'left') return { kind: 'none' }; + // A press on the prompt, the status strip, or the live streaming region is not a selection. `cellAt` would clamp + // it onto the viewport's last visible line and anchor there — the user would then drag across, and copy, text + // they never pressed on. + if (!containsRow(event.row, viewport)) return { kind: 'none' }; + const anchor = cellAt(event.row, event.column, viewport); + return { kind: 'set', state: { anchor, focus: anchor } }; // collapsed: a click alone highlights nothing + } + case 'drag': { + if (event.button !== 'left' || current === undefined) return { kind: 'none' }; + const focus = cellAt(event.row, event.column, viewport); + return { kind: 'set', state: { anchor: current.anchor, focus } }; + } + case 'release': { + if (current === undefined) return { kind: 'none' }; + // Only the release that ENDS a left gesture copies. A middle/right button coming up while a selection is live + // must leave it alone — otherwise every right-click re-emits the whole selection over OSC 52. `undefined` is a + // terminal still reporting the X10 "no button" code 3: honour the legacy meaning and end the gesture. + if (event.button === 'middle' || event.button === 'right') return { kind: 'none' }; + // A click that never moved: clear the previous highlight rather than copy a single cell. + if (isCollapsed(current)) return { kind: 'clear' }; + return { kind: 'copy', state: current }; + } + case 'wheel': + case 'other': + return { kind: 'none' }; + } +} + +/** + * The surface's side of one mouse gesture. Everything here is a capability the ink tree owns (React state, the scroll + * reducer, the clipboard); `routeMouseSelection` orchestrates them and stays testable without a terminal. + */ +export interface SelectionRouterPorts { + /** The viewport as it is RIGHT NOW. Called again after a scroll, so the focus maps against the new offset. */ + readonly geometry: () => SelectionViewport; + /** The live selection, read from a ref — a drag burst arrives in one tick, before React re-renders. */ + readonly current: () => SelectionState | undefined; + readonly setSelection: (state: SelectionState | undefined) => void; + /** Write the selection to the system clipboard. */ + readonly copy: (state: SelectionState) => void; + /** Scroll the transcript by one line (the surface applies `reduceScroll` against its live geometry). */ + readonly scrollBy: (motion: 'line-up' | 'line-down') => void; + /** Pin the transcript where it is, so a completing turn cannot move it under the pointer. Returns whether the view + * WAS following, which the caller stores for {@link SelectionRouterPorts.restoreFollow}. */ + readonly pauseFollow: () => void; + /** Undo a {@link SelectionRouterPorts.pauseFollow} that turned out to belong to a plain click, not a drag. */ + readonly restoreFollow: () => void; + /** Whether a LEFT-button gesture is currently in flight — set by a press that lands inside the viewport, cleared on + * its release. Distinct from a RETAINED selection (the highlight kept after a copy). Without it a stray release — + * a click on the prompt after a copy — re-copies the retained highlight over OSC 52. */ + readonly gestureActive: () => boolean; + readonly setGestureActive: (active: boolean) => void; +} + +/** + * Route one non-wheel mouse report into the selection. SHARED by `relavium chat` and the in-Home chat, which is the + * point: the two `useInput` handlers had byte-identical copies of this and would have drifted the moment either grew + * a behaviour (2.6.F Step 6f, Opus review). + * + * Beyond the pure {@link reduceSelection} it owns three things the reducer cannot, because they touch scroll state: + * + * 1. **Edge auto-scroll.** A drag on the viewport's first or last row scrolls a line BEFORE the focus is mapped, so a + * selection can grow past one screenful. Without it `cellAt` clamps and the user just re-selects the last row. + * 2. **Freeze auto-follow on press.** While following, every completed turn re-pins the view to the tail — which would + * slide the transcript out from under a drag and leave the highlight on different text than the pointer. + * 3. **Un-freeze on a plain click.** A click is a press+release with no movement. Pausing follow for it would silently + * stop the transcript from following the stream, with nothing on screen to explain why. The `clear` a collapsed + * release produces restores exactly what the press paused. + */ +export function routeMouseSelection(event: MouseEvent, ports: SelectionRouterPorts): void { + // A DRAG or RELEASE only acts while a gesture is in flight (a press that landed inside the viewport). Without this + // gate a retained highlight — kept on screen after a copy — is re-copied by the release of a stray click on the + // prompt or the status strip, and a drag whose press missed the viewport would resurrect it. A PRESS always runs: + // it is what STARTS a gesture. + if ((event.kind === 'drag' || event.kind === 'release') && !ports.gestureActive()) return; + + // The scroll must happen first: the focus is then mapped against the offset the user can actually see. + if (event.kind === 'drag' && event.button === 'left') { + const motion = dragScrollMotion(event.row, ports.geometry()); + if (motion !== undefined) ports.scrollBy(motion); + } + + const action = reduceSelection(ports.current(), event, ports.geometry()); + switch (action.kind) { + case 'none': + return; + case 'clear': + ports.setSelection(undefined); + ports.setGestureActive(false); + ports.restoreFollow(); // it was a click, not a drag + return; + case 'set': + ports.setSelection(action.state); + if (event.kind === 'press') { + ports.setGestureActive(true); // a valid press inside the viewport opens the gesture + ports.pauseFollow(); + } + return; + case 'copy': + ports.setSelection(action.state); // keep the highlight, as every terminal does + ports.setGestureActive(false); + ports.copy(action.state); + return; + } +} diff --git a/apps/cli/src/render/tui/session-view-model.test.ts b/apps/cli/src/render/tui/session-view-model.test.ts index bde8f102..99954439 100644 --- a/apps/cli/src/render/tui/session-view-model.test.ts +++ b/apps/cli/src/render/tui/session-view-model.test.ts @@ -11,6 +11,8 @@ import { MAX_WARNINGS, reduceSessionEvent, type SessionViewState, + FULLSCREEN_TRANSCRIPT_BOUND, + INLINE_TRANSCRIPT_BOUND, } from './session-view-model.js'; // --- A typed session-event factory: monotonic sequenceNumber + a 1ms-per-event clock (for durations). ---- @@ -801,3 +803,77 @@ describe('session-view-model — reasoning fold (EA6, 2.5.H)', () => { expect(cancelled.liveReasoning).toBe(''); }); }); + +/** + * THE CAPS-LIFT (2.6.F Step 6g, ADR-0068 Decision (c)). + * + * ADR-0068 exists because "a long response is clipped and its full text survives only in SQLite — unreachable by + * scrolling". Until Step 6g the bound was a CONSTANT and `reduceTurnCompleted` baked the transcript entry from the + * live region's 4000-char render budget — so the full-screen viewport, built to scroll a long answer, could never be + * given one. The Step-4b-3 amendment's "caps-lift" was a name collision: it shipped the per-entry wrap cache. + */ +describe('the transcript bake is bounded by the RENDERER, not by a constant', () => { + /** Reduce `evs` from a state seeded with an explicit renderer bound. */ + const reduceWithBound = ( + bound: number, + evs: readonly SessionStreamHandleEvent[], + ): SessionViewState => evs.reduce(reduceSessionEvent, initialSessionViewState(undefined, bound)); + + const oneTurn = (bound: number, chars: number): SessionViewState => { + const e = events(); + return reduceWithBound(bound, [e.turnStarted(), e.token('X'.repeat(chars)), e.turnCompleted()]); + }; + + const lastEntry = (state: SessionViewState): string => state.transcript.at(-1)?.text ?? ''; + + it('FULL-SCREEN keeps a 10 000-character answer whole — the defect this phase exists to fix', () => { + const state = oneTurn(FULLSCREEN_TRANSCRIPT_BOUND, 10_000); + expect(lastEntry(state)).toHaveLength(10_000); + expect(lastEntry(state).startsWith('…')).toBe(false); + }); + + it('INLINE keeps the historical trailing tail and its elision marker — byte-identical to before', () => { + const state = oneTurn(INLINE_TRANSCRIPT_BOUND, 10_000); + expect(lastEntry(state)).toHaveLength(MAX_LIVE_TOKEN_CHARS + 1); + expect(lastEntry(state).startsWith('…')).toBe(true); + }); + + it('the LIVE REGION stays bounded on BOTH renderers — it re-wraps every frame', () => { + // The two accumulators answer different questions. Unbounding `liveTokens` would re-segment 10 000 chars at 30fps. + for (const bound of [FULLSCREEN_TRANSCRIPT_BOUND, INLINE_TRANSCRIPT_BOUND]) { + const e = events(); + const state = reduceWithBound(bound, [e.turnStarted(), e.token('X'.repeat(10_000))]); + expect(state.liveTokens).toHaveLength(MAX_LIVE_TOKEN_CHARS); + expect(state.liveTokensTruncated).toBe(true); + expect(state.turnText).toHaveLength(bound === INLINE_TRANSCRIPT_BOUND ? 4000 : 10_000); + } + }); + + it('a TOOL CALL resets the kept text too — the entry is the segment after the last tool call', () => { + // Mirrors the engine's `result.text` and the persister. Forgetting to reset `turnText` here would bake the + // pre-tool-call prose into the final entry, which the durable record does not contain. + const e = events(); + const state = reduceWithBound(FULLSCREEN_TRANSCRIPT_BOUND, [ + e.turnStarted(), + e.token('BEFORE'), + e.toolCall('read_file'), + e.token('AFTER'), + e.turnCompleted(), + ]); + expect(lastEntry(state)).toBe('AFTER'); + }); + + it('a CANCELLED turn clears the kept text, so a later turn cannot inherit it', () => { + const e = events(); + const state = reduceWithBound(FULLSCREEN_TRANSCRIPT_BOUND, [ + e.turnStarted(), + e.token('partial'), + e.cancelled(), + ]); + expect(state.turnText).toBe(''); + }); + + it('the default bound is the INLINE one — a caller that forgets keeps today’s behaviour', () => { + expect(initialSessionViewState().transcriptBound).toBe(INLINE_TRANSCRIPT_BOUND); + }); +}); diff --git a/apps/cli/src/render/tui/session-view-model.ts b/apps/cli/src/render/tui/session-view-model.ts index 0bdb52c5..d72c92af 100644 --- a/apps/cli/src/render/tui/session-view-model.ts +++ b/apps/cli/src/render/tui/session-view-model.ts @@ -69,8 +69,23 @@ export interface SessionViewState { * ink `` tracks already-printed items by the array's length delta, so trimming the head would freeze * its cursor at the cap and silently stop rendering entries past it (see {@link appendTranscript}). */ readonly transcript: readonly TranscriptEntry[]; - /** The in-flight assistant text — reset per turn AND on each tool call, so it holds the active segment. */ + /** The in-flight assistant text — reset per turn AND on each tool call, so it holds the active segment. + * Bounded to {@link MAX_LIVE_TOKEN_CHARS} because it is what the LIVE REGION paints on every frame. */ readonly liveTokens: string; + /** The SAME active segment, bounded instead by {@link transcriptBound} — the text `reduceTurnCompleted` bakes into + * the transcript entry. Separate from {@link liveTokens} because the two answer different questions: how much text + * can we afford to re-wrap at 30fps (a few thousand chars), versus how much of the model's answer the user is + * allowed to keep (all of it, in the full-screen renderer). 2.6.F Step 6g, ADR-0068 Decision (c). */ + readonly turnText: string; + /** Whether {@link turnText}'s head was elided to stay within {@link transcriptBound}. */ + readonly turnTextTruncated: boolean; + /** + * The RENDERER-INJECTED bound on the transcript bake, per ADR-0068's Decision (c): "the full-screen renderer + * supplies an effectively-unbounded transcript that its viewport manages, while the inline fallback keeps a + * trailing-tail bound (it has no viewport)". It lives on the state so `reduceSessionEvent` stays a pure + * `(state, event)` function — the store sets it once, at construction. + */ + readonly transcriptBound: number; /** Whether the head of `liveTokens` has been elided to stay within {@link MAX_LIVE_TOKEN_CHARS}. The render shows * a leading elision marker so the scroll-out is VISIBLE, not a silent loss (2.5.H). Reset with `liveTokens`. */ readonly liveTokensTruncated: boolean; @@ -114,8 +129,24 @@ export interface SessionViewState { readonly warnings: readonly string[]; } -/** Trailing assistant token chars kept in the live region (older text scrolls out). */ +/** Trailing assistant token chars kept in the live region (older text scrolls out). A RENDER budget, not a content + * one: this buffer is re-wrapped every frame. */ export const MAX_LIVE_TOKEN_CHARS = 4000; + +/** The transcript bake bound for the INLINE renderer — the historical behaviour, kept byte-identical. It has no + * viewport, so a completed entry goes straight to ink `` and the terminal's own scrollback. */ +export const INLINE_TRANSCRIPT_BOUND = MAX_LIVE_TOKEN_CHARS; + +/** + * The transcript bake bound for the FULL-SCREEN renderer: effectively none. Its viewport windows the transcript, so a + * long answer costs a wrap (cached per entry) rather than a frame. + * + * This is the defect ADR-0068 was chartered to fix. Until 2.6.F Step 6g the bound was a CONSTANT (4000) and a 10 000- + * character answer landed in the transcript as 4 001 characters — its first 6 000 unscrollable, unselectable, and + * uncopyable, surviving only in SQLite. The Step-4b-3 amendment's "caps-lift" was a name collision: it delivered the + * per-entry wrap CACHE, not this. + */ +export const FULLSCREEN_TRANSCRIPT_BOUND = Number.MAX_SAFE_INTEGER; /** Tool-call annotations kept in the in-flight turn. */ export const MAX_LIVE_TOOL_CALLS = 16; /** Recent warnings kept for display. */ @@ -135,7 +166,10 @@ export interface SessionViewSeed { readonly turnCount?: number; } -export function initialSessionViewState(seed?: SessionViewSeed): SessionViewState { +export function initialSessionViewState( + seed?: SessionViewSeed, + transcriptBound: number = INLINE_TRANSCRIPT_BOUND, +): SessionViewState { // A resumed session (2.N) seeds the model but never re-emits session:started, so derive the context window here // too (ADR-0062 §7) — else a resumed session would show no fullness indicator until the (unrelated) next start. const seedWindow = seed?.model === undefined ? undefined : contextWindowForModel(seed.model); @@ -147,8 +181,11 @@ export function initialSessionViewState(seed?: SessionViewSeed): SessionViewStat status: 'idle', compacting: false, transcript: [], + transcriptBound, liveTokens: '', liveTokensTruncated: false, + turnText: '', + turnTextTruncated: false, liveReasoning: '', liveReasoningTruncated: false, liveToolCalls: [], @@ -300,6 +337,8 @@ export function reduceSessionEvent( compacting: false, liveTokens: '', liveTokensTruncated: false, + turnText: '', + turnTextTruncated: false, liveReasoning: '', liveReasoningTruncated: false, liveToolCalls: [], @@ -319,12 +358,19 @@ export function reduceSessionEvent( } case 'agent:token': { + // TWO accumulators over the same tokens, with different budgets. `liveTokens` is what the live region repaints + // every frame (cheap, bounded). `turnText` is what the transcript keeps (bounded only by what the RENDERER can + // hold — unbounded in the full-screen viewport). Before 2.6.F Step 6g there was only the first, and the + // transcript was baked from it: a long answer lost its head permanently. const appended = appendBounded(base.liveTokens, event.token, MAX_LIVE_TOKEN_CHARS); + const kept = appendBounded(base.turnText, event.token, base.transcriptBound); return { ...base, liveTokens: appended.text, // Sticky within the segment: once the head scrolled out, the elision marker stays until the buffer resets. liveTokensTruncated: base.liveTokensTruncated || appended.truncated, + turnText: kept.text, + turnTextTruncated: base.turnTextTruncated || kept.truncated, }; } @@ -335,6 +381,8 @@ export function reduceSessionEvent( ...base, liveTokens: '', liveTokensTruncated: false, + turnText: '', + turnTextTruncated: false, liveToolCalls: pushBounded( base.liveToolCalls, { id: `tc-${event.sequenceNumber}`, toolId: event.toolId, resolved: false }, @@ -370,6 +418,8 @@ export function reduceSessionEvent( compacting: false, liveTokens: '', liveTokensTruncated: false, + turnText: '', + turnTextTruncated: false, liveReasoning: '', liveReasoningTruncated: false, liveToolCalls: [], @@ -470,14 +520,15 @@ function reduceTurnCompleted(base: SessionViewState, event: TurnCompletedEvent): ? {} : { errorCode: event.error.code, errorMessage: event.error.message }), }; - const rawText = base.liveTokens; - // Preserve the elision marker into the FINALIZED entry (2.5.H). The live busy line prepends `…` at render, but a - // completed turn lands in ink `` (the terminal's permanent scrollback) rendered VERBATIM — so bake the - // marker into the display text here, else a truncated answer would silently lose its head the instant the turn - // completes (the very loss this makes visible). The view-model transcript is DISPLAY-ONLY and bounded to - // {@link MAX_LIVE_TOKEN_CHARS}; the durable session record (via the persister, from the raw events) keeps the FULL - // text — only this live terminal echo is short, so the marker signals "the live echo scrolled; see the full record". - const text = base.liveTokensTruncated ? `…${rawText}` : rawText; + // Bake from `turnText`, NOT `liveTokens`: the latter is the live region's 4000-char render budget, and baking from + // it is what clipped every long answer before 2.6.F Step 6g. `turnText` carries the renderer's own bound — none, in + // the full-screen viewport. + const rawText = base.turnText; + // Preserve the elision marker into the FINALIZED entry (2.5.H). A completed turn is rendered VERBATIM (ink + // `` inline, the viewport in full-screen), so bake the marker in here — else a truncated answer would + // silently lose its head the instant the turn completes. The durable session record (via the persister, from the + // raw events) always keeps the FULL text; the marker says "this echo was shortened; see the record". + const text = base.turnTextTruncated ? `…${rawText}` : rawText; // Append an entry for a turn that produced text, that ERRORED, OR that was ABORTED (EA7) — so an Esc during // an approval prompt (before any assistant text streamed) still leaves a visible trace ("aborted · …" via the // summary), confirming the abort took effect rather than silently clearing the live region. Guard on the RAW @@ -500,6 +551,8 @@ function reduceTurnCompleted(base: SessionViewState, event: TurnCompletedEvent): ...(event.tokensUsed.input > 0 ? { lastInputTokens: event.tokensUsed.input } : {}), liveTokens: '', liveTokensTruncated: false, + turnText: '', + turnTextTruncated: false, liveReasoning: '', liveReasoningTruncated: false, liveToolCalls: [], diff --git a/apps/cli/src/render/tui/synchronized-output.test.tsx b/apps/cli/src/render/tui/synchronized-output.test.tsx new file mode 100644 index 00000000..85ab6e11 --- /dev/null +++ b/apps/cli/src/render/tui/synchronized-output.test.tsx @@ -0,0 +1,146 @@ +import { Box, render, Text, type RenderOptions } from 'ink'; +import { Writable } from 'node:stream'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; + +import { FRAME_MS } from './tui-constants.js'; + +/** + * **DEC-2026 synchronized output** (2.6.F Step 5f, ADR-0068). + * + * ADR-0068's Decision says flicker "is avoided with terminal synchronized output (DEC 2026, `\x1b[?2026h/l`) framing, + * since `ink` does not emit it", and Step 5f was scheduled to build it — a `Proxy` over `process.stdout` wrapping every + * write. That claim is FALSE for `ink` 7. It ships `build/write-synchronized.js` (`bsu`/`esu` = `?2026h`/`?2026l`) and + * wraps every frame write in it, gated on `shouldSynchronize(stream, interactive)`. Building the Proxy would have + * NESTED the escapes — ink emits `bsu` as its own `write()` call — for no benefit at all. + * + * So there is nothing to implement, and everything to PIN. These tests are the regression guard: if an `ink` bump drops + * the framing, or a future render option turns it off, the frame flicker returns silently and only a human staring at a + * 60-row repaint would notice. They also pin the other half of the contract — that a NON-TTY / `--json` / CI path emits + * no `2026` byte at all, which the ADR's "byte-identical inline output" guarantee depends on. + * + * `ink` keeps one renderer per `stdout`, so every mount here gets its own stream. + */ + +const BSU = '\x1b[?2026h'; +const ESU = '\x1b[?2026l'; + +/** A capture stream that can pretend to be a TTY — `shouldSynchronize` reads `isTTY`. */ +interface CaptureStream extends Writable { + isTTY?: boolean; + columns?: number; + rows?: number; + written: string[]; +} + +function captureStdout(isTTY: boolean): CaptureStream { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(String(chunk)); + callback(); + }, + }) as CaptureStream; + if (isTTY) stream.isTTY = true; + stream.columns = 80; + stream.rows = 24; + stream.written = chunks; + return stream; +} + +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, FRAME_MS * 4)); + +/** Mount a one-line tree with RELAVIUM's production render options, rerender once, and return everything written. */ +async function frames( + isTTY: boolean, + // `Partial` (ink's own public option type) rather than a broad record — an unknown key is rejected at + // the call site, not silently ignored. + options: Partial = {}, +): Promise<{ all: string; chunks: string[] }> { + const stdout = captureStdout(isTTY); + const stdin = new PassThrough(); + const app = render( + + first + , + { + // ink only ever reads `isTTY` / `columns` / `on('resize')` and calls `write()` — a Writable is enough. The cast + // is the test double's, not production's. + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + exitOnCtrlC: false, + patchConsole: false, + // The SAME cadence both surfaces pin (`chat-ink.tsx`, `drive-home.tsx`, `ink-renderer.ts`). + maxFps: Math.max(1, Math.round(1000 / FRAME_MS)), + ...options, + }, + ); + await settle(); + app.rerender( + + second + , + ); + await settle(); + app.unmount(); + return { all: stdout.written.join(''), chunks: stdout.written }; +} + +const count = (haystack: string, needle: string): number => haystack.split(needle).length - 1; + +/** + * `shouldSynchronize(stream, interactive)` is `stream.isTTY && (interactive ?? !isInCi)`, and `is-in-ci` computes + * `isInCi` ONCE, at import, from `process.env`. A test therefore CANNOT un-set CI: deleting `process.env.CI` in a + * `beforeEach` is inert (the first version of this file did exactly that, and turned CI red — it was caught by the + * whole-phase review, not by CI, because it had not run there yet). + * + * So the TTY assertions pass `interactive: true` explicitly, which is the same branch production takes on a developer's + * terminal and bypasses the frozen constant. `interactiveDefault` below then pins the OTHER half — that ink really + * does gate on the env — without pretending the env is something it is not. + */ +const IN_CI = ['CI', 'CONTINUOUS_INTEGRATION'].some( + (k) => k in process.env && process.env[k] !== '0' && process.env[k] !== 'false', +); + +describe('ink 7 already frames every write in DEC-2026 synchronized output', () => { + it('a TTY gets a balanced BSU/ESU pair around each frame it writes', async () => { + const { all } = await frames(true, { interactive: true }); + expect(count(all, BSU)).toBeGreaterThan(0); + expect(count(all, ESU)).toBe(count(all, BSU)); // never a stranded BSU — that FREEZES the terminal + }); + + it('the BSU precedes the frame and the ESU follows it', async () => { + const { all } = await frames(true, { interactive: true }); + const open = all.indexOf(BSU); + const body = all.indexOf('first'); + const close = all.indexOf(ESU); + expect(open).toBeGreaterThanOrEqual(0); + expect(open).toBeLessThan(body); + expect(body).toBeLessThan(close); + }); + + it('a NON-TTY (a pipe, `--json`) emits no 2026 byte at all — the inline path stays byte-identical', async () => { + const { all } = await frames(false, { interactive: true }); + expect(all).not.toContain('\x1b[?2026'); + }); + + it('`interactive: false` disables it even on a TTY', async () => { + const { all } = await frames(true, { interactive: false }); + expect(all).not.toContain('\x1b[?2026'); + }); + + it('with `interactive` UNSET, ink gates on the CI environment — as production does', async () => { + // The one assertion that must hold in BOTH worlds. It is what makes the `interactive: true` tests above legitimate: + // production never passes the option, so this pins that the option we force is the one production would resolve. + const { all } = await frames(true); + expect(all.includes(BSU)).toBe(!IN_CI); + }); + + it('SANITY: the escapes are ink’s own, written as separate chunks — a stdout Proxy would have NESTED them', async () => { + // This is why Step 5f built nothing. A `Proxy` wrapping every `write()` in `?2026h`…`?2026l`, as the ADR planned, + // would have wrapped ink's own `bsu` write in a second pair. + const { chunks } = await frames(true, { interactive: true }); + expect(chunks).toContain(BSU); + expect(chunks).toContain(ESU); + }); +}); diff --git a/apps/cli/src/render/tui/transcript-viewport.test.tsx b/apps/cli/src/render/tui/transcript-viewport.test.tsx new file mode 100644 index 00000000..0ee189e7 --- /dev/null +++ b/apps/cli/src/render/tui/transcript-viewport.test.tsx @@ -0,0 +1,227 @@ +import './force-color.js'; +import { Box, Text } from 'ink'; +import { cleanup, render } from 'ink-testing-library'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { settleFrames } from './harness-util.js'; +import { INITIAL_SCROLL } from './scroll.js'; +import type { ViewportGeometry } from './scroll.js'; +import { TranscriptViewport } from './transcript-viewport.js'; +import type { DisplayLine } from './viewport.js'; + +/** + * `TranscriptViewport`'s reported geometry (2.6.F Step 6). `top` is the whole basis of mouse selection: a terminal + * mouse report carries an ABSOLUTE 1-based row, and turning it into a wrapped-transcript line index needs to know + * which frame row the viewport's first row occupies — + * + * displayLine = scrollOffset + (mouseRow - 1 - top) + * + * `measureElement` exposes only width/height, so `top` is summed from yoga up the `parentNode` chain. That is an + * assumption about ink's internals, so it is not assumed: each test below renders a REAL ink tree and asserts the + * reported `top` equals the frame's own line index for the viewport's first row. If a future ink bump changes how + * layout maps to the frame, these fail before anything user-visible does. + */ + +afterEach(cleanup); + +const lines = (n: number): DisplayLine[] => + Array.from({ length: n }, (_, i) => ({ text: `L${i}`, style: 'assistant' as const })); + +/** Mount the viewport inside a rows-bounded column, with `header` rows above and `live` rows below — the shape both + * surfaces use (the Home puts its management strip above; the prompt/overlays sit below). */ +function mountAt( + header: number, + live: number, + rows: number, +): { frame: () => string; geometry: () => ViewportGeometry | undefined } { + let geometry: ViewportGeometry | undefined; + const h = render( + + {Array.from({ length: header }, (_, i) => ( + HDR{i} + ))} + { + geometry = g; + }} + /> + {Array.from({ length: live }, (_, i) => ( + FTR{i} + ))} + , + ); + return { frame: () => h.lastFrame() ?? '', geometry: () => geometry }; +} + +/** The frame row at which the viewport's FIRST rendered line appears — the ground truth `top` must match. */ +const firstViewportRow = (frame: string): number => + frame.split('\n').findIndex((line) => /^L\d+/.test(line.trimEnd())); + +describe('TranscriptViewport — the reported frame offset', () => { + it('top === 0 when the viewport is the first child (the `relavium chat` shape)', async () => { + const { frame, geometry } = mountAt(0, 2, 12); + await settleFrames(); + expect(geometry()?.top).toBe(0); + expect(geometry()?.top).toBe(firstViewportRow(frame())); // ground truth: the frame itself + }); + + it('top counts the rows above it (the Home’s management strip)', async () => { + const { frame, geometry } = mountAt(3, 2, 12); + await settleFrames(); + expect(geometry()?.top).toBe(3); + expect(geometry()?.top).toBe(firstViewportRow(frame())); + }); + + it('top is UNCHANGED when the live region grows (an overlay opens) — only the height shrinks', async () => { + const small = mountAt(3, 2, 12); + await settleFrames(); + const tall = mountAt(3, 6, 12); + await settleFrames(); + + expect(tall.geometry()?.top).toBe(3); + expect(tall.geometry()?.top).toBe(firstViewportRow(tall.frame())); + // The overlay eats the viewport's height, never its position — a selection anchored before it opened stays valid. + expect(tall.geometry()?.height).toBeLessThan(small.geometry()?.height ?? 0); + }); + + it('SUMS the whole parentNode chain — a NESTED viewport’s own yoga top is not its frame row', async () => { + // The load-bearing case. In both surfaces today every ancestor happens to sit at offset 0, so reading the box's + // OWN `getComputedTop()` would coincidentally agree — and a break-verify proved the chain walk was untested. + // Here an intermediate Box is itself offset, so the box's own top (1) differs from its frame row (2). + let geometry: ViewportGeometry | undefined; + const h = render( + + HDR0 + + SUB0 + { + geometry = g; + }} + /> + + FTR0 + , + ); + await settleFrames(); + expect(geometry?.top).toBe(2); // 1 (header) + 1 (the nested Box's own first row) + expect(geometry?.top).toBe(firstViewportRow(h.lastFrame() ?? '')); + }); + + it('reports the measured width and the total wrapped line count alongside the position', async () => { + const { geometry } = mountAt(0, 2, 12); + await settleFrames(); + const g = geometry(); + expect(g?.totalLines).toBe(40); // every wrapped line, not just the visible window + expect(g?.height).toBe(10); // 12 rows − 2 live rows + expect(g?.width).toBeGreaterThan(0); + expect(g?.left).toBe(0); + }); +}); + +/** + * The selection HIGHLIGHT itself (2.6.F Step 6f, Opus review). Every other selection test asserts what lands on the + * CLIPBOARD; nothing asserted what the user SEES. An `inverse` attribute is invisible to a default vitest frame + * snapshot (chalk level 0), so a viewport that highlighted the wrong row — or no row — passed every suite. + * + * `./force-color.js` is imported first so chalk emits `ESC[7m` … `ESC[27m` and the frame can be read. + */ +describe('TranscriptViewport — the rendered highlight', () => { + const INVERSE_OPEN = '\x1b[7m'; + + /** The 0-based frame rows that contain an inverse run, and the text inside each run. */ + const inverseRuns = (frame: string): { row: number; text: string }[] => + frame.split('\n').flatMap((line, row) => { + // eslint-disable-next-line no-control-regex -- ESC (U+001B) IS the thing under test + const m = /\x1b\[7m(.*?)\x1b\[27m/.exec(line); + return m?.[1] === undefined ? [] : [{ row, text: m[1] }]; + }); + + const mount = async ( + scroll: { offset: number; following: boolean }, + selection: { start: { line: number; column: number }; end: { line: number; column: number } }, + ): Promise => { + const h = render( + + undefined} + /> + , + ); + await settleFrames(); + return h.lastFrame() ?? ''; + }; + + it('highlights the selected cells, and ONLY them', async () => { + const frame = await mount(INITIAL_SCROLL, { + start: { line: 36, column: 0 }, + end: { line: 36, column: 1 }, + }); + expect(inverseRuns(frame)).toEqual([{ row: 2, text: 'L3' }]); // rows 34..39 are shown; 36 is the third + }); + + it('a selection is placed by its ABSOLUTE line, so a scrolled viewport highlights the right row', async () => { + // The load-bearing case. `lineSpan(offset + index, …)` — drop the `offset` and the highlight lands on the row + // whose INDEX matches the line number, silently marking text the clipboard will not contain. + const frame = await mount( + { offset: 10, following: false }, + { + start: { line: 12, column: 0 }, + end: { line: 12, column: 2 }, + }, + ); + expect(inverseRuns(frame)).toEqual([{ row: 2, text: 'L12' }]); // offset 10 ⇒ line 12 is the third visible row + }); + + it('a multi-line selection highlights every row it spans, to the end of each inner row', async () => { + const frame = await mount( + { offset: 0, following: false }, + { + start: { line: 1, column: 1 }, + end: { line: 3, column: 0 }, + }, + ); + await settleFrames(); + expect(inverseRuns(frame)).toEqual([ + { row: 1, text: '1' }, // from column 1 to the end of `L1` + { row: 2, text: 'L2' }, // a whole inner row + { row: 3, text: 'L' }, // up to and including the inclusive end cell + ]); + }); + + it('the three pieces render IN ORDER — head, inverse span, tail', async () => { + // `inverseRuns` above reads only what is INSIDE the escape, so swapping `before` and `after` around the span + // stays green there. Assert the row's exact bytes: `L` then an inverse `1` then `2`. + const frame = await mount( + { offset: 10, following: false }, + { start: { line: 12, column: 1 }, end: { line: 12, column: 1 } }, + ); + const row = frame.split('\n')[2] ?? ''; + expect(row.trimEnd()).toBe('L\x1b[7m1\x1b[27m2'); + }); + + it('NO selection means no inverse anywhere — the attribute is not left on', async () => { + const h = render( + + undefined} + /> + , + ); + await settleFrames(); + expect(h.lastFrame() ?? '').not.toContain(INVERSE_OPEN); + }); +}); diff --git a/apps/cli/src/render/tui/transcript-viewport.tsx b/apps/cli/src/render/tui/transcript-viewport.tsx index 94257595..f71ac316 100644 --- a/apps/cli/src/render/tui/transcript-viewport.tsx +++ b/apps/cli/src/render/tui/transcript-viewport.tsx @@ -2,7 +2,8 @@ import { Box, Text, measureElement, type DOMElement } from 'ink'; import { useEffect, useRef, useState, type ComponentProps, type ReactElement } from 'react'; import { colorProps, dimProps } from './projection.js'; -import { effectiveOffset, type ScrollGeometry, type ScrollState } from './scroll.js'; +import { lineSpan, splitRow, type SelectionRange } from './selection.js'; +import { effectiveOffset, type ScrollState, type ViewportGeometry } from './scroll.js'; import { windowLines, type DisplayLine } from './viewport.js'; /** @@ -53,10 +54,34 @@ export interface TranscriptViewportProps { /** The owner-held scroll/auto-follow state (2.6.F Step 4b-2); the viewport derives the effective top-line offset * from it + its own measured height (tail while following, else the clamped frozen offset). */ readonly scroll: ScrollState; - /** Reports the live geometry (total wrapped lines + the measured visible-row height) UP after each measure, so the - * owner's scroll keymap can `reduceScroll` against the SAME geometry the viewport windows with (the height lives - * here, behind `measureElement`). Omitted ⇒ not lifted (a caller with no scroll keymap). */ - readonly onMeasure?: ((geom: ScrollGeometry) => void) | undefined; + /** The active mouse selection, in WRAPPED-transcript coordinates (2.6.F Step 6). Absent ⇒ nothing highlighted. The + * viewport owns no selection state: it renders what its owner reduced, exactly as it does for `scroll`. */ + readonly selection?: SelectionRange | undefined; + /** Reports the live geometry UP after each measure — total wrapped lines, the measured visible-row height, and the + * box's position in ink's frame — so the owner's scroll keymap can `reduceScroll` against the SAME geometry the + * viewport windows with (the height lives here, behind `measureElement`), and its MOUSE handler can turn a terminal + * row into a transcript line (Step 6). Omitted ⇒ not lifted (a caller with no scroll keymap). */ + readonly onMeasure?: ((geom: ViewportGeometry) => void) | undefined; +} + +/** + * The box's position in ink's FRAME, by summing yoga's computed offsets up the `parentNode` chain. `measureElement` + * only exposes width/height, and a mouse report carries an absolute terminal row — so this is the missing half of the + * mapping (2.6.F Step 6). + * + * MEASURED, not assumed: `transcript-viewport.test.tsx` renders a real ink tree and asserts this equals the frame's + * own line index for the viewport's first row, with and without a header above it and with the live region grown. + */ +function frameOffset(node: DOMElement): { top: number; left: number } { + let top = 0; + let left = 0; + let current: DOMElement | undefined = node; + while (current !== undefined) { + top += current.yogaNode?.getComputedTop() ?? 0; + left += current.yogaNode?.getComputedLeft() ?? 0; + current = current.parentNode; + } + return { top, left }; } export function TranscriptViewport(props: Readonly): ReactElement { @@ -75,7 +100,14 @@ export function TranscriptViewport(props: Readonly): Re const node = ref.current; if (node === null) return; const measured = measureElement(node); - props.onMeasure?.({ totalLines: props.lines.length, height: measured.height }); + const { top, left } = frameOffset(node); + props.onMeasure?.({ + totalLines: props.lines.length, + height: measured.height, + width: measured.width, + top, + left, + }); if (measured.height !== height) setHeight(measured.height); }); // The effective top-line offset from the scroll state: the tail (maxOffset) while following, else the clamped @@ -91,14 +123,31 @@ export function TranscriptViewport(props: Readonly): Re lines are common, so keys would COLLIDE (duplicate-key warning + wrong reuse), and every scroll notch would churn mounts instead of updating text. The usual index-key hazard (reordering items that own state) cannot arise here — nothing below holds state. */} - {visible.map((line, index) => ( - - {line.text === '' ? ' ' : line.text} - - ))} + {visible.map((line, index) => { + // A blank row renders as a single space so it still occupies a terminal row — and so a selection that spans it + // has something to highlight, exactly as the emulator's own selection would show. + const text = line.text === '' ? ' ' : line.text; + // `index` is the row on screen; the SELECTION lives in absolute wrapped-transcript coordinates, so translate. + const span = + props.selection === undefined ? undefined : lineSpan(offset + index, props.selection); + const segments = span === undefined ? undefined : splitRow(text, span); + return ( + + {segments === undefined ? ( + text + ) : ( + <> + {segments.before} + {segments.selected} + {segments.after} + + )} + + ); + })} ); } diff --git a/apps/cli/src/render/tui/tui-constants.ts b/apps/cli/src/render/tui/tui-constants.ts index c0f8deb8..f6f1bcf2 100644 --- a/apps/cli/src/render/tui/tui-constants.ts +++ b/apps/cli/src/render/tui/tui-constants.ts @@ -13,3 +13,8 @@ export const FRAME_MS = 80; * Home (the `endChat` path). The teardown still runs to completion in the background; only the UI/exit is bounded. */ export const FORCE_TEARDOWN_MS = 2000; + +/** How long the "✓ Copied" toast lingers after a copy-on-select, before it auto-dismisses (2.6.F Step 6i). Long + * enough to register, short enough not to linger over the next selection. It renders OUTSIDE the transcript, so it + * never re-wraps the lines the user just selected — the reason success was silent until now. */ +export const COPIED_TOAST_MS = 2000; diff --git a/apps/cli/src/render/tui/viewport.test.ts b/apps/cli/src/render/tui/viewport.test.ts index 7de56b8e..ab6f9c3f 100644 --- a/apps/cli/src/render/tui/viewport.test.ts +++ b/apps/cli/src/render/tui/viewport.test.ts @@ -1,5 +1,10 @@ +import stringWidth from 'string-width'; import { describe, expect, it } from 'vitest'; +/** What ink measures with (`ink/build/output.js` imports `string-width`). The tests compare against IT, not against + * `displayWidth`, so they still mean something if `displayWidth` is ever re-implemented. */ +const inkWidth = (s_: string): number => stringWidth(s_); + import { clampOffset, displayWidth, @@ -35,7 +40,9 @@ describe('displayWidth (2.6.F Step 4b, ADR-0068 §c)', () => { expect(displayWidth('​')).toBe(0); // zero-width space expect(displayWidth('a‍b')).toBe(2); // ZWJ contributes nothing expect(displayWidth('')).toBe(0); // BOM - expect(displayWidth('🇹️')).toBe(2); // regional indicator (wide) + variation selector (0) + // A LONE regional indicator is not an RGI emoji (a flag needs a pair), so `string-width` — and therefore ink — + // gives it the East-Asian width of U+1F1F9, which is Neutral: 1. The old hand-rolled table said 2. + expect(displayWidth('🇹️')).toBe(1); }); it('counts control chars as zero (defensive — they are sanitized before display)', () => { @@ -180,3 +187,126 @@ describe('windowLines', () => { expect(windowLines(lines, 0, 0)).toEqual([]); }); }); + +/** + * `displayWidth` IS ink's width function (2.6.F Step 6g, ADR-0069). The load-bearing invariant is + * **1 DisplayLine == 1 real terminal row**: a line we think fits must fit ink's own re-measure, or ink re-wraps that + * `` to two rows, `overflowY: hidden` clips the tail, and every scroll offset and mouse row→line mapping below + * it shifts by one. + * + * The hand-rolled table this replaced claimed to "never under-count vs ink". Measured across the BMP and SMP it + * under-counted 8 539 code points, all East-Asian Wide. These are the biggest families. + */ +describe('displayWidth agrees with the terminal on the wide scripts the old table missed', () => { + it.each([ + ['Tangut', '\u{17000}', 2], // 7 382 code points, every one counted as 1 before + ['Tangut components', '\u{18800}', 2], + ['Yijing hexagram', '\u{4DC0}', 2], + ['Kana Supplement', '\u{1B000}', 2], + ['Hangul Jamo Extended-A', '\u{A960}', 2], + ['Vertical form', '\u{FE10}', 2], + ['Small form variant', '\u{FE50}', 2], + ['Angle bracket', '\u{2329}', 2], + ['Tai Xuan Jing symbol', '\u{1D300}', 2], + ])('%s is two cells', (_name, char, cells) => { + expect(displayWidth(char)).toBe(cells); + }); + + it('a Tangut line fills twice the cells the old table budgeted for it', () => { + // 40 Tangut ideographs = 80 cells. The old table said 40, so the line was wrapped at 80 columns, rendered at 160, + // and every DisplayLine after it was one real row out of step. + const line = '\u{17000}'.repeat(40); + expect(displayWidth(line)).toBe(80); + expect(wrapLogicalLine(line, 80)).toHaveLength(1); + expect(wrapLogicalLine(line, 40)).toHaveLength(2); + }); + + it('NEVER under-counts a single code point — the invariant the whole viewport rests on', () => { + // Exhaustive over the assigned planes a transcript can realistically carry: ~196 000 code points. Structural + // today (`displayWidth` IS `inkWidth`), and the guard the moment anyone re-hand-rolls it. + // + // Two things it deliberately does NOT do, both learned from CI: + // - it does not call `expect` per code point. Vitest's `expect` overhead alone took ~7 s on a runner. + // - it does not segment each code point first. A single code point is ALWAYS exactly one grapheme cluster + // (verified over the same range), so that guard was dead weight costing seconds. + // What remains is inherently a long sweep, so it carries an explicit timeout rather than sitting a hair under + // the default and flaking on a slow runner. + const underCounted: string[] = []; + for (let cp = 0x20; cp <= 0x2ffff; cp += 1) { + if (cp >= 0xd800 && cp <= 0xdfff) continue; // lone surrogates are not text + const ch = String.fromCodePoint(cp); + if (displayWidth(ch) < inkWidth(ch) && underCounted.length < 10) { + underCounted.push(`U+${cp.toString(16).toUpperCase()}`); + } + } + expect(underCounted).toEqual([]); + }, 30_000); + + it('a wrapped line never exceeds `cols` by ink’s own measure', () => { + const messy = '日本語です a👍b \u{17000}\u{17001} café \u{A960}\u{1160} ❤️ 1️⃣ 🇹🇷 end'; + for (const cols of [10, 20, 37, 80]) { + for (const row of wrapLogicalLine(messy, cols)) { + expect(inkWidth(row), `cols=${cols} row=${JSON.stringify(row)}`).toBeLessThanOrEqual(cols); + } + } + }); +}); + +/** + * `wrapLogicalLine`'s ASCII FAST PATH (2.6.F Step 6g). `Intl.Segmenter` costs ~32 ms on a 200 000-character line, and + * the caps-lift made such a line reachable — a long answer now enters the viewport whole instead of being clipped to + * 4 000 characters. Printable ASCII needs no segmentation: every character is its own cluster and every cluster is one + * cell. The risk is that the two paths DISAGREE, so they are compared directly. + */ +describe('wrapLogicalLine — the ASCII fast path is the general path', () => { + /** The general path, expressed independently, so this is a comparison and not a tautology. */ + const generalPath = (line: string, cols: number): string[] => { + if (cols <= 0 || line === '') return [line]; + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + const rows: string[] = []; + let current = ''; + let width = 0; + for (const { segment } of segmenter.segment(line)) { + const w = displayWidth(segment); + if (width + w > cols && current !== '') { + rows.push(current); + current = ''; + width = 0; + } + current += segment; + width += w; + } + rows.push(current); + return rows; + }; + + it('agrees with the general path on 3 200 (line, cols) pairs of printable ASCII', () => { + const alphabet = ' !"#$%&()*+,-./0123456789:;<=>?@ABCabc~'; + for (let trial = 0; trial < 400; trial += 1) { + const length = 1 + ((trial * 7) % 60); + let line = ''; + for (let i = 0; i < length; i += 1) { + line += alphabet[(trial * 13 + i * 5) % alphabet.length]; + } + for (const cols of [1, 2, 3, 7, 20, 79, 80, 200]) { + expect(wrapLogicalLine(line, cols), `${JSON.stringify(line)} @ ${String(cols)}`).toEqual( + generalPath(line, cols), + ); + } + } + }); + + it('a NON-ASCII line takes the general path — one wide glyph is enough to disqualify it', () => { + expect(wrapLogicalLine('ab日', 2)).toEqual(['ab', '日']); // fixed-width chunking would give ['ab', '日']… by luck + expect(wrapLogicalLine('a日b', 2)).toEqual(['a', '日', 'b']); // …here it would give ['a日', 'b'] and overflow + }); + + it('a TAB or an ESC is not printable ASCII, so it does not take the fast path', () => { + // 0x09 and 0x1b are outside [0x20,0x7e] and are ZERO-width, so fixed-width chunking would break the row. Both are + // stripped upstream; the guard is what keeps the paths honest. The width must be small enough for the zero-width + // control to matter — at `cols = 80` both paths agree by accident, which a break-verify proved. + for (const line of ['a\tbc', 'a\x1bbc', '\tabc']) { + expect(wrapLogicalLine(line, 2), JSON.stringify(line)).toEqual(generalPath(line, 2)); + } + }); +}); diff --git a/apps/cli/src/render/tui/viewport.ts b/apps/cli/src/render/tui/viewport.ts index 9a525668..a61c9262 100644 --- a/apps/cli/src/render/tui/viewport.ts +++ b/apps/cli/src/render/tui/viewport.ts @@ -1,3 +1,5 @@ +import stringWidth from 'string-width'; + /** * Pure line-wrapping + windowing math for the full-screen alt-screen transcript **viewport** (2.6.F Step 4b, * [ADR-0068](../../../../docs/decisions/0068-full-screen-tui-renderer-ink7-harness.md) §c). The alt buffer has no @@ -7,17 +9,18 @@ * counts RENDERED terminal rows, not logical lines) and **offset windowing** — kept pure so they are exhaustively * unit-testable with no ink mount. * - * Display width is a PRAGMATIC hand-roll (wide/emoji = 2, zero-width/combining = 0, else 1) — the repo deliberately - * avoids a `string-width` runtime dependency (see chat-projection.ts). The load-bearing invariant is **1 DisplayLine - * == 1 real terminal row**: each wrapped line must fit ink's own re-measure of it. That holds as long as - * `displayWidth` never UNDER-counts relative to ink (which uses the full Unicode tables via `string-width`) — an - * under-count makes a DisplayLine wider than `cols`, so ink re-wraps that `` to 2 real rows and the viewport's - * `overflowY: hidden` clips the tail. OVER-counting is the safe direction (the wrap just breaks a cell early → a - * slightly narrower line). Today's condensed table over-counts a ZWJ emoji sequence (safe) but can under-count a - * composed emoji-presentation cluster (a VS16 `❤️` / an enclosing keycap `1️⃣`) — cosmetic at Step 4b-1 (tail-follow - * is a row-INDEX with no persisted offset, so nothing corrupts), but the 1:1 invariant becomes load-bearing for the - * Step-4b-2 persisted-offset scroll, where the table should be hardened (grapheme-aware, e.g. `Intl.Segmenter`) so it - * never under-counts. Tracked as a Step-4b-2 obligation. + * The load-bearing invariant is **1 DisplayLine == 1 real terminal row**: each wrapped line must fit ink's own + * re-measure of it. An under-count makes a DisplayLine wider than `cols`, so ink re-wraps that `` to 2 real rows, + * the viewport's `overflowY: hidden` clips the tail, and every scroll offset and mouse row→line mapping below it + * shifts. Which is why {@link displayWidth} is now the SAME function ink measures with — `string-width` — rather than a + * hand-rolled table (2.6.F Step 6g, [ADR-0069](../../../../docs/decisions/0069-string-width-for-the-cli-renderer.md)). + * + * The table it replaces claimed to "never under-count vs ink". Measured across the BMP and SMP it under-counted 8 539 + * code points — Tangut (7 382 of them), Yijing hexagrams, Kana Supplement, Hangul Jamo Extended-A, vertical forms — + * all East-Asian **Wide**, all rendered as 2 cells by every terminal and by ink, and all counted as 1 by us. It had + * already been patched twice by review (BMP emoji presentation in Step 4b-2), and its own docstring recorded the + * hardening as an open obligation. A Unicode width table is not Relavium's core, it rots with every Unicode release, + * and getting it wrong corrupts the renderer. */ /** The per-entry render style a display line inherits from its transcript entry (mirrors `TranscriptLine`'s colors: @@ -33,134 +36,138 @@ export interface DisplayLine { /** * Grapheme segmenter (Node 22 has `Intl.Segmenter`, ADR-0067 floor) — so a composed glyph (an emoji ZWJ sequence, a * VS16 emoji, an enclosing keycap, a regional-indicator flag, a base + combining marks) is measured + wrapped as ONE - * unit, never split mid-cluster and never mis-summed per code point. Created once (constructing one is not cheap). + * unit, never split mid-cluster. Created once (constructing one is not cheap). + * + * `string-width` segments with the same defaults internally, so a per-cluster sum equals `stringWidth` of the whole + * string by construction — the two can never disagree about where a line ends. */ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); +/** Printable ASCII: one code unit, one cluster, one cell. `string-width`'s own fast-path predicate. */ +const ASCII_ONLY = /^[\u0020-\u007e]*$/; + /** - * Terminal display width of ONE grapheme cluster in cells. A cluster renders as a single glyph: an emoji-presentation - * cluster (one that carries VS16 `U+FE0F` or the enclosing keycap `U+20E3` OVER a base, or whose base is a wide/emoji - * code point) is 2 cells; a base + combining marks is the base's width; a zero-width-only cluster is 0. Biased so it - * NEVER UNDER-counts vs a terminal (an under-count would make a wrapped line wider than `cols`, so ink re-wraps it to 2 - * real rows and the viewport clips the tail — the load-bearing 1-DisplayLine-==-1-real-row invariant, ADR-0068 §c). + * Terminal display width of ONE grapheme cluster in cells, as ink measures it. + * + * `countAnsiEscapeCodes: true` skips `string-width`'s `strip-ansi` pass. The text reaching here is already stripped of + * ANSI/C0/C1 by `sanitizeInline`, and skipping it keeps the hot path (one cluster per character of a wrapped line) off + * a regex it does not need. */ function graphemeWidth(cluster: string): number { - let base = 0; - let emojiPresentation = false; - for (const ch of cluster) { - const cp = ch.codePointAt(0) ?? 0; - if (cp === 0xfe0f || cp === 0x20e3) emojiPresentation = true; // VS16 / enclosing keycap ⇒ forces 2 cells (on a base) - const w = codePointWidth(cp); - if (w > base) base = w; // the widest constituent (a wide/emoji base survives its combining/joiner code points) - } - // The VS16/keycap width-2 override applies only when there IS a base: a DEGENERATE lone selector cluster (a stray - // `U+FE0F`/`U+20E3` that `Intl.Segmenter` returns as its own cluster with nothing to attach to) renders as 0 cells, - // like ink — forcing it to 2 would over-count that cluster (Step-4b-2 Sonnet review). - return emojiPresentation && base > 0 ? 2 : base; + return stringWidth(cluster, { countAnsiEscapeCodes: true }); } /** - * Terminal display width of a string in cells — the sum over its GRAPHEME CLUSTERS, so an astral emoji, a ZWJ - * sequence, a keycap, or a flag each count once (2), and a base + combining marks counts as the base. See - * {@link graphemeWidth}. Zero-width-only content is 0. Never under-counts vs ink (the safe direction). + * Terminal display width of a string in cells — exactly what ink's `Output` computes for the same string, because it + * is the same function. An emoji ZWJ sequence, a keycap, a flag each count 2; a base + combining marks counts as the + * base; a zero-width-only cluster is 0. `string-width` has an ASCII fast path, so the common line costs one regex. */ export function displayWidth(str: string): number { - let width = 0; - for (const { segment } of graphemeSegmenter.segment(str)) { - width += graphemeWidth(segment); - } - return width; + return stringWidth(str, { countAnsiEscapeCodes: true }); } -function codePointWidth(cp: number): number { - // Zero-width: C0/C1 controls (sanitized upstream, defensive here), combining marks, and the invisible joiners. - if (cp === 0) return 0; - if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0; // control - if ( - (cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks - (cp >= 0x0483 && cp <= 0x0489) || // combining cyrillic - (cp >= 0x0591 && cp <= 0x05bd) || // combining hebrew - (cp >= 0x0610 && cp <= 0x061a) || // combining arabic - (cp >= 0x064b && cp <= 0x065f) || // combining arabic marks - (cp >= 0x1ab0 && cp <= 0x1aff) || // combining diacritical marks extended - (cp >= 0x1dc0 && cp <= 0x1dff) || // combining diacritical marks supplement - (cp >= 0x20d0 && cp <= 0x20ff) || // combining marks for symbols - (cp >= 0xfe20 && cp <= 0xfe2f) || // combining half marks - cp === 0x200b || // zero-width space - cp === 0x200c || // zero-width non-joiner - cp === 0x200d || // zero-width joiner (ZWJ) - cp === 0xfeff || // BOM / zero-width no-break space - (cp >= 0xfe00 && cp <= 0xfe0f) // variation selectors - ) { - return 0; +/** Which of the three pieces a grapheme cluster belongs to, relative to a display-column span. */ +type ColumnPiece = 'before' | 'selected' | 'after'; + +/** + * Walk `str`'s grapheme clusters and hand each one to `emit` exactly ONCE, tagged with the piece of the display-column + * span `[startColumn, endColumn)` it belongs to. Both {@link sliceDisplayColumns} and {@link partitionDisplayColumns} + * are thin readers of this walk, which is the whole point: they used to encode the same membership rule twice and + * DRIFTED (Step-6 Opus review). Two invariants are now structural rather than asserted — + * + * `partitionDisplayColumns(s, a, b).selected === sliceDisplayColumns(s, a, b)` (highlight === clipboard) + * `before + selected + after === str` (nothing lost, nothing moved) + * + * MEMBERSHIP. A cluster with width belongs to the span when its cell range INTERSECTS it — clicking either half of a + * wide character takes the whole character, exactly as every terminal's own selection does. + * + * A ZERO-WIDTH cluster (a combining mark, a ZWJ, a lone variation selector) occupies no cell, so no click can ever + * land on it. It rides the cluster it modifies: the one BEFORE it. A cluster that leads the string modifies nothing, + * has no cell of its own, and previously fell through both tests into `after` — which physically MOVED it past its + * base (`'́ab'` rendered as `'ab́'`) and dropped it from the copy. Such a leading run is held back and + * emitted with the first cluster that does have a cell, so it stays where the user sees it. A string with no cells at + * all is entirely `before`: there is nothing to select. + */ +/** Which piece a cell-bearing cluster at `column` (width `width`) belongs to. `empty` ⇒ a degenerate span, so + * nothing is `selected` however it straddles. Extracted so {@link walkDisplayColumns} is not a nested ternary. */ +function classifyColumn( + column: number, + width: number, + startColumn: number, + endColumn: number, + empty: boolean, +): ColumnPiece { + if (!empty && column < endColumn && column + width > startColumn) return 'selected'; + if (column < startColumn) return 'before'; + return 'after'; +} + +function walkDisplayColumns( + str: string, + startColumn: number, + endColumn: number, + emit: (segment: string, piece: ColumnPiece) => void, +): void { + const empty = endColumn <= startColumn; // a degenerate span selects nothing, whatever it straddles + let column = 0; + let leading = ''; // zero-width clusters seen before any cell — no cluster to ride yet + let previous: ColumnPiece | undefined; + + for (const { segment } of graphemeSegmenter.segment(str)) { + const width = graphemeWidth(segment); + if (width === 0) { + if (previous === undefined) leading += segment; + else emit(segment, previous); + continue; + } + const piece = classifyColumn(column, width, startColumn, endColumn, empty); + if (leading !== '') { + emit(leading, piece); + leading = ''; + } + emit(segment, piece); + previous = piece; + column += width; } - return isWide(cp) ? 2 : 1; + if (leading !== '') emit(leading, 'before'); // the whole string is zero-width: no cell, nothing selectable } -/** East-Asian Wide / Fullwidth + emoji ranges → double-width. A condensed, pragmatic subset of the Unicode tables. */ -function isWide(cp: number): boolean { - return ( - (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo - (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals / Kangxi / CJK symbols - (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK symbols - (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A - (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs - (cp >= 0xa000 && cp <= 0xa4cf) || // Yi - (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables - (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs - (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compatibility Forms - (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth Forms - (cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs - isBmpEmojiPresentation(cp) || // BMP default-emoji-presentation singletons (✅⭐⚡… render 2 without VS16) - (cp >= 0x1f000 && cp <= 0x1f2ff) || // Mahjong / Domino / Playing cards / enclosed - (cp >= 0x1f300 && cp <= 0x1faff) || // emoji + symbols + pictographs - (cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext B+ (astral wide) - ); +/** + * Slice `str` to the DISPLAY-COLUMN half-open range `[startColumn, endColumn)` — the width-aware counterpart of + * `String.slice`, for mouse selection (2.6.F Step 6). A terminal reports the CELL a click landed on, not a character + * index, and a grapheme cluster may occupy 2 cells (CJK, emoji) or 0 (a combining mark). + * + * This is what lands on the CLIPBOARD. See {@link walkDisplayColumns} for the membership rule. + */ +export function sliceDisplayColumns(str: string, startColumn: number, endColumn: number): string { + let out = ''; + walkDisplayColumns(str, startColumn, endColumn, (segment, piece) => { + if (piece === 'selected') out += segment; + }); + return out; } /** - * BMP code points with `Emoji_Presentation=Yes` (they render as a 2-cell emoji by DEFAULT, without a VS16 selector) - * — Misc Symbols/Dingbats/Misc-Symbols-and-Arrows emoji (✅ ❌ ⭐ ⚡ ✨ ❗ ➕ ⌚ ⏰ ⛄ ✋ …). The per-code-point table - * skips these (they are outside the astral 0x1F3xx emoji blocks), so `displayWidth` UNDER-counted them (1) vs a - * terminal's 2 — the load-bearing under-count the 4b-2 scroll exposes (Step-4b-2 Opus review). The canonical Unicode - * Emoji_Presentation set for the BMP: + * Partition `str` into the three pieces around the display-column span `[startColumn, endColumn)`: the head before it, + * the span itself, and the tail after. Each grapheme cluster lands in EXACTLY ONE piece. + * + * This is what the viewport RENDERS (`before`, an inverse `selected`, `after`). It cannot disagree with + * {@link sliceDisplayColumns} because both read the one {@link walkDisplayColumns}. */ -function isBmpEmojiPresentation(cp: number): boolean { - return ( - (cp >= 0x231a && cp <= 0x231b) || - (cp >= 0x23e9 && cp <= 0x23ec) || - cp === 0x23f0 || - cp === 0x23f3 || - (cp >= 0x25fd && cp <= 0x25fe) || - (cp >= 0x2614 && cp <= 0x2615) || - (cp >= 0x2648 && cp <= 0x2653) || - cp === 0x267f || - cp === 0x2693 || - cp === 0x26a1 || - (cp >= 0x26aa && cp <= 0x26ab) || - (cp >= 0x26bd && cp <= 0x26be) || - (cp >= 0x26c4 && cp <= 0x26c5) || - cp === 0x26ce || - cp === 0x26d4 || - cp === 0x26ea || - (cp >= 0x26f2 && cp <= 0x26f3) || - cp === 0x26f5 || - cp === 0x26fa || - cp === 0x26fd || - cp === 0x2705 || - (cp >= 0x270a && cp <= 0x270b) || - cp === 0x2728 || - cp === 0x274c || - cp === 0x274e || - (cp >= 0x2753 && cp <= 0x2755) || - cp === 0x2757 || - (cp >= 0x2795 && cp <= 0x2797) || - cp === 0x27b0 || - cp === 0x27bf || - (cp >= 0x2b1b && cp <= 0x2b1c) || - cp === 0x2b50 || - cp === 0x2b55 - ); +export function partitionDisplayColumns( + str: string, + startColumn: number, + endColumn: number, +): { before: string; selected: string; after: string } { + let before = ''; + let selected = ''; + let after = ''; + walkDisplayColumns(str, startColumn, endColumn, (segment, piece) => { + if (piece === 'selected') selected += segment; + else if (piece === 'before') before += segment; + else after += segment; + }); + return { before, selected, after }; } /** @@ -172,6 +179,18 @@ function isBmpEmojiPresentation(cp: number): boolean { */ export function wrapLogicalLine(line: string, cols: number): string[] { if (cols <= 0 || line === '') return [line]; + + // FAST PATH: printable ASCII. Every character is its own grapheme cluster and every cluster is one cell, so the + // wrap is a fixed-width chunking — no segmentation, no width lookup. This is the same predicate `string-width` uses + // to short-circuit, and it matters: `Intl.Segmenter` costs ~32ms on a 200 000-character line, which the Step-6g + // caps-lift made reachable (a long answer now enters the viewport whole instead of being clipped to 4 000 chars). + // English prose and code take this path; the general path below is unchanged. + if (ASCII_ONLY.test(line)) { + const rows: string[] = []; + for (let i = 0; i < line.length; i += cols) rows.push(line.slice(i, i + cols)); + return rows; + } + const rows: string[] = []; let current = ''; let currentWidth = 0; diff --git a/docs/decisions/0068-full-screen-tui-renderer-ink7-harness.md b/docs/decisions/0068-full-screen-tui-renderer-ink7-harness.md index a12c4819..9b564c8f 100644 --- a/docs/decisions/0068-full-screen-tui-renderer-ink7-harness.md +++ b/docs/decisions/0068-full-screen-tui-renderer-ink7-harness.md @@ -157,7 +157,7 @@ perf thresholds for the full-screen frame loop. > render-heavy — a per-frame wall-clock budget is notoriously CI-flaky on shared runners, so asserting it now would > buy flakiness, not signal. Frame assertions poll (`waitFor`) rather than assume a single macrotask yield, because > React 19's commit can be deferred past one yield under parallel-file CPU contention. - +> > **Amended 2026-07-09 (Step 4a landed).** The alt-screen renderer's **lifecycle substrate** shipped: > `resolveRenderMode` (`apps/cli/src/render/render-mode.ts`) resolving `alt | inline` with precedence > machine/non-TTY → `--no-alt-screen` flag → `[preferences].alt_screen` → phase default (`DEFAULT_ALT_SCREEN`, the @@ -171,7 +171,7 @@ perf thresholds for the full-screen frame loop. > output**, the **branded banner**, and **mouse-wheel** scroll — after which `DEFAULT_ALT_SCREEN` flips to `true` > (alt-on with the `--no-alt-screen` opt-out). Until then, enabling `alt_screen` is a preview (no scrollback, no > hatches — see config-spec.md's caveat). - +> > **Amended 2026-07-09 (Step 4b-1 landed).** The transcript **viewport** shipped for BOTH surfaces (the bare Home + > `relavium chat`): ink's `` is replaced on the alt screen by a `TranscriptViewport` that width-wraps the > transcript to display lines (`viewport.ts` `displayWidth`/`wrapText` — the row-measurement §c requires), renders @@ -184,7 +184,7 @@ perf thresholds for the full-screen frame loop. > renderer-injected unbounded transcript + true virtualization) + `DEFAULT_ALT_SCREEN`→`true`. **Deferred to Step 5**: > DEC-2026 synchronized output, the branded banner, the `[`/`v` escape hatches, and mouse-wheel. A known 4b-1 limit: > a live region taller than the terminal has no scrollback (deferred-tasks.md). - +> > **Amended 2026-07-09 (Step 4b-2 landed).** The **scroll / auto-follow** state machine shipped for both surfaces: > a single `following` boolean (default true) pinned to the tail; **PgUp/PgDn** page + **Ctrl+Home/Ctrl+End** jump > to top/bottom (`scroll.ts` `reduceScroll`/`scrollMotionForKey`, shared by both surfaces); any upward scroll pauses @@ -197,7 +197,7 @@ perf thresholds for the full-screen frame loop. > layout, and a force-follow would only yank the user off history they are reading. **Deferred to Step 4b-3**: the > caps-lift + true virtualization + `DEFAULT_ALT_SCREEN`→`true`; **Step 5**: mouse-wheel scroll (reuses this same > viewport scroll + follow actions). Resolves the §e force-scroll deferred item by design. - +> > **Amended 2026-07-09 (Step 4b-2 Sonnet-review fold).** Three verified fixes on top of the 4b-2 landing: **(a)** the > Home's scroll-reset effect now keys on the **session OBJECT identity** (`state.session`), not the durable `sessionId` > string — a `/models` reseat deliberately PRESERVES the id across the swap, so an id-keyed effect missed it and left a @@ -213,7 +213,7 @@ perf thresholds for the full-screen frame loop. > overlay, never the transcript), and the **paused-mid-page boundary** at the mount (a partial page-down does not > resume follow). The canonical render-mode docs (home.md / chat-session.md / config-spec.md `alt_screen` caveat) were > reconciled to reflect that scroll-back + auto-follow shipped at 4b-2. - +> > **Amended 2026-07-09 (Step 4b-3 landed — caps-lift, flicker hoist, DEFAULT flip).** The full-screen renderer became > first-class and is now the **default on a TTY** (`DEFAULT_ALT_SCREEN = true`): a bare `relavium` / `relavium chat` > opens full-screen; `--no-alt-screen` (per invocation) or `[preferences].alt_screen = false` (durable) opts back into @@ -236,7 +236,7 @@ perf thresholds for the full-screen frame loop. > exit paths are unit-tested (`withHoistedAltScreen`, 6 cases) around injected write/lifecycle seams; the real-TTY > signal paths (double-Ctrl-C, `kill -TERM`/`-HUP`) are a manual PR-time check. **Still pending (Step 5):** the `[`-dump > / `v`-open-in-`$EDITOR` copy-and-search hatches, mouse-wheel scroll, the branded banner, and the a11y note. - +> > **Amended 2026-07-09 (Step 4b-3 Opus + Sonnet review folds).** SUPERSEDES the caps-lift mechanism in the note above: > the bounded per-logical-line LRU (keyed on `(cols, line)`) thrashed to a 0% hit rate once a session exceeded the > cache size (a sequential re-scan from the head evicts the very lines it is about to re-read), so it was replaced by a @@ -252,9 +252,212 @@ perf thresholds for the full-screen frame loop. > needs threading the instance unmount before the alt-exit), and session-side raw-`io` notices that fire mid-session — > the budget-cap **warning** and the `/clear`/reseat **MCP-skipped** diagnostic — still land on the alt buffer and are > lost in alt mode (they must route through the CURRENT session's view-store `notice`, an architectural follow-up). +> +> **Amended 2026-07-10 (Step 5 landed — a11y note, mouse-wheel, the copy-and-search hatches, the mouse opt-out).** +> Step 5 is complete. Four clarifications; §e's *mechanisms* are unchanged, one of its *defaults* is not. +> +> **(a) The `[` / `v` escape hatches ship as PALETTE COMMANDS, not bare keys** — **`/scrollback`** (dump the transcript +> into the terminal's native scrollback; press Enter to return) and **`/edit`** (open it read-only in `$EDITOR`; +> edits are never read back). A bare `[` or `v` collides with the text prompt, where they are far commoner than `/`. +> Both are `availableIn: ['chat']` — the bare Home has no transcript. They are ONE implementation shared by the +> standalone chat and the in-Home chat, dispatched through the existing slash path with no render-layer interception: +> unlike `/models` they open no React overlay, so a `SuspendPort` (the repo's first React→core capability bridge) +> carries ink's `suspendTerminal` out of the tree to the command context. +> +> **(b) ink 7's `suspendTerminal` contract, read from its source, drives the design and is documented in +> `apps/cli/src/render/suspend.ts`.** `beginSuspend()` erases ink's frame and turns raw mode AND bracketed paste off +> (so we must touch neither); it toggles DECSET-1049 only when ink's `alternateScreen` **render option** is on — which +> is `true` for the bare Home but hard-`false` for `relavium chat`, whose hoisted controller owns 1049 (§c). The +> suspension is therefore surface-divergent. ink writes no mouse escapes anywhere, so 1000/1006 is entirely ours. And +> because raw mode is off for the whole window, a keyboard **Ctrl-C arrives as a real SIGINT** — the chat's signal +> handler must yield while a hatch owns the terminal, or it tears the session down behind the suspension's back and +> the pending reclaim re-enters the alt buffer on the user's shell. +> +> **(c) §e's "the first release defaults OFF (opt-in)" for mouse reporting is SUPERSEDED: it ships ON, with the +> mandatory opt-out.** Maintainer decision (2026-07-09). The wheel is what users expect of a full-screen TUI, and +> PgUp/PgDn-only surprised them. §e's *reason* for defaulting off stands unchanged and is exactly why the opt-out is +> no longer a follow-up but a shipped, tested part of this step: mouse capture disables the emulator's native +> copy-on-select (worst over SSH/tmux) and Relavium still has no in-app copy-on-select. The mitigations are now all +> live — **`--no-mouse`** / **`[preferences].mouse = false`**, the emulator's bypass modifier (Shift; Option on +> iTerm2), and the `/scrollback` + `/edit` hatches above. Read §e's "(default off first release)" in Consequences, +> and its "then flips to on-with-opt-out after real-terminal validation (a tracked follow-up)", as satisfied here: +> the flip happened together with the opt-out rather than after it. `resolveMouseMode` (render-mode.ts) makes one +> §e guarantee structural rather than conventional — it takes the ALREADY-RESOLVED render mode, so the inline +> renderer can never enable the mouse whatever the flag or the key say. +> +> **(d) §e's "suspend mouse around any TTY-inheriting subprocess" was, until Step 5d, vacuous.** Every other spawn in +> the repo (`run_command`, `git_*`, the `!`-shell) runs behind the tool sandbox with piped stdio and never touches the +> terminal. `$EDITOR` is the first TTY-inheriting child, and `suspendFullScreen` discharges the obligation for it. +> +> **Still pending from §e:** **DEC-2026 synchronized output** framing and the **branded Home banner** (+ +> `[preferences].show_banner`). The banner's "evaluated against the three themes" acceptance criterion in +> [phase-2.6](../roadmap/phases/phase-2.6-conversational-authoring.md) cannot be met here — the renderer has no theme +> system at all (`[preferences].theme` exists in the config schema and nothing reads it); theming is 2.6.L, so the +> banner ships with colour / `NO_COLOR` variants and its theme variants move there. +> +> **Amended 2026-07-10 (Step 6 — in-app text selection + copy-on-select; §e's mouse deferrals are WITHDRAWN).** +> Maintainer decision, taken after using the shipped renderer. §e twice constrains this area and both constraints are +> superseded here; the §e text is left verbatim (append-only). +> +> **(a) "never 1002/1003" → we enable DECSET 1002.** §e admits only button reporting (1000) so the wheel scrolls. But +> a terminal either reports mouse events to the application or performs its own click-drag selection — never both, and +> there is no mode in between. (DECSET 1007 "alternate scroll" converts the wheel into cursor keys, which collide +> irrecoverably with the prompt's Up/Down history keys.) §e therefore forced a choice between a scrolling wheel and +> native selection, and Step 5e's `--no-mouse` only lets the user pick which one to lose. Enabling **1002** +> (button-event tracking: press, release, wheel, and motion *only while a button is held*) gives the app the drag it +> needs to implement selection itself. **1003** (any-motion) stays forbidden — it reports every pointer move. +> +> **(b) "Mouse click / drag / text-selection / copy-on-select … deferred to Phase 3" → shipped now.** The alt screen's +> copy regression is the renderer's single worst ergonomic cost, and §e's own mitigations (the bypass modifier, the +> `/scrollback` + `/edit` hatches, `--no-mouse`) are workarounds, not the affordance users expect. Competing agent +> CLIs (e.g. OpenCode) implement exactly this — the TUI captures the mouse, renders its own highlight, and writes the +> selection to the system clipboard with **OSC 52** on release. Their published issues also map the hazards we must +> design for up front rather than patch later: inside `tmux`/Zellij both the multiplexer and the app handle OSC 52 +> (the escape needs DCS passthrough); OSC 52 is silently dropped over VS Code Remote SSH; and users want the behaviour +> switchable. So: `$TMUX`/`$ZELLIJ` detection, a `[preferences].copy_on_select` key, and `--no-mouse` continuing to +> disable the whole subsystem and hand native selection back. +> +> **Coordinate mapping — measured, not assumed.** A mouse report carries an absolute 1-based terminal row; the +> viewport needs a wrapped-transcript line index. Summing `yogaNode.getComputedTop()` up the `DOMElement.parentNode` +> chain yields the box's frame row, which was verified against the rendered frame's own line index across three +> layouts (with and without a header strip, with the live region grown). Both surfaces bind their ink root to +> `height: terminal rows` and ink's `log-update` writes a frame without a trailing newline, so the frame is anchored +> at terminal row 1. Hence `displayLine = scrollOffset + (mouseRow - 1 - viewportTop)`. The real-TTY confirmation of +> that anchor is a PR-time check. +> +> **Selection copies the VISUAL rows** the user highlighted — a wrapped paragraph comes back with those wraps as +> newlines, exactly as the terminal's own selection would have given, and exactly what the highlight showed. `/edit` +> and (Step 6) `/copy` hand over the UNWRAPPED document when fidelity matters more than the visual. +> +> ### Amendment — 2026-07-10 (2.6.F Steps 6e/6f, after the Step-6 adversarial review) +> +> Four claims made above, or in the Step-6 code, were **wrong**, and are corrected here rather than quietly patched. +> Append-only: the text above is left verbatim. +> +> **(a) The tmux plan was backwards.** The Step-6 amendment says "inside `tmux`/Zellij both the multiplexer and the app +> handle OSC 52 (the escape needs DCS passthrough)", and Step 6c shipped the DCS passthrough alone. Read from tmux's +> source: `input_osc_52_parse()` returns early unless `set-clipboard` is `on`, whose **default is `external`**; and +> `input_dcs_dispatch()` returns early unless `allow-passthrough` is on, whose **default is `off`**. Stock tmux honours +> **neither** form. Relavium therefore emits **both** (Step 6f-2), so setting either option suffices. The ESC-doubling +> inside the passthrough is confirmed correct by tmux's DCS state table. +> +> **(b) `$TMUX`/`$ZELLIJ` detection does NOT gate `copy_on_select`.** The plan was to auto-disable it inside a +> multiplexer. That rested on (a), and (a) was false. A copy inside tmux may silently do nothing — but that is +> indistinguishable from VS Code Remote SSH dropping the escape, which the design already accepts and reports honestly +> (`'written'`, never `'copied'`). Guessing at a multiplexer's configuration and silently disabling a feature is worse +> than attempting it. `copy_on_select` defaults ON whenever the mouse is on, and is a plain preference. +> +> **(c) "SGR does not encode which button was released"** — a comment in Step 6a's `mouse.ts`, on which the reducer was +> built. xterm's `ctlseqs` says the opposite of the `m` final byte: *"A different final character is used for button +> release to resolve the X10 ambiguity regarding which button was released."* Resolving that ambiguity is the reason +> the byte exists. Until Step 6f-3, any button coming up while a selection was live re-emitted the whole selection over +> OSC 52 — so every right-click re-copied. +> +> **(d) The coordinate mapping needed one more rule.** `cellAt` clamps a mouse row into the viewport, which is right for +> a DRAG (the pointer leaves it constantly) and wrong for a PRESS: a press on the prompt or the status strip anchored +> the selection to the viewport's last visible line. And because the focus is clamped, a drag could never select more +> than one screenful. Step 6f-5 adds `containsRow` (a press outside the viewport starts nothing) and `dragScrollMotion` +> (a drag on the viewport's first or last row scrolls a line **before** the focus is mapped). The boundary rows are +> inside the edge zone deliberately: `relavium chat` binds its viewport to frame row 0, so the pointer can never go +> above it and there would otherwise be no signal to scroll up at all — which is why vim and tmux copy-mode scroll on +> the boundary row too. +> +> **Also settled here.** A press freezes auto-follow for the gesture (a completing turn would otherwise slide the +> transcript out from under the pointer) and a plain click restores it. `Esc` dismisses a live selection while the chat +> is idle, never mid-turn, where `Esc` is the abort. `/copy` (Step 6e) copies the UNWRAPPED transcript document and +> suspends nothing — OSC 52 is one control write. +> +> ### Amendment — 2026-07-10 (2.6.F Step 5f: there was nothing to build) +> +> The Decision above says flicker "is avoided with terminal **synchronized output** (DEC 2026, `\x1b[?2026h/l`) +> framing, **since `ink` does not emit it**", and Step 5f was scheduled to build it — a `Proxy` over `process.stdout` +> wrapping every write. **That claim is false for `ink` 7.** It ships `build/write-synchronized.js` +> (`bsu` = `\x1b[?2026h`, `esu` = `\x1b[?2026l`) and frames every write in it, gated on +> `shouldSynchronize(stream, interactive)` = `stream.isTTY && (interactive ?? !isInCi)`. +> +> Measured against a real mount with Relavium's own render options: a TTY stdout receives one balanced BSU/ESU pair per +> frame; a piped stdout, an `interactive: false` mount, and a `debug: true` mount receive none. The `--json` / CI / +> non-TTY byte-identical guarantee is therefore already honoured by ink itself. +> +> Worse, the planned Proxy would have been actively wrong: ink writes `bsu` as its **own separate `write()` call**, so +> wrapping every write would have nested the escapes. Step 5f therefore implements nothing and instead PINS the +> behaviour (`synchronized-output.test.tsx`), so an ink bump that drops the framing fails loudly rather than silently +> restoring the flicker this ADR set out to remove. +> +> ### Amendment — 2026-07-10 (2.6.F Step 5g: the banner ships, its trigger does not) +> +> The Decision says the banner is "shown on the first few Home opens, then auto-dismissed — re-enabled via +> `[preferences].show_banner`", and the phase plan pins that at **five** opens. A five-open counter needs durable +> storage, and both places to keep one are the wrong trade for an element this ADR itself calls **cosmetic**: +> +> - a `history.db` migration in `@relavium/db` — schema, migration, store and tests, for a decoration; or +> - auto-writing `[preferences]` on startup — mutating a `config.toml` the user may hand-author and commit, on every +> Home open, through a seam (ADR-0063) built for *user-initiated* writes. +> +> **An empty Home is the first-opens signal.** `show_banner` is therefore tri-state: `true` ⇒ always, `false` ⇒ never, +> **absent ⇒ shown while `snapshot.isEmpty`**. It greets a fresh install and auto-dismisses the moment the user's first +> chat gives them something to continue — the behaviour the counter was a proxy for, at zero storage cost, and legible +> from the code rather than from a number in a file. +> +> Two guards keep it from crowding a small terminal: never below `HOME_MIN_ROWS`, and a FORCED banner (`true`) also +> needs `BANNER_EXTRA_ROWS` of headroom, so it stands down rather than push the management strip off an 80x24 screen. +> `NO_COLOR` / `--no-color` takes the box-drawing glyphs with it, not just the colour: a terminal told to be plain is a +> terminal we should assume renders conservatively, and a mis-rendered `╭` is worse than a `+`. +> +> Themes remain deferred to 2.6.L, per the maintainer's Step-5 decision; this ships colour + `NO_COLOR` only. +> +> ### Amendment — 2026-07-10 (2.6.F Step 6g: the whole-phase review, and the caps-lift that was never built) +> +> A second adversarial review, over the WHOLE phase rather than one step, found what the per-step reviews could not. +> The headline: +> +> **THE CAPS-LIFT WAS NEVER IMPLEMENTED.** The Context above names the defect this ADR exists to fix — "live output is +> capped at 4000 chars *and* `reduceTurnCompleted` bakes the finalized transcript entry from that capped buffer, so a +> long response is clipped and its full text survives only in SQLite — unreachable by scrolling" — and Decision (c) +> promises the fix: "made a **renderer-injected bound** (not a constant)". `MAX_LIVE_TOKEN_CHARS = 4000` remained an +> unconditional constant. The Step-4b-3 amendment's "caps-lift" was a NAME COLLISION: it delivered the per-entry wrap +> cache, an unrelated performance fix. Measured against the real store, a 10 000-character answer landed in the +> transcript as 4 001 characters, and `/scrollback`, `/edit`, `/copy` and copy-on-select all read that transcript. +> Step 6g implements the ADR's own design: `liveTokens` stays bounded (the live region's render budget), `turnText` +> carries the renderer's bound, and `reduceTurnCompleted` bakes from `turnText`. +> +> **Also folded:** +> - A keyboard Ctrl-C during a Home `/scrollback`/`/edit` tore the Home down behind the suspension and stranded +> DECSET 1002+1006 on the shell. `relavium chat` had gated this since Step 5d; the Home never did. And `chat` had a +> SIGINT-uncovered window during a `/clear` / `/models` rebuild, between ink's unmount and the next mount. +> - `displayWidth` **under-counted 8 539 code points** vs ink's `string-width` — Tangut, Kana Supplement, Yijing, +> Hangul Jamo Extended-A — breaking the 1-`DisplayLine`-==-1-row invariant. The hand-rolled table is deleted; see +> [ADR-0069](0069-string-width-for-the-cli-renderer.md). +> - The `/clear` intro carrying `relavium chat-resume ` — the only pointer back to the conversation it ended — was +> written into the alt buffer before ink mounted, and painted over. So were the Home's budget warnings. +> - `/edit`'s temp file, holding the whole conversation, survived the process when its cleanup `rm` failed: `dispose` +> removed the `process.on('exit')` net in a `finally`, disarming it exactly when it was needed. +> - Mouse capture was armed for the whole Home, stripping the LANDING of the emulator's native selection while giving +> it no in-app selection. Capture now follows the chat. +> +> **Open, and deliberately not fixed here:** the Home LANDING can overflow a short terminal, and the alt buffer has no +> scrollback to recover the top. That predates this phase (the strip is 2.5.B) and 2.6.G's management browsers replace +> it wholesale. ## Consequences +> **Read the dated amendments above first.** This section was written before Steps 5f, 5g, 6 and 6g and is left +> verbatim (append-only). Four of its statements are SUPERSEDED: +> +> - *"sync-output framing"* as something this phase builds → **ink 7 already emits DEC-2026**; Step 5f pins it +> (amendment of 2026-07-10). +> - *"Mouse capture disables native copy-on-select … the opt-out (default off first release) … deferred in-app +> copy-on-select"* → **mouse defaults ON, and in-app selection + copy-on-select shipped in Step 6**; capture is armed +> only while a chat owns the screen (Step 6g). +> - *"the `[` / `v` hatches"* → they are the palette commands **`/scrollback`** and **`/edit`**, joined by **`/copy`** +> (Step-5 and Step-6e amendments). +> - *"New `[preferences]` keys (`alt_screen`, `mouse`, `show_banner`)"* → also **`copy_on_select`** (Step 6e). +> +> And one statement it makes was NOT TRUE when written, and is now: *"The long-response clipping defect is +> structurally fixed for chat — the viewport shows the full response and scrolls."* The caps-lift the Decision promised +> was never implemented; a 10 000-character answer reached the transcript as 4 001 characters until **Step 6g**. See +> the caps-lift amendment. + ### Positive - The long-response clipping defect is **structurally** fixed for chat — the viewport shows the full diff --git a/docs/decisions/0069-string-width-for-the-cli-renderer.md b/docs/decisions/0069-string-width-for-the-cli-renderer.md new file mode 100644 index 00000000..aca29c0e --- /dev/null +++ b/docs/decisions/0069-string-width-for-the-cli-renderer.md @@ -0,0 +1,105 @@ +# ADR-0069: `string-width` for the CLI renderer's display-width measurement + +- **Status**: Proposed +- **Date**: 2026-07-10 +- **Related**: [ADR-0068](0068-full-screen-tui-renderer-ink7-harness.md) · [ADR-0047](0047-cli-render-seam-and-framework-free-cores.md) · [ADR-0067](0067-node-supported-floor-22-reaffirm-better-sqlite3.md) + +> **Awaiting maintainer approval.** [CLAUDE.md](../../CLAUDE.md) rule 2 gates every new runtime dependency behind an +> ADR. The code in 2.6.F Step 6g already depends on this decision; if it is rejected, the revert is `viewport.ts`'s +> width functions plus the one manifest line. + +## Context + +The full-screen renderer ([ADR-0068](0068-full-screen-tui-renderer-ink7-harness.md) §c) rests on one invariant: + +> **1 `DisplayLine` == 1 real terminal row.** + +`viewport.ts` wraps the transcript into `DisplayLine`s, and the scroll state machine, the row-measurement, and (since +Step 6) the mouse row→line mapping all count those lines as terminal rows. If a `DisplayLine` is in fact *wider* than +`cols`, ink re-wraps that `` into two real rows, the viewport's `overflowY: hidden` clips the tail, and every +offset and mouse mapping below it is off by one — silently, and permanently for that session. + +So the wrap's width function must never **under-count** relative to the width function *ink* measures with. ink's +`Output` (`ink/build/output.js`) imports **`string-width`**. + +Relavium hand-rolled its own table instead, under the rule "build in-house; minimise dependencies". The module's own +docstring recorded the debt: + +> *"Display width is a PRAGMATIC hand-roll … the repo deliberately avoids a `string-width` runtime dependency … the +> table should be hardened … so it never under-counts. **Tracked as a Step-4b-2 obligation.**"* + +It also claimed the table "never under-counts vs ink". The whole-phase adversarial review disputed that, and a +measurement over every assigned code point in the BMP and SMP settled it: + +| Direction | Code points | Consequence | +|---|---:|---| +| We over-count (safe) | 3 716 | The wrap breaks a cell early — a slightly narrow line. | +| **We under-count (unsafe)** | **8 539** | **The `DisplayLine` overflows its row.** | + +The under-counts are not exotic combining marks. They are East-Asian **Wide** characters we called narrow: + +- `U+17000..U+18CD5` — **Tangut**, 7 382 of them +- `U+1B000..U+1B2FB` — Kana Supplement / Kana Extended +- `U+4DC0..U+4DFF` — Yijing hexagrams +- `U+A960..U+A97C` — Hangul Jamo Extended-A +- `U+FE10..U+FE19`, `U+FE50..U+FE6B` — vertical and small form variants +- `U+1D300..U+1D376` — Tai Xuan Jing symbols, counting rods + +The table had already been patched twice by review — Step 4b-2's Opus fold added a hand-transcribed +`isBmpEmojiPresentation` set after that review found *the same class of bug*. A Unicode width table is a moving target: +it changes with every Unicode release, and each release we do not track becomes a new under-count. + +## Decision + +**`apps/cli` declares `string-width` (`catalog: ^8.2.0`) as a direct runtime dependency, and `viewport.ts`'s +`displayWidth` / `graphemeWidth` become thin calls to it.** The hand-rolled `codePointWidth` / `isWide` / +`isBmpEmojiPresentation` tables (128 lines) are deleted. + +`displayWidth` is now, by construction, the same function ink measures with. The two cannot disagree about where a +line ends. + +Three things make this a narrower decision than "add a dependency": + +1. **It adds nothing to the install.** `string-width@^8.2.0` is *already* a runtime dependency of the shipped CLI — + `ink` depends on it, at the same range. Declaring it directly removes a phantom dependency; it does not add a + package, install weight, or supply-chain surface. +2. **It stays out of the engine.** `packages/core` and `packages/llm` remain platform-free and dependency-free. This + is a *renderer* dependency, in `apps/cli` only, where ink already lives. +3. **It is not the core.** [CLAUDE.md](../../CLAUDE.md) rule 2 says "write our own better implementations **for the + core**", and rule 3 says never reinvent primitives that must be exactly right. A Unicode width table is the second + kind: not Relavium's value, exactly right or the renderer corrupts, and already vendored. + +`countAnsiEscapeCodes: true` is passed at both call sites: the text is already stripped of ANSI/C0/C1 by +`sanitizeInline`, so `string-width`'s `strip-ansi` pass is dead work on the hot path. + +### Alternatives considered + +- **Keep the table, widen it.** Rejected. It is the third patch to the same class of bug, and it rots on a schedule we + do not control (Unicode 16 added Tangut components; 17 will add more). +- **Keep the table, but bias every unknown to width 2.** Safe against under-counting, but it over-counts most of the + BMP, so ordinary CJK and emoji lines wrap far too early. The cure is worse. +- **Vendor `string-width`'s tables into the repo.** All of the rot, none of the upstream fixes, and a licence header + to carry. +- **Use it as a `devDependency` only, to test the hand-rolled table.** This pins the bug in place rather than fixing + it, and every future Unicode release turns CI red with no fix available but the one above. + +## Consequences + +### Positive + +- The 1-`DisplayLine`-==-1-row invariant becomes **structural**, not asserted: the wrap and ink measure with the same + function. The Step-4b-2 obligation recorded in `viewport.ts` is closed. +- 128 lines of table, and the class of bug it produced, are deleted. +- The common line gets *faster*: `string-width` short-circuits pure-ASCII input with one regex, where the table ran a + per-code-point loop. +- Unicode releases arrive through a dependency bump rather than through a review finding. + +### Negative + +- One more directly-declared runtime dependency in `apps/cli` — mitigated by it already shipping via `ink`, and by + `packages/core` / `packages/llm` staying untouched. +- Relavium now inherits `string-width`'s judgement calls. One is user-visible today: a **lone** regional indicator + (`U+1F1F9` with no pair) is 1 cell, where the old table said 2. `string-width` is right that it is not an RGI emoji; + which is *rendered* correctly is terminal-dependent, and matching ink is what the invariant requires. +- A future ink major that swaps its width library would silently re-open the disagreement. `viewport.test.ts` pins the + contract against `string-width` directly (`inkWidth`), so the drift fails a test rather than a user's scroll. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 673a3198..6528e1d6 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -112,6 +112,7 @@ flowchart TD | 0066 | [Normalized reasoning-effort control — a provider-agnostic tier, per-adapter native mapping, and a per-model capability](0066-normalized-reasoning-effort-control.md) | Accepted | 2026-07-06 | | 0067 | [Node supported-floor `>=22` and re-affirmed `better-sqlite3` (supersedes ADR-0021)](0067-node-supported-floor-22-reaffirm-better-sqlite3.md) | Accepted | 2026-07-09 | | 0068 | [Full-screen TUI renderer, `ink` 7, and the CLI component test harness (refines ADR-0047)](0068-full-screen-tui-renderer-ink7-harness.md) | Accepted | 2026-07-09 | +| 0069 | [`string-width` for the CLI renderer's display-width measurement](0069-string-width-for-the-cli-renderer.md) | Proposed | 2026-07-10 | ## Creating a new ADR diff --git a/docs/reference/cli/accessibility.md b/docs/reference/cli/accessibility.md index 728a8737..67299e14 100644 --- a/docs/reference/cli/accessibility.md +++ b/docs/reference/cli/accessibility.md @@ -13,7 +13,7 @@ path back to the accessible one — an escape hatch, never a trap. | Terminal buffer | the **alternate screen** (DECSET 1049) — a fresh buffer with **no scrollback** | the **primary** buffer + its native **scrollback** | | Scroll-back | in-app only (**PgUp/PgDn**, **Ctrl+Home/Ctrl+End**, auto-follow) | the terminal emulator's own scrollback | | Screen readers | **inherently inaccessible** — a raw-mode, full-screen redraw loop carries no live-region / document semantics for assistive tech to follow | the emulator's own accessibility support applies (it is ordinary line output) | -| Mouse text-selection | needs the terminal's **bypass modifier** — mouse reporting is on so the wheel scrolls | native click-drag selection | +| Mouse text-selection | **in-app** click-drag + copy-on-select (2.6.F Step 6); the emulator's **bypass modifier** still reaches its own selection | native click-drag selection | The full-screen mode is a keyboard-driven, `ink`-redrawn viewport: it takes over the whole terminal, runs in **raw mode**, and repaints frames in place. That is what makes long responses @@ -22,12 +22,37 @@ DOM/live-region model for assistive technology to track, and the alternate buffe scrollback a screen reader would otherwise read. This limitation is intrinsic to a full-screen TUI, not specific to Relavium. -Full-screen mode also enables terminal **mouse reporting** (DECSET 1000 + 1006) so the wheel scrolls the -transcript. The cost is that the emulator forwards clicks to Relavium instead of running its own -selection, so **click-drag select-and-copy needs the emulator's bypass modifier** — commonly **Shift** -(xterm, GNOME Terminal, Konsole, Windows Terminal), **Option (⌥)** on iTerm2. Which modifier applies is a -property of the terminal, not of Relavium. The inline renderer never enables mouse reporting, so selection -there is untouched. +Full-screen mode also enables terminal **mouse reporting** (DECSET 1002 + 1006) so the wheel scrolls the +transcript. The emulator then forwards clicks to Relavium instead of running its own selection — so Relavium +runs the selection itself (2.6.F Step 6): **drag to select, release to copy**. The highlight shows exactly +what will be copied, the selection extends past a screenful by auto-scrolling at the viewport's edges, and +the copy goes to the system clipboard over **OSC 52**, which works over SSH and inside a container. + +Copy-on-select is on by default. Turn it off with **`[preferences].copy_on_select = false`** and the highlight +stays while the clipboard is left alone. + +**OSC 52 has no acknowledgement**, so a copy can be attempted but never *confirmed*: + +- **tmux** honours an application's OSC 52 only under `set-clipboard on`, and the DCS passthrough only under + `allow-passthrough on`. Relavium emits both forms, so setting *either* option works; stock tmux sets neither. +- **VS Code's Remote SSH terminal** silently drops OSC 52 entirely. + +If you would rather keep the emulator's native selection than the wheel, turn mouse reporting off: +**`--no-mouse`** for one invocation, or **`[preferences].mouse = false`** durably +([config-spec.md](../contracts/config-spec.md)). This also turns off in-app selection and copy-on-select — there +is no gesture left to produce them. The keyboard scroll keys (PgUp/PgDn, Ctrl+Home/Ctrl+End) are unaffected. +The inline renderer never enables mouse reporting at all, and the bare **Home landing** does not either — capture is armed only while a chat owns the screen. + +Even with mouse reporting on, the emulator's **bypass modifier** still reaches its own selection — commonly +**Shift** (xterm, GNOME Terminal, Konsole, Windows Terminal), **Option (⌥)** on iTerm2. Which modifier applies is +a property of the terminal, not of Relavium. + +Three in-app **copy-and-search hatches** exist, in a live chat on either surface: +**`/scrollback`** prints the whole transcript to the primary buffer — where the emulator's own scrollback, +search, selection and copy all work — and waits for Enter before repainting; **`/edit`** opens the transcript +read-only in `$EDITOR`; **`/copy`** puts the whole transcript on the system clipboard over OSC 52 (the unwrapped +document, unlike a mouse selection's visual rows). None needs the mouse, and all restore every terminal mode on +the way back. ## The escape hatch — the inline renderer diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index 5f6e0c07..065753c4 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -22,7 +22,7 @@ relavium chat --agent code-reviewer # resolved inside .relavium/ `relavium chat` opens an `ink`-rendered interactive REPL when a TTY is attached. The session is **auto-persisted and resumable** from the moment it starts — there is no separate save step (see [agent-session-spec.md](../contracts/agent-session-spec.md#validation-and-persistence)). Resume a prior conversation with `relavium chat-resume ` and list past sessions with `relavium chat-list` (see [commands.md](commands.md)). -Since Step 4b-3 the REPL **defaults on a TTY to the full-screen alternate-screen** mode, with **inline** as the opt-out — **`--no-alt-screen`** (one invocation) or **`[preferences].alt_screen = false`** (durable) ([ADR-0068](../../decisions/0068-full-screen-tui-renderer-ink7-harness.md) §e; the resolution is shared verbatim with the [Home](home.md)). A non-TTY / `--json` path is always inline (byte-identical). The alt screen renders the transcript through a resize-tracked **viewport** with **scroll-back + auto-follow** — **PgUp/PgDn** page, **Ctrl+Home/Ctrl+End** jump to top/tail, an upward scroll pauses the tail-follow and reaching the bottom resumes it (gated behind any keyboard-owning overlay); a `/clear` or `/models` swap holds the alt buffer across sessions, so it no longer flickers (Step 4b-3). The **`[`/`v`** copy-and-search hatches are the one piece still pending at Step 5 — see [config-spec.md](../contracts/config-spec.md)'s `alt_screen` caveat. The full-screen mode is inherently inaccessible to screen readers; [accessibility.md](accessibility.md) documents the trade-off + the inline-renderer escape hatch. +Since Step 4b-3 the REPL **defaults on a TTY to the full-screen alternate-screen** mode, with **inline** as the opt-out — **`--no-alt-screen`** (one invocation) or **`[preferences].alt_screen = false`** (durable) ([ADR-0068](../../decisions/0068-full-screen-tui-renderer-ink7-harness.md) §e; the resolution is shared verbatim with the [Home](home.md)). A non-TTY / `--json` path is always inline (byte-identical). The alt screen renders the transcript through a resize-tracked **viewport** with **scroll-back + auto-follow** — **PgUp/PgDn** page, **Ctrl+Home/Ctrl+End** jump to top/tail, an upward scroll pauses the tail-follow and reaching the bottom resumes it (gated behind any keyboard-owning overlay); a `/clear` or `/models` swap holds the alt buffer across sessions, so it no longer flickers (Step 4b-3). Mouse reporting captures click-drag, so the alt screen runs **its own selection** (ADR-0068 §e, Step 6): drag to select, release to copy over **OSC 52**; the highlight shows exactly what is copied, dragging at the viewport's top or bottom row auto-scrolls to extend it, a plain click clears it, `Esc` dismisses it while idle, and `[preferences].copy_on_select = false` keeps the highlight without touching the clipboard. Because the alt buffer also has no scrollback, three **copy-and-search hatches** (Step 5d, Step 6e) give the transcript back to the tools you already have: **`/scrollback`** dumps it into the terminal's native scrollback (scroll, search, select, copy — then press Enter to return), **`/edit`** opens it read-only in `$EDITOR` (edits are never read back), and **`/copy`** sends the whole unwrapped transcript to the system clipboard. The first two suspend the renderer and restore every terminal mode on exit; `/copy` suspends nothing. None is available before the first turn. The full-screen mode is inherently inaccessible to screen readers; [accessibility.md](accessibility.md) documents the trade-off + the inline-renderer escape hatch. A chat also starts from the bare-invocation **Home** (2.5.B): typing a message at a bare `relavium` on a TTY graduates the Home into a chat in the **same** process — bound to the built-in default chat agent (the zero-config first run) — and returns to a freshly-read Home when the chat ends (the chat's exit code `4` is consumed by the Home loop, never leaked). The in-Home chat is the same REPL described here; see [home.md](home.md) for the Home shell, its TTY gate, and the signal/exit-code lifecycle. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index bf360f5d..a2f25037 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -98,6 +98,7 @@ before parsing the subcommand). | `--cwd ` | Run as if started in `` (project discovery and relative paths resolve from here). | | `--config ` | Use an explicit global config file instead of `~/.relavium/config.toml` — the project `.relavium/` layers still apply ([config-spec.md](../contracts/config-spec.md)). | | `--no-alt-screen` | Keep the byte-identical inline renderer for the bare Home + `relavium chat` (no full-screen alternate screen) — the screen-reader fallback. Overrides `[preferences].alt_screen`; a non-TTY / `--json` / CI path is always inline regardless ([ADR-0068](../../decisions/0068-full-screen-tui-renderer-ink7-harness.md)). | +| `--no-mouse` | Disable terminal mouse reporting (DECSET 1002+1006) inside the full-screen renderer. The wheel stops scrolling the transcript (PgUp/PgDn + Ctrl+Home/Ctrl+End still page), in-app selection and copy-on-select go with it, and the emulator's own click-drag selection works again without a modifier. Overrides `[preferences].mouse`, and forces `[preferences].copy_on_select` off. Ignored outside the alt screen ([ADR-0068](../../decisions/0068-full-screen-tui-renderer-ink7-harness.md) §e, [accessibility.md](accessibility.md)). | | `-v, --verbose` | Print verbose diagnostics to stderr. | | `-q, --quiet` | Suppress non-essential output. (`--verbose` and `--quiet` cannot be combined → exit `2`.) | | `-V, --version` | Print the version and exit `0`. | @@ -161,7 +162,7 @@ The **command manifest** is the one source the **shell** command surfaces derive ### In-REPL slash commands -The interactive `/` palette + slash commands inside the **Home and chat** are a SEPARATE, **curated** surface ([ADR-0056](../../decisions/0056-cli-in-app-slash-command-system-and-manifest.md) amendment, 2.5.C) — the runtime registry is `apps/cli/src/commands/repl-commands.ts` (`REPL_COMMANDS`), the single source for the palette, the `/help` list, and the unknown-slash hint. It surfaces only the commands that make sense in a live REPL: lifecycle (`/exit`, `/cancel`, `/export`, `/clear`), info/discovery (`/help`, `/workflows`, `/cost`, `/doctor`), and — in a chat — `/mode`, `/effort`, `/thinking` (also `Ctrl+T`), the ADR-0062 context commands (`/compact`, `/trim`), and `/models`. **Their in-chat behavior is spec'd in [chat-session.md](chat-session.md) and its ADRs** — the `/models` live-reseat vs bare-Home write-default and its reasoning-effort sub-step + picker UX ([ADR-0059](../../decisions/0059-cli-mid-session-model-reseat.md) reseat, [ADR-0064](../../decisions/0064-live-model-catalog.md)/[ADR-0063](../../decisions/0063-cli-config-write-contract.md) catalog+write), `/effort`'s per-turn override ([ADR-0066](../../decisions/0066-normalized-reasoning-effort-control.md)), and `/thinking`'s panel toggle (2.5.H) — this reference does not restate them. The heavy, session-starting shell commands (`run`, `chat`, `provider`, …) are **never** in-REPL slashes — they stay shell-only (`relavium …`). A bare `/` at an **empty** prompt opens the filterable palette (the footer hint-bar surfaces `/ for commands` there, 2.5.C S6); an unknown slash — or an undeclared argument on a known command (`/exit now`) — prints a sanitized, secret-free hint. A command may declare flags (`/doctor --deep`) or a single positional value (`/mode plan`); the palette runs the bare form, so a flag/value is opt-in by typing it. There is no separate `/shortcuts` command — the palette's own nav hints (`↑/↓ · Enter · Esc`) + the footer keep keys discoverable in context. +The interactive `/` palette + slash commands inside the **Home and chat** are a SEPARATE, **curated** surface ([ADR-0056](../../decisions/0056-cli-in-app-slash-command-system-and-manifest.md) amendment, 2.5.C) — the runtime registry is `apps/cli/src/commands/repl-commands.ts` (`REPL_COMMANDS`), the single source for the palette, the `/help` list, and the unknown-slash hint. It surfaces only the commands that make sense in a live REPL: lifecycle (`/exit`, `/cancel`, `/export`, `/clear`), info/discovery (`/help`, `/workflows`, `/cost`, `/doctor`), and — in a chat — `/mode`, `/effort`, `/thinking` (also `Ctrl+T`), the ADR-0062 context commands (`/compact`, `/trim`), `/models`, and the [ADR-0068](../../decisions/0068-full-screen-tui-renderer-ink7-harness.md) §e copy-and-search hatches `/scrollback`, `/edit` + `/copy`. **Their in-chat behavior is spec'd in [chat-session.md](chat-session.md) and its ADRs** — the `/models` live-reseat vs bare-Home write-default and its reasoning-effort sub-step + picker UX ([ADR-0059](../../decisions/0059-cli-mid-session-model-reseat.md) reseat, [ADR-0064](../../decisions/0064-live-model-catalog.md)/[ADR-0063](../../decisions/0063-cli-config-write-contract.md) catalog+write), `/effort`'s per-turn override ([ADR-0066](../../decisions/0066-normalized-reasoning-effort-control.md)), and `/thinking`'s panel toggle (2.5.H) — this reference does not restate them. The heavy, session-starting shell commands (`run`, `chat`, `provider`, …) are **never** in-REPL slashes — they stay shell-only (`relavium …`). A bare `/` at an **empty** prompt opens the filterable palette (the footer hint-bar surfaces `/ for commands` there, 2.5.C S6); an unknown slash — or an undeclared argument on a known command (`/exit now`) — prints a sanitized, secret-free hint. A command may declare flags (`/doctor --deep`) or a single positional value (`/mode plan`); the palette runs the bare form, so a flag/value is opt-in by typing it. There is no separate `/shortcuts` command — the palette's own nav hints (`↑/↓ · Enter · Esc`) + the footer keep keys discoverable in context. ### `relavium run` diff --git a/docs/reference/cli/home.md b/docs/reference/cli/home.md index 7b8eeb96..cfe7c95c 100644 --- a/docs/reference/cli/home.md +++ b/docs/reference/cli/home.md @@ -112,22 +112,32 @@ The Home receives a bracketed paste on **ink 7's native `usePaste` channel** (se ## Render mode (inline / alt-screen) -The Home renders in one of two modes ([ADR-0068](../../decisions/0068-full-screen-tui-renderer-ink7-harness.md) §e): since Step 4b-3 the **default on a TTY is the full-screen alternate-screen renderer**, with the **inline** renderer (native scrollback, the screen-reader-friendly fallback) as the opt-out — **`--no-alt-screen`** ([commands.md](commands.md#global-options)) for one invocation, or **`[preferences].alt_screen = false`** ([config-spec.md](../contracts/config-spec.md)) durably. A non-TTY / `--json` / CI path is **always** inline (byte-identical). The alt screen renders the transcript through a resize-tracked **viewport** with **scroll-back + auto-follow** — **PgUp/PgDn** page, **Ctrl+Home/Ctrl+End** jump to top/tail, an upward scroll pauses the tail-follow and reaching the bottom resumes it (the scroll keymap is gated behind any keyboard-owning overlay) — and a per-entry wrap cache (keyed on the immutable transcript entry) keeps even a very large transcript cheap (Step 4b-3). Still pending (Step 5): the **`[`-dump / `v`-open-in-$EDITOR** copy-and-search hatches; the resolution is shared verbatim with `relavium chat` ([chat-session.md](chat-session.md)). The full-screen mode is inherently inaccessible to screen readers — see [accessibility.md](accessibility.md) for the trade-off and the inline-renderer escape hatch. +A **branded banner** — a wordmark + tagline plaque — is drawn where the plain `relavium` heading otherwise sits, on a +fresh install: `[preferences].show_banner` is `true` (always) / `false` (never), and **absent** means *shown only while +the Home is empty*, so it greets a first run and auto-dismisses once there is anything to continue. It degrades to +plain ASCII under `NO_COLOR` / `--no-color`, and a forced banner stands down on a terminal too short to hold it beside +the strip. It is cosmetic and gates no feature. + +The Home renders in one of two modes ([ADR-0068](../../decisions/0068-full-screen-tui-renderer-ink7-harness.md) §e): since Step 4b-3 the **default on a TTY is the full-screen alternate-screen renderer**, with the **inline** renderer (native scrollback, the screen-reader-friendly fallback) as the opt-out — **`--no-alt-screen`** ([commands.md](commands.md#global-options)) for one invocation, or **`[preferences].alt_screen = false`** ([config-spec.md](../contracts/config-spec.md)) durably. A non-TTY / `--json` / CI path is **always** inline (byte-identical). The alt screen renders the transcript through a resize-tracked **viewport** with **scroll-back + auto-follow** — **PgUp/PgDn** page, **Ctrl+Home/Ctrl+End** jump to top/tail, an upward scroll pauses the tail-follow and reaching the bottom resumes it (the scroll keymap is gated behind any keyboard-owning overlay) — and a per-entry wrap cache (keyed on the immutable transcript entry) keeps even a very large transcript cheap (Step 4b-3). The in-Home chat also carries the mouse **selection + copy-on-select** and the **`/scrollback`**, **`/edit`** and **`/copy`** copy-and-search hatches — the same code as `relavium chat` ([chat-session.md](chat-session.md)); the hatches are chat-only, so they never appear in the bare Home's palette. **Mouse reporting is armed only while the chat owns the screen**: the Home landing has no viewport to wheel-scroll and no in-app selection, so it keeps the emulator's own click-drag selection instead. The full-screen mode is inherently inaccessible to screen readers — see [accessibility.md](accessibility.md) for the trade-off and the inline-renderer escape hatch. ## Minimum terminal size Below **80×24** the Home **degrades** to a single line — `Terminal too small (WxH) — resize to at least 80×24.` plus a `Ctrl-C to exit` affordance — and **suspends** the strip render until a terminal **resize** arrives, rather than drawing a broken/garbled TUI. The resize is observed on `process.stdout`'s cross-platform `'resize'` event (backed by `SIGWINCH` on POSIX), not a bare `SIGWINCH` binding (unreliable on Windows). Every dynamic strip row and the prompt are truncated at the terminal edge (`truncate-end`), never soft-wrapped. +> **Known limitation (tracked).** *At or above* 80×24, a very tall landing — a populated **Attention required** section (many pending gates) plus the Continue lists — can still exceed the terminal's rows, and the alt buffer has no scrollback to recover the top. This predates 2.6.F (the strip is 2.5.B) and is resolved by **2.6.G**'s management browsers, which replace the strip wholesale. See [docs/roadmap/current.md](../../roadmap/current.md). + ## Signal lifecycle & exit codes -The Home owns **one signal lifecycle (SIGINT/SIGTERM)** covering the Home, the in-Home chat, and MCP teardown (`closeMcp`): +The Home owns **one signal lifecycle (SIGINT/SIGTERM/SIGHUP/SIGQUIT)** covering the Home, the in-Home chat, and MCP teardown (`closeMcp`), plus a synchronous `process.on('exit')` net behind all of them: | Outcome | How | Exit code | | --- | --- | --- | | **Clean Home exit** | Ctrl-C / EOF in `home` mode | `0` | -| **Signal-driven** | an external SIGINT / SIGTERM (`kill -INT` / a parent's signal) | `128 + signo` — **`130`** (SIGINT) / **`143`** (SIGTERM) | +| **Signal-driven** | an external SIGINT / SIGTERM / SIGHUP / SIGQUIT (`kill -INT`, closing the terminal window, a parent's signal) | `128 + signo` — **`130`** (SIGINT) / **`143`** (SIGTERM) / **`129`** (SIGHUP) / **`131`** (SIGQUIT) | + +On an external signal the handler restores the terminal (unmount ink, disable bracketed paste, disable mouse reporting), tears the live chat — or an in-flight build — down **bounded** (a stuck MCP teardown can't hang the exit; a second signal force-exits immediately), closes the db once, and exits `128+signo` so a shell pipeline still detects the interruption. -On an external signal the handler restores the terminal (unmount ink, disable bracketed paste), tears the live chat — or an in-flight build — down **bounded** (a stuck MCP teardown can't hang the exit; a second signal force-exits immediately), closes the db once, and exits `128+signo` so a shell pipeline still detects the interruption. A keyboard Ctrl-C does **not** reach the process as SIGINT (raw mode), so the controller handles it (Home → `0`, chat → `/cancel`) and the `process.on('SIGINT')` handler covers only **out-of-band** signals. +A keyboard Ctrl-C does **not** normally reach the process as SIGINT (raw mode), so the controller handles it (Home → `0`, chat → `/cancel`). There is **one exception**: while a `/scrollback` or `/edit` suspension owns the terminal, ink has turned raw mode off and the kernel delivers a real SIGINT. That signal belongs to the hatch — the Home's handler drops it (`suspendPort.isSuspended()`), the hatch's own listener resumes the renderer, and the session survives. An external SIGTERM/SIGHUP/SIGQUIT still tears down, suspended or not. A chat launched from the Home has its **own** exit code `4` ([chat-session.md](chat-session.md)) — but inside the Home that `4` is **consumed by the mode loop** (a chat ending returns to Home), **never leaked**. The Home's own exit code is `0` on a clean exit. See the canonical [Exit codes](commands.md#exit-codes) table. diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index dcc6e7e5..ccf13506 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -68,8 +68,11 @@ update_channel = "stable" # stable | beta default_model = "claude-sonnet-4-6" reasoning_effort = "medium" # ADR-0066 §6: the GLOBAL default reasoning-effort tier — off | low | medium | high | max; the fallback BELOW any [chat].reasoning_effort. Written by the /models picker's effort sub-step. Absent ⇒ no reasoning control (the provider default). theme = "dark" -alt_screen = false # ADR-0068 §e (2.6.F): the full-screen alternate-screen renderer for the bare Home + `relavium chat`. Since Step 4b-3 the DEFAULT is ON — a TTY opens full-screen — so this key is the durable OPT-OUT: `false` keeps the byte-identical INLINE renderer (native scrollback + the emulator's own a11y — the screen-reader fallback), `true` forces it on. The `--no-alt-screen` flag is the per-invocation opt-out and overrides this key; a non-TTY / `--json` / CI path always renders inline regardless. Absent ⇒ the phase default (alt-ON since 4b-3 — ADR-0068 §b). +alt_screen = true # ADR-0068 §e (2.6.F): the full-screen alternate-screen renderer for the bare Home + `relavium chat`. Since Step 4b-3 the DEFAULT is ON — a TTY opens full-screen — so this key is the durable OPT-OUT: `false` keeps the byte-identical INLINE renderer (native scrollback + the emulator's own a11y — the screen-reader fallback), `true` forces it on. The `--no-alt-screen` flag is the per-invocation opt-out and overrides this key; a non-TTY / `--json` / CI path always renders inline regardless. Absent ⇒ the phase default (alt-ON since 4b-3 — ADR-0068 §b). # What the full-screen renderer actually does (viewport, scroll keymap, mouse-wheel) is NOT restated here — see [ADR-0068](../../decisions/0068-full-screen-tui-renderer-ink7-harness.md) and, for the inline/screen-reader tradeoff this key controls, [accessibility.md](../cli/accessibility.md). +mouse = true # ADR-0068 §e (2.6.F Step 5e): terminal mouse reporting (DECSET 1002+1006) INSIDE the full-screen renderer — what makes the wheel scroll the transcript and what lets Relavium run its OWN click-drag selection (Step 6). DEFAULT ON. Set `false` (or pass `--no-mouse`) to turn it off durably: the wheel stops scrolling (PgUp/PgDn + Ctrl+Home/Ctrl+End still page), in-app selection and copy-on-select go with it, and the emulator's native click-drag SELECTION works again without a modifier. The `--no-mouse` flag overrides this key. Ignored outside the alt screen — the inline renderer never enables mouse reporting. See [accessibility.md](../cli/accessibility.md). +show_banner = true # ADR-0068 (2.6.F Step 5g): the branded Home banner — a wordmark + tagline plaque drawn where the plain `relavium` heading otherwise sits. `true` ⇒ always, `false` ⇒ never. ABSENT ⇒ shown only while the Home is EMPTY (no sessions/runs/agents to continue), so it greets a fresh install and auto-dismisses the moment there is something to continue. Degrades to plain ASCII under `NO_COLOR` / `--no-color`. A forced banner stands down on a terminal too short for it. Cosmetic: it gates no feature. +copy_on_select = true # ADR-0068 §e (2.6.F Step 6e): releasing a mouse drag writes the selection to the system clipboard over OSC 52. DEFAULT ON. Set `false` to keep the highlight while never touching the clipboard — a stray drag then cannot clobber what you copied elsewhere. There is no flag: `--no-mouse` already removes the gesture. Meaningless without `mouse`, and IGNORED when it is off. `/copy` copies the whole transcript on demand regardless. OSC 52 has no acknowledgement, so a copy is attempted, never confirmed — see [accessibility.md](../cli/accessibility.md) for the tmux and VS Code Remote SSH caveats. [[mcp_servers]] # repeatable — an agent references one by name via `ref:` (ADR-0052 §5) name = "filesystem" diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index a800e35b..bdada920 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -10,10 +10,12 @@ This page tracks what is active **right now** and the immediate next concrete ac The full phase plan and the global milestone spine are in [README.md](README.md). **Phase 2.5 (CLI Consolidation) is complete** (milestone **M2.5-4**, PR #69, 2026-07-08) — its breakdown, now historical, is in -[phases/phase-2.5-cli-consolidation.md](phases/phase-2.5-cli-consolidation.md). The next phase, -**Phase 2.6 — Conversational Authoring and the First-Class CLI** (Planned; unblocked by the 2.5 -close; re-scoped 2026-07-08), is in +[phases/phase-2.5-cli-consolidation.md](phases/phase-2.5-cli-consolidation.md). +**Phase 2.6 — Conversational Authoring and the First-Class CLI** is **in progress** (re-scoped +2026-07-08); its plan is in [phases/phase-2.6-conversational-authoring.md](phases/phase-2.6-conversational-authoring.md). +Workstream **2.6.F (platform floor + the full-screen TUI renderer)** is **complete on `development`** +(2026-07-10, pending PR to `main`) — see [Active now](#what-is-active-now). ## Where we are @@ -40,6 +42,46 @@ and the [reference specs](../reference/). ## What is active now +### Phase 2.6.F — platform floor + the full-screen TUI renderer (complete on `development`, 2026-07-10) + +The first 2.6 workstream. Behind [ADR-0067](../decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.md) +(Node `>=22` published floor, `>=22.13.0` dev-install floor; `better-sqlite3` re-affirmed), +[ADR-0068](../decisions/0068-full-screen-tui-renderer-ink7-harness.md) (`ink` 7 + a hand-built alternate-screen +renderer + the `ink-testing-library` harness), and the **Proposed** +[ADR-0069](../decisions/0069-string-width-for-the-cli-renderer.md) (`string-width` for display width — +**awaiting maintainer approval**, since CLAUDE.md gates a new runtime dependency behind one). + +Shipped: `ink` 6→7 and the component-render harness; the alt-screen lifecycle with restore nets on every +termination path; a hand-built transcript **viewport** (grapheme-aware wrapping, windowing, scroll + auto-follow, a +per-entry wrap cache) and the inter-session alt-buffer **hoist**; the **`/scrollback`**, **`/edit`** and **`/copy`** +copy-and-search hatches over a `suspendFullScreen` primitive; **in-app mouse text selection with copy-on-select over +OSC 52** on both surfaces, with edge auto-scroll and a frozen auto-follow; `--no-mouse` / +`[preferences].{alt_screen,mouse,copy_on_select,show_banner}`; a branded Home banner; and DEC-2026 synchronized +output — which `ink` 7 turned out to already emit, so the step **pins** it rather than building the planned +`stdout` Proxy. + +The full-screen renderer is now the **default on a TTY**; `--no-alt-screen` and a machine / non-TTY / `--json` / CI +path stay on the byte-identical inline renderer. + +Two adversarial review rounds (one per step, one over the whole phase) drove the last third of the work. The +whole-phase round found that **the caps-lift ADR-0068 exists to deliver was never implemented** — a name collision +with the Step-4b-3 wrap cache — so a >4000-character answer was still clipped in the very viewport built to scroll +it; that `displayWidth` under-counted 8 539 code points against `ink`'s own width function; and that a keyboard +Ctrl-C during a Home hatch stranded mouse reporting on the user's shell. All folded, each with a break-verified +regression test. + +**Open obligations carried out of 2.6.F:** + +- **ADR-0069 is Proposed.** The `string-width` runtime dependency needs the maintainer's accept (or a revert of two + functions and one manifest line). +- **The Home landing can overflow a short terminal** — a populated "Attention required" section pushes the top off, + and the alt buffer has no scrollback to recover it. Predates 2.6.F (the strip is 2.5.B); **2.6.G**'s management + browsers replace the strip wholesale, which is the right place to fix it. +- **`relavium run`'s TUI stays inline** (a deliberate 2.6.F scope cut); its retained-scrollable-history is tracked. +- **Themes** (beyond colour / `NO_COLOR`) are deferred to **2.6.L**, per the maintainer's Step-5 decision. +- Real-TTY signal paths (double-Ctrl-C, `kill -TERM`/`-HUP`/`-QUIT`, a hatch under `tmux`) remain a **manual PR-time + check** — the unit tests pin the orchestration around injected seams, not the kernel. + **Phase 2 — CLI (milestone M3) is feature-complete** (every in-phase workstream 2.A–2.S merged; published as **v0.1.1**). The CLI is the first real `@relavium/core` consumer and doubles as the engine's regression harness — validating the @@ -242,8 +284,9 @@ keychain no-raw-key IPC test. ## Not started yet -The immediate next phase is **Phase 2.6 — Conversational Authoring and the First-Class CLI** -([phase-2.6-conversational-authoring.md](phases/phase-2.6-conversational-authoring.md), Planned, +The rest of **Phase 2.6 — Conversational Authoring and the First-Class CLI** +([phase-2.6-conversational-authoring.md](phases/phase-2.6-conversational-authoring.md), in progress — +**2.6.F is done**, unblocked by the 2.5 close and **re-scoped 2026-07-08** from maintainer UX findings + a competitor research pass + the deferred-tasks triage): a full-screen, Home-managed CLI (browsers for workflows/runs/agents, provider + MCP + settings management, onboarding v2 with the Relavium-account diff --git a/docs/roadmap/phases/phase-2.6-conversational-authoring.md b/docs/roadmap/phases/phase-2.6-conversational-authoring.md index 9b46a587..a1537ba3 100644 --- a/docs/roadmap/phases/phase-2.6-conversational-authoring.md +++ b/docs/roadmap/phases/phase-2.6-conversational-authoring.md @@ -312,14 +312,13 @@ render-v2 (2.6.M) all build on it. behavior for long responses so that content above the visible area is never lost. Renderer choice is orthogonal to session state (switching relaunches the view in place, conversation intact). The run TUI's persistent plain-text exit summary is preserved on unmount. -- **Branded Home banner**: a full-width, ink-native banner rendered at the top of the full-screen Home - on mount. The design is developed during implementation (ASCII-art or Unicode box-drawing character - variants evaluated against the three themes and `--no-color` degradation); the banner is shown on the - **first five** Home opens, then auto-dismissed — re-enabled via `[preferences].show_banner`. It must: - adapt to terminal width (truncated or centered at every supported width ≥80), degrade to plain ASCII - when `NO_COLOR` / `--no-color` is active, render in under one frame (no measure-then-draw flicker), - and never obscure the management strip or prompt on a 80×24 terminal. The banner is a cosmetic - substrate element — it does not gate any feature. +- **Branded Home banner** — ✅ **shipped in 2.6.F Step 5g**. `show_banner` is spec'd in + [config-spec.md](../../reference/contracts/config-spec.md) and its behaviour in + [home.md](../../reference/cli/home.md); this entry does not restate them. Two deviations from the plan + above, recorded in [ADR-0068](../../decisions/0068-full-screen-tui-renderer-ink7-harness.md)'s dated + amendments: the **"first five Home opens"** counter became an **empty-Home** trigger (a durable counter + would need a `history.db` migration or a `config.toml` auto-write per open — the wrong trade for a + cosmetic element), and **themes** moved to **2.6.L**, so 5g ships colour + `NO_COLOR` only. - **TUI component test harness** *(deferred pull-in)*: the first CLI component-render harness (a new devDependency — part of this workstream's ADR), so render-cadence bugs (the 2.5.H frozen-clock class) get regression tests; add performance regression thresholds (frame time / render count) for the diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index b21c9862..a30c5e98 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -144,10 +144,25 @@ export const GlobalConfigSchema = z // Full-screen alt-screen renderer (2.6.F, ADR-0068 §e). The DEFAULT is ON for an interactive TTY, so this key // is the durable OPT-OUT: `false` (like the `--no-alt-screen` flag) keeps the byte-identical INLINE renderer // (native scrollback + the emulator's own a11y), the screen-reader fallback; `true` forces it on. The flag - // overrides this key; a non-TTY / machine (`--json`/CI) path ignores both and always renders inline. The - // transcript renders through a resize-tracked viewport with scroll-back + auto-follow (PgUp/PgDn, - // Ctrl+Home/Ctrl+End) and mouse-wheel; only the `[`/`v` copy-and-search hatches remain (Step 5). + // overrides this key; a non-TTY / machine (`--json`/CI) path ignores both and always renders inline. alt_screen: z.boolean().optional(), + // Terminal MOUSE reporting (DECSET 1002+1006) in the full-screen renderer (2.6.F, ADR-0068 §e). It is what + // makes the wheel scroll the transcript and what lets Relavium run its OWN click-drag selection, since the + // emulator forwards clicks to us instead of selecting. DEFAULT ON. `false` (like `--no-mouse`) turns it off + // durably: the wheel stops scrolling (PgUp/PgDn/Ctrl+Home/Ctrl+End still page), in-app selection goes with it, + // and the emulator's native selection works again. The flag overrides this key. + // Ignored outside the alt screen — the inline renderer NEVER enables mouse reporting. + mouse: z.boolean().optional(), + // COPY-ON-SELECT (2.6.F Step 6e, ADR-0068 §e amendment): releasing a drag writes the selection to the system + // clipboard over OSC 52. DEFAULT ON, matching every competing agent CLI. `false` keeps the highlight (and the + // wheel) but never touches the clipboard — for anyone who does not want a stray drag clobbering what they + // copied elsewhere. `/copy` still copies the whole transcript on demand. Meaningless without `mouse`, and + // ignored when it is off: there is no selection to copy. + copy_on_select: z.boolean().optional(), + // The branded Home BANNER (2.6.F Step 5g, ADR-0068). `true` ⇒ always shown, `false` ⇒ never. + // ABSENT ⇒ shown only while the Home is EMPTY (no sessions/runs/agents to continue) — an empty Home is the + // "first opens" signal the ADR wanted, without a durable counter. Cosmetic: it gates no feature. + show_banner: z.boolean().optional(), }) .strict() .optional(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1904104c..634cbc2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,12 +81,15 @@ catalogs: smol-toml: specifier: ^1.3.4 version: 1.7.0 + string-width: + specifier: ^8.2.0 + version: 8.2.1 tsup: specifier: ^8.5.0 version: 8.5.1 turbo: - specifier: ^2.3.3 - version: 2.9.16 + specifier: ^2.10.4 + version: 2.10.4 typescript: specifier: ^5.7.2 version: 5.9.3 @@ -124,7 +127,7 @@ importers: version: 3.8.3 turbo: specifier: 'catalog:' - version: 2.9.16 + version: 2.10.4 typescript: specifier: 'catalog:' version: 5.9.3 @@ -179,6 +182,9 @@ importers: smol-toml: specifier: 'catalog:' version: 1.7.0 + string-width: + specifier: 'catalog:' + version: 8.2.1 yaml: specifier: 'catalog:' version: 2.9.0 @@ -1374,33 +1380,33 @@ packages: '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} - '@turbo/darwin-64@2.9.16': - resolution: {integrity: sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw==} + '@turbo/darwin-64@2.10.4': + resolution: {integrity: sha512-m1MUEI4MJ69r5CwfMYxmHi0H0rrgiYCBOp0tgBZ9x/YVvOb5uu/lRIDyDwdtH054R2yWeQaIigUGu6aCX9f8cA==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.9.16': - resolution: {integrity: sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw==} + '@turbo/darwin-arm64@2.10.4': + resolution: {integrity: sha512-VQ1Yxs5zkPT+2z7t1P4mvn6JmcKLkOCAsPuK9XbOvuVj0DlTlETfIXNisX0771v/vTWHOQqiwoGi+TtAUq8efw==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.9.16': - resolution: {integrity: sha512-vAEf1H6l26lTpl9FJ/peQo1NUB8RC0sbEJJz5mPcUhHA2bPDup2x3CZPgo/bH8S4cUcBLm4FN3UHd5iUO2RAew==} + '@turbo/linux-64@2.10.4': + resolution: {integrity: sha512-IzV1QovmwX7mfGnVinmE++2IB8tbeo38weltiuH5zNqwCTBjLs/DytyRKx+bmnhHdXIq9SheR8p0Nip/LBUPHg==} cpu: [x64] os: [linux] - '@turbo/linux-arm64@2.9.16': - resolution: {integrity: sha512-xDBLR2PZg4BrQOchfG6svgpv5FCNJ2TOtT2psLdEJcdKo1BH+pnPs9Xj6pvUjgfkHbuvBOfeE4R6tvxMoQKDHQ==} + '@turbo/linux-arm64@2.10.4': + resolution: {integrity: sha512-rfujSQkP5aYiRn0PgTM7F00WkJCP/bKDVZbOx3WmrZwa/vHA0bplhCl328kpX7VI9HH2vI90ISGwuSVgJgoqTw==} cpu: [arm64] os: [linux] - '@turbo/windows-64@2.9.16': - resolution: {integrity: sha512-NBAJnaUiGdgkSzQwUIdOvkCkcpTSu58G/sBGa0mvBtzfvFOOgrQwepKOOQ8cp6sWM6OcKDNFj2p1dsZA1OWjPg==} + '@turbo/windows-64@2.10.4': + resolution: {integrity: sha512-NnspP7Wd5fa3Wwnqv9bKfhegqZzuHBgbPxdZU/idTLQcazx/vgKu95JlCx2YHY0hdvKCnPcARrDwM+KEUmaO7A==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.9.16': - resolution: {integrity: sha512-Y7SJppD0Z8wjO3Ec0ZGd9KQ4Yv0BMnA8CIowj5Vp+OEVsosXDG2weK6/t1RRLfJmc2Ozrnd6y4DOgQys+mn3WQ==} + '@turbo/windows-arm64@2.10.4': + resolution: {integrity: sha512-Iv02YgOpaEShc2OkG7mgCJ2pEw1RUKiKbs0h8W5wAf4jZ5vpmraTEjuGTgHRuOORQnC1GN3KHo5WB+hu1abRMA==} cpu: [arm64] os: [win32] @@ -2983,8 +2989,8 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - turbo@2.9.16: - resolution: {integrity: sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==} + turbo@2.10.4: + resolution: {integrity: sha512-GQpduILaKjoaGljw097ScsSyKTtZSY7cZ3bJktzfTkPMyCf3ShKLuXK2IaOEN2Plziml+ArR7WJ1m+V4VbnaKQ==} hasBin: true type-check@0.4.0: @@ -3818,22 +3824,22 @@ snapshots: '@stablelib/base64@1.0.1': {} - '@turbo/darwin-64@2.9.16': + '@turbo/darwin-64@2.10.4': optional: true - '@turbo/darwin-arm64@2.9.16': + '@turbo/darwin-arm64@2.10.4': optional: true - '@turbo/linux-64@2.9.16': + '@turbo/linux-64@2.10.4': optional: true - '@turbo/linux-arm64@2.9.16': + '@turbo/linux-arm64@2.10.4': optional: true - '@turbo/windows-64@2.9.16': + '@turbo/windows-64@2.10.4': optional: true - '@turbo/windows-arm64@2.9.16': + '@turbo/windows-arm64@2.10.4': optional: true '@types/better-sqlite3@7.6.13': @@ -5497,14 +5503,14 @@ snapshots: dependencies: safe-buffer: 5.2.1 - turbo@2.9.16: + turbo@2.10.4: optionalDependencies: - '@turbo/darwin-64': 2.9.16 - '@turbo/darwin-arm64': 2.9.16 - '@turbo/linux-64': 2.9.16 - '@turbo/linux-arm64': 2.9.16 - '@turbo/windows-64': 2.9.16 - '@turbo/windows-arm64': 2.9.16 + '@turbo/darwin-64': 2.10.4 + '@turbo/darwin-arm64': 2.10.4 + '@turbo/linux-64': 2.10.4 + '@turbo/linux-arm64': 2.10.4 + '@turbo/windows-64': 2.10.4 + '@turbo/windows-arm64': 2.10.4 type-check@0.4.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 432429de..11798ce1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,7 +11,7 @@ catalog: eslint: ^9.17.0 eslint-config-prettier: ^9.1.0 prettier: ^3.4.2 - turbo: ^2.3.3 + turbo: ^2.10.4 typescript: ^5.7.2 typescript-eslint: ^8.18.1 vitest: ^3.0.0 @@ -57,6 +57,7 @@ catalog: # at 2.A; smol-toml at 2.B; @napi-rs/keyring at 2.C). Versions taken under the §9a cooling window. commander: ^12.1.0 smol-toml: ^1.3.4 + string-width: ^8.2.0 tsup: ^8.5.0 # CLI OS-keychain accessor (2.C) — a maintained N-API credential lib, NOT the archived # keytar (ADR-0019); confined to apps/cli behind a `KeychainStore` interface. Floor pinned to the diff --git a/turbo.json b/turbo.json index fb0dbb5e..23e141a4 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turbo.build/schema.json", + "$schema": "https://v2-10-4.turborepo.dev/schema.json", "ui": "stream", "globalDependencies": [ "tsconfig.base.json",