Skip to content

Phase 2.6.F — full-screen TUI renderer (Home + chat): alt-screen, scroll/auto-follow, flicker-free swaps, mouse-wheel, a11y#73

Merged
cemililik merged 37 commits into
mainfrom
development
Jul 9, 2026
Merged

Phase 2.6.F — full-screen TUI renderer (Home + chat): alt-screen, scroll/auto-follow, flicker-free swaps, mouse-wheel, a11y#73
cemililik merged 37 commits into
mainfrom
development

Conversation

@cemililik

@cemililik cemililik commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What this is

Phase 2.6.F — the full-screen TUI foundation (ADR-0068). A bare relavium on a TTY (and relavium 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

  • Viewport + scroll / auto-follow (4b-2): PgUp/PgDn page, Ctrl+Home/Ctrl+End jump to top/tail; an upward scroll pauses tail-follow, reaching the bottom (or Ctrl+End) resumes it. Grapheme-aware 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.
  • caps-lift (4b-3): a per-entry WeakMap wrap cache keyed on the immutable transcript entry — an append is O(history) lookups + ONE segmentation, and it never thrashes.
  • Inter-session flicker fix (4b-3): ink 7 tied the DECSET-1049 enter/exit to per-session mount/unmount, so a /clear or /models re-drive flickered primary→alt→primary. Verified against ink 7.1.0's compiled build that full-screen rendering is decoupled from the alternateScreen option, 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 flip (4b-3): DEFAULT_ALT_SCREENtrue. --no-alt-screen / [preferences].alt_screen = false opt out; --json / CI / non-TTY stay inline + byte-identical.
  • Mouse-wheel scroll (5b): SGR mouse reporting drives the same scroll actions (default-on in full-screen; native selection needs Shift/Option — documented).
  • Accessibility (5a): a dedicated accessibility.md — the alt screen is screen-reader-hostile; the inline renderer is the documented escape hatch.

Review outcomes (both rounds, folded)

  • Opus caught a real flip regression: the /clear/reseat rebuild-failure hint (with the resumable chat-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.
  • Sonnet caught a self-inflicted test blind spot (the hoist-wiring altActive derivation 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 build are 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

  • bare relavium opens full-screen; scroll (PgUp/PgDn/Ctrl+Home/End) + mouse-wheel work; typing at the prompt is unaffected
  • /clear and /models do not flicker the terminal
  • --no-alt-screen (and [preferences].alt_screen = false) fall back to the inline renderer cleanly
  • double Ctrl-C and kill -TERM <pid> restore the terminal (primary buffer, cursor, mouse selection) — not stranded in alt
  • relavium chat --json / a piped run is byte-identical to before (no escape bytes)

Not in this PR (Step 5 remainder — done fresh)

  • /scrollback + /edit copy-and-search hatches (ink suspendTerminal; findings recorded)
  • the branded Home banner
  • Step 5's own review rounds

Refs: ADR-0068 (+ ADR-0067 floor, ADR-0057/0059/0062 surfaces)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added full-screen alternate-screen transcript mode with bounded viewport, tail-follow scrolling, PgUp/PgDn + Ctrl+Home/End controls, and resize re-clamping.
    • Added ink-native bracketed paste handling with CRLF/CR normalization and stricter paste eligibility; supports inline opt-out via --no-alt-screen and [preferences].alt_screen=false.
  • Bug Fixes
    • Improved alternate-screen lifecycle safety across exit, signals, and errors (idempotent enter/restore; final output on the primary buffer only).
    • Routed budget warnings and MCP “skipped lines” into in-app transcript notices.
  • Documentation
    • Updated accessibility, chat/home references, and config/CLI option docs for render-mode and paste behavior.
  • Chores / Tests
    • Raised supported Node minimums, added a CI Node 22 floor-check job, and expanded Vitest discovery to *.test.tsx.

cemililik and others added 30 commits July 9, 2026 04:53
…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>
cemililik and others added 3 commits July 9, 2026 18:36
… 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>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @cemililik, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 940f744d-260b-44cf-9fe9-93ca77006779

📥 Commits

Reviewing files that changed from the base of the PR and between 7d4899f and 4c01aef.

📒 Files selected for processing (6)
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/render/tui/chat-app.test.tsx
  • apps/cli/src/render/tui/harness-util.ts
  • apps/cli/src/render/tui/home-app.test.tsx
  • docs/reference/cli/accessibility.md
  • docs/reference/contracts/config-spec.md
✅ Files skipped from review due to trivial changes (2)
  • docs/reference/cli/accessibility.md
  • docs/reference/contracts/config-spec.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/cli/src/render/tui/chat-app.test.tsx
  • apps/cli/src/render/tui/home-app.test.tsx
  • apps/cli/src/commands/chat.ts

📝 Walkthrough

Walkthrough

This 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.

Changes

Node 22 supported floor

Layer / File(s) Summary
Runtime floor updates
package.json, apps/cli/package.json, .nvmrc, .npmrc, apps/cli/README.md, apps/cli/tsup.config.ts, pnpm-workspace.yaml, packages/mcp/src/sdk-websocket.ts, packages/shared/src/config.ts, packages/shared/src/config.test.ts
Sets Node and type/package floors to >=22, updates the build target, and aligns runtime comments and config schema docs.
CI and documentation rollout
.github/workflows/ci.yml, docs/decisions/*, docs/tech-stack.md, docs/reference/desktop/database-schema.md, docs/roadmap/*, docs/reference/cli/*, docs/standards/testing.md
Updates CI gates, ADRs, roadmap notes, and CLI docs to record the floor change and the related runtime/support wording.

Ink 7 full-screen renderer

Layer / File(s) Summary
Render mode and alt-screen primitives
apps/cli/src/config/resolve.ts, apps/cli/src/process/options.ts, apps/cli/src/program.ts, apps/cli/src/render/alt-screen.ts, apps/cli/src/render/render-mode.ts, apps/cli/src/render/tui/scroll.ts, apps/cli/src/render/tui/viewport.ts, apps/cli/src/render/tui/transcript-viewport.tsx, related tests
Adds the alt-screen preference, --no-alt-screen, render-mode precedence, controller, scrolling model, wrapping/measurement helpers, and transcript viewport.
Chat renderer viewport and lifecycle
apps/cli/src/render/tui/chat-ink.tsx, apps/cli/src/render/tui/chat-app.test.tsx, apps/cli/src/render/tui/chat-ink.test.ts, apps/cli/src/render/tui/chat-input.ts, apps/cli/src/render/tui/chat-input.test.ts, apps/cli/src/render/tui/chat-projection.ts, apps/cli/src/render/tui/chat-projection.test.ts
Wires the chat renderer to the new viewport, scroll, paste, transcript wrapping, and exit-finalization flow.
Chat command hoist and notices
apps/cli/src/commands/chat.ts, apps/cli/src/commands/chat-alt-hoist.test.ts, apps/cli/src/commands/chat.test.ts, apps/cli/src/engine/mcp-servers.ts, apps/cli/src/engine/mcp-servers.test.ts
Adds the hoisted alt-screen wrapper, lifecycle hooks, rebuild forwarding, and notice routing for budget/MCP diagnostics.
Home renderer alt-screen and paste wiring
apps/cli/src/home/drive-home.tsx, apps/cli/src/render/tui/home-app.tsx, apps/cli/src/render/tui/home-controller.ts, apps/cli/src/render/tui/home-controller.test.ts, apps/cli/src/render/tui/home-input.ts, apps/cli/src/render/tui/home-input.test.ts, apps/cli/src/render/tui/home-app.test.tsx
Adds Home render-mode resolution, alt-screen scroll/view wiring, and native handlePaste behavior.
Harness and build config for ink 7
apps/cli/src/render/tui/harness-util.ts, apps/cli/src/render/tui/harness-smoke.test.tsx, vitest.config.ts, pnpm-workspace.yaml, apps/cli/package.json, docs/standards/testing.md
Adds ink-testing-library support, .test.tsx collection, and shared TUI harness utilities.
ADR-0068 and CLI docs
docs/decisions/0068*, docs/decisions/0047*, docs/reference/cli/*, docs/reference/contracts/config-spec.md, docs/roadmap/deferred-tasks.md
Documents the ink 7 renderer decision and updates CLI-facing docs for alt-screen, paste, and accessibility behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • HodeTech/Relavium#54: Introduced the core relavium chat REPL implementation that this PR extends with hoisted alt-screen behavior and summary handling.
  • HodeTech/Relavium#55: Shares the same chat driver and session-finalization code paths that this PR extends for alt-screen loop control.
  • HodeTech/Relavium#61: Related to the Home paste path that this PR rewires from marker-based handling to native handlePaste.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: a full-screen TUI renderer for Home and chat with alt-screen, scrolling, mouse support, and accessibility work.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch development

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Comment on lines +74 to +80
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);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Comment thread packages/shared/src/config.ts Outdated
Comment on lines +148 to +150
// ⚠ 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The comment here states that scroll-back (PgUp/PgDn, Step 4b-2) is still pending. However, scroll-back has been fully implemented and tested in this PR. Please update the comment to reflect that scroll-back is now supported, leaving only the copy-and-search hatches as pending.

Comment thread docs/reference/contracts/config-spec.md Outdated
Comment on lines +71 to +72
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The documentation states that mouse-wheel scroll is still pending. However, mouse-wheel scroll has been fully implemented and tested in this PR. Please update the documentation to remove 'mouse-wheel' from the pending list.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (11)
docs/roadmap/phases/phase-2.6-conversational-authoring.md (1)

782-789: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Contradictory "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 win

Stale 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: 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." 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 value

Prefer toHaveLength for the length assertion.

Line 77 compares out.length via toEqual; toHaveLength gives 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 never cast bypasses the "no unsafe as" rule.

{ upArrow: true } as never forces an object with an unrelated property through scrollMotionForKey. Since ScrollKey'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 unsafe as, 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 value

Array index as React key (Sonar hint) — low risk here, optional to change.

visible.map((line, index) => ...) keys on index; 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 value

Overview doc block documents wrapTranscript but sits above the private wrapEntry.

The lines 265-277 JSDoc describes flattening the whole transcript into DisplayLines (the public contract), but it's attached to the private per-entry helper wrapEntry rather than the exported wrapTranscript (line 312), which has no doc comment of its own. Consider moving the transcript-level description to sit above wrapTranscript and keeping only the per-entry doc above wrapEntry.

🤖 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 value

Prefer 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 win

Derive gates from the flags object instead of hardcoding the key list.

The exhaustive gate-coverage test is only as good as the manually maintained gates array. If a new field is added to PasteEditableFlags without 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 tradeoff

Module-level liveSessionNotice singleton — 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/chatResumeCommand invocation model, but it's a global mutable coupling point — any future concurrent use (e.g. a test harness running two chatCommand() calls via Promise.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 through ChatReplDeps/ReplWiring instead 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 value

Minor duplication: terminal-restore sequence repeated in onSignal and finally.

writeControl(DISABLE_BRACKETED_PASTE); writeControl(DISABLE_MOUSE); (plus the enclosing try/catch) is now written out twice. Extracting a small restoreTerminalControls() 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 win

Sentence fragment: "Still pending (Step 5): the [/v copy-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

📥 Commits

Reviewing files that changed from the base of the PR and between 88f8fff and 721e4b5.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (65)
  • .github/workflows/ci.yml
  • .npmrc
  • .nvmrc
  • apps/cli/README.md
  • apps/cli/package.json
  • apps/cli/src/commands/chat-alt-hoist.test.ts
  • apps/cli/src/commands/chat.test.ts
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/config/resolve.ts
  • apps/cli/src/engine/mcp-servers.test.ts
  • apps/cli/src/engine/mcp-servers.ts
  • apps/cli/src/engine/media-wiring.test.ts
  • apps/cli/src/home/drive-home.test.ts
  • apps/cli/src/home/drive-home.tsx
  • apps/cli/src/process/options.test.ts
  • apps/cli/src/process/options.ts
  • apps/cli/src/program.ts
  • apps/cli/src/render/alt-screen.test.ts
  • apps/cli/src/render/alt-screen.ts
  • apps/cli/src/render/render-mode.test.ts
  • apps/cli/src/render/render-mode.ts
  • apps/cli/src/render/tui/chat-app.test.tsx
  • apps/cli/src/render/tui/chat-ink.test.ts
  • apps/cli/src/render/tui/chat-ink.tsx
  • apps/cli/src/render/tui/chat-input.test.ts
  • apps/cli/src/render/tui/chat-input.ts
  • apps/cli/src/render/tui/chat-projection.test.ts
  • apps/cli/src/render/tui/chat-projection.ts
  • apps/cli/src/render/tui/harness-smoke.test.tsx
  • apps/cli/src/render/tui/harness-util.ts
  • apps/cli/src/render/tui/home-app.test.tsx
  • apps/cli/src/render/tui/home-app.tsx
  • apps/cli/src/render/tui/home-controller.test.ts
  • apps/cli/src/render/tui/home-controller.ts
  • apps/cli/src/render/tui/home-input.test.ts
  • apps/cli/src/render/tui/home-input.ts
  • apps/cli/src/render/tui/scroll.test.ts
  • apps/cli/src/render/tui/scroll.ts
  • apps/cli/src/render/tui/transcript-viewport.tsx
  • apps/cli/src/render/tui/viewport.test.ts
  • apps/cli/src/render/tui/viewport.ts
  • apps/cli/tsup.config.ts
  • docs/decisions/0021-node-sqlite-driver-better-sqlite3.md
  • docs/decisions/0047-cli-framework-commander-ink-clack.md
  • docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md
  • docs/decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.md
  • docs/decisions/0068-full-screen-tui-renderer-ink7-harness.md
  • docs/decisions/README.md
  • docs/reference/cli/accessibility.md
  • docs/reference/cli/chat-session.md
  • docs/reference/cli/commands.md
  • docs/reference/cli/home.md
  • docs/reference/contracts/config-spec.md
  • docs/reference/desktop/database-schema.md
  • docs/roadmap/deferred-tasks.md
  • docs/roadmap/phases/node-runtime-upgrade.md
  • docs/roadmap/phases/phase-2.6-conversational-authoring.md
  • docs/standards/testing.md
  • docs/tech-stack.md
  • package.json
  • packages/mcp/src/sdk-websocket.ts
  • packages/shared/src/config.test.ts
  • packages/shared/src/config.ts
  • pnpm-workspace.yaml
  • vitest.config.ts

Comment thread apps/cli/README.md Outdated
Comment thread apps/cli/src/commands/chat.ts
Comment thread apps/cli/src/render/tui/home-app.tsx
Comment thread apps/cli/src/render/tui/home-controller.ts
Comment thread docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md
cemililik and others added 2 commits July 9, 2026 23:24
…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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between bdbe426 and 2d6c0c2.

📒 Files selected for processing (6)
  • .github/workflows/ci.yml
  • apps/cli/README.md
  • apps/cli/package.json
  • docs/decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.md
  • docs/tech-stack.md
  • package.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>
@HodeTech HodeTech deleted a comment from coderabbitai Bot Jul 9, 2026
@cemililik

Copy link
Copy Markdown
Contributor Author

@coderabbitai review all

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

@cemililik, sure — I'll do a full review of all the changes in this PR.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 win

Remove the duplicate openSessionStore properties.

Each affected object literal defines openSessionStore twice. 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 win

Remove the duplicate BudgetWarn declarations.

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 value

Trim the restated renderer mechanics; link instead.

The alt_screen key 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 win

Extract the repeated settle helper into harness-util.js.

The same 3-line settle closure (loop 4×flush()) is redefined identically in six separate test cases. Since flush/waitFor/bracketed already live in harness-util.js, hoisting a shared settleFrames() 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

📥 Commits

Reviewing files that changed from the base of the PR and between 88f8fff and 7d4899f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (65)
  • .github/workflows/ci.yml
  • .npmrc
  • .nvmrc
  • apps/cli/README.md
  • apps/cli/package.json
  • apps/cli/src/commands/chat-alt-hoist.test.ts
  • apps/cli/src/commands/chat.test.ts
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/config/resolve.ts
  • apps/cli/src/engine/mcp-servers.test.ts
  • apps/cli/src/engine/mcp-servers.ts
  • apps/cli/src/engine/media-wiring.test.ts
  • apps/cli/src/home/drive-home.test.ts
  • apps/cli/src/home/drive-home.tsx
  • apps/cli/src/process/options.test.ts
  • apps/cli/src/process/options.ts
  • apps/cli/src/program.ts
  • apps/cli/src/render/alt-screen.test.ts
  • apps/cli/src/render/alt-screen.ts
  • apps/cli/src/render/render-mode.test.ts
  • apps/cli/src/render/render-mode.ts
  • apps/cli/src/render/tui/chat-app.test.tsx
  • apps/cli/src/render/tui/chat-ink.test.ts
  • apps/cli/src/render/tui/chat-ink.tsx
  • apps/cli/src/render/tui/chat-input.test.ts
  • apps/cli/src/render/tui/chat-input.ts
  • apps/cli/src/render/tui/chat-projection.test.ts
  • apps/cli/src/render/tui/chat-projection.ts
  • apps/cli/src/render/tui/harness-smoke.test.tsx
  • apps/cli/src/render/tui/harness-util.ts
  • apps/cli/src/render/tui/home-app.test.tsx
  • apps/cli/src/render/tui/home-app.tsx
  • apps/cli/src/render/tui/home-controller.test.ts
  • apps/cli/src/render/tui/home-controller.ts
  • apps/cli/src/render/tui/home-input.test.ts
  • apps/cli/src/render/tui/home-input.ts
  • apps/cli/src/render/tui/scroll.test.ts
  • apps/cli/src/render/tui/scroll.ts
  • apps/cli/src/render/tui/transcript-viewport.tsx
  • apps/cli/src/render/tui/viewport.test.ts
  • apps/cli/src/render/tui/viewport.ts
  • apps/cli/tsup.config.ts
  • docs/decisions/0021-node-sqlite-driver-better-sqlite3.md
  • docs/decisions/0047-cli-framework-commander-ink-clack.md
  • docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md
  • docs/decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.md
  • docs/decisions/0068-full-screen-tui-renderer-ink7-harness.md
  • docs/decisions/README.md
  • docs/reference/cli/accessibility.md
  • docs/reference/cli/chat-session.md
  • docs/reference/cli/commands.md
  • docs/reference/cli/home.md
  • docs/reference/contracts/config-spec.md
  • docs/reference/desktop/database-schema.md
  • docs/roadmap/deferred-tasks.md
  • docs/roadmap/phases/node-runtime-upgrade.md
  • docs/roadmap/phases/phase-2.6-conversational-authoring.md
  • docs/standards/testing.md
  • docs/tech-stack.md
  • package.json
  • packages/mcp/src/sdk-websocket.ts
  • packages/shared/src/config.test.ts
  • packages/shared/src/config.ts
  • pnpm-workspace.yaml
  • vitest.config.ts

...a.d,
...recA.hoist,
io: { ...a.d.io, stdoutIsTty: true },
openSessionStore: () => ({ store: a.store, db: client.db, close: () => undefined }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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

Comment on lines +1570 to +1579
/** 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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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>
@sonarqubecloud

sonarqubecloud Bot commented Jul 9, 2026

Copy link
Copy Markdown

@cemililik
cemililik merged commit 6d0fa58 into main Jul 9, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant