Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
6ca6b30
feat(cli): Step 5d-1 — the suspend-full-screen primitive (ADR-0068 §e…
cemililik Jul 9, 2026
8d175c3
feat(cli): Step 5d-2 — the hatch bodies: transcript document, $EDITOR…
cemililik Jul 9, 2026
c3b2b28
fix(cli): fold the Step-5d-1 Opus review — the root cause must win ov…
cemililik Jul 9, 2026
4a4602e
fix(cli): fold the Step-5d Sonnet review — a Ctrl-C during $EDITOR le…
cemililik Jul 9, 2026
413561e
feat(cli): Step 5d-3a — the suspend PORT + the shared `/scrollback` /…
cemililik Jul 9, 2026
5309a71
feat(cli): Step 5d-3b — wire `/scrollback` + `/edit` into the registr…
cemililik Jul 9, 2026
28fc4d5
docs(cli): the `/scrollback` + `/edit` hatches shipped — update their…
cemililik Jul 9, 2026
bb4dead
fix(cli): fold the Step-5d-3 Opus review — a guardrail that looked up…
cemililik Jul 9, 2026
6287104
fix(cli): fold the Step-5d-3 Sonnet review — a Ctrl-C during a hatch …
cemililik Jul 9, 2026
a2c4721
feat(cli): Step 5e — the mouse opt-out (`--no-mouse` + `[preferences]…
cemililik Jul 10, 2026
4805780
fix(cli): fold the Step-5e review — record the ADR deviation, pin the…
cemililik Jul 10, 2026
c551097
feat(cli): Step 6a — the selection primitives (SGR mouse events, cell…
cemililik Jul 10, 2026
e519915
feat(cli): Step 6b — the viewport reports where it sits in ink's frame
cemililik Jul 10, 2026
0f3f2ce
feat(cli): Step 6c — the OSC 52 clipboard writer
cemililik Jul 10, 2026
16a682e
feat(cli): Step 6d-1 — DECSET 1002, and the viewport renders a select…
cemililik Jul 10, 2026
5fe51b0
feat(cli): Step 6d-2 — `cellAt` + the shared selection reducer
cemililik Jul 10, 2026
79e8a33
feat(cli): Step 6d-3a — mouse selection is live in `relavium chat`
cemililik Jul 10, 2026
f8f2473
feat(cli): Step 6d-3b — mouse selection is live on the Home surface too
cemililik Jul 10, 2026
bbf6478
fix(cli): Step 6f-1 - one walker for the highlight and the clipboard …
cemililik Jul 10, 2026
fa9f61f
fix(cli): Step 6f-2 - tmux honours NEITHER OSC 52 form by default, so…
cemililik Jul 10, 2026
7bcd3e8
fix(cli): Step 6f-3 - SGR release carries its button; a split report …
cemililik Jul 10, 2026
f3e10cd
fix(cli): Step 6f-4 - the bare Home stranded mouse reporting on SIGHU…
cemililik Jul 10, 2026
2a5efa5
feat(cli): Step 6f-5 - selection grows past one screenful, and stops …
cemililik Jul 10, 2026
f491393
test(cli): Step 6f-6 - pin the rendered highlight and the Home's geom…
cemililik Jul 10, 2026
a644694
feat(cli): Step 6e - `[preferences].copy_on_select`, the `/copy` hatc…
cemililik Jul 10, 2026
1c18a30
test(cli): Step 5f - ink 7 already emits DEC-2026, so pin it instead …
cemililik Jul 10, 2026
e15fd87
feat(cli): Step 5g - the branded Home banner, with an empty-Home trig…
cemililik Jul 10, 2026
01684c9
fix(cli): Step 6g-1 - THE CAPS-LIFT. The full-screen viewport finally…
cemililik Jul 10, 2026
810f585
fix(cli): Step 6g-2 - two SIGINT paths that strand the terminal (whol…
cemililik Jul 10, 2026
2140e50
fix(cli): Step 6g-3 - two bugs I shipped last night (whole-phase Opus…
cemililik Jul 10, 2026
583fd87
fix(cli): Step 6g-4 - displayWidth IS ink's width function now (128 l…
cemililik Jul 10, 2026
2b9bbda
fix(cli): Step 6g-5 - two notices the alt buffer was eating (whole-ph…
cemililik Jul 10, 2026
cb28053
fix(cli): Step 6g-6 - the /edit transcript could survive the process;…
cemililik Jul 10, 2026
323333d
fix(cli): Step 6g-7 - mouse capture follows the chat, so the Home lan…
cemililik Jul 10, 2026
2b61f03
docs: Step 6g-8 - reconcile every doc the phase made false, and recor…
cemililik Jul 10, 2026
1131d6a
perf(cli): Step 6g-9 - an ASCII fast path for the wrap, which the cap…
cemililik Jul 10, 2026
76ad481
fix(cli): Step 6h-1 - a failed terminal write disarmed every retry; a…
cemililik Jul 10, 2026
a5b14d6
fix(cli): Step 6h-2 - an ordinary streaming answer corrupted the alt-…
cemililik Jul 10, 2026
75c4dbd
test(cli): Step 6h-3 - the caps-lift had no coverage on `relavium cha…
cemililik Jul 10, 2026
98a35ab
fix(cli): Step 6h-4 - `/edit` leaked one exit listener per failed cle…
cemililik Jul 10, 2026
f920d25
docs: Step 6h-5 - two canonical pages the phase left describing the w…
cemililik Jul 10, 2026
c9bed8e
test(cli): the exhaustive width test timed out on CI - one expect, no…
cemililik Jul 10, 2026
0fc6791
fix(cli): fold the PR-time review — a bidi character in the Trojan-So…
cemililik Jul 10, 2026
e4cd6e1
test(cli): the exhaustive width sweep segmented every code point for …
cemililik Jul 10, 2026
f70256c
refactor(cli): fold the PR-review nits — dedupe, extract, narrow, and…
cemililik Jul 10, 2026
e7ae755
fix(cli): fold the second PR-review batch — a stale-copy bug, two tea…
cemililik Jul 11, 2026
3e65f9a
feat(cli): a "Copied" toast so copy-on-select is no longer silent
cemililik Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"quickjs-emscripten-core": "catalog:",
"react": "catalog:",
"smol-toml": "catalog:",
"string-width": "catalog:",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 'Status|Accepted|Proposed|string-width' \
  docs/decisions/0069-string-width-for-the-cli-renderer.md \
  apps/cli/package.json \
  pnpm-workspace.yaml

Repository: HodeTech/Relavium

Length of output: 8709


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C2 'ADR-0069|0069-string-width|open obligation|approval|Accepted|Proposed' \
  docs apps packages .github \
  --glob '!**/node_modules/**'

Repository: HodeTech/Relavium

Length of output: 50374


Hold string-width in apps/cli/package.json:62 until ADR-0069 is Accepted. The dependency is documented, but the ADR is still Proposed, so this runtime addition should wait for approval.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/cli/package.json` at line 62, Remove the "string-width" dependency entry
from apps/cli/package.json for now, and restore it only after ADR-0069 is
accepted.

Source: Coding guidelines

"yaml": "catalog:",
"zod": "catalog:"
},
Expand Down
148 changes: 143 additions & 5 deletions apps/cli/src/commands/chat-alt-hoist.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createSuspendPort, type SuspendPort } from '../render/suspend.js';
import { describe, expect, it, vi } from 'vitest';

import {
Expand Down Expand Up @@ -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<typeof vi.fn>;
readonly removeSignal: ReturnType<typeof vi.fn>;
readonly removeInterrupt: ReturnType<typeof vi.fn>;
readonly setRawMode: ReturnType<typeof vi.fn>;
readonly exit: ReturnType<typeof vi.fn>;
readonly onProcessExit: ReturnType<typeof vi.fn>;
readonly onTerminationSignal: ReturnType<typeof vi.fn>;
readonly onInterrupt: ReturnType<typeof vi.fn>;
}

const harness = (): Harness => {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
158 changes: 157 additions & 1 deletion apps/cli/src/commands/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -141,6 +146,7 @@ const INERT_HOIST = {
lifecycle: {
onProcessExit: (): (() => void) => () => undefined,
onTerminationSignal: (): (() => void) => () => undefined,
onInterrupt: (): (() => void) => () => undefined,
setRawMode: (): void => undefined,
exit: (): void => undefined,
},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ChatCommandDeps> = {},
): Promise<ChatStoreController> => {
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)', () => {
Expand Down
Loading
Loading