Phase 2.6.F — full-screen TUI renderer (Home + chat): alt-screen, scroll/auto-follow, flicker-free swaps, mouse-wheel, a11y#73
Conversation
…tifact two-root model - Split 2.6.N into three workstreams along safety gradient: 2.6.N — catalog child-session foundation (safe mechanism) 2.6.O — on-the-fly generation + parallel fan-out (security-reviewed) 2.6.P — subworkflow node + schema_version 1.1 additive migration - Add artifact two-root model: project .relavium/ vs central ephemeral root, with engine-core purity via host artifact-store port - Expand i18n scoping: Latin-script only in-phase, CJK/RTL forward-compat - Add fs-floor home-anchoring prerequisite (deferred-tasks) - Add ask_user pause/resume engine interaction to toolbelt ADR - Update sequencing, milestones (7), exit criteria, risk table - Fix deferred-tasks links to use relative markdown references
…l-screen renderer) Two Accepted ADRs (reviewed by an adversarial Opus pass, all findings folded): - ADR-0067 — raise the Node supported floor 20.12 → >=22 and re-affirm better-sqlite3; supersedes ADR-0021 (its node:sqlite rejection rationale is materially withdrawn by the floor rise, and the floor is a new breaking decision). node:sqlite stays rejected: RC (not Stable), no drizzle-kit adapter (#5471), and its error shape would break the 2.5.I busy-retry. Flip ADR-0021 to Superseded + forward-pointer; index updated. - ADR-0068 — adopt ink 7, hand-build the full-screen renderer (Home + chat) on ink 7's native alternateScreen (not OpenTUI), add ink-testing-library; refines ADR-0047 (in-place amend note). Scope: Home + chat only (the relavium run TUI stays inline, avoiding its SIGINT-cancel rework). Includes mouse-WHEEL scroll (feasibility-verified: dedicated stdin SGR listener, wheel-only, opt-out, all-exit-path cleanup) — the deferred-mouse call reversed to first-class per the maintainer's first-class-experience goal. Record 2.6.F deferrals in deferred-tasks: vitest 5 / eslint 10 (own PRs), full mouse (click/drag/select/copy → Phase 3), the mouse-default flip, and the relavium-run full-screen follow-up. Refs: ADR-0067, ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
….6.F Step 1) Implements ADR-0067. Node 20 is EOL and ships no better-sqlite3 prebuild (forcing a C++ source build), and ink 7 (the 2.6.F full-screen renderer) hard-requires >=22. - engines.node 20.12 -> >=22 (root + apps/cli); .nvmrc 22 -> 24 (Active LTS); catalog @types/node ^20.12 -> ^22 (pinned to the floor); lockfile updated. - ci.yml: new floor-check job pinned to the exact floor (Node 22.0.0) running typecheck/test/build, proving the published minimum stays green and better-sqlite3 resolves its Node-22 prebuild (no node-gyp source build). - tech-stack.md + the catalog comment repointed to ADR-0067; ADR-0021 already flipped to Superseded (prior commit); node-runtime-upgrade.md marked acted-on. better-sqlite3 is re-affirmed over node:sqlite (RC + no drizzle-kit adapter + would break the 2.5.I busy-retry). Toolchain green on Node 22.23 (lint, typecheck, test 1643/1643, build, format); no source-build. BREAKING CHANGE: the published `relavium` binary now requires Node >=22 (drops Node 20/21). Pre-1.0, so a 0.x MINOR bump (e.g. 0.2.0) at publish per ADR-0067. Refs: ADR-0067 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rectness Verified findings from the aggressive Opus review of Step 1 (367e4f5): - HIGH: apps/cli/README.md (shipped to npm) advertised Node ≥20.12 in the badge + install line — bumped to ≥22 so the published minimum is honest. - HIGH: the ci.yml floor-check job could be a silent false-green — it inherits the workflow TURBO_TOKEN/TURBO_TEAM and Turbo's hash omits the Node version, so it could replay the Node-24 `ci` job's cache instead of running on Node 22. Add --force so it genuinely re-runs on the floor. - Forgotten floor references corrected: .npmrc engine-strict comment, the two pnpm-workspace catalog comments (ink + @Clack), packages/mcp sdk-websocket runtime-requirement comment, and the ci.yml header comment. - Incomplete supersede repoint: database-schema.md (the canonical DB-concurrency home) now co-cites ADR-0067 alongside ADR-0021. - tsup build target node20 → node22 (matches the >=22 floor), recorded via an append-only amendment note on ADR-0051. - deferred-tasks: the floor-bump backlog item is checked off (done by ADR-0067 + Step 1) and its "SemVer-major" wording corrected to the 0.x MINOR conclusion. Toolchain green (24/24, 1643 tests, build, format); all links resolve. Refs: ADR-0067, ADR-0051 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r wording Verified findings from the Sonnet review of Step 1: - HIGH: --force alone left the CI floor-gate half-open. `--force` = `--cache=local:w,remote:w` — it disables cache READS but keeps WRITES, so the floor-check job (Node 22) would still write its results into the shared Turbo remote cache that the REQUIRED `ci` job (Node 24) reads (their task hashes match — Turbo's hash omits the runtime Node). The required gate could then replay a Node-22 result as Node-24-proven, masking a version-specific failure. Fix: blank TURBO_TOKEN/TURBO_TEAM at the floor-check job level so it neither reads nor writes the shared cache — fully isolated (--force kept for the read side). - MEDIUM: the "SemVer-major" wording ADR-0067 corrected to a 0.x MINOR bump was only fixed in deferred-tasks last round; corrected the remaining occurrences in phase-2.6-conversational-authoring.md (task + acceptance) and annotated the node-runtime-upgrade.md analysis body via its status banner. - NIT: .npmrc comment repositioned so "ADR-0067" scopes to the Node clause only (it did not decide the pnpm floor). current.md is left for the end-of-workstream docs pass (per the project's per-workstream update cadence). format green; ci.yml valid. Refs: ADR-0067 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements ADR-0068 part (a): bump the catalog ink ^6.8.0 -> ^7.1.0 for its native alternateScreen / useWindowSize / usePaste / suspendTerminal, the substrate for the full-screen renderer (Steps 4-5). Pin 7.1.0 (has suspendTerminal + the #752 content-==-rows fix). Requires Node >=22 (ADR-0067, Step 1) + React >=19.2 (already pinned); react-devtools-core resolves as a peer via auto-install-peers. The migration is clean — the whole existing suite is green on ink 7 with NO code change: typecheck 11/11, test 12/12 (1643 CLI + all packages), build 12/12, lint 11/11. The input reducers are already ink-7-safe by design — they dual-fold `key.backspace || key.delete` (so ink 7's Backspace->key.backspace still deletes) and check `key.escape` before the meta-based Alt+digit approval guard (so ink 7's Escape-no-longer-sets-key.meta doesn't regress). Remaining for Step 2's review rounds: an explicit ink-7 input-semantics regression pass (Alt word-motions + the Alt+digit guard), and confirming whether the hand-rolled DECSET-2004 bracketed-paste still functions on ink 7.1.0 or must migrate to native usePaste (unit tests don't exercise ink's actual paste delivery). Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Step-2 Opus review)
The Step-2 Opus review found the hand-rolled DECSET-2004 paste latch is DEAD on
ink 7.1.0: ink's input parser strips the 2004 markers and (with no usePaste
listener) re-emits the unwrapped content as one fallback `input` event, so the
markers never reach useInput → isPasteStart/isPasteEnd never match. It didn't
hard-break (the reduceEditorMotion catch-all still appended the block), but it
left a real MEDIUM security regression: a paste whose content is an approval
token ('a'/'y'/'n') during a pending approval was routed to reduceApprovalKey and
AUTO-ANSWERED the ADR-0057 fail-closed floor.
Fix (Home surface — RootApp + HomeController): route paste to ink 7's native
`usePaste` → new `HomeController.handlePaste`, which inserts into the prompt only
when it is the active editable target and DROPS otherwise via `pasteEditable`
(mid-turn / build / !-shell / submit / palette / reverse-search / @-mention /
/models / /effort / the [c] reason capture / AND a pending approval — the security
gate). Removed the dead latch, the `pasting` field + its 4 resets, the
isPasteStart/isPasteEnd markers, and the app's mount-time DECSET-2004 ENABLE (ink
now owns enable on mount / disable on unmount; a defensive DISABLE stays on the
signal/exit teardown). Updated the reducer's ink-6 backspace comment to ink-7.
Tests: replaced the synthetic-marker latch tests with handlePaste tests including
a SECURITY test (a pasted approval token during a pending approval never grants
it) and drop-gate tests (palette / reason capture / loading / mid-turn); adapted
the drive-home init-fault test to a throwing render (paste-enable is no longer a
mount-time writeControl). CLI green: typecheck + 1639 tests.
The standalone `relavium chat` (ChatApp) paste path is migrated next.
Refs: ADR-0068, ADR-0061, ADR-0057
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s review) Completes the usePaste migration on the second surface: the standalone `relavium chat` (ChatApp). Like the Home fix (b51120e), route bracketed paste to ink 7's native `usePaste` channel → the whole paste is one `text` event that appends to the compose buffer verbatim, and a pasted approval token can never reach reduceApprovalKey (the ADR-0057 fail-closed floor). The paste is dropped unless the compose buffer is the active editable target — the same gate as the Home's `pasteEditable`, read against ChatApp's ref-shadows (running / shell / submit / palette / search / @-mention / /models / /effort / [c] reason capture) plus a FRESH pending-approval read from the store. This also closes the standalone-chat paste gap the review flagged: ChatApp never enabled DECSET 2004 before, so a multi-line paste there fell to char-by-char / submit-on-newline; usePaste enables 2004 natively and delivers the whole block. The paste-gate LOGIC is identical to the Home's exhaustively-unit-tested handlePaste (incl. the security case); a mounted ChatApp paste e2e rides the Step-3 ink-testing-library harness. CLI green: typecheck + 1639 tests + format. Refs: ADR-0068, ADR-0061, ADR-0057 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cal docs The Sonnet adversarial pass verified the security fix is sound (ink 7 routes paste exclusively to the usePaste channel — never useInput; every overlay is gated; the latch is fully removed). Folded findings: - MEDIUM (dup gate, only Home tested): extract the paste-gate predicate into ONE shared pure `pasteIsEditable(flags)` in chat-input.ts, used by BOTH the Home (HomeController.pasteEditable, + its Home-only `mode !== 'loading'`) and the standalone ChatApp usePaste handler — so the two surfaces can never drift. Added an exhaustive unit test (all-clear → true; the SECURITY approval-pending case → false; one drop per AND-term), which now covers BOTH surfaces' gate at once (closing the untested-ChatApp gap without mounting ink). - MEDIUM (stale canonical spec): rewrote docs/reference/cli/home.md's paste section (it documented the REMOVED DECSET-2004 latch) to the ink-7 usePaste mechanism + the ADR-0057 security property; added the same to chat-session.md (the standalone chat now supports whole-block paste) and fixed its ink-6 backspace note. - LOW: corrected the catalog's react-devtools-core note (it's an OPTIONAL ink-7 peer, unmet-is-harmless, not "resolved via auto-install-peers"). - LOW (teardown asymmetry): recorded the redundant Home paste-DISABLE cleanup as a deferred item to ride Step 4's writeControl rework (ink's own unmount cleanup already disables 2004 on both surfaces — verified). CLI green: typecheck + 1642 tests + format; all doc links resolve. Refs: ADR-0068, ADR-0057 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adopt `ink-testing-library` (ADR-0068 part f) as the workspace's first `.test.tsx` harness — mounts an ink 7 tree off-screen, captures rendered frames, and drives stdin — and establish the render-test pattern with three suites: - `harness-smoke.test.tsx` — pins the ink-7 render/stdin contract empirically (ink-testing-library 4.0.0 was authored against ink 5 and declares no `ink` peer): mount + `lastFrame()`, `stdin.write` reaching `useInput` (incl. the ink-7 raw backspace byte `\x7f` and `\r`→return), clean `unmount()`, and the load-bearing `await flush()` timing contract (React 19 commits a stdin-driven state update on a later microtask, so frames must be read after a macrotask yield, never synchronously). - `chat-app.test.tsx` — mounted `ChatApp`: idle paste insert, CRLF/CR→LF normalization, the 2.5.H frozen-clock regression (elapsed tracks wall time via a `Date`-only fake clock, re-read each render), a render-economy perf guard (idle ticks do not repaint), and the ADR-0057 SECURITY pin — a bracketed paste can never answer the fail-closed per-tool approval floor (ink routes the whole DECSET-2004 block to `usePaste`, dropped behind the gate), while a real `y` keystroke still answers it. - `home-app.test.tsx` — the same paste-wiring + ADR-0057 security pin end-to-end through a mounted `RootApp` (the Home's `usePaste` → `controller.handlePaste`). Extend the root vitest `include` to `**/*.test.tsx` (the additive JSX-component convention, same `.test.` prefix, coverage-excluded like all apps/) so the new files run. No production code changes — this is the Step-4/5 renderer's regression net. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iscriminators
The Step-3 Opus round found the harness passed but several pins were vacuous
(false-passes) — a passing test that cannot fail on the regression it names.
Empirically verified each fix by breaking the production code and confirming
the test now fails, then reverting:
- SECURITY (BLOCKER): the ADR-0057 approval-floor pin pasted a 4-char token
('yaya'). ink's no-usePaste fallback re-emits a paste to useInput as ONE
multi-char string, and reduceApprovalKey only matches SINGLE chars — so
'yaya' answered nothing on either path and the test passed whether usePaste
was wired or removed. Switched both surfaces to a LONE 'y': with the handler
gone, ink's fallback re-emits 'y' to useInput and answers the floor, so the
test now fails iff the wiring regresses. (Verified: neutralizing usePaste in
chat-ink.tsx flips the assertion.)
- PERF GUARD (MAJOR): 20 store.tick()s ran in a synchronous for-loop before a
single trailing flush; React/ink coalesce that into ONE commit, so a
repaint-every-tick store also produced delta=1 and passed `<=1`. Now flush
AFTER EACH tick and assert `frames.length === baseline` — a broken store
yields ~20 frames. (Verified: an always-flush tick() gives "expected 21 to
be 1".)
- HOME FROZEN-CLOCK (MAJOR): the Home renders the live timer through a distinct
per-frame now() FUNCTION prop (home-app.tsx warns a frozen number prop sticks
it at 0s) and had NO pin. Added a Home frozen-clock test mirroring ChatApp's.
(Verified: freezing ChatRegion's clock at mount fails the "3s" assertion.)
- CRLF (MAJOR/MINOR): the normalization test asserted only `not.toContain('\r')`
— green even on a dropped/stripped paste. chat-app now asserts distinctive
per-line tokens survive; home-app pins the exact buffer `'a\nb\nc'`.
- ink-7 CANARY (MINOR): harness-smoke's `\x7f` Probe folded `backspace||delete`,
so it passed under ink-6 labelling too. Now checks `key.backspace` ONLY — a
genuine ink-major discriminator (ink 7 reports bs:1 del:0 for DEL).
- LEAK-ON-THROW (MAJOR): no afterEach(cleanup) meant a failing assertion skipped
the trailing unmount() and leaked a live ink tree. Added `afterEach(cleanup)`
to all three suites and dropped the per-test unmount().
- DRY (NIT): extracted the shared `flush`/`bracketed` helpers into
harness-util.ts (dev-only, not bundled) so the timing/paste contract has one
home. Standardized on a single `await flush()` (LegacyRoot sync + debug mode
⇒ one macrotask yield suffices). Added the cooling-window date arithmetic to
the ink-testing-library catalog entry (published 2024-05-22, ~778 days).
1657 tests pass; lint/typecheck/build/format green.
Refs: ADR-0068
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gaps The Step-3 Sonnet round (after the Opus fold) found two MAJOR defects and three doc/coverage gaps. Verified each fix by breaking production and confirming the test fails, then reverting. - FLAKY SINGLE-FLUSH (MAJOR): the single `await flush()` (one setImmediate) is NOT reliable for frame assertions — React 19's commit can be deferred past one macrotask under parallel-file CPU contention (the agent reproduced ~11/15 then 4/8 failures at the Home "Working… 3s" assertion running the three .test.tsx files together; a second flush fixed 12/12). Replaced the fixed single yield with `waitFor(predicate, maxYields)` — POSITIVE assertions poll until the commit lands (fast when quick, robust when slow); NEGATIVE ones (security, perf) give the bad outcome a bounded window to manifest, then confirm it did not. Struck the false "a second yield is never needed" claim in harness-util.ts. (Ran the suite 10× post-fix: 0 failures.) - CHATAPP SECURITY LEAK (MAJOR): the ChatApp security pin only asserted the floor stays unanswered — it never checked the paste is DROPPED from the buffer, so a regression in ChatApp's OWN `approvalPending` gate (independent of the usePaste channel) went uncaught, unlike the hardened Home sibling. Added facet 2: a distinctive token pasted during the approval must not render. (Verified: regressing chat-ink.tsx:882 `approvalPending` → false renders `> yzLEAKz` and the new assertion fails.) - RESIZE SMOKE (MINOR): RootApp's subscribeResize → setSize re-measure wiring existed in production but was stubbed inert everywhere. Added a Home resize test that captures the registered callback, changes the size, fires it, and asserts a re-render. (Verified: no-op'ing the useEffect fails "expected 1 to be greater than 1".) - ADR-0068(f) FRAME-TIME (MINOR): amended part (f) in place (dated note) — the harness pins render-COUNT + both frozen-clock paths + the security/CRLF/ backspace discriminators; a wall-clock FRAME-TIME threshold is deferred to the render-heavy Step-4/5 frame loop (per-frame budgets are CI-flaky). - testing.md (MINOR): documented `.test.tsx` as the sanctioned convention for ink-mounted component tests (one-canonical-home, CLAUDE.md #8). - cleanup() quirk (NIT): noted in harness-util.ts that ink-testing-library's cleanup() never clears its instances array (harmless today; idempotent unmount) so a future double-unmount-throws release is diagnosable. 1658 tests pass; lint/typecheck/build/format green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ut (2.6.F Step 4a) The full-screen alternate-screen renderer's lifecycle substrate (ADR-0068 §e), default OPT-IN until the hand-built viewport lands (Step 4b). No transcript viewport / scroll yet — this wires the alt-screen enter/exit, the opt-in/out controls, and the byte-identical inline fallback. - `[preferences].alt_screen` boolean added to the global config schema (@relavium/shared) + surfaced as `ResolvedConfig.altScreen` (a global-only UX preference — no project/workspace layer). - `--no-alt-screen` global flag (position-independent, help-documented) → `GlobalOptions.noAltScreen` (optional so the many predating GlobalOptions test fixtures need no churn; `resolveGlobalOptions` always populates it). - `resolveRenderMode` (new pure module) resolves `alt | inline` with precedence machine/non-TTY → `--no-alt-screen` → `[preferences].alt_screen` → phase default (`DEFAULT_ALT_SCREEN = false` at 4a; flips at 4b). A `plain` output mode (`--json` / CI / non-TTY) short-circuits to inline FIRST, so the machine path stays byte-identical to today — the ADR's hard guarantee. - driveHome (bare Home): resolves the mode and passes ink 7's native `alternateScreen` option; the injectable `render` seam now carries the decision so a test can observe it (added a flag/config/default decision test). - driveInk (`relavium chat`): same resolution, plus the ADR-0068 §c UNMOUNT ORDER fix — the persistent session summary now writes AFTER `instance.unmount()` (which exits the alt screen), so it lands on the PRIMARY buffer, not the torn-down alt buffer. The reorder preserves the prior error semantics (a turn-core reject still tears down and propagates → exit 1). - Docs: `--no-alt-screen` in commands.md's global-flag table; `alt_screen` in config-spec.md's `[preferences]` block. driveInk's alt-screen wiring is verified via resolveRenderMode's tests + the parallel driveHome decision test + review (driveInk itself needs a real TTY + full SessionHandle, which is why the suite injects `deps.drive` to bypass it). 1666 CLI + 459 shared tests pass; lint/typecheck/build/format green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… pin the unmount order
The Step-4a Opus round found two real defects (verified by code-path + a new
test) plus doc/deferral gaps.
- ALT MODE DROPPED ON /clear + /models RESEAT (MAJOR): the initial ReplWiring
set `altScreen: config.altScreen`, but the two REBUILD wiring constructors
(`buildFreshChatWiring` for /clear, `buildReseatWiring` for reseat) omitted it
— so a re-drive reverted the render mode to the phase default mid-conversation
(with `alt_screen = true`, the first /clear flipped chat back to inline; and
once Step 4b flips the default to on, an `alt_screen = false` opt-out would be
dropped on /clear, flipping opt-out users INTO alt-screen). Threaded
`config.altScreen` through `FreshChatWiringDeps` / `ReseatWiringDeps` + both
`createClearRebuild` / `createReseatRebuild` params + all six wiring sites,
mirroring the initial pattern.
- UNMOUNT-ORDER UNVERIFIED (MAJOR): the ADR-0068 §c reorder (summary AFTER
unmount, so it lands on the primary buffer) was covered by no test — a refactor
moving it back ahead of the unmount would have passed the suite. Extracted the
ordering into a pure `finalizeInkExit(exited, {teardown, writeSummary, outcome})`
helper and unit-tested it: teardown-before-summary on resolve, and on reject
teardown-then-skip-summary-and-propagate (→ exit 1, pre-2.6.F behavior). Cleaner
than mocking ink — it isolates exactly the guarantee, no unsafe cast.
- config-spec.md + the schema comment now carry a ⚠ PREVIEW caveat (MINOR): at
Step 4a enabling `alt_screen` gives an alt buffer with no viewport/scroll and
no [/v hatches, so scrolled-off history is unrecoverable — fully usable at 4b.
- deferred-tasks.md records the inter-session alt-buffer flicker (NIT) to resolve
at Step 4b when the default flips (keep the alt buffer entered across a re-drive
/ DEC-2026 sync output).
1669 CLI + 459 shared tests pass; lint/typecheck/build/format green.
Refs: ADR-0068
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…threading + doc parity The Step-4a Sonnet round confirmed both Opus fixes sound (Lens 2 clean, 0 findings) and surfaced one untested fix + doc/robustness gaps. - ALT-MODE-SURVIVES-RE-DRIVE untested (MAJOR): the exact fix the Opus round shipped (threading `alt_screen` through the /clear + /models-reseat rebuild wirings) had no regression test — a future drop of `altScreen: deps.altScreen` from buildFreshChatWiring/buildReseatWiring would pass the whole suite. Added two focused tests (chat.test.ts) that write `[preferences].alt_screen = true`, capture `ctx.altScreen` on BOTH the pre-swap and rebuilt-session driver invocations, and assert `[true, true]`. Verified true discriminators: removing the threading flips them to `[true, undefined]` and both fail. - driveInk unmount guard (NIT, pre-existing symmetry): `finalizeInkExit`'s teardown now wraps `instance?.unmount()` in try/catch, matching driveHome — a throwing terminal-restore can't mask the outcome or skip the SIGINT-listener removal. (Pre-dates Step 4a per git-blame; fixed here since the reorder touches this exact teardown.) - ADR-0068 gains a dated "Step 4a landed" amend note (MINOR) recording what shipped (resolveRenderMode + the flag/key + both mount sites + the §c order) vs what's deferred to 4b (viewport/scroll/caps-lift/[·v hatches/DEC-2026/banner/ mouse), naming DEFAULT_ALT_SCREEN as the single 4b flip-point — mirroring the Step-3 note's convention. - home.md + chat-session.md gain a one-line render-mode pointer (MINOR) to the flag + config key + ADR-0068 §e (pointers, not restatements — one canonical home preserved). 1671 CLI + 459 shared tests pass; lint/typecheck/build/format green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pure algorithmic keystone of the full-screen transcript viewport (ADR-0068 §c) — isolated + exhaustively unit-tested BEFORE the component wires it, so the two delicate algorithms get review in isolation. The alt buffer has no scrollback, so the transcript can no longer live in ink's `<Static>`; it will be wrapped to fixed-width display lines and a fixed window rendered, offset-driven by the Step-4b-2 scroll/auto-follow state machine. - `displayWidth(str)` — a PRAGMATIC width-aware cell count (wide/emoji = 2, zero-width/combining/ZWJ/variation-selectors = 0, control = 0, else 1), iterating by code point so an astral emoji counts once. Deliberately no `string-width` runtime dep (the repo's standing choice — chat-projection.ts); a rare exotic-glyph mis-width is cosmetic + self-correcting, never a scroll corruption (the SAME fn measures both the wrap and the window). - `wrapLogicalLine` / `wrapText` — width-aware char-wrap counting RENDERED terminal rows (not logical lines), with the ADR's required edges: an empty line stays one blank row, a single glyph wider than cols lands alone (never loops/drops), a non-positive width degrades to the whole line. - `maxOffset` / `clampOffset` / `windowLines` — the offset windowing (clamp into [0, total-height], truncate fractional, NaN→0, never read past the ends). 23 unit tests (ASCII/CJK/emoji/combining widths; wrap fits/over/wide-glyph/ empty/over-cols/degenerate; multi-line; the full windowing matrix). Not yet wired — the `TranscriptViewport` component + the ChatView Static-vs-viewport conditional land in 4b-1b. 1694 CLI tests pass; lint/typecheck/format green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lowing (2.6.F Step 4b-1b) Wire the Step-4b-1a viewport math into a rendered component + both surfaces, so the alt-screen renderer shows the transcript through a bounded scroll viewport instead of ink's `<Static>` (which writes to a scrollback the alt buffer lacks). Following-only for now — the scroll/auto-follow keymap is Step 4b-2. - `wrapTranscript` (chat-projection.ts) flattens the transcript to width-wrapped, style-tagged DisplayLines, mirroring `TranscriptLine` EXACTLY (user `> `+cyan, notice dim, assistant text + gray summary + optional yellow hint) and applying the SAME `stripTerminalControls` display-boundary sanitization — so a streamed/pasted control sequence can neither forge a row nor inject ANSI, and the two renderers are visually identical row-for-row. + `DisplayLine`/`LineStyle` added to viewport.ts. - `TranscriptViewport` (new) flex-grows into the leftover height beside the fixed live region and renders only the visible window. It reads its allocated height via ink 7 `measureElement` (post-commit effect) rather than re-deriving the live-region height — a stale count would corrupt the scroll position (ADR-0068 §c's named risk). An absent offset follows the TAIL. - ChatView renders `<Static>` (inline) OR the viewport (alt), gated by a new `viewport` prop; in alt mode its Box flex-grows inside a terminal-`rows`-bounded container so overlays (palette/search/…) are never pushed off-screen. Threaded `alternateScreen` → the viewport prop through BOTH surfaces (driveInk→ChatApp and driveHome→RootApp→ChatRegion). - Tests: `wrapTranscript` (user/notice/assistant/hint/sanitize/order) + mounted ChatApp alt-screen (follows the tail, drops the overflow off the top, bounded to the terminal rows) with the inline-`<Static>`-keeps-everything discriminator. 1701 CLI tests pass; lint/typecheck/build/format green. Empirically verified in the harness: 40 entries → the tail (MSG39) shows, the oldest (MSG0) is dropped, the frame is bounded to 24 rows. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…at viewport
The Step-4b-1 Opus round found the standalone-chat alt-screen path missed
terminal resizes, plus several doc/coverage/robustness items.
- RESIZE on ChatApp (MAJOR): the standalone `relavium chat` alt-screen path
sourced the viewport {rows,cols} from `process.stdout` only inside render + the
frame loop does not repaint at idle, so an idle SIGWINCH left the container
height, the wrap width, AND the measure all STALE (a shrunk terminal overflows
the alt buffer; a narrower one double-wraps → the row count / tail window
diverge — ADR-0068 §c's "stale offset on resize" risk). The Home (RootApp)
already handled it via subscribeResize. Switched ChatApp to ink 7
`useWindowSize()`, which re-renders the component on resize (getWindowSize
falls back to 80×24 off a TTY), so a resize now re-wraps + re-measures — parity
with the Home. Also drives the reasoning-panel `columns`.
- wrapTranscript now MEMOIZED in ChatView on (transcript, cols) (NIT): a
streaming turn reused the stable completed-transcript reference, so it no longer
re-wraps all history every frame. (The 4b-3 caps-lift depends on this.)
- TranscriptViewport seeds its height from `initialHeight` (the terminal rows)
(NIT) so the first paint shows a near-tail window instead of a one-frame blank.
- Corrected the width-invariant comment (viewport.ts) — the real invariant is
1 DisplayLine == 1 real row, requiring displayWidth never UNDER-count vs ink;
over-count is the safe direction. Softened the "row-for-row identical" claim
(char-wrap vs ink's inline word-wrap) and the "settles in one frame" claim
(converges over a few frames on a live-region size change).
- Tests: a mounted Home (RootApp) alt-screen viewport pin (the Home threads it
differently), plus ChatApp edge cases (empty transcript; a single entry taller
than the window shows its bottom rows).
- deferred-tasks: the live-region-taller-than-terminal limitation + the 4b-2
grapheme-aware displayWidth hardening (the under-count becomes load-bearing at
the 4b-2 persisted-offset scroll).
1704 CLI tests pass; lint/typecheck/build/format green.
Refs: ADR-0068
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…me seed, test the resize The Step-4b-1 Sonnet round caught a regression the Opus fold itself introduced, the resize fix shipping untested, and a type hole. - GARBLED FIRST FRAME (MAJOR, a regression from the Opus fold): seeding TranscriptViewport's height from the terminal `rows` (added to avoid a blank first paint) made the first frame window PAST the real flex-allocated box capacity, so ink's write-coalescing rendered a NON-CONTIGUOUS (garbled) slice — worse than the blank it replaced. Reverted to `useState(0)`: a one-frame blank transcript before the post-commit measure lands is the SAFE transient. - RESIZE now TESTED (MAJOR): the Opus fix (ink 7 useWindowSize) shipped with no test. Added a mounted ChatApp resize test that forces a deterministic window size (Object.defineProperty on the harness Stdout's columns/rows + emit 'resize') and asserts the viewport re-bounds 24→10 rows + stays tail-following. The SAME `setWindowSize` helper also makes the other alt-screen tests deterministic — the harness otherwise sourced `rows` from ink's terminal-size → the real `/dev/tty`, making a row-count assertion environment-dependent (a local-first CLI's dev workflow would flake). - styleProps TYPE (MINOR): was `Record<string, unknown>` spread onto `<Text>`, which bypassed prop type-checking entirely. Typed as `Partial<ComponentProps<typeof Text>>` — verified it now catches a bogus/ mistyped prop at compile time (TS2353). - ADR-0068 gets a Step-4b-1 amend note (viewport + row-measurement + tail-follow + resize landed for both surfaces; scroll/force-scroll/grapheme-width → 4b-2; caps-lift → 4b-3; DEC-2026/banner/[·v/mouse → Step 5). - The stale "alt screen has no viewport yet" caveats (config-spec.md + the schema comment, home.md, chat-session.md) now say the viewport HAS landed (tail-following, resize-tracked); only scroll-back + the hatches remain. - deferred-tasks: the live-region-taller-than-terminal failure mode is character-level overlap / silent line loss, not a clean top-clip (empirical). 1705 CLI + 459 shared tests pass; lint/typecheck/build/format green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pure keystone of the alt-screen viewport's scroll/auto-follow (ADR-0068 §c) —
isolated + unit-tested before the keymap/geometry wiring, like the 4b-1a math.
- A single `following` boolean (default true): while following the view is pinned
to the TAIL and every append shows at the bottom; any UPWARD scroll pauses it +
freezes the offset; scrolling back to the bottom (or `bottom`/Ctrl+End) resumes.
- `reduceScroll(state, motion, {totalLines, height})` — geometry-parameterized so
it is pure/testable: every motion computes from the CURRENT effective offset (an
upward scroll while following starts at the tail), then re-derives `following`
from whether the result lands at maxOffset. Pages overlap one row (height-1) so
context carries across; a 0/1-row viewport degrades to a single-row step.
- `effectiveOffset(state, geom)` — the tail (maxOffset) while following, else the
clamped frozen offset; the number the viewport windows from.
13 unit tests (following↔paused transitions, page overlap, top/bottom jumps,
edge clamps, content-fits + tiny-height degenerate geometry). Not yet wired —
the PgUp/PgDn/Ctrl+Home-End keymap + the measured-height lift + the approval
force-scroll land in 4b-2b. lint/typecheck/format green.
Refs: ADR-0068
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the Step-4b-2a scroll state machine + keymap into the alt-screen transcript
viewport on both surfaces, so PgUp/PgDn/Ctrl+Home-End scroll the history.
- The GEOMETRY LIFT (the architectural crux): the viewport's measured height +
wrapped-line count live in TranscriptViewport (behind measureElement), but the
scroll keymap runs in the input owner (useInput). TranscriptViewport now takes
the owner-held `scroll` state (computing `effectiveOffset` from it + its own
height) and reports its live `{totalLines, height}` UP via `onMeasure` into the
owner's `scrollGeomRef`, so a scroll key reduces against the SAME geometry the
viewport windows with (no re-render — onMeasure only updates a ref).
- ChatApp (standalone `relavium chat`): React-local scroll state + ref-shadow +
geomRef; PgUp/PgDn/Ctrl+Home-End intercepted BEFORE the editor routing, only in
alt mode + only when no overlay/approval owns the keyboard (they return first),
not gated on `running` (scroll history while a turn streams).
- Home (RootApp): the same keymap intercepted before `controller.handleKey`
(scroll is a pure-render concern, held in RootApp like `size`, NOT session
state); `scrollMotionForKey` is SHARED so the two surfaces never diverge.
- `pageUp`/`pageDown` added to `ChatKey`. Bare Home/End stay the editor's
line-start/line-end; only Ctrl+Home/End scroll — no keymap collision.
- The ADR-0068 §c force-scroll override is deliberately NOT wired: the approval /
gate prompt renders in the FIXED live region below the viewport, so it is
always on-screen regardless of the transcript scroll — a force-follow would
only yank the user off history they are reading (documented in ChatApp).
- Tests: the scrollMotionForKey keymap (PgUp/PgDn/Ctrl+Home-End; bare Home/End
left to the editor) + mounted end-to-end scroll on BOTH surfaces (PgUp leaves
the tail + pauses follow; PgDn back to the bottom resumes it) driving real
stdin escape sequences.
1724 CLI tests pass; lint/typecheck/build/format green. Verified in the harness:
PgUp scrolls the tail off-screen, PgDn resumes following.
Refs: ADR-0068
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
….F Step 4b-2) The 4b-2 persisted-offset scroll makes the 1-DisplayLine-==-1-real-row invariant LOAD-BEARING (an under-count → ink re-wraps the line to 2 real rows → the viewport clips the tail + the scroll math drifts). The per-code-point width could UNDER- count a composed emoji-presentation cluster — the dangerous direction. Fixed via Intl.Segmenter (Node 22, ADR-0067): - `displayWidth` + `wrapLogicalLine` now segment into GRAPHEME CLUSTERS and measure per cluster (`graphemeWidth`): a cluster carrying VS16 `U+FE0F` or the enclosing keycap `U+20E3` is 2 cells, else the widest constituent code point. A cluster is never split mid-glyph while wrapping. - Fixes the exact wrong cases: keycap `1️⃣` 1→2 (the under-count that clipped the tail), VS16 `❤️` 1→2, ZWJ family `👨👩` 4→2 (a safe over-count, now exact), flag `🇹🇷` + skin-tone `👋🏽` = 2. Bias preserved: never under-count (the safe side). - Segmenter constructed once (module-level); the wrap is memoized (Step-4b-1), so the per-cluster cost only lands on a transcript change, not every frame. 7 new width/wrap pins in viewport.test.ts. Closes the Step-4b-1-Opus-flagged deferred obligation. 1726 CLI tests pass; lint/typecheck/build/format green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… scroll reset, parity The Step-4b-2 Opus round found the grapheme hardening STILL under-counted a common emoji class, plus scroll-wiring parity/coverage gaps. - EMOJI-PRESENTATION UNDER-COUNT (MAJOR, both lenses): bare BMP Emoji_Presentation=Yes singletons (✅❌⭐⚡✨❗➕⌚⛄✋…) render 2 in a terminal WITHOUT a VS16 selector, but graphemeWidth returned 1 (no VS16, not in isWide) — the exact load-bearing under-count the 4b-2 scroll exposes (ink re-wraps the over-long line → the viewport clips the tail + the offset drifts). Added the canonical BMP Emoji_Presentation set to isWide. Verified: ✅❌⭐⚡… now 2, while text symbols in the SAME blocks (→ ✓ ✗ • © ®) STAY 1 (no cosmetic over-count) — the ✓-vs-✅ distinction matches ink. - HOME SCROLL not reset across sessions (MINOR): RootApp holds scroll React-local but is NOT remounted across a /clear or /models-reseat swap (unlike driveInk's ChatApp), so a new session inherited the prior offset. Added a useEffect keyed on the session id that resets to INITIAL_SCROLL (a fresh chat / swap starts at the tail). - KEYMAP INTERCEPTION PARITY (MINOR): the Home intercepted scroll BEFORE the controller with no overlay check, while ChatApp routes it AFTER the overlay early-returns. Gated the Home scroll on "no keyboard-owning overlay" (palette/search/mention/model-picker/effort-picker/reason-capture) so an overlay that later pages with PgUp/PgDn keeps its keys — true two-surface parity. - Added a mounted Ctrl+Home/Ctrl+End test (`\x1b[1;5H` / `\x1b[1;5F`) — the top/bottom motions were only unit-tested, not driven through ink's parser. - ADR-0068 §c gets a Step-4b-2 amend note (scroll+auto-follow+grapheme landed; the force-scroll override intentionally omitted — the fixed live region keeps every decision on-screen; caps-lift→4b-3, mouse-wheel→Step 5). Corrected the ChatApp scroll-interception comment (a pending approval IS reached, safely). - deferred-tasks: the Intl.Segmenter re-segment-on-resize perf (fold an LRU per-line wrap cache into the 4b-3 virtualization). 1728 CLI tests pass; lint/typecheck/build/format green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ometry, grapheme gate Three verified fixes on the 2.6.F Step-4b-2 alt-screen scroll landing, each proven a true discriminator by breaking production + confirming the pinning test fails, then reverting: - Home scroll-reset now keys on the session OBJECT identity (state.session), not the durable sessionId string: a /models reseat deliberately PRESERVES the id across the swap (the reseated session adopts the same row), so an id-keyed effect missed it and left a scrolled-away transcript frozen after a live model switch. RootApp is not remounted across a swap, so the effect is the only reset. - The scroll keymap reduces against LIVE geometry read at the keypress (liveScrollGeometry wraps the store's current transcript for a fresh totalLines) instead of the onMeasure ref, which lags by up to a commit — a mid-stream burst landing between a commit and its measure effect let `settle` resume-follow against a stale, undercounted bottom and yank a paused reader to the tail on a single PgDn. Height lag stays benign (resize re-renders+re-measures). - graphemeWidth's VS16/keycap width-2 override is gated on a base (base > 0) so a DEGENERATE lone variation-selector / keycap cluster counts 0 cells (as ink renders it), not an over-counting 2. Adds the mounted regression coverage the fold flagged as missing: a /models reseat re-follow pin (object-identity discriminator), the overlay scroll-gate on both surfaces (a scroll key behind an open `/` palette reaches the overlay, never the transcript), the paused-mid-page boundary at the mount, a liveScrollGeometry unit test, and a lone-selector width test. Reconciles the render-mode docs (home.md / chat-session.md / config-spec.md alt_screen caveat) to reflect that scroll-back + auto-follow shipped at 4b-2, and defers the isWide non-emoji EAW-punctuation NIT into the tracked EAW/wrap-cache hardening. lint/typecheck/test (1734)/build all green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…size re-clamp pin The alt-screen transcript is unbounded + append-only, and wrapTranscript re-wraps every entry on each append (a new immutable transcript reference busts the render memo), re-segmenting all of history through Intl.Segmenter — O(history) per append, a visible hitch on a very large transcript (Step-4b-2 Opus review). - Front wrapLogicalLine with a bounded (8192-entry) LRU keyed on (cols, line) with a NUL separator that can't occur in a sanitized display line. A logical line's wrap is a pure function of (line, cols) and history lines are stable across renders, so an append is now O(history) cheap map lookups + ONE segmentation (the new line), and a re-render at an unchanged size is all hits. The cache is transparent (a warm wrap equals the cold wrap and the uncached primitive) and LRU-bumps hits so a hot line survives eviction churn. - Pin the resize re-clamp of a PAUSED (non-tail) scroll offset: effectiveOffset / windowLines already clamp on every render, so a frozen offset valid at a tall size re-settles to a valid contiguous window on a shrink (never a blank past-the-end slice) and Ctrl+End still resumes the tail — now covered at the mount. Verified the cache-key collision test is a true discriminator (dropping cols from the key makes a cols=30 wrap return the cached cols=10 wrap → the test fails). lint/typecheck/test (1738)/build all green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ggle (foundation)
The pure, idempotent AltScreenController the inter-session flicker fix is built on
(ADR-0068 §c). `relavium chat` mounts+unmounts ink PER SESSION, and ink 7 ties the
alternate-screen enter/exit to mount/unmount, so a /clear or /models re-drive flips
the terminal primary->alt->primary (a visible flicker + the intro/clearedNotice
landing on the primary buffer). ink 7 renders full-screen via log-update (relative
cursor moves), independent of the alternateScreen option — so the fix (next commit)
enters the alt buffer ONCE above the per-session loop, mounts each session with the
ink option alternateScreen:false, and exits ONCE.
This controller owns that toggle. restore() is IDEMPOTENT (a restored latch) so it is
safe to call from the finally, a process.on('exit') net, and a signal handler alike,
and NEVER exits a buffer it never entered; when inactive (inline / non-TTY / --json /
CI) every method writes nothing (byte-identical opt-out). Byte sequences copied
verbatim from ink so the hoisted toggle is indistinguishable from ink's. 6 unit tests.
Refs: ADR-0068
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e the per-session loop
`relavium chat` mounts+unmounts ink PER SESSION, and ink 7 ties the alternate-screen
enter/exit (DECSET 1049) to mount/unmount — so a /clear or /models re-drive flipped the
terminal primary->alt->primary (a visible flicker, and the intro/clearedNotice landed on
the primary buffer). A parallel investigation of ink 7.1.0's compiled build confirmed ink
renders full-screen via log-update (relative cursor moves), FULLY DECOUPLED from the
alternateScreen render option. So:
- driveInk now passes the ink render OPTION alternateScreen:false (ink toggles the buffer
no more) while KEEPING the ChatApp component PROP (viewport vs <Static>) — proven
independent. ink still full-screen-renders.
- runReplLoop resolves alt-mode ONCE (same signals driveInk uses, so they can't desync)
and wraps the per-session loop in withHoistedAltScreen: enter DECSET-1049 once above the
loop, clear the buffer between re-drives (no stacked transcripts), exit once after.
- EXIT SAFETY (the load-bearing part — ink's own 1049-exit is now inert): restore() runs on
EVERY path — the finally (cooperative ends + a throw), a process.on('exit') net (the
2nd-SIGINT force process.exit that bypasses the finally), and explicit SIGTERM/SIGHUP
handlers that restore the full terminal state (buffer + cursor + bracketed paste + raw
mode) then exit 128+signo. All idempotent via the AltScreenController latch.
- The end-of-session summary is LIFTED out of driveInk onto the outcome (summaryText) and
printed AFTER the single alt-exit, on the primary buffer (ADR-0068 §c ordering preserved).
The Home is unchanged — driveHome is a single long-lived mount (flicker-free by
construction), the reference for this pattern. withHoistedAltScreen is extracted + exported
so every exit path is unit-tested (6 tests) around injected write/lifecycle seams; the
finally-restore was break-verified. Real-TTY signal validation (double-Ctrl-C, kill -TERM)
is the maintainer's manual check at PR time. lint/typecheck/test (1750)/build green.
Refs: ADR-0068
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e default on a TTY With the viewport (4b-1), scroll/auto-follow (4b-2), caps-lift wrap cache, and the inter-session flicker hoist all landed, the full-screen renderer is first-class — so a bare `relavium` / `relavium chat` on a TTY now opens full-screen by default (ADR-0068 §b, the 2.6.F acceptance). `--no-alt-screen` (per invocation) or `[preferences].alt_screen = false` (durable) opts back into the inline renderer (native scrollback + the emulator's own a11y — the screen-reader fallback); a machine / non-TTY / `--json` / CI path is ALWAYS inline + byte-identical (resolveRenderMode short-circuits 'plain' first — unchanged). Reconciles the render-mode docs (config-spec.md alt_screen — now the opt-OUT key; home.md; chat-session.md) to the flipped default, adds the ADR-0068 Step-4b-3 amend note (caps-lift + flicker hoist + flip), and closes the two deferred items (wrap cache + inter-session flicker) now done. lint/typecheck/test (1750)/build green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cache, SIGQUIT, test isolation Adversarial Opus review (4 lenses, each finding verified by an independent skeptic) confirmed 8 of 9; folded 7, deferred 1. Each fix break-verified. - MAJOR (flip regression): a /clear or /models-reseat rebuild FAILURE wrote its actionable error + `chat-resume <id>` hint to stderr while STILL inside the alt buffer, so the single alt-exit discarded it — the user was dropped to the shell with no error + no way to resume, once alt became the default. The message now rides on HoistedLoopResult.errorText and withHoistedAltScreen emits it via writeErr AFTER the alt-exit (primary buffer), mirroring the summary lift. Break-verified (emit-before-exit fails the ordering test). - caps-lift redesign: the fixed-size per-line LRU thrashed to a 0% hit rate once a session exceeded 8192 distinct lines (the exact case it was for — sequential re-scan evicts the head it's about to re-read). Replaced with a per-ENTRY WeakMap cache in wrapTranscript keyed on the immutable, append-only entry object: an append is O(history) lookups + ONE wrapEntry, a resize replaces each entry's single cached wrap, and it NEVER thrashes (holds exactly the live entries, GC-reclaimable). viewport.ts wrapText is pure again. This also removes the invisible-NUL cache key (the confusingly-documented collision comment). - exit-safety: SIGQUIT (128+3=131) joins SIGTERM/SIGHUP in the signal net — an external `kill -QUIT` was stranding the terminal on the alt buffer (ink's own 1049-exit is inert with the render option false). Pinned via defaultReplLifecycle + the handler's 128+signo. - test isolation: the DEFAULT_ALT_SCREEN flip silently drove 3 interactive-TTY tests into the REAL alt buffer + REAL process signal/exit handlers (a SIGTERM mid-test would process.exit the whole runner). They now inject an INERT_HOIST (no-op writeControl + lifecycle) via the new ChatCommandDeps/ChatResumeCommandDeps seams. - rewrote the stale driveInk render-mode comment (it claimed ink mounts/exits the alt screen and writes the summary — both removed by the hoist). Deferred (documented, cosmetic + external-signal-only): a SIGTERM/SIGHUP/SIGQUIT dumps ink's final frame onto the primary buffer (needs threading the instance unmount before the alt-exit). lint/typecheck/test (1752)/build green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oc reconcile Adversarial Sonnet review (post-Opus-fold, 4 lenses, each finding verified by an independent skeptic) confirmed 3 of 7; folded 2, deferred 1 (documented). - MAJOR: the Opus fold's INERT_HOIST (no-op writeControl) made the test suite unable to detect a TOTAL regression in runReplLoop's `altActive` derivation — the one boolean deciding whether the hoist enters the alt buffer. Hard-coding altActive to false (which would resurrect the per-swap flicker for every interactive session, or leak DECSET bytes into --json/CI) passed all 81 tests. Added a `recordingHoist` (records the control-seq writes, never touches the real fd) + a derivation test asserting the hoist ENTERS on an interactive TTY by default, and STAYS OUT with --no-alt-screen / on a non-TTY. Break-verified (altActive→false fails it). - Reconciled the docs that still described the discarded per-line LRU wrap cache (home.md, a new append-only ADR-0068 amend note superseding the caps-lift mechanism, deferred-tasks.md) to the actual shipped per-ENTRY WeakMap cache. Deferred (documented, architectural + conditional): mid-session raw-io notices — the budget-cap warning + the /clear/reseat MCP-skipped diagnostic — still land on the alt buffer and are lost in alt mode (the flip widened a pre-existing latent bug). The fix routes them through the CURRENT session's view-store notice via a per-session store ref — tracked in deferred-tasks.md. lint/typecheck/test (1753)/build green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… alt mode (Step 4b-3) Folds the deferred Step-4b-3 Sonnet finding [2]. With the alt buffer now the default, two session-side callbacks wrote via raw io.writeErr WHILE the hoisted alt buffer was entered, so ink's next log-update frame overwrote them and they were LOST: the budget-cap onBudgetWarning (fires mid-turn from the governor) and the /clear/reseat MCP-skipped diagnostic. A lost cost-cap warning on the default full-screen path is a cost-safety gap. - A file-private `liveSessionNotice` pointer holds the CURRENT session's view-store notice: driveOneSession sets it to `store.notice` for the session's lifetime and clears it in the finally (a REPL runs exactly one session at a time). All four onBudgetWarning wiring sites route through `emitLiveNotice`, which uses the live sink when a session is up and falls back to raw io (primary buffer) otherwise — so the warning renders as a transcript notice, surviving the alt buffer, and the non-interactive path is unchanged. - The re-drive MCP-skipped diagnostic fires between sessions (before the sink is live), so it routes to the FRESH session's store.notice directly via a new `mcpSkippedLines` helper (extracted from surfaceMcpSkipped; other callers unchanged). Break-verified the live-routing (forcing raw io fails the transcript assertion). Pinned by a live-routing test, a fallback test, and a mcpSkippedLines unit test. lint/typecheck/test (1755)/build green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… renderer The 2.6.F acceptance calls for a user-facing accessibility note documenting the screen-reader trade-off + the escape hatch. Adds docs/reference/cli/accessibility.md: the full-screen alternate-screen renderer (now the TTY default) is inherently inaccessible to screen readers (a raw-mode, full-redraw TUI with no live-region semantics + no scrollback), and the inline renderer — reached via --no-alt-screen (per invocation) or [preferences].alt_screen = false (durable), and always used on a non-TTY / --json / CI path — is the byte-identical, screen-reader-friendly escape hatch. Also records the color-not-required-for-meaning (NO_COLOR), keyboard-only, and display-boundary-sanitization properties. Linked from home.md + chat-session.md. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The alt screen now enables terminal mouse reporting (X11 button 1000 + SGR 1006), so the transcript viewport wheel-scrolls: a wheel notch scrolls WHEEL_LINES (3) lines via the same reduceScroll actions PgUp/PgDn use. The maintainer opted for on-by-default in full-screen mode (the trade-off — native mouse text-selection now needs Shift/Option — is documented in accessibility.md; the `[`/`v` copy hatches cover selection-free copy). - alt-screen.ts: ENABLE_MOUSE / DISABLE_MOUSE bytes, bundled into the AltScreenController's enter/restore so the chat hoist enables mouse with the alt buffer and DISABLES it on EVERY exit path (restoring native selection). driveHome enables it after its single ink mount and disables it on every teardown (unconditional, mirroring the bracketed-paste belt). - scroll.ts: parseMouseScroll classifies an SGR mouse report — wheel-up/down → a line motion, any other report → 'ignore', non-mouse → undefined. The ChatApp + RootApp scroll intercepts CONSUME any mouse report (return) so a click/drag's raw bytes never type into the editor, and apply the wheel motion when it is a wheel. Break-verified the wheel scroll; a mounted click-is-consumed test pins that mouse bytes never leak into the prompt. lint/typecheck/test (1761)/build green. Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Sorry @cemililik, your pull request is larger than the review limit of 150000 diff characters
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
✅ Files skipped from review due to trivial changes (2)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThis PR raises the Node.js floor to >=22 across runtime, CI, and docs, and adds an ink 7 alternate-screen renderer for chat and Home. It also threads render-mode selection, scroll/view handling, native paste, and alt-screen lifecycle control through the CLI. ChangesNode 22 supported floor
Ink 7 full-screen renderer
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Code Review
This pull request raises the Node.js supported floor to >=22 and upgrades ink to version 7 to implement a full-screen alternate-screen renderer for the Home and chat REPL. Key additions include a scrollable transcript viewport with keyboard and mouse-wheel navigation, native bracketed paste handling, and a new component test suite using ink-testing-library. The review feedback is highly constructive and should be addressed: first, refactor the module-global liveSessionNotice in chat.ts to a session-local reference to prevent potential race conditions and test interference; second, optimize TranscriptViewport by adding a dependency array to useEffect to avoid performance overhead from calling measureElement on every render; and finally, update the outdated comments in config.ts and documentation in config-spec.md that still list scroll-back and mouse-wheel scroll as pending features.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| * and it is lost when the buffer is torn down (the default full-screen path). Absent (pre-loop / between swaps / | ||
| * non-interactive) ⇒ the raw-`io` fallback on the primary buffer, exactly as before. | ||
| */ | ||
| let liveSessionNotice: ((text: string) => void) | undefined; |
There was a problem hiding this comment.
The module-global variable liveSessionNotice introduces shared state across the module. While a single REPL runs at a time in production, this can cause race conditions, cross-talk, or test interference if multiple sessions or tests run concurrently in the same process. To make the session lifecycle fully re-entrant and robust, consider threading a session-local reference (e.g., a mutable ref object { current?: (text: string) => void }) through ChatReplDeps and FreshChatWiringDeps instead of relying on a module-level global variable.
| useEffect(() => { | ||
| const node = ref.current; | ||
| if (node === null) return; | ||
| const measured = measureElement(node); | ||
| props.onMeasure?.({ totalLines: props.lines.length, height: measured.height }); | ||
| if (measured.height !== height) setHeight(measured.height); | ||
| }); |
There was a problem hiding this comment.
Calling measureElement on every single render (due to the lack of a dependency array in useEffect) can cause performance bottlenecks and layout thrashing, especially during fast token streaming where the component re-renders frequently. To optimize this, consider passing the terminal dimensions (cols and rows) as props to TranscriptViewport and adding a dependency array to useEffect (e.g., [props.lines.length, props.cols, props.rows, height, props.onMeasure]). This ensures layout measurement only runs when the terminal size or the transcript line count actually changes, significantly reducing CPU overhead during active streaming.
| // ⚠ PREVIEW at Step 4b-1: `true` renders the transcript through a tail-following, resize-tracked VIEWPORT, | ||
| // but scroll-back (PgUp/PgDn, Step 4b-2) + the `[`/`v` copy-and-search hatches (Step 5) are still pending, so | ||
| // history that scrolls off the top is not yet reachable — fully usable once scroll lands. |
There was a problem hiding this comment.
| 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). | ||
| # The transcript renders 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 yields to any keyboard-owning overlay); a `/clear` or `/models` swap no longer flickers the terminal (the alt buffer is held across sessions). Still pending (Step 5): the `[`-dump / `v`-open-in-$EDITOR copy-and-search hatches + mouse-wheel. Set `false` (or `--no-alt-screen`) for the inline renderer — the screen-reader-friendly path. |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (11)
docs/roadmap/phases/phase-2.6-conversational-authoring.md (1)
782-789: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winContradictory "non-configurable" vs. "configurable ceiling" wording.
Line 787 introduces guardrails as "engine-enforced and non-configurable where noted," then in the same breath describes the concurrency ceiling as "a configurable ceiling (default 5)." As written, it's ambiguous whether the concurrency ceiling is meant to be a hard constant (like the depth-3 limit) or a tunable setting — this will matter once the 2.6.N ADR is drafted from this spec.
✏️ Suggested clarification
- **Hard limits are engine-enforced - and non-configurable where noted:** maximum nesting depth **3** (parent → child → grandchild; a - grandchild cannot spawn further); maximum concurrent children per turn bounded at a configurable ceiling - (default **5**). Hard-coding these bounds lets downstream UX (error messages, history tree views, cost + **Hard limits are engine-enforced and non-configurable:** maximum nesting depth **3** (parent → + child → grandchild; a grandchild cannot spawn further). Maximum concurrent children per turn is + bounded at a **configurable** ceiling (default **5**). Hard-coding the depth bound (and defaulting + the concurrency bound) lets downstream UX (error messages, history tree views, cost indentation) design against fixed limits from day one.🤖 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 `@docs/roadmap/phases/phase-2.6-conversational-authoring.md` around lines 782 - 789, Clarify the guardrail wording in the 2.6. O permissions section so it is not contradictory. Update the paragraph around the hard limits statement to distinguish the fixed nesting depth enforced by the engine from the concurrent-children ceiling, and make it explicit whether the ceiling is configurable or non-configurable. Keep the language consistent in the surrounding `Permission model + hard guardrails` description so readers can tell which limits are hard-coded versus tunable.packages/shared/src/config.ts (1)
144-151: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winStale schema comment — out of sync with the shipped feature.
This inline comment describes the "⚠ PREVIEW at Step 4b-1" state, claiming scroll-back and the default are still pending: "⚠ PREVIEW at Step 4b-1:
truerenders the transcript through a tail-following, resize-tracked VIEWPORT, but scroll-back (PgUp/PgDn, Step 4b-2) + the[/vcopy-and-search hatches (Step 5) are still pending, so history that scrolls off the top is not yet reachable — fully usable once scroll lands." It also says the phase default is "opt-in until the viewport lands."But
docs/reference/contracts/config-spec.md(same PR) says the default flipped to alt-ON and scroll-back already shipped: "Since Step 4b-3 the DEFAULT is ON — a TTY opens full-screen — so this key is the durable OPT-OUT... The transcript renders through a resize-tracked VIEWPORT with SCROLL-BACK + auto-follow — PgUp/PgDn page, Ctrl+Home/Ctrl+End jump to top/tail... Still pending (Step 5): the[-dump /v-open-in-$EDITOR copy-and-search hatches + mouse-wheel." The PR stack also lists mouse-wheel scrolling as delivered in a later step. This schema doc-comment should be updated to match the current (post Step 4b-3 / Step 10) state so engineers reading the schema aren't misled about pending work and the default direction.📝 Suggested comment update
- // Full-screen alt-screen renderer opt-in/out (2.6.F, ADR-0068 §e) — `false` (or the `--no-alt-screen` flag) - // keeps the byte-identical INLINE renderer (native scrollback + the emulator's own a11y), the screen-reader - // fallback. The `--no-alt-screen` flag overrides this key; a non-TTY / machine (`--json`/CI) path ignores - // both and always renders inline. Absent ⇒ the phase default (opt-in until the viewport lands — ADR-0068 §b). - // ⚠ PREVIEW at Step 4b-1: `true` renders the transcript through a tail-following, resize-tracked VIEWPORT, - // but scroll-back (PgUp/PgDn, Step 4b-2) + the `[`/`v` copy-and-search hatches (Step 5) are still pending, so - // history that scrolls off the top is not yet reachable — fully usable once scroll lands. + // Full-screen alt-screen renderer opt-in/out (2.6.F, ADR-0068 §e) — `false` (or the `--no-alt-screen` flag) + // keeps the byte-identical INLINE renderer (native scrollback + the emulator's own a11y), the screen-reader + // fallback. The `--no-alt-screen` flag overrides this key; a non-TTY / machine (`--json`/CI) path ignores + // both and always renders inline. Absent ⇒ the phase default (alt-ON since Step 4b-3 — ADR-0068 §b). The + // viewport supports scroll-back (PgUp/PgDn, Ctrl+Home/Ctrl+End) and mouse-wheel; the `[`/`v` + // copy-and-search hatches (Step 5) are still pending. See config-spec.md for the full description. alt_screen: z.boolean().optional(),🤖 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 `@packages/shared/src/config.ts` around lines 144 - 151, Update the inline schema comment for alt_screen in config.ts so it matches the shipped behavior described by the current config spec. Replace the stale “preview / opt-in / scroll-back pending” wording with the current state: default ON for TTY/full-screen, this key acting as the durable opt-out, and scroll-back/auto-follow already available. Keep the comment aligned with the alt_screen z.boolean().optional() setting and remove references to Step 4b-1/4b-2 that are no longer accurate.apps/cli/src/render/alt-screen.test.ts (1)
74-78: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuePrefer
toHaveLengthfor the length assertion.Line 77 compares
out.lengthviatoEqual;toHaveLengthgives clearer failure output.♻️ Suggested tweak
- const afterRestore = out.length; - c.clearBetween(); // after restore → no-op (buffer is gone) - expect(out.length).toBe(afterRestore); + const afterRestore = out.length; + c.clearBetween(); // after restore → no-op (buffer is gone) + expect(out).toHaveLength(afterRestore);🤖 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/src/render/alt-screen.test.ts` around lines 74 - 78, The length assertion in the alt-screen test should use `toHaveLength` instead of comparing `out.length` directly. Update the expectation in `alt-screen.test.ts` around the `c.clearBetween()` no-op check so it asserts on `out` with `toHaveLength`, keeping the existing `afterRestore` value and the `c.restore` / `c.clearBetween` flow unchanged.Source: Linters/SAST tools
apps/cli/src/render/tui/scroll.test.ts (1)
118-122: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
as nevercast bypasses the "no unsafeas" rule.
{ upArrow: true } as neverforces an object with an unrelated property throughscrollMotionForKey. SinceScrollKey's fields are all optional, the same "not a scroll key" case can be tested without a cast (e.g.{ ctrl: false }or simply relying on the existing{}case above at line 119).As per coding guidelines, "All source must be TypeScript, in strict mode: no
any, no unsafeas, and prefer type guards."🤖 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/src/render/tui/scroll.test.ts` around lines 118 - 122, Remove the unsafe `as never` cast in the `scrollMotionForKey` test and keep the non-scroll-key coverage using a valid `ScrollKey`-shaped object or the existing empty-object case. Update the `returns undefined for a non-scroll key` test in `scroll.test.ts` so it exercises the same fallback path without forcing an unrelated property through type checking, preserving the intent of `scrollMotionForKey` while complying with the no-unsafe-`as` rule.Source: Coding guidelines
apps/cli/src/render/tui/transcript-viewport.tsx (1)
85-93: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueArray index as React key (Sonar hint) — low risk here, optional to change.
visible.map((line, index) => ...)keys onindex; since<Text>is a stateless leaf fully determined by its props each render, this is unlikely to cause visible bugs, but a key derived from content/offset would be more defensive if the list ever gains reordering/animation.🤖 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/src/render/tui/transcript-viewport.tsx` around lines 85 - 93, The `visible.map((line, index) => ...)` in `TranscriptViewport` uses the array index as the React key; switch to a stable key derived from the line’s identity (for example, its text plus any available offset or sequence field) so `<Text>` instances remain correctly keyed even if the list order changes. Update the key in the `visible` render loop without changing the displayed output or `styleProps` behavior.Source: Linters/SAST tools
apps/cli/src/render/tui/chat-projection.ts (2)
265-311: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOverview doc block documents
wrapTranscriptbut sits above the privatewrapEntry.The lines 265-277 JSDoc describes flattening the whole transcript into
DisplayLines (the public contract), but it's attached to the private per-entry helperwrapEntryrather than the exportedwrapTranscript(line 312), which has no doc comment of its own. Consider moving the transcript-level description to sit abovewrapTranscriptand keeping only the per-entry doc abovewrapEntry.🤖 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/src/render/tui/chat-projection.ts` around lines 265 - 311, Move the transcript-level JSDoc that describes flattening the full transcript into width-wrapped DisplayLines so it sits above wrapTranscript instead of wrapEntry, since that comment documents the public contract of the exported function. Keep wrapEntry with only a short per-entry doc that explains its one-entry wrapping behavior, and make sure the symbols wrapTranscript and wrapEntry are clearly matched to their own docs.
318-320: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuePrefer optional chaining, per static analysis.
♻️ Proposed simplification
- const cached = entryWrapCache.get(entry); - let lines: readonly DisplayLine[]; - if (cached !== undefined && cached.cols === cols) { + const cached = entryWrapCache.get(entry); + let lines: readonly DisplayLine[]; + if (cached?.cols === cols) {🤖 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/src/render/tui/chat-projection.ts` around lines 318 - 320, The cache lookup in chat projection uses an explicit undefined check before reading cols; simplify this by using optional chaining in the cached entry handling within the chat-projection logic. Update the condition around entryWrapCache.get and the cached.cols comparison so it safely reads cols only when a cached value exists, keeping the behavior unchanged while matching the static analysis suggestion.Source: Linters/SAST tools
apps/cli/src/render/tui/chat-input.test.ts (1)
547-565: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDerive
gatesfrom the flags object instead of hardcoding the key list.The exhaustive gate-coverage test is only as good as the manually maintained
gatesarray. If a new field is added toPasteEditableFlagswithout also adding it here, the "drops the paste on any single busy/overlay gate" guarantee silently loses coverage for that field — on a fail-closed, approval-gating predicate where that matters.♻️ Proposed fix to keep the gate list in sync automatically
- const gates: (keyof PasteEditableFlags)[] = [ - 'running', - 'shellBusy', - 'submitBusy', - 'paletteOpen', - 'searchOpen', - 'mentionOpen', - 'modelPickerOpen', - 'effortPickerOpen', - 'reasonCaptureOpen', - 'approvalPending', - ]; + const gates = Object.keys(ALL_CLEAR) as (keyof PasteEditableFlags)[];🤖 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/src/render/tui/chat-input.test.ts` around lines 547 - 565, The exhaustive gate test in chat-input.test.ts is manually maintaining the list used by pasteIsEditable, so new PasteEditableFlags fields can slip through unnoticed. Update the test to derive the gate keys from the flags object/ALL_CLEAR shape instead of hardcoding them, while still iterating through each gate and asserting a single enabled flag makes pasteIsEditable return false.apps/cli/src/commands/chat.ts (1)
405-414: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoffModule-level
liveSessionNoticesingleton — verify the "one session per process" invariant always holds.This file-private pointer is documented as safe because "a REPL runs exactly ONE session at a time per process." That's true for the current single
chatCommand/chatResumeCommandinvocation model, but it's a global mutable coupling point — any future concurrent use (e.g. a test harness running twochatCommand()calls viaPromise.all, or a future embedding scenario) would cross-wire budget/MCP-skipped notices between unrelated sessions. Worth a short comment reiterating the invariant is load-bearing, or threading the sink throughChatReplDeps/ReplWiringinstead of module state.🤖 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/src/commands/chat.ts` around lines 405 - 414, The module-level liveSessionNotice singleton relies on a strict “one session per process” invariant, so make that assumption explicit and load-bearing in the surrounding chat flow. Update the chatCommand/chatResumeCommand and driveOneSession wiring to either clearly document and guard the single-session guarantee, or better, pass the notice sink through ChatReplDeps/ReplWiring instead of keeping it as module state so concurrent invocations cannot cross-wire notices between sessions.apps/cli/src/home/drive-home.tsx (1)
472-480: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMinor duplication: terminal-restore sequence repeated in
onSignalandfinally.
writeControl(DISABLE_BRACKETED_PASTE); writeControl(DISABLE_MOUSE);(plus the enclosing try/catch) is now written out twice. Extracting a smallrestoreTerminalControls()closure would remove the duplication without changing behavior.♻️ Optional dedup
+ const restoreTerminal = (): void => { + instance?.unmount(); + writeControl(DISABLE_BRACKETED_PASTE); + writeControl(DISABLE_MOUSE); + }; 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) + restoreTerminal(); } catch {Also applies to: 554-566
🤖 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/src/home/drive-home.tsx` around lines 472 - 480, The terminal restore logic is duplicated in both the signal handler and the cleanup path, so extract the repeated best-effort sequence into a small restoreTerminalControls helper/closure near onSignal and finally. Move the instance?.unmount() and the DISABLE_BRACKETED_PASTE/DISABLE_MOUSE writeControl calls, along with the existing try/catch, into that helper and invoke it from both places to keep behavior identical while removing duplication.docs/reference/cli/chat-session.md (1)
25-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSentence fragment: "Still pending (Step 5): the
[/vcopy-and-search hatches — see …" is missing a verb/subject-predicate structure.Minor grammar nit flagged by static analysis; a quick rewrite reads more cleanly.
✏️ Suggested rewrite
-Still pending (Step 5): the **`[`/`v`** copy-and-search hatches — see [config-spec.md](../contracts/config-spec.md)'s `alt_screen` caveat. +The **`[`/`v`** copy-and-search hatches are still pending (Step 5) — see [config-spec.md](../contracts/config-spec.md)'s `alt_screen` caveat.🤖 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 `@docs/reference/cli/chat-session.md` around lines 25 - 26, The sentence in the CLI chat-session docs is a fragment and needs a complete subject-verb structure. Rewrite the “Still pending (Step 5)” line in the relevant documentation text so it reads as a full sentence while preserving the meaning about the `[`/`v` copy-and-search hatches and the `alt_screen` caveat, and keep the surrounding references to ADR-0068 and config-spec.md intact.Source: Linters/SAST tools
🤖 Prompt for all review comments with 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.
Inline comments:
In `@apps/cli/README.md`:
- Around line 22-23: The CLI README support note is overstating the LTS status
by listing 24 and 26 as LTS lines; update the wording in the Node.js requirement
blurb to keep the support story accurate. Rephrase the sentence around the Node
support claim so it only says Node.js ≥ 22, or at most 22/24, and avoid
describing 26 as LTS while leaving the rest of the install/runtime text
unchanged.
In `@apps/cli/src/commands/chat.ts`:
- Around line 405-426: The budget warning routing in
emitLiveNotice/livingSessionNotice is being installed for plain and JSON
sessions too, but those drivers do not render store.notice, so the warning never
reaches stderr. Update the wiring around driveOneSession so liveSessionNotice =
(text) => store.notice(text) is only used for the ink/alt-screen path, and make
drivePlain and driveJson keep using io.writeErr for mid-session notices; use the
existing emitLiveNotice, liveSessionNotice, and budgetWarningText symbols to
keep the fix localized.
In `@apps/cli/src/render/tui/home-app.tsx`:
- Around line 197-233: Mouse reports are only being parsed inside the altChat
path, so SGR mouse input can still fall through to controller.handleKey in other
Home states. Update the useInput handler in home-app.tsx to detect and consume
parseMouseScroll(input) whenever alternateScreen is enabled, regardless of
state.mode or noOverlay, and keep only the scroll application logic behind the
existing altChat check so raw mouse bytes never reach the prompt/palette/reason
capture handlers.
In `@apps/cli/src/render/tui/home-controller.ts`:
- Around line 1473-1482: `handlePaste` mutates the input buffer through
`insertAtCursor` but never clears history navigation, unlike other real edits
such as `append`, `backspace`, `newline`, `kill`, `mention-restore`, and
`search-accept`. Update `handlePaste` in `home-controller` to reset the history
nav state with `resetHistoryNav(history)` when a paste is accepted, alongside
the existing `doctorRunId`/`set` update, so a paste behaves like any other
buffer-changing edit and does not leave stale Up/Down recall state behind.
In `@docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md`:
- Around line 5-11: The amendment note in this ADR is inconsistent with the
later mechanics section, so update the document in both places together. In the
ADR body around the bundle build and runtime floor references, align the
published `engines.node` and `tsup` target statements with the amended Node 22
floor so there is no remaining `node20` / `>=20.12.0` wording; keep the same
bundle-boundary decision while replacing the outdated floor-specific values.
In `@docs/decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.md`:
- Around line 56-60: The wording in the decision note overstates Node 26 as an
LTS line; update the `better-sqlite3 under >=22` section to refer only to Node
22/24 as supported LTS lines, or change the phrasing to “supported lines” if you
want to keep Node 26 included. Make the same terminology fix in the related
`85-87` passage so the `supported LTS lines` wording is consistent throughout
the document.
---
Nitpick comments:
In `@apps/cli/src/commands/chat.ts`:
- Around line 405-414: The module-level liveSessionNotice singleton relies on a
strict “one session per process” invariant, so make that assumption explicit and
load-bearing in the surrounding chat flow. Update the
chatCommand/chatResumeCommand and driveOneSession wiring to either clearly
document and guard the single-session guarantee, or better, pass the notice sink
through ChatReplDeps/ReplWiring instead of keeping it as module state so
concurrent invocations cannot cross-wire notices between sessions.
In `@apps/cli/src/home/drive-home.tsx`:
- Around line 472-480: The terminal restore logic is duplicated in both the
signal handler and the cleanup path, so extract the repeated best-effort
sequence into a small restoreTerminalControls helper/closure near onSignal and
finally. Move the instance?.unmount() and the
DISABLE_BRACKETED_PASTE/DISABLE_MOUSE writeControl calls, along with the
existing try/catch, into that helper and invoke it from both places to keep
behavior identical while removing duplication.
In `@apps/cli/src/render/alt-screen.test.ts`:
- Around line 74-78: The length assertion in the alt-screen test should use
`toHaveLength` instead of comparing `out.length` directly. Update the
expectation in `alt-screen.test.ts` around the `c.clearBetween()` no-op check so
it asserts on `out` with `toHaveLength`, keeping the existing `afterRestore`
value and the `c.restore` / `c.clearBetween` flow unchanged.
In `@apps/cli/src/render/tui/chat-input.test.ts`:
- Around line 547-565: The exhaustive gate test in chat-input.test.ts is
manually maintaining the list used by pasteIsEditable, so new PasteEditableFlags
fields can slip through unnoticed. Update the test to derive the gate keys from
the flags object/ALL_CLEAR shape instead of hardcoding them, while still
iterating through each gate and asserting a single enabled flag makes
pasteIsEditable return false.
In `@apps/cli/src/render/tui/chat-projection.ts`:
- Around line 265-311: Move the transcript-level JSDoc that describes flattening
the full transcript into width-wrapped DisplayLines so it sits above
wrapTranscript instead of wrapEntry, since that comment documents the public
contract of the exported function. Keep wrapEntry with only a short per-entry
doc that explains its one-entry wrapping behavior, and make sure the symbols
wrapTranscript and wrapEntry are clearly matched to their own docs.
- Around line 318-320: The cache lookup in chat projection uses an explicit
undefined check before reading cols; simplify this by using optional chaining in
the cached entry handling within the chat-projection logic. Update the condition
around entryWrapCache.get and the cached.cols comparison so it safely reads cols
only when a cached value exists, keeping the behavior unchanged while matching
the static analysis suggestion.
In `@apps/cli/src/render/tui/scroll.test.ts`:
- Around line 118-122: Remove the unsafe `as never` cast in the
`scrollMotionForKey` test and keep the non-scroll-key coverage using a valid
`ScrollKey`-shaped object or the existing empty-object case. Update the `returns
undefined for a non-scroll key` test in `scroll.test.ts` so it exercises the
same fallback path without forcing an unrelated property through type checking,
preserving the intent of `scrollMotionForKey` while complying with the
no-unsafe-`as` rule.
In `@apps/cli/src/render/tui/transcript-viewport.tsx`:
- Around line 85-93: The `visible.map((line, index) => ...)` in
`TranscriptViewport` uses the array index as the React key; switch to a stable
key derived from the line’s identity (for example, its text plus any available
offset or sequence field) so `<Text>` instances remain correctly keyed even if
the list order changes. Update the key in the `visible` render loop without
changing the displayed output or `styleProps` behavior.
In `@docs/reference/cli/chat-session.md`:
- Around line 25-26: The sentence in the CLI chat-session docs is a fragment and
needs a complete subject-verb structure. Rewrite the “Still pending (Step 5)”
line in the relevant documentation text so it reads as a full sentence while
preserving the meaning about the `[`/`v` copy-and-search hatches and the
`alt_screen` caveat, and keep the surrounding references to ADR-0068 and
config-spec.md intact.
In `@docs/roadmap/phases/phase-2.6-conversational-authoring.md`:
- Around line 782-789: Clarify the guardrail wording in the 2.6. O permissions
section so it is not contradictory. Update the paragraph around the hard limits
statement to distinguish the fixed nesting depth enforced by the engine from the
concurrent-children ceiling, and make it explicit whether the ceiling is
configurable or non-configurable. Keep the language consistent in the
surrounding `Permission model + hard guardrails` description so readers can tell
which limits are hard-coded versus tunable.
In `@packages/shared/src/config.ts`:
- Around line 144-151: Update the inline schema comment for alt_screen in
config.ts so it matches the shipped behavior described by the current config
spec. Replace the stale “preview / opt-in / scroll-back pending” wording with
the current state: default ON for TTY/full-screen, this key acting as the
durable opt-out, and scroll-back/auto-follow already available. Keep the comment
aligned with the alt_screen z.boolean().optional() setting and remove references
to Step 4b-1/4b-2 that are no longer accurate.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 7cff1acf-c368-418f-8832-6fe449b38537
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (65)
.github/workflows/ci.yml.npmrc.nvmrcapps/cli/README.mdapps/cli/package.jsonapps/cli/src/commands/chat-alt-hoist.test.tsapps/cli/src/commands/chat.test.tsapps/cli/src/commands/chat.tsapps/cli/src/config/resolve.tsapps/cli/src/engine/mcp-servers.test.tsapps/cli/src/engine/mcp-servers.tsapps/cli/src/engine/media-wiring.test.tsapps/cli/src/home/drive-home.test.tsapps/cli/src/home/drive-home.tsxapps/cli/src/process/options.test.tsapps/cli/src/process/options.tsapps/cli/src/program.tsapps/cli/src/render/alt-screen.test.tsapps/cli/src/render/alt-screen.tsapps/cli/src/render/render-mode.test.tsapps/cli/src/render/render-mode.tsapps/cli/src/render/tui/chat-app.test.tsxapps/cli/src/render/tui/chat-ink.test.tsapps/cli/src/render/tui/chat-ink.tsxapps/cli/src/render/tui/chat-input.test.tsapps/cli/src/render/tui/chat-input.tsapps/cli/src/render/tui/chat-projection.test.tsapps/cli/src/render/tui/chat-projection.tsapps/cli/src/render/tui/harness-smoke.test.tsxapps/cli/src/render/tui/harness-util.tsapps/cli/src/render/tui/home-app.test.tsxapps/cli/src/render/tui/home-app.tsxapps/cli/src/render/tui/home-controller.test.tsapps/cli/src/render/tui/home-controller.tsapps/cli/src/render/tui/home-input.test.tsapps/cli/src/render/tui/home-input.tsapps/cli/src/render/tui/scroll.test.tsapps/cli/src/render/tui/scroll.tsapps/cli/src/render/tui/transcript-viewport.tsxapps/cli/src/render/tui/viewport.test.tsapps/cli/src/render/tui/viewport.tsapps/cli/tsup.config.tsdocs/decisions/0021-node-sqlite-driver-better-sqlite3.mddocs/decisions/0047-cli-framework-commander-ink-clack.mddocs/decisions/0051-cli-distribution-thin-bundle-private-engine.mddocs/decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.mddocs/decisions/0068-full-screen-tui-renderer-ink7-harness.mddocs/decisions/README.mddocs/reference/cli/accessibility.mddocs/reference/cli/chat-session.mddocs/reference/cli/commands.mddocs/reference/cli/home.mddocs/reference/contracts/config-spec.mddocs/reference/desktop/database-schema.mddocs/roadmap/deferred-tasks.mddocs/roadmap/phases/node-runtime-upgrade.mddocs/roadmap/phases/phase-2.6-conversational-authoring.mddocs/standards/testing.mddocs/tech-stack.mdpackage.jsonpackages/mcp/src/sdk-websocket.tspackages/shared/src/config.test.tspackages/shared/src/config.tspnpm-workspace.yamlvitest.config.ts
…nsume, paste history Verified each reported finding against the current code; fixed the still-valid ones, skipped the rest with reasons. Every fix is break-verified (break the production code, watch the new test fail, revert). CI blocker - `vite@7.3.5` declares `engines.node: "^20.19.0 || >=22.12.0"`, so the floor leg pinned to the exact declared floor (22.0.0) failed `pnpm install --frozen-lockfile` with ERR_PNPM_UNSUPPORTED_ENGINE. The EFFECTIVE installable floor is 22.12.0: `engines.node` in the root + `apps/cli` manifests and the ci.yml floor leg all move to it. ADR-0067's DECISION (the Node 22 line is the floor) is unchanged, so the ADR gets a dated, appended amendment note — never a body rewrite (CLAUDE.md #9, append-only). Bugs - `chat.ts`: the mid-session notice sink was installed for EVERY driver, but only the interactive ink driver renders `store.notice` — a budget warning during a plain or `--json` run was written into a view store nobody reads and LOST from stderr. Gate it on `chatIsInteractive` (`liveNoticeSinkFor`), restoring the stderr fallback. - `home-app.tsx` / `chat-ink.tsx`: an SGR mouse report (DECSET 1006) was only consumed on the scroll path, so a wheel notch on the BARE Home — or behind any keyboard-owning overlay — fell through to `controller.handleKey` and typed its raw bytes into the prompt. Consume every mouse report whenever the alt screen is active, ahead of the overlay routing; a non-wheel report is still swallowed, never typed. - `home-controller.ts`: `handlePaste` did not end history navigation, so a paste after a history recall left the nav index live and a following Down clobbered the pasted text. Now resets it, matching append/backspace/kill. Quality - `drive-home.tsx`: the signal handler and the `finally` carried a byte-identical unmount + DISABLE_BRACKETED_PASTE + DISABLE_MOUSE try/catch. Extracted `restoreTerminalControls` so the two exit paths can never drift. - `chat-projection.ts`: the transcript JSDoc sat above `wrapEntry` (the per-entry helper) instead of `wrapTranscript` (its subject); optional-chain the wrap-cache lookup. - `scroll.test.ts`: drop an `as never` cast (CLAUDE.md #1) for a `ChatKey`-typed value. - `chat-input.test.ts`: DERIVE the paste-gate list from `PasteEditableFlags` rather than restating it, so a new gate is covered the moment it is added. - `alt-screen.test.ts` / `chat-app.test.tsx`: `toHaveLength` over `.length).toBe(`. - Docs: alt_screen schema comment + config-spec + chat-session (mouse-wheel shipped; only the `[`/`v` hatches pend), README Node >= 22.12, phase-2.6 guardrail wording. Skipped (still-valid-by-design) - transcript-viewport array-index keys: the list is a positionally-rendered window; text-derived keys would collide on duplicate/blank rows. - transcript-viewport `measureElement` deps array: the box height is flexbox-derived and moves with the live region — a deps array would stale-clip the viewport. - threading `liveSessionNotice` through deps: one REPL per process, cleared in `finally`; the reported plain/JSON bug is fixed by the `interactive` gate above. - ADR-0051 body edit: ADRs are append-only and the code already targets node22. Refs: ADR-0067, ADR-0068 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…3.0` The previous commit moved BOTH `engines.node` manifests to `>=22.12.0` after reading the CI failure as "vite needs 22.12". That was a symptom, not the cause: the floor leg then failed again on `eslint-visitor-keys@5.0.1` (`^20.19.0 || ^22.13.0 || >=24`). Rather than chase a third guess, the whole tree was enumerated — and it shows two DIFFERENT floors that the single manifest value was conflating: - PUBLISHED floor `>=22` (`apps/cli`). The CLI's RUNTIME closure is 239 packages, 144 declaring `engines.node`; the strictest is `ink@7` / `cli-truncate` / `slice-ansi` → `>=22`. `22.0.0` satisfies every one. ADR-0067's decision was correct and is RESTORED — raising the user-facing floor because a *lint* package wants 22.13 would narrow the supported range for no runtime reason. - DEV-INSTALL floor `>=22.13.0` (the private root). Forced only by devDependencies (`vite` via vitest → `>=22.12.0`; `eslint-visitor-keys` via eslint → `^22.13.0`). `.npmrc` sets `engine-strict=true`, so a workspace `pnpm install` hard-fails below it. 22.13.0 is the first 22.x satisfying all 302 packages in the dev tree. So the floor leg cannot install at 22.0.0. It is pinned to 22.13.0 — the lowest Node the workspace installs on — and renamed `node 22-line floor (22.13.0)`. It still exercises the published 22 line end-to-end and proves the better-sqlite3 Node-22 prebuild resolves (ABI 127 is one binary for all of 22.x, so 22.13.0 loads exactly what 22.0.0 would). Residual gap, recorded rather than hidden: the leg no longer mechanically proves "no runtime dep requires >22.0.0". That claim is now verified by hand in the ADR note; a CI assertion over `pnpm --filter relavium ls --prod` would restore it mechanically. The ADR-0067 amendment note added minutes ago in this same unmerged branch asserted the effective floor was 22.12.0. It is corrected in place with the derivation above — the Decision body is untouched (CLAUDE.md #9, append-only), only the annotation that had not yet reached main. tech-stack.md + the CLI README follow the canonical value. Refs: ADR-0067 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with 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.
Inline comments:
In `@docs/decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.md`:
- Around line 81-107: The amendment updates the CI floor leg to Node 22.13.0,
but the Consequences section still says Node 22.0.0, so the ADR is now
inconsistent. Update the stale Consequences bullet to match the revised CI floor
in the decision note, and if you prefer to keep the original text there, add a
brief amendment pointer that explicitly calls out the Consequences section using
its existing wording around the dedicated CI floor leg and published minimum.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: e41c5d17-9d4a-46d1-815a-87d5daf81ca4
📒 Files selected for processing (6)
.github/workflows/ci.ymlapps/cli/README.mdapps/cli/package.jsondocs/decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.mddocs/tech-stack.mdpackage.json
🚧 Files skipped from review as they are similar to previous changes (4)
- package.json
- apps/cli/package.json
- apps/cli/README.md
- docs/tech-stack.md
…ter, index-key rationale
The Sonar "parameterize these 3 tests" nit turned out to be load-bearing. Break-verifying
the parameterization (make the Probe fold `key.delete`, i.e. ink-6 semantics, and watch the
backspace case fail) showed it PASSED — so the assertion never discriminated anything:
'value:start'.includes('value:star') === true
`toContain('value:star')` was satisfied by the UNCHANGED initial frame, so `waitFor`
returned on the first poll and the expect passed without a single byte being deleted. The
file's header claims this test "would FAIL under ink-6 semantics — a genuine ink-major
discriminator, not merely a liveness canary". It would not have. The whole point of the
ink-7 harness pin was silently absent.
- harness-smoke.test.tsx: every frame assertion is now an EXACT match on the trimmed
one-line frame (`toBe`, not `toContain`); `frameOf` trims. Re-running the same break now
fails with `expected 'value:start' to be 'value:star'` — the discriminator is real. The
three key-delivery cases are folded into one `it.each` table (the Sonar nit) with the
ink-6 reasoning kept at the table.
- transcript-viewport.tsx: index keys are CORRECT here — `windowLines` returns a positional
`slice`, the rows are stateless <Text>, blank rows all render as `' '` so content-derived
keys would collide, and nothing below holds state. Kept, with the rationale at the site
and a `// NOSONAR —` matching the repo's existing precedent.
- ADR-0067: the amendment note now explicitly supersedes the two stale Consequences bullets
by quoting their wording ("A dedicated CI floor leg (Node `22.0.0`)…" and "the supported
LTS lines (22/24/26)"). The body is left verbatim — ADRs are append-only (CLAUDE.md #9).
Refs: ADR-0067, ADR-0068
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@coderabbitai review all |
|
✅ Action performedFull review finished. |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/cli/src/commands/chat-alt-hoist.test.ts (2)
657-657: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick winRemove the duplicate
openSessionStoreproperties.Each affected object literal defines
openSessionStoretwice. TypeScript reports duplicate object properties, so these tests will not compile until one copy is removed from each object.Also applies to: 674-674, 688-688, 719-719
🤖 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/src/commands/chat-alt-hoist.test.ts` at line 657, The affected test object literals in chat-alt-hoist.test.ts define openSessionStore twice, which causes TypeScript duplicate-property errors. Update each impacted object literal referenced in the test cases by removing the redundant openSessionStore entry so only one property remains, and keep the change localized to the relevant test setup objects.Source: Linters/SAST tools
477-477: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick winRemove the duplicate
BudgetWarndeclarations.The same type alias is emitted four times in the same scope, which causes a duplicate-identifier TypeScript error. Keep a single declaration.
🤖 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/src/commands/chat-alt-hoist.test.ts` at line 477, The generated test scope contains duplicate BudgetWarn type alias declarations, which triggers a TypeScript duplicate-identifier error. In chat-alt-hoist.test.ts, locate the repeated BudgetWarn declarations in the same scope and keep only one shared type alias, then update any nearby references to use that single definition.
🧹 Nitpick comments (2)
docs/reference/contracts/config-spec.md (1)
71-72: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueTrim the restated renderer mechanics; link instead.
The
alt_screenkey description is appropriately detailed for the setting itself (default, precedence, opt-out), but the second sentence re-describes the full viewport/scroll/mouse-wheel renderer mechanics and pending follow-ups — content that already has (or should have) a canonical home in ADR-0068 / accessibility.md. Restating it here risks drift if the renderer behavior changes but only one copy is updated.✏️ Suggested trim
- # The transcript renders 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 yields to any keyboard-owning overlay); a `/clear` or `/models` swap no longer flickers the terminal (the alt buffer is held across sessions). Mouse-wheel scrolling works too. Still pending (Step 5): the `[`-dump / `v`-open-in-$EDITOR copy-and-search hatches. Set `false` (or `--no-alt-screen`) for the inline renderer — the screen-reader-friendly path. + # See ADR-0068 and ../cli/accessibility.md for the full-screen renderer's viewport/scroll/mouse-wheel behavior and its inline-fallback rationale. Set `false` (or `--no-alt-screen`) for the inline renderer — the screen-reader-friendly path.As per coding guidelines, "Specs live in [docs/reference/]; link, don't restate. One canonical home per artifact."
🤖 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 `@docs/reference/contracts/config-spec.md` around lines 71 - 72, Trim the redundant renderer-mechanics prose in the `alt_screen` entry so this spec only describes the setting’s behavior, default, and precedence. In the `config-spec.md` section for `alt_screen`, remove the re-stated viewport/scroll/mouse-wheel/session-flicker details and pending Step 5 notes, and replace them with a brief pointer to the canonical ADR-0068/accessibility documentation.Source: Coding guidelines
apps/cli/src/render/tui/chat-app.test.tsx (1)
232-234: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the repeated
settlehelper intoharness-util.js.The same 3-line
settleclosure (loop 4×flush()) is redefined identically in six separate test cases. Sinceflush/waitFor/bracketedalready live inharness-util.js, hoisting a sharedsettleFrames()there would remove the duplication as this suite keeps growing.♻️ Proposed refactor
// harness-util.ts +export async function settleFrames(times = 4): Promise<void> { + for (let i = 0; i < times; i += 1) await flush(); +}// chat-app.test.tsx (each occurrence) - const settle = async (): Promise<void> => { - for (let i = 0; i < 4; i += 1) await flush(); - }; + const settle = settleFrames;Also applies to: 271-273, 297-299, 334-336, 362-364, 389-391, 416-418
🤖 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/src/render/tui/chat-app.test.tsx` around lines 232 - 234, The repeated settle helper in chat-app.test.tsx is duplicated across multiple test cases, so hoist this 4x flush loop into harness-util.js as a shared settleFrames() utility alongside flush/waitFor/bracketed, then update each test to use the shared helper instead of redefining the same closure.
🤖 Prompt for all review comments with 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.
Inline comments:
In `@apps/cli/src/commands/chat.test.ts`:
- Line 657: The object literals in chat.test.ts define openSessionStore more
than once, causing a duplicate-property TypeScript error. In each affected test
case, update the mocked client/fixture object so it keeps only one
openSessionStore definition and remove the redundant duplicate, using the
surrounding test setup and the openSessionStore stub as the place to fix it.
In `@apps/cli/src/commands/chat.ts`:
- Around line 1570-1579: The `buildInteractiveShellRunner` wrapper is leaking an
`any` from `built.session.runUserCommand`, causing strict-TS lint failures. Fix
the source type on the session path by ensuring `BuiltChatSession.session` and
`AgentSession.runUserCommand` are explicitly typed with `UserCommandOutcome`/the
expected command signature, and import any missing type definitions so the call
in `buildInteractiveShellRunner` resolves without unsafe member access or
return. Do not silence it with `any` or casts; make the underlying
`session`/`runUserCommand` types precise.
---
Outside diff comments:
In `@apps/cli/src/commands/chat-alt-hoist.test.ts`:
- Line 657: The affected test object literals in chat-alt-hoist.test.ts define
openSessionStore twice, which causes TypeScript duplicate-property errors.
Update each impacted object literal referenced in the test cases by removing the
redundant openSessionStore entry so only one property remains, and keep the
change localized to the relevant test setup objects.
- Line 477: The generated test scope contains duplicate BudgetWarn type alias
declarations, which triggers a TypeScript duplicate-identifier error. In
chat-alt-hoist.test.ts, locate the repeated BudgetWarn declarations in the same
scope and keep only one shared type alias, then update any nearby references to
use that single definition.
---
Nitpick comments:
In `@apps/cli/src/render/tui/chat-app.test.tsx`:
- Around line 232-234: The repeated settle helper in chat-app.test.tsx is
duplicated across multiple test cases, so hoist this 4x flush loop into
harness-util.js as a shared settleFrames() utility alongside
flush/waitFor/bracketed, then update each test to use the shared helper instead
of redefining the same closure.
In `@docs/reference/contracts/config-spec.md`:
- Around line 71-72: Trim the redundant renderer-mechanics prose in the
`alt_screen` entry so this spec only describes the setting’s behavior, default,
and precedence. In the `config-spec.md` section for `alt_screen`, remove the
re-stated viewport/scroll/mouse-wheel/session-flicker details and pending Step 5
notes, and replace them with a brief pointer to the canonical
ADR-0068/accessibility documentation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0ab045d0-3e48-436b-81fa-58734c61b843
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (65)
.github/workflows/ci.yml.npmrc.nvmrcapps/cli/README.mdapps/cli/package.jsonapps/cli/src/commands/chat-alt-hoist.test.tsapps/cli/src/commands/chat.test.tsapps/cli/src/commands/chat.tsapps/cli/src/config/resolve.tsapps/cli/src/engine/mcp-servers.test.tsapps/cli/src/engine/mcp-servers.tsapps/cli/src/engine/media-wiring.test.tsapps/cli/src/home/drive-home.test.tsapps/cli/src/home/drive-home.tsxapps/cli/src/process/options.test.tsapps/cli/src/process/options.tsapps/cli/src/program.tsapps/cli/src/render/alt-screen.test.tsapps/cli/src/render/alt-screen.tsapps/cli/src/render/render-mode.test.tsapps/cli/src/render/render-mode.tsapps/cli/src/render/tui/chat-app.test.tsxapps/cli/src/render/tui/chat-ink.test.tsapps/cli/src/render/tui/chat-ink.tsxapps/cli/src/render/tui/chat-input.test.tsapps/cli/src/render/tui/chat-input.tsapps/cli/src/render/tui/chat-projection.test.tsapps/cli/src/render/tui/chat-projection.tsapps/cli/src/render/tui/harness-smoke.test.tsxapps/cli/src/render/tui/harness-util.tsapps/cli/src/render/tui/home-app.test.tsxapps/cli/src/render/tui/home-app.tsxapps/cli/src/render/tui/home-controller.test.tsapps/cli/src/render/tui/home-controller.tsapps/cli/src/render/tui/home-input.test.tsapps/cli/src/render/tui/home-input.tsapps/cli/src/render/tui/scroll.test.tsapps/cli/src/render/tui/scroll.tsapps/cli/src/render/tui/transcript-viewport.tsxapps/cli/src/render/tui/viewport.test.tsapps/cli/src/render/tui/viewport.tsapps/cli/tsup.config.tsdocs/decisions/0021-node-sqlite-driver-better-sqlite3.mddocs/decisions/0047-cli-framework-commander-ink-clack.mddocs/decisions/0051-cli-distribution-thin-bundle-private-engine.mddocs/decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.mddocs/decisions/0068-full-screen-tui-renderer-ink7-harness.mddocs/decisions/README.mddocs/reference/cli/accessibility.mddocs/reference/cli/chat-session.mddocs/reference/cli/commands.mddocs/reference/cli/home.mddocs/reference/contracts/config-spec.mddocs/reference/desktop/database-schema.mddocs/roadmap/deferred-tasks.mddocs/roadmap/phases/node-runtime-upgrade.mddocs/roadmap/phases/phase-2.6-conversational-authoring.mddocs/standards/testing.mddocs/tech-stack.mdpackage.jsonpackages/mcp/src/sdk-websocket.tspackages/shared/src/config.test.tspackages/shared/src/config.tspnpm-workspace.yamlvitest.config.ts
| ...a.d, | ||
| ...recA.hoist, | ||
| io: { ...a.d.io, stdoutIsTty: true }, | ||
| openSessionStore: () => ({ store: a.store, db: client.db, close: () => undefined }), |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Remove the duplicate openSessionStore properties.
These object literals contain the same property twice, which produces a TypeScript duplicate-property error. Retain only one definition in each scenario.
Also applies to: 674-674, 688-688, 719-719
🧰 Tools
🪛 ESLint
[error] 657-657: Unsafe assignment of an error typed value.
(@typescript-eslint/no-unsafe-assignment)
[error] 657-657: Unsafe assignment of an error typed value.
(@typescript-eslint/no-unsafe-assignment)
[error] 657-657: Unsafe member access .db on a type that cannot be resolved.
(@typescript-eslint/no-unsafe-member-access)
🤖 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/src/commands/chat.test.ts` at line 657, The object literals in
chat.test.ts define openSessionStore more than once, causing a
duplicate-property TypeScript error. In each affected test case, update the
mocked client/fixture object so it keeps only one openSessionStore definition
and remove the redundant duplicate, using the surrounding test setup and the
openSessionStore stub as the place to fix it.
Source: Linters/SAST tools
| /** The `!`-shell runner (2.5.D step 5, ADR-0061) — a thin wrapper over the session's `runUserCommand`, wired ONLY on | ||
| * the interactive driver (a plain / `--json` stream has no `!`-shell surface). Mirrors {@link buildInteractiveMentionReader}. */ | ||
| function buildInteractiveShellRunner( | ||
| interactive: boolean, | ||
| built: ReplWiring['built'], | ||
| ): ((command: string, args: readonly string[]) => Promise<UserCommandOutcome>) | undefined { | ||
| if (!interactive) return undefined; | ||
| return (command, args) => built.session.runUserCommand(command, args); | ||
| } | ||
|
|
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Unsafe any flagged on built.session.runUserCommand — strict-TS violation.
ESLint reports no-unsafe-return/no-unsafe-call/no-unsafe-member-access on this exact line, meaning built.session.runUserCommand is resolving to any even though the enclosing function declares a typed return signature. This is new code in this PR, so it's a regression against strict typing, and would fail a lint-gated CI (at odds with this PR's "green lint" claim).
As per coding guidelines, "TypeScript-first, strict. No any, no unsafe as." Please run the verification below to locate why session/runUserCommand's type isn't resolving cleanly (likely a broken/implicit-any type on AgentSession.runUserCommand or on BuiltChatSession.session), then add an explicit type/import fix rather than letting the any leak through.
#!/bin/bash
set -euo pipefail
rg -n 'runUserCommand' packages/core/src/engine/agent-session.ts -A 6 -B 6
rg -n 'interface BuiltChatSession' apps/cli/src/chat/session-host.ts -A 20
rg -n "session:\s*" apps/cli/src/chat/session-host.ts -A 3 -B 3🧰 Tools
🪛 ESLint
[error] 1577-1577: Unsafe return of a value of type error.
(@typescript-eslint/no-unsafe-return)
[error] 1577-1577: Unsafe call of a type that could not be resolved.
(@typescript-eslint/no-unsafe-call)
[error] 1577-1577: Unsafe member access .runUserCommand on a type that cannot be resolved.
(@typescript-eslint/no-unsafe-member-access)
🤖 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/src/commands/chat.ts` around lines 1570 - 1579, The
`buildInteractiveShellRunner` wrapper is leaking an `any` from
`built.session.runUserCommand`, causing strict-TS lint failures. Fix the source
type on the session path by ensuring `BuiltChatSession.session` and
`AgentSession.runUserCommand` are explicitly typed with `UserCommandOutcome`/the
expected command signature, and import any missing type definitions so the call
in `buildInteractiveShellRunner` resolves without unsafe member access or
return. Do not silence it with `any` or casts; make the underlying
`session`/`runUserCommand` types precise.
Sources: Coding guidelines, Linters/SAST tools
…rphaned JSDoc, doc trims Four of the six findings were false positives; verified against the code rather than assumed. The two valid nits are applied, plus one real defect they led me to. Verified FALSE (no change) - "chat.test.ts:657 defines `openSessionStore` twice". It does not: the literal is a SPREAD (`...a.d`) followed by one explicit `openSessionStore` — a legal, intentional override, not a duplicate key. Lines 657/674/688 are three DIFFERENT literals. A real duplicate key is a hard TS error; typecheck is green. - "chat-alt-hoist.test.ts:657 / :477 duplicate `openSessionStore` / `BudgetWarn`". That file is 203 lines and contains NEITHER symbol; both live in chat.test.ts, `BudgetWarn` exactly once. The line numbers were carried over from the wrong file. - "`buildInteractiveShellRunner` leaks an `any` from `built.session.runUserCommand`". `BuiltChatSession.session` is `AgentSession`, whose `runUserCommand(command: string, args: readonly string[])` returns `Promise<UserCommandOutcome>`. Proven, not assumed: narrowing the wrapper's return annotation to `Promise<number>` makes tsc reject it — an `any` would have been assignable. `recommendedTypeChecked` (no-unsafe-return / -member-access) is active and green. - Docstring coverage 72.58% (advisory): no repo standard sets this gate, and the new helpers all carry JSDoc. Skipped as a metric — though see below for the one real docstring bug, found by reading rather than by the percentage. Fixed - harness-util.ts: hoist `settleFrames(frames = 4)` beside flush/waitFor/bracketed. The 4x flush loop was copy-pasted TWELVE times (7 in chat-app.test.tsx, 4 + 1 inline in home-app.test.tsx) — in a file whose own header claims the timing contract has "ONE home ... a drift would otherwise have to be fixed in three places". Break-verified it is load-bearing: `frames = 0` fails the three scroll/wheel tests. - chat.ts: REUNITE `driveOneSession`'s JSDoc with `driveOneSession`. Adding `buildInteractiveShellRunner` two commits ago wedged the new helper between that doc and its subject, so the doc described the wrong function — the same defect the review flagged in chat-projection.ts, which I had introduced here at the same time. - config-spec.md: the `alt_screen` entry restated viewport/scroll/mouse-wheel/flicker mechanics that belong to ADR-0068. Replaced with a pointer (CLAUDE.md #8, one canonical home per artifact). - accessibility.md: document the mouse-selection tradeoff. alt-screen.ts:33 already told readers to "see accessibility.md" for it and config-spec.md now leans on that citation too, but the file never covered it — a dangling canonical-home reference. Adds the table row + the bypass-modifier note (Shift; Option on iTerm2). Refs: ADR-0068 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|



What this is
Phase 2.6.F — the full-screen TUI foundation (ADR-0068). A bare
relaviumon a TTY (andrelavium chat) now opens a full-screen alternate-screen renderer with an in-app scrolling viewport, replacing the inline<Static>output that clipped long responses. The inline renderer is retained, byte-identical, as the opt-out + the screen-reader / machine path.Built on the ink-7 substrate + the hand-rolled viewport from earlier 2.6.F steps; this PR carries Step 4b-2 → Step 5b, each landed under a strict per-step discipline: implement → Opus adversarial review (3 finders + independent skeptic verification) → fold verified findings → Sonnet review → fold → next step. Every fix was empirically break-verified (break production, confirm the pinning test fails, revert).
The renderer, end to end
displayWidth(Intl.Segmenter) so the 1-DisplayLine==1-row invariant holds under emoji/CJK. The scroll geometry is read live at the keypress so a mid-stream burst can't yank a paused reader to the tail.WeakMapwrap cache keyed on the immutable transcript entry — an append is O(history) lookups + ONE segmentation, and it never thrashes./clearor/modelsre-drive flickered primary→alt→primary. Verified against ink 7.1.0's compiled build that full-screen rendering is decoupled from thealternateScreenoption, then hoisted the alt-buffer toggle above the per-session loop (withHoistedAltScreen): enter once, exit once, no flicker. Terminal restore is guaranteed on every exit path (finally +process.on('exit')+ SIGTERM/SIGHUP/SIGQUIT), all idempotent.DEFAULT_ALT_SCREEN→true.--no-alt-screen/[preferences].alt_screen = falseopt out;--json/ CI / non-TTY stay inline + byte-identical.Review outcomes (both rounds, folded)
/clear/reseat rebuild-failure hint (with the resumablechat-resume <id>) was written into the alt buffer and discarded → user dropped to a bare shell with no error. Fixed (lifted past the alt-exit). Also: the >8192-line LRU thrash (→ per-entry cache), SIGQUIT stranding, and interactive-test terminal pollution.altActivederivation had no coverage) and the mid-session notice class (budget-cap warning / MCP-skipped diagnostic lost in the alt buffer) — both fixed.How to verify
pnpm turbo run lint typecheck test+pnpm turbo run buildare green (1761 CLI tests). The signal / terminal-lifecycle paths that can't be unit-tested need a real-TTY manual pass (see the checklist below).Manual real-TTY checklist
relaviumopens full-screen; scroll (PgUp/PgDn/Ctrl+Home/End) + mouse-wheel work; typing at the prompt is unaffected/clearand/modelsdo not flicker the terminal--no-alt-screen(and[preferences].alt_screen = false) fall back to the inline renderer cleanlykill -TERM <pid>restore the terminal (primary buffer, cursor, mouse selection) — not stranded in altrelavium chat --json/ a piped run is byte-identical to before (no escape bytes)Not in this PR (Step 5 remainder — done fresh)
/scrollback+/editcopy-and-search hatches (inksuspendTerminal; findings recorded)Refs: ADR-0068 (+ ADR-0067 floor, ADR-0057/0059/0062 surfaces)
🤖 Generated with Claude Code
Summary by CodeRabbit
--no-alt-screenand[preferences].alt_screen=false.*.test.tsx.