diff --git a/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-8-conversation-viewer.md b/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-8-conversation-viewer.md index 57ed8ad..9e694ca 100644 --- a/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-8-conversation-viewer.md +++ b/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-8-conversation-viewer.md @@ -2,40 +2,417 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Migrate the conversation viewer and composer to the shared framed dashboard layout while preserving live control of background agents. +**Goal:** Migrate the live conversation viewer and steering composer to the shared dashboard frame without changing transcript ordering, background-agent control, or lifecycle behavior. -**Architecture:** Frame the existing viewer, raise its viewport allocation from `70%` to `85%`, and forward focus to the native composer. Keep subscriptions, steering, stop actions, and scrolling in the existing component. +**Architecture:** Keep `ConversationViewer` as the single stateful component. Replace only its private rounded-box renderer with the existing dashboard frame/viewport primitives, raise its internal height cap from `70%` to the shared `85%`, and implement Pi/TUI's `Focusable` contract so the wrapper forwards overlay focus to the native `Input` created for steering. `FleetList` remains the sole caller and already supplies the shared overlay options. -**Tech Stack:** TypeScript, Pi/TUI `0.84.3`, Vitest. +**Tech Stack:** TypeScript, `@earendil-works/pi-coding-agent@0.84.3`, `@earendil-works/pi-tui@0.84.3`, Vitest, pnpm, Node `24.15.0`. -**Parent plan:** `docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui.md` +**Spec:** `docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui.md#approved-design` -**Prerequisite:** Phase 7 fleet list. +**Prerequisite:** Phase 7 is merged at `c0bd95d`; `src/tui/fleet-list.ts` already opens the viewer with `DASHBOARD_OVERLAY_OPTIONS` (`92%` centered width, `85%` max height). -**Usable result:** A responsive, focus-correct conversation overlay can inspect, steer, and stop background agents. +**Usable result:** A heavy-framed, width-safe conversation overlay remains live while an agent runs, scrolls predictably, exposes a correctly focused native steering input, and still steers, stops, and closes through the existing callbacks. -## Constraints +## Assumptions and Current Repository Fit -- Preserve message rendering, live subscription updates, scrolling, steer submission, stop, Escape, and cleanup. -- Use shared centered `92%` width/`85%` max-height options. -- Reuse Pi's native input for the composer; do not build a custom editor. +- `src/tui/conversation-viewer.ts` currently owns the complete transcript, scroll, stop-confirmation, composer, subscription, and disposal state. Keep that ownership; no controller or second component is needed. +- `tests/conversation-viewer.test.ts` currently has 21 passing tests but does not cover shared-frame sizing, small-terminal rendering, wrapper focus, release events, actual steering submission, or subscription-driven refresh. +- `src/tui/dashboard-style.ts` already provides every presentation primitive needed: `DASHBOARD_MAX_HEIGHT_RATIO`, `MIN_DASHBOARD_FRAME_WIDTH`, `dashboardContentWidth()`, `fitDashboardViewport()`, `renderDashboardFrame()`, and `renderDashboardTooSmall()`. +- `VIEWPORT_HEIGHT_PCT` is an exported integer percentage today. Preserve that API shape and change its value from `70` to `85`; do not reinterpret it as the ratio `0.85`. +- Phase 7's instruction to retain the `70` assertion applied while implementing Phase 7. This phase now intentionally changes that assertion to `85`. +- `FleetList`, dashboard primitives, agent manager behavior, and the shared overlay options are verification-only in this phase. -### Task 1: Add failing viewer regressions +## Reference Decisions -**Files:** `tests/conversation-viewer.test.ts` +- Pi/TUI `0.84.3` sets focus on the component returned by `ctx.ui.custom()`. An embedded `Input` does not become the focused TUI component, so the wrapper must implement `Focusable`, remember `_focused`, and copy that value to the active composer. +- Pi/TUI's native `Input` already handles legacy input, CSI-u printable characters, submit, Escape, cursor movement, deletion, paste, and `CURSOR_MARKER`. Route composer input to it; do not duplicate any editor logic. +- Follow the established `ChainClarifyComponent` focus pattern and the dashboard logical layout: title/header, blank row, bounded body, blank row or composer title/input, and a dim key-hint footer. +- The shared frame contributes four rows: top border, top padding, bottom padding, and bottom border. The idle viewer therefore has eight non-message rows; composer mode has nine because it adds one row while preserving the total overlay height. +- Keep the current scroll percentage meaning: progress is measured at the bottom edge of the visible viewport. Do not redefine it during a visual migration. -- [ ] Add assertions for heavy frame, header/status hierarchy, `85%` viewport constant, footer hints, narrow-terminal fallback, scroll bounds, and composer visibility. -- [ ] Add/retain behavior tests for focus forwarding, CSI-u and legacy keys, key releases, live subscription updates, steering submit, stop action, Escape, and disposal. Run the file; expected: new layout/focus assertions fail. +## Global Constraints -### Task 2: Apply the frame and focus contract +- Modify only `src/tui/conversation-viewer.ts` and `tests/conversation-viewer.test.ts`. The plan file belongs to the planning change and is not staged with implementation commits. +- Preserve the `ConversationViewer` constructor parameter order, `ViewerKeybindings`, `VIEWPORT_HEIGHT_PCT` export, `handleInput()`, `render()`, `invalidate()`, and `dispose()` surface. +- Preserve chronological message rendering, supported message roles, the waiting state, the running activity line, 500-character result/output truncation, ANSI-aware wrapping, custom keybindings, and live reads from the mutable session/record. +- Preserve single-line and page scrolling, `j`/`k`, arrows, Home/End, auto-follow at the bottom, manual position while scrolled up, and the existing bottom-edge percentage calculation. +- Preserve Escape/`q` close, Enter-to-compose only for running/queued steerable agents, trimmed non-empty steering, empty-submit/cancel behavior, and the two-press `x` stop confirmation. +- Ignore Kitty key-release events before they reach either viewer actions or the native composer. Repeats remain available for scrolling; do not add a new key policy. +- Use only the active Pi theme roles. Keep running/accent, completed/success, error/error, fallback/dim status icons; bold the agent type, keep the description muted, and keep metadata/footer text dim. +- Use `renderDashboardFrame()` rather than maintaining a second border implementation. Do not change `src/tui/dashboard-style.ts` or copy its fixed-width logic locally. +- Use `dashboardContentWidth(width)` (`width - 6`, clamped) for message wrapping and `Input.render()`. The old `width - 4` value does not match the shared frame's two-column horizontal padding. +- Return `renderDashboardTooSmall()` when the seven-column frame or a three-row message body cannot fit. Escape and composer cancel must still work because input handling is independent of rendering. +- Do not import `DASHBOARD_OVERLAY_OPTIONS` into the viewer; it cannot open itself. Verify the already-merged Fleet caller instead. +- Add no dependency, setting, command, keybinding, scrollbar, search, transcript cache, custom editor, mouse action, or new generic UI abstraction. +- Existing repository-wide Biome diagnostics are out of scope; do not edit unrelated files to silence them. -**Files:** `src/tui/conversation-viewer.ts` +## Target Logical Layout -- [ ] Change `VIEWPORT_HEIGHT_PCT` from `0.7` to `DASHBOARD_MAX_HEIGHT_RATIO` (`0.85`). Render header, messages, composer, and footer through the shared frame and small-terminal fallback. -- [ ] Implement/retain `Focusable`; forward `focused` to the native composer when it is active. Fit the message viewport without changing chronological ordering or subscription behavior. -- [ ] Keep steer and stop routing exactly as-is; visual buttons/hints must call the existing actions. +The shared renderer supplies borders and two columns of horizontal padding. At normal width, pass this logical content to it: -### Task 3: Verify and commit +```text +● coder Fix the bug • 2 tools • 5.0s (running) • 10 token -- [ ] Run `pnpm vitest run tests/conversation-viewer.test.ts tests/fleet-list.test.ts`, then `pnpm check` and `git diff --check`. Manually inspect a live background agent, scroll, steer, stop, and close at small and normal sizes. -- [ ] Commit with `git add src/tui/conversation-viewer.ts tests/conversation-viewer.test.ts && git commit -m "feat: migrate conversation viewer to dashboard layout"`. + + +29 lines • 100% • Enter Steer • x Stop ↑/↓ Scroll • PgUp/PgDn or Shift+↑/↓ • Esc Close +``` + +When composing, replace the idle spacer/footer pair with three rows, reducing the message viewport by one so total height is unchanged: + +```text +● coder Fix the bug • 2 tools • 5.0s (running) • 10 token + + +Steer agent +> native Pi/TUI Input +Enter Send • Esc Cancel +``` + +Keep the existing responsive footer rule: show the line-count/percentage only when it fits, keep actions on the left and navigation on the right, and let the shared frame perform the final ANSI-safe clamp. + +## File Structure + +- Modify `tests/conversation-viewer.test.ts`: extend the existing lightweight factory into a realistic viewer driver and own frame, sizing, focus, input, scroll, subscription, action, and cleanup regressions. +- Modify `src/tui/conversation-viewer.ts`: consume the shared dashboard primitives, implement `Focusable`, keep native `Input`, and retain all existing transcript/control state. +- Verify only `src/tui/dashboard-style.ts`, `src/tui/fleet-list.ts`, `tests/dashboard-style.test.ts`, `tests/fleet-list.test.ts`, and `tests/agent-manager.test.ts`. + +--- + +### Task 1: Lock the Viewer Contract with Focused Regressions + +**Files:** + +- Modify: `tests/conversation-viewer.test.ts` +- Do not modify production code in this task. + +**Interfaces:** + +- Continue constructing the real `ConversationViewer`; do not mock the component or Pi/TUI `Input`. +- Use public rendering and input behavior for assertions. The only state exposed by the harness should be mutable session/record fixtures and callback spies. + +- [ ] **Step 1: Strengthen the existing test driver without adding a second harness.** + + Import `CURSOR_MARKER` and `visibleWidth` from Pi/TUI. Retain the current `makeRecord()` and `makeViewer()` entry points, then extend them so tests can: + + - mutate `tui.terminal.rows` and inspect `requestRender`, + - capture the callback passed to `session.subscribe()` and invoke it with `session.emit()`, + - mutate `session.messages` and the same `AgentRecord` after construction, + - provide `onSteer`, `onStop`, `done`, and an optional `ViewerKeybindings`, and + - render with a plain theme or a semantic/ANSI theme that records `fg()` and `bold()` calls. + + Add named real encodings rather than invented aliases: + + ```ts + const LEGACY_ENTER = "\r"; + const LEGACY_ESCAPE = "\x1b"; + const LEGACY_UP = "\x1b[A"; + const KITTY_UP = "\x1b[1;1A"; + const KITTY_DOWN = "\x1b[1;1B"; + const CSI_U_ENTER = "\x1b[13u"; + const CSI_U_ESCAPE = "\x1b[27u"; + const CSI_U_X = "\x1b[120u"; + const CSI_U_ENTER_RELEASE = "\x1b[13;1:3u"; + const CSI_U_X_RELEASE = "\x1b[120;1:3u"; + ``` + + Keep fixtures structural and local; do not instantiate Pi internals or add a generic TUI test utility. Replace fixed row assumptions while touching the harness: the shared frame inserts a blank row after the top border and before the bottom border, so existing header/status/stat tests must find the row containing the agent type, and the scroll test must find the row containing the percentage, rather than indexing `[1]` or `at(-2)`. + +- [ ] **Step 2: Replace the old rounded-frame assertions with red dashboard layout tests.** + + Replace the current `"is 70"` case, including its name, with: + + ```ts + it("is 85", () => { + expect(VIEWPORT_HEIGHT_PCT).toBe(85); + }); + ``` + + At `terminal.rows = 40` and render width `80`, assert the idle viewer: + + - starts with `┏━` and ends with `━┛`, with none of `╭╮╰╯│`, + - returns exactly `Math.floor(40 * 0.85) === 34` rows, + - returns rows whose `visibleWidth()` is exactly `80`, including with an ANSI-producing theme, + - contains the type, description, status icon, tool count, duration, and token count, + - applies the current semantic roles to status/description/metadata and bolds the type, and + - contains dashboard vocabulary and bullet separators: `Enter Steer`, `x Stop`, `↑/↓ Scroll`, `PgUp/PgDn`, and `Esc Close`. + + Add a composer render at the same dimensions and assert it is also 34 rows and width-safe, contains `Steer agent`, the native `> ` prompt, and `Enter Send • Esc Cancel`, and no longer displays the idle `Enter Steer` hint. + +- [ ] **Step 3: Add small-terminal and resize regressions.** + + Cover both failure dimensions: + + - width `6` with normal terminal height returns bounded fallback rows instead of `[]`, and every row is exactly six columns; + - a terminal whose `Math.floor(rows * 0.85)` is below the idle `8 + 3` row minimum shows `Terminal too small · Esc` when width permits; + - an 11-row budget can render the idle viewer but switches to fallback when the composer raises the minimum to `9 + 3`, then returns to the viewer when the composer is cancelled; and + - after scrolling away from the bottom, rendering the fallback and restoring the same normal dimensions preserves the prior line position. + + While in fallback, send Escape and assert `done(undefined)` still fires. In a separate composer case, send Escape and assert only the composer closes—the overlay remains open. + +- [ ] **Step 4: Add focus, modern-input, and release-event regressions.** + + Exercise the order used by the real overlay host: focus the wrapper before the composer exists, then open it. + + ```ts + viewer.focused = true; + viewer.handleInput(CSI_U_ENTER); + expect(viewer.render(80).join("\n")).toContain("Steer agent"); + expect(viewer.render(80).join("\n")).toContain(CURSOR_MARKER); + viewer.focused = false; + expect(viewer.render(80).join("\n")).not.toContain(CURSOR_MARKER); + ``` + + Also prove focus toggles correctly when changed after the composer opens. Table-test legacy and CSI-u Enter/Escape, type at least one character through CSI-u printable input, and submit a trimmed message through both legacy and CSI-u Enter. + + Directly send `CSI_U_ENTER_RELEASE` to an idle viewer and assert no composer opens. Send `CSI_U_X_RELEASE` twice and assert it neither arms the stop footer nor calls `onStop`. These tests require a first-line `isKeyRelease()` guard even though Pi/TUI normally filters releases during standard component dispatch. + +- [ ] **Step 5: Fill the existing behavior gaps without duplicating message-format tests.** + + Add the smallest cases that lock the state being refactored: + + - a mixed user/assistant/tool-result transcript remains in chronological order; + - initial render follows the newest message, Home/repeated Up clamp at the first message, End/repeated Down clamp at the newest message, and Page Up/Down stay within the same bounds; + - a supplied `ViewerKeybindings` still handles its configured up/down/page IDs while `j`/`k` fallbacks remain available; + - while manually scrolled up, `session.emit()` requests a render but does not jump to the bottom; after End, a new message is auto-followed; + - a subscription event requests one render, a live status/activity mutation appears on the next render, and the viewer remains open when the record becomes completed; + - a trimmed non-empty composer submission calls `onSteer` once, empty submit and Escape call it zero times, scroll keys type into the composer rather than moving the transcript, and each composer key requests at most one component render; + - two press events for `x` call `onStop` once, another key disarms the first press, and completed/read-only viewers expose neither action; and + - outer legacy/CSI-u Escape and `q` each close once, while `dispose()` unsubscribes once, suppresses later subscription renders, and remains idempotent. + + Retain the current waiting, role-label, status-icon, live-activity, and aggregate-stat tests. Do not port the large upstream transcript-width suite; the shared renderer already owns generic frame-width behavior, and one viewer-level ANSI regression covers this integration. + +- [ ] **Step 6: Run the red test and record the expected boundary.** + + ```bash + mise exec node@24.15.0 -- pnpm vitest run tests/conversation-viewer.test.ts + ``` + + Expected before production changes: existing transcript/action tests and most new preservation tests pass; the `85` constant, heavy frame, 34-row cap, fallback, `Steer agent` hierarchy, focus removal, and release-event assertions fail. Do not weaken those assertions to match the old renderer. + +--- + +### Task 2: Apply the Shared Frame, Viewport, and Focus Contract + +**Files:** + +- Modify: `src/tui/conversation-viewer.ts` +- Test: `tests/conversation-viewer.test.ts` + +**Interfaces:** + +- Consume from `src/tui/dashboard-style.ts`: + - `DASHBOARD_MAX_HEIGHT_RATIO` + - `MIN_DASHBOARD_FRAME_WIDTH` + - `dashboardContentWidth()` + - `fitDashboardViewport()` + - `renderDashboardFrame()` + - `renderDashboardTooSmall()` +- Consume from Pi/TUI: `Focusable`, `Input`, `isKeyRelease()`, existing key matching/wrapping/width helpers, and no new component. + +- [ ] **Step 1: Establish release-safe wrapper focus and preserve the exported percentage.** + + Import `type Focusable` and `isKeyRelease`, make the class implement `Component, Focusable`, and add only the established wrapper state: + + ```ts + private _focused = false; + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + if (this.composer) this.composer.focused = value; + } + ``` + + At the first line of `handleInput()`, return for `isKeyRelease(data)`. Keep all later branch order unchanged: active composer, close, open composer, stop confirmation, then scrolling. + + Preserve the integer export while deriving it from the shared source of truth: + + ```ts + export const VIEWPORT_HEIGHT_PCT = DASHBOARD_MAX_HEIGHT_RATIO * 100; + ``` + + Do not export a second ratio or leave a duplicate literal `0.85`/`85` in height calculations. + +- [ ] **Step 2: Replace the private rounded frame with shared logical composition.** + + Remove the local `pad`, `row`, `hrTop`, `hrMid`, and `hrBot` implementation. Put the shared cap in one private method because both rendering and key-driven scrolling need it: + + ```ts + private targetRows(): number { + return Math.max( + 1, + Math.floor(this.tui.terminal.rows * DASHBOARD_MAX_HEIGHT_RATIO), + ); + } + ``` + + In `render()`, set `const targetRows = this.targetRows()`. If `width < MIN_DASHBOARD_FRAME_WIDTH` or `targetRows < this.chromeLines() + MIN_VIEWPORT`, immediately return `renderDashboardTooSmall(width, targetRows, theme)`. Do not mutate `lastInnerW` or scroll state in this branch, so a resize back to the same normal dimensions restores the prior viewport. Make `viewportHeight()` subtract `chromeLines()` from `targetRows()` and retain the existing `MIN_VIEWPORT` floor for input received while fallback is visible. + + For a normal render: + + 1. Set `innerW = dashboardContentWidth(width)` and retain it in `lastInnerW` for key-driven scroll calculations. + 2. Build content lines from the live session at that width. + 3. Keep the existing `maxScroll` and `autoScroll` decision. + 4. Call `fitDashboardViewport(contentLines, undefined, viewportHeight, this.scrollOffset)` to clamp/pad the message body; store its returned offset and use it for the percentage calculation. + 5. Pass the target logical layout from this plan to `renderDashboardFrame()`. + + The final composition is deliberately only one conditional tail: + + ```ts + return renderDashboardFrame( + [ + header, + "", + ...viewport.lines, + ...(this.composer + ? [ + theme.fg("accent", "Steer agent"), + this.composer.render(innerW)[0] ?? "", + theme.fg("dim", "Enter Send • Esc Cancel"), + ] + : ["", idleFooter]), + ], + width, + theme, + ); + ``` + + Replace `CHROME_LINES_BASE = 6` with a private dashboard-specific base of `8`; `chromeLines()` continues to add one while a composer exists. The message viewport is therefore `targetRows - 8` when idle and `targetRows - 9` while composing, and both framed results remain exactly `targetRows` rows. + +- [ ] **Step 3: Keep the current header data and adopt dashboard hierarchy only.** + + Keep the current status mapping, duration calculation, tool pluralization, and token sum/order. Change only presentation: + + - status icon in its existing semantic color, + - agent type bold, + - description muted, + - metadata dim, and + - `•` as the dim separator between description and metadata and within metadata. + + Do not introduce a model name, turn counter, custom color registry, second title row, or snapshot of the record; the same mutable record must be read on every render. + +- [ ] **Step 4: Preserve footer priority while changing its vocabulary.** + + Keep the current two-group width calculation with `visibleWidth()`: + + - optional left prefix: ` lines • ` when it fits, + - left actions: `Enter Steer` and `x Stop`, or error-styled `x Again to STOP`, only when the existing capability checks allow them, + - right navigation: `↑/↓ Scroll • PgUp/PgDn or Shift+↑/↓ • Esc Close`, and + - dim ` • ` separators. + + Keep at least one space between left and right groups and let `renderDashboardFrame()` apply the final clamp. Hints remain text for the existing keyboard branches; do not add button objects or duplicate callbacks. + +- [ ] **Step 5: Seed and clean up the native composer from wrapper state.** + + In `openComposer()`, replace unconditional `input.focused = true` with: + + ```ts + input.focused = this._focused; + ``` + + Render `theme.fg("accent", "Steer agent")`, `input.render(innerW)[0] ?? ""`, and `theme.fg("dim", "Enter Send • Esc Cancel")` in the composer layout. Continue forwarding every non-release event to `Input.handleInput()` while it exists. + + On submit or Escape, blur that same input, clear its submit/Escape callbacks, and then clear `this.composer`; submit only the trimmed non-empty value. The surrounding composer branch in `handleInput()` already requests a render after `Input.handleInput()`, so remove the callback-level `requestRender()` calls instead of rendering twice. In `dispose()`, set `_focused = false`, blur an open composer, clear its callbacks/reference, and then retain the existing idempotent subscription cleanup. Do not call `done()` from `dispose()`. + +- [ ] **Step 6: Leave transcript construction and control routing in place.** + + Do not rewrite `extractText()`, `createViewerKeys()`, `buildContentLines()`, `isStoppable()`, or `canSteer()` beyond formatting/import adjustments required by the new content width. In particular: + + - message role order and separators remain unchanged, + - streaming activity remains the last body line for running agents, + - user/result/bash content still wraps through Pi/TUI, + - result and bash output truncation remains 500 characters, + - `onSteer` and `onStop` retain their current arguments and call counts, and + - a completed agent changes affordances/status but does not close the viewer. + +- [ ] **Step 7: Run focused and neighboring green verification.** + + ```bash + mise exec node@24.15.0 -- pnpm vitest run \ + tests/conversation-viewer.test.ts \ + tests/dashboard-style.test.ts \ + tests/fleet-list.test.ts \ + tests/agent-manager.test.ts + mise exec node@24.15.0 -- pnpm typecheck + git diff --check + ``` + + Expected: all viewer layout/control tests pass; dashboard primitives remain unchanged; Fleet still supplies `DASHBOARD_OVERLAY_OPTIONS`, routes steering to the selected id, stops once, ignores release events at its own listener, and restores selection after the overlay closes. + +--- + +### Task 3: Verify the Complete Conversation Viewer Phase + +**Files:** + +- Verify/commit only: `src/tui/conversation-viewer.ts` +- Verify/commit only: `tests/conversation-viewer.test.ts` +- Verify only: `src/tui/dashboard-style.ts` +- Verify only: `src/tui/fleet-list.ts` + +**Interfaces:** + +- Produces no new extension API, setting, dependency, command, manager behavior, or Fleet behavior. +- Accept the phase only when automated checks, diff scope, and a real modern-terminal path pass. + +- [ ] **Step 1: Run the complete repository check at the pinned runtime.** + + ```bash + mise exec node@24.15.0 -- env GIT_CONFIG_GLOBAL=/dev/null pnpm check + git diff --check + ``` + + Expected: Biome lint, TypeScript, and every Vitest suite exit `0`. Existing warnings or infos may print, but no unrelated file is edited to silence them. + +- [ ] **Step 2: Inspect scope and deletion-first implementation shape.** + + ```bash + git status --short + git diff --stat + git diff -- src/tui/conversation-viewer.ts tests/conversation-viewer.test.ts + git diff -- src/tui/dashboard-style.ts src/tui/fleet-list.ts tests/fleet-list.test.ts + ``` + + Expected: implementation changes are limited to the viewer and its test. The old rounded-border helpers are deleted rather than wrapped, no shared helper is duplicated, and the verification-only files have no diff. + +- [ ] **Step 3: Perform modern-terminal acceptance.** + + In Pi `0.84.3+` under Ghostty, Kitty, or another terminal with CSI-u enabled: + + 1. Start a background agent, activate FleetList from an empty prompt, and open that agent. Verify the centered `92%`/`85%` overlay has the heavy frame and live activity updates. + 2. Scroll with arrows and `j`/`k`; use Page Up/Down and Home/End. While scrolled up, wait for an update and verify position stays put; return to End and verify new output auto-follows. + 3. Press Enter, verify the hardware cursor is in the native `> ` composer, type/edit a message, and submit it. Confirm the selected agent receives the steering message once. + 4. Reopen the composer, cancel with Escape, and verify the overlay remains open. Then press `x`, disarm it with another key, and press `x` twice to stop the agent once. + 5. Let an agent complete while viewed. Verify the overlay remains open, status/affordances update, and outer Escape closes back to the same Fleet row. + 6. Resize below the frame/height minimum and back. Verify `Terminal too small · Esc`, retained scroll state, and working Escape. + + Record terminal name and Pi version in the implementation handoff. Any failed path blocks completion. + +- [ ] **Step 4: Commit the focused phase.** + + ```bash + git add src/tui/conversation-viewer.ts tests/conversation-viewer.test.ts + git commit -m "refactor: migrate conversation viewer to dashboard frame" + ``` + +## Risks and Verification Points + +- **Footer clipping from double-counted frame rows:** idle and composer tests must both equal `floor(rows * 0.85)` and retain their footer; the dynamic `8/9` chrome count is the acceptance point. +- **Message reflow from shared padding:** use `dashboardContentWidth()` consistently for body wrapping and native input; ANSI-width tests catch mixed old/new widths. +- **Focus arriving before composer creation:** `_focused` is persistent wrapper state; the cursor-marker test opens the composer only after focus is already true. +- **Release events triggering destructive actions:** guard before composer/action routing; direct Enter/X release tests catch regressions. +- **Resize mutating navigation:** fallback returns before viewport fitting; resize-away-and-back tests preserve offset. +- **Phase 7 regression:** Fleet source remains untouched and its focused suite verifies overlay sizing, selected-id steering/stopping, and cleanup. + +## Completion Criteria + +- Heavy frame, semantic header, dashboard footer, small-terminal fallback, and every row are ANSI-width safe. +- `VIEWPORT_HEIGHT_PCT === 85`, and idle/composer renders stay within the shared `85%` cap without clipping. +- Wrapper focus controls the native composer cursor; legacy and CSI-u input work; release events do nothing. +- Live updates, transcript order, scrolling/auto-follow, steer, stop, close, and disposal behavior remain intact. +- Only the two phase-owned implementation files change, all automated checks pass, and the manual modern-terminal path succeeds. diff --git a/src/tui/conversation-viewer.ts b/src/tui/conversation-viewer.ts index 3c9fd44..5bad7b6 100644 --- a/src/tui/conversation-viewer.ts +++ b/src/tui/conversation-viewer.ts @@ -8,7 +8,9 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent"; import { type Component, + type Focusable, Input, + isKeyRelease, type KeyId, matchesKey, type TUI, @@ -18,12 +20,19 @@ import { } from "@earendil-works/pi-tui"; import type { AgentRecord } from "../shared/types.js"; import type { Theme } from "./agent-widget.js"; +import { + DASHBOARD_MAX_HEIGHT_RATIO, + dashboardContentWidth, + fitDashboardViewport, + MIN_DASHBOARD_FRAME_WIDTH, + renderDashboardFrame, + renderDashboardTooSmall, +} from "./dashboard-style.js"; import { describeActivity, formatMs, formatTokens } from "./format.js"; -/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */ -const CHROME_LINES_BASE = 6; + const MIN_VIEWPORT = 3; /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */ -export const VIEWPORT_HEIGHT_PCT = 70; +export const VIEWPORT_HEIGHT_PCT = DASHBOARD_MAX_HEIGHT_RATIO * 100; function extractText(content: string | Array<{ type: string; text?: string }>): string { if (typeof content === "string") return content; @@ -52,11 +61,12 @@ function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys { scrollUp: (data) => m(data, "tui.select.up", "up") || matchesKey(data, "k"), scrollDown: (data) => m(data, "tui.select.down", "down") || matchesKey(data, "j"), pageUp: (data) => m(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"), - pageDown: (data) => m(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"), + pageDown: (data) => + m(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"), }; } -export class ConversationViewer implements Component { +export class ConversationViewer implements Component, Focusable { private scrollOffset = 0; private autoScroll = true; private unsubscribe: (() => void) | undefined; @@ -67,6 +77,7 @@ export class ConversationViewer implements Component { private keys: ViewerKeys; /** Steering composer — present while the user is typing a message to the agent. */ private composer: Input | undefined; + private _focused = false; constructor( private tui: TUI, @@ -88,7 +99,19 @@ export class ConversationViewer implements Component { }); } + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + if (this.composer) this.composer.focused = value; + } + handleInput(data: string): void { + if (isKeyRelease(data)) return; + if (this.closed) return; + // While composing a steer message, the input owns all keys (Enter sends, // Esc cancels — both wired in openComposer()). Editing keys flow through. if (this.composer) { @@ -128,8 +151,8 @@ export class ConversationViewer implements Component { } if (this.stopArmed) this.stopArmed = false; - const totalLines = this.buildContentLines(this.lastInnerW).length; const viewportHeight = this.viewportHeight(); + const totalLines = this.buildContentLines(this.lastInnerW).length; const maxScroll = Math.max(0, totalLines - viewportHeight); if (this.keys.scrollUp(data)) { @@ -154,28 +177,14 @@ export class ConversationViewer implements Component { } render(width: number): string[] { - if (width < 6) return []; // too narrow for any meaningful rendering + const targetRows = this.targetRows(); + if (width < MIN_DASHBOARD_FRAME_WIDTH || targetRows < this.chromeLines() + MIN_VIEWPORT) { + return renderDashboardTooSmall(width, targetRows, this.theme); + } + const th = this.theme; - const innerW = width - 4; // border + padding + const innerW = dashboardContentWidth(width); this.lastInnerW = innerW; - const lines: string[] = []; - - const pad = (s: string, len: number) => { - const vis = visibleWidth(s); - return s + " ".repeat(Math.max(0, len - vis)); - }; - const row = (content: string) => - th.fg("border", "│") + - " " + - truncateToWidth(pad(content, innerW), innerW) + - " " + - th.fg("border", "│"); - const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`); - const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`); - const hrMid = row(th.fg("dim", "─".repeat(innerW))); - - // Header - lines.push(hrTop); const statusIcon = this.record.status === "running" ? th.fg("accent", "●") @@ -184,118 +193,93 @@ export class ConversationViewer implements Component { : this.record.status === "error" ? th.fg("error", "✗") : th.fg("dim", "○"); - const duration = this.record.completedAt ? formatMs(this.record.completedAt - this.record.startedAt) : `${formatMs(Date.now() - this.record.startedAt)} (running)`; - + const duration = this.record.completedAt + ? formatMs(this.record.completedAt - this.record.startedAt) + : `${formatMs(Date.now() - this.record.startedAt)} (running)`; const headerParts: string[] = [duration]; - const toolUses = this.record.toolUses; - if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`); + if (this.record.toolUses > 0) { + headerParts.unshift(`${this.record.toolUses} tool${this.record.toolUses === 1 ? "" : "s"}`); + } const tokens = this.record.lifetimeUsage.inputTokens + this.record.lifetimeUsage.outputTokens + this.record.lifetimeUsage.cacheWriteTokens; if (tokens > 0) headerParts.push(formatTokens(tokens)); + const header = `${statusIcon} ${th.bold(this.record.type)} ${th.fg("muted", this.record.description)} ${th.fg("dim", "•")} ${th.fg("dim", headerParts.join(" • "))}`; - lines.push( - row( - `${statusIcon} ${th.bold(this.record.type)} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`, - ), - ); - lines.push(hrMid); - - // Content area — rebuild every render (live data, no cache needed) const contentLines = this.buildContentLines(innerW); const viewportHeight = this.viewportHeight(); - const maxScroll = Math.max(0, contentLines.length - viewportHeight); - - if (this.autoScroll) { - this.scrollOffset = maxScroll; - } - - const visibleStart = Math.min(this.scrollOffset, maxScroll); - const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight); - - for (let i = 0; i < viewportHeight; i++) { - lines.push(row(visible[i] ?? "")); - } - - // Footer - lines.push(hrMid); - if (this.composer) { - // Composer row: the Input renders its own `> ` prompt and cursor. - lines.push(row(this.composer.render(innerW)[0] ?? "")); - const composeHint = th.fg("dim", "Enter send · Esc cancel"); - const composeLeft = th.fg("accent", "✎ steer"); - const composeGap = Math.max( - 1, - innerW - visibleWidth(composeLeft) - visibleWidth(composeHint), - ); - lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint)); - } else { - // Actions on the left, navigation on the right. The scroll hint keeps its - // full key list so the less-obvious bindings stay discoverable; it leads - // the right group so "Esc close" is the only part that truncates first. - const sep = th.fg("dim", " · "); - const actions: string[] = []; - if (this.canSteer()) actions.push(th.fg("dim", "Enter steer")); - if (this.isStoppable()) { - actions.push( - this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"), - ); - } - const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close"); - - // Prepend the line-count/scroll-% readout only when there's spare width — - // it's the first thing dropped so it never crowds out the hints. - const scrollPct = - contentLines.length <= viewportHeight - ? "100%" - : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`; - const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`); - const withCount = [count, ...actions].join(sep); - const footerLeft = - visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW - ? withCount - : actions.join(sep); - - const footerGap = Math.max( - 1, - innerW - visibleWidth(footerLeft) - visibleWidth(footerRight), - ); - lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight)); + if (this.autoScroll) this.scrollOffset = Math.max(0, contentLines.length - viewportHeight); + const viewport = fitDashboardViewport( + contentLines, + undefined, + viewportHeight, + this.scrollOffset, + ); + this.scrollOffset = viewport.offset; + + const scrollPct = + contentLines.length <= viewportHeight + ? "100%" + : `${Math.round(((viewport.offset + viewportHeight) / contentLines.length) * 100)}%`; + const sep = th.fg("dim", " • "); + const actions: string[] = []; + if (this.canSteer()) actions.push(th.fg("dim", "Enter Steer")); + if (this.isStoppable()) { + actions.push(this.stopArmed ? th.fg("error", "x Again to STOP") : th.fg("dim", "x Stop")); } - lines.push(hrBot); - - return lines; + const footerRight = th.fg("dim", "↑/↓ Scroll • PgUp/PgDn or Shift+↑/↓ • Esc Close"); + const withCount = [ + th.fg("dim", `${contentLines.length} lines • ${scrollPct}`), + ...actions, + ].join(sep); + const footerLeft = + visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW + ? withCount + : actions.join(sep); + const idleFooter = + footerLeft + + " ".repeat(Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight))) + + footerRight; + + return renderDashboardFrame( + [ + header, + "", + ...viewport.lines, + ...(this.composer + ? [ + th.fg("accent", "Steer agent"), + this.composer.render(innerW)[0] ?? "", + th.fg("dim", "Enter Send • Esc Cancel"), + ] + : ["", idleFooter]), + ], + width, + th, + ); } /** Stoppable only when a stop handler exists and the agent is still active. */ private isStoppable(): boolean { - return ( - !!this.onStop && (this.record.status === "running" || this.record.status === "queued") - ); + return !!this.onStop && (this.record.status === "running" || this.record.status === "queued"); } /** Steerable only when a steer handler exists and the agent is still active. */ private canSteer(): boolean { - return ( - !!this.onSteer && (this.record.status === "running" || this.record.status === "queued") - ); + return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued"); } /** Open the inline steering composer and route subsequent input to it. */ private openComposer(): void { const input = new Input(); - input.focused = true; + input.focused = this._focused; input.onSubmit = (value: string) => { const message = value.trim(); - this.composer = undefined; + this.closeComposer(); if (message) this.onSteer?.(message); - this.tui.requestRender(); - }; - input.onEscape = () => { - this.composer = undefined; - this.tui.requestRender(); }; + input.onEscape = () => this.closeComposer(); this.composer = input; this.tui.requestRender(); } @@ -306,6 +290,8 @@ export class ConversationViewer implements Component { dispose(): void { this.closed = true; + this._focused = false; + this.closeComposer(); if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = undefined; @@ -314,16 +300,24 @@ export class ConversationViewer implements Component { // ---- Private ---- + private closeComposer(): void { + if (!this.composer) return; + this.composer.focused = false; + this.composer.onSubmit = undefined; + this.composer.onEscape = undefined; + this.composer = undefined; + } + + private targetRows(): number { + return Math.max(1, Math.floor(this.tui.terminal.rows * DASHBOARD_MAX_HEIGHT_RATIO)); + } + private viewportHeight(): number { - // Cap mirrors the overlay's maxHeight — otherwise the viewer would render - // more lines than the overlay shows and clip the footer. - const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100); - return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines()); + return Math.max(MIN_VIEWPORT, this.targetRows() - this.chromeLines()); } private chromeLines(): number { - // The composer adds one row above the footer hint while it's open. - return CHROME_LINES_BASE + (this.composer ? 1 : 0); + return 8 + (this.composer ? 1 : 0); } private buildContentLines(width: number): string[] { @@ -381,9 +375,7 @@ export class ConversationViewer implements Component { lines.push(truncateToWidth(th.fg("muted", ` $ ${msg.command}`), width)); if (msg.output?.trim()) { const out = - msg.output.length > 500 - ? `${msg.output.slice(0, 500)}... (truncated)` - : msg.output; + msg.output.length > 500 ? `${msg.output.slice(0, 500)}... (truncated)` : msg.output; for (const line of wrapTextWithAnsi(out.trim(), width)) { lines.push(th.fg("dim", line)); } diff --git a/tests/conversation-viewer.test.ts b/tests/conversation-viewer.test.ts index 2ccf4fd..b352d19 100644 --- a/tests/conversation-viewer.test.ts +++ b/tests/conversation-viewer.test.ts @@ -1,28 +1,48 @@ -import { describe, expect, it, vi } from "vitest"; import type { AgentSession } from "@earendil-works/pi-coding-agent"; -import type { TUI } from "@earendil-works/pi-tui"; -import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "../src/tui/conversation-viewer.js"; +import { CURSOR_MARKER, type TUI, visibleWidth } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; import type { AgentRecord } from "../src/shared/types.js"; +import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "../src/tui/conversation-viewer.js"; -// Raw escape sequences for key input (VT100/xterm) -const ESC = "\x1b"; -const ENTER = "\r"; -const UP = "\x1b[A"; +const LEGACY_ENTER = "\r"; +const LEGACY_ESCAPE = "\x1b"; +const LEGACY_UP = "\x1b[A"; +const LEGACY_DOWN = "\x1b[B"; +const LEGACY_PAGE_UP = "\x1b[5~"; +const LEGACY_PAGE_DOWN = "\x1b[6~"; +const LEGACY_HOME = "\x1b[H"; +const LEGACY_END = "\x1b[F"; +const KITTY_UP = "\x1b[1;1A"; +const KITTY_DOWN = "\x1b[1;1B"; +const CSI_U_ENTER = "\x1b[13u"; +const CSI_U_ESCAPE = "\x1b[27u"; +const CSI_U_X = "\x1b[120u"; +const CSI_U_ENTER_RELEASE = "\x1b[13;1:3u"; +const CSI_U_X_RELEASE = "\x1b[120;1:3u"; -const makeTheme = () => ({ - fg: (_color: string, text: string) => text, - bold: (text: string) => text, +const makeTheme = (ansi = false) => ({ + fg: vi.fn((_: string, text: string) => (ansi ? `\x1b[36m${text}\x1b[39m` : text)), + bold: vi.fn((text: string) => (ansi ? `\x1b[1m${text}\x1b[22m` : text)), }); -const makeTui = (rows = 40) => ({ - terminal: { rows, columns: 80 }, - requestRender: vi.fn(), -}); +const makeTui = (rows = 40) => + ({ terminal: { rows, columns: 80 }, requestRender: vi.fn() }) as unknown as TUI & { + terminal: { rows: number; columns: number }; + requestRender: ReturnType; + }; const makeSession = (messages: unknown[] = []) => { + let notify: (() => void) | undefined; const unsubscribe = vi.fn(); - const subscribe = vi.fn(() => unsubscribe); - return { messages, subscribe, _unsubscribe: unsubscribe }; + return { + messages, + subscribe: vi.fn((listener: () => void) => { + notify = listener; + return unsubscribe; + }), + emit: () => notify?.(), + _unsubscribe: unsubscribe, + }; }; const makeRecord = (overrides: Partial = {}): AgentRecord => ({ @@ -38,206 +58,414 @@ const makeRecord = (overrides: Partial = {}): AgentRecord => ({ ...overrides, }); -function makeViewer(opts: { - rows?: number; - messages?: unknown[]; - record?: AgentRecord; - onStop?: () => void; - onSteer?: (msg: string) => void; -} = {}) { +function makeViewer( + opts: { + rows?: number; + messages?: unknown[]; + record?: AgentRecord; + onStop?: () => void; + onSteer?: (message: string) => void; + keybindings?: { matches(data: string, id: string): boolean }; + theme?: ReturnType; + } = {}, +) { const tui = makeTui(opts.rows); const session = makeSession(opts.messages); const done = vi.fn(); const viewer = new ConversationViewer( - tui as unknown as TUI, + tui, session as unknown as AgentSession, opts.record ?? makeRecord(), - makeTheme(), - done as unknown as (result: undefined) => void, + opts.theme ?? makeTheme(), + done, opts.onStop, - undefined, + opts.keybindings, opts.onSteer, ); return { viewer, tui, session, done }; } +const messages = (count: number) => + Array.from({ length: count }, (_, index) => ({ + role: "user" as const, + content: `message ${index}`, + timestamp: Date.now(), + })); + describe("VIEWPORT_HEIGHT_PCT", () => { - it("is 70", () => { - expect(VIEWPORT_HEIGHT_PCT).toBe(70); + it("is 85", () => { + expect(VIEWPORT_HEIGHT_PCT).toBe(85); }); }); describe("ConversationViewer", () => { - describe("render()", () => { - it("returns an array of strings", () => { - const { viewer } = makeViewer(); - const lines = viewer.render(80); - expect(Array.isArray(lines)).toBe(true); - for (const line of lines) { - expect(typeof line).toBe("string"); - } + it("renders the shared heavy frame at its 85% height with semantic header and footer", () => { + const theme = makeTheme(true); + const { viewer } = makeViewer({ + theme, + onSteer: vi.fn(), + onStop: vi.fn(), + record: makeRecord({ + toolUses: 2, + lifetimeUsage: { inputTokens: 4, outputTokens: 5, cacheWriteTokens: 1 }, + }), }); + const lines = viewer.render(80); + const rendered = lines.join("\n"); - it("first line starts with box border ╭", () => { - const { viewer } = makeViewer(); - const lines = viewer.render(80); - expect(lines[0].startsWith("╭")).toBe(true); - }); + expect(lines).toHaveLength(34); + expect(lines[0].replaceAll("\x1b[36m", "").replaceAll("\x1b[39m", "").startsWith("┏━")).toBe( + true, + ); + expect( + (lines.at(-1) ?? "").replaceAll("\x1b[36m", "").replaceAll("\x1b[39m", "").endsWith("━┛"), + ).toBe(true); + expect(rendered).not.toMatch(/[╭╮╰╯│]/); + expect(lines.every((line) => visibleWidth(line) === 80)).toBe(true); + for (const text of [ + "coder", + "Fix the bug", + "●", + "2 tools", + "10 token", + "Enter Steer", + "x Stop", + "↑/↓ Scroll", + "PgUp/PgDn", + "Esc Close", + ]) { + expect(rendered).toContain(text); + } + expect(rendered).toMatch(/\d+\.\d+s \(running\)/); + expect(theme.bold).toHaveBeenCalledWith("coder"); + expect(theme.fg).toHaveBeenCalledWith("accent", "●"); + expect(theme.fg).toHaveBeenCalledWith("muted", "Fix the bug"); + }); - it("last line ends with ╯", () => { - const { viewer } = makeViewer(); - const lines = viewer.render(80); - expect(lines[lines.length - 1].endsWith("╯")).toBe(true); - }); + it.each([ + ["running", "●", "accent"], + ["completed", "✓", "success"], + ["error", "✗", "error"], + ["queued", "○", "dim"], + ] as const)("renders the %s status with its semantic role", (status, icon, role) => { + const theme = makeTheme(); + const { viewer } = makeViewer({ record: makeRecord({ status }), theme }); - it("header contains record.type and record.description", () => { - const { viewer } = makeViewer(); - const lines = viewer.render(80); - // header is the second line (after the top border) - expect(lines[1]).toContain("coder"); - expect(lines[1]).toContain("Fix the bug"); - }); + expect(viewer.render(80).join("\n")).toContain(icon); + expect(theme.fg).toHaveBeenCalledWith(role, icon); + }); - it("shows ● for running status", () => { - const { viewer } = makeViewer({ record: makeRecord({ status: "running" }) }); - const lines = viewer.render(80); - expect(lines[1]).toContain("●"); - }); + it("renders the waiting state for an empty session", () => { + const { viewer } = makeViewer({ messages: [] }); - it("shows ✓ for completed status", () => { - const { viewer } = makeViewer({ record: makeRecord({ status: "completed" }) }); - const lines = viewer.render(80); - expect(lines[1]).toContain("✓"); - }); + expect(viewer.render(80).join("\n")).toContain("(waiting for first message...)"); + }); - it("shows ✗ for error status", () => { - const { viewer } = makeViewer({ record: makeRecord({ status: "error" }) }); - const lines = viewer.render(80); - expect(lines[1]).toContain("✗"); - }); + it.each([ + ["user", { role: "user", content: "hello", timestamp: 1 }, "[User]"], + ["assistant", { role: "assistant", content: [{ type: "text", text: "hello" }] }, "[Assistant]"], + ["tool result", { role: "toolResult", content: [{ type: "text", text: "hello" }] }, "[Result]"], + ])("renders the %s role label", (_name, message, label) => { + const { viewer } = makeViewer({ messages: [message] }); - it("shows (waiting for first message...) with empty session", () => { - const { viewer } = makeViewer({ messages: [] }); - const all = viewer.render(80).join("\n"); - expect(all).toContain("(waiting for first message...)"); - }); + expect(viewer.render(80).join("\n")).toContain(label); + }); - it("renders [User] for user messages", () => { - const messages = [{ role: "user", content: "hello", timestamp: Date.now() }]; - const { viewer } = makeViewer({ messages }); - const all = viewer.render(80).join("\n"); - expect(all).toContain("[User]"); - }); + it("renders the native composer in the same capped frame", () => { + const { viewer } = makeViewer({ onSteer: vi.fn() }); + viewer.focused = true; + viewer.handleInput(CSI_U_ENTER); + const lines = viewer.render(80); + const rendered = lines.join("\n"); - it("renders [Assistant] for assistant messages", () => { - const messages = [{ role: "assistant", content: [{ type: "text", text: "response" }] }]; - const { viewer } = makeViewer({ messages }); - const all = viewer.render(80).join("\n"); - expect(all).toContain("[Assistant]"); - }); + expect(lines).toHaveLength(34); + expect(lines.every((line) => visibleWidth(line) === 80)).toBe(true); + expect(rendered).toContain("Steer agent"); + expect(rendered).toContain("> "); + expect(rendered).toContain("Enter Send • Esc Cancel"); + expect(rendered).not.toContain("Enter Steer"); + }); - it("renders [Result] for toolResult messages", () => { - const messages = [{ role: "toolResult", content: [{ type: "text", text: "some result" }] }]; - const { viewer } = makeViewer({ messages }); - const all = viewer.render(80).join("\n"); - expect(all).toContain("[Result]"); - }); + it("uses bounded fallback rendering without changing navigation state", () => { + const { viewer, tui } = makeViewer({ rows: 40, messages: messages(20) }); + viewer.render(80); + viewer.handleInput(LEGACY_UP); + expect(viewer.render(80).join("\n")).not.toContain("100%"); - it("reads streaming activity from the live record on every render", () => { - const record = makeRecord({ - status: "running", - live: { activeTools: [], responseText: "first thought" }, - }); - const { viewer } = makeViewer({ - record, - messages: [{ role: "user", content: "hello", timestamp: Date.now() }], - }); - expect(viewer.render(80).join("\n")).toContain("first thought"); - - record.live.responseText = "second thought"; - expect(viewer.render(80).join("\n")).toContain("second thought"); - }); + tui.terminal.rows = 10; + expect(viewer.render(80).join("\n")).toContain("Terminal too small · Esc"); + tui.terminal.rows = 40; + expect(viewer.render(80).join("\n")).not.toContain("100%"); + }); - it("reads tool and token totals from the record", () => { - const { viewer } = makeViewer({ - record: makeRecord({ - toolUses: 2, - lifetimeUsage: { inputTokens: 4, outputTokens: 5, cacheWriteTokens: 1 }, - }), - }); - const header = viewer.render(80)[1]; - expect(header).toContain("2 tools"); - expect(header).toContain("10 token"); - }); + it("closes from fallback rendering", () => { + const { viewer, done } = makeViewer({ rows: 10 }); + + expect(viewer.render(80).join("\n")).toContain("Terminal too small · Esc"); + viewer.handleInput(LEGACY_ESCAPE); + expect(done).toHaveBeenCalledWith(undefined); }); - describe("session subscription", () => { - it("calls session.subscribe() in constructor", () => { - const { session } = makeViewer(); - expect(session.subscribe).toHaveBeenCalledOnce(); - }); + it("handles both width and composer height fallback limits", () => { + const narrow = makeViewer(); + const narrowLines = narrow.viewer.render(6); + expect(narrowLines.length).toBeGreaterThan(0); + expect(narrowLines.every((line) => visibleWidth(line) === 6)).toBe(true); - it("dispose() calls the unsubscribe function", () => { - const { viewer, session } = makeViewer(); - viewer.dispose(); - expect(session._unsubscribe).toHaveBeenCalledOnce(); - }); + const { viewer } = makeViewer({ rows: 13, onSteer: vi.fn() }); + expect(viewer.render(80).join("\n")).not.toContain("Terminal too small"); + viewer.handleInput(LEGACY_ENTER); + expect(viewer.render(80).join("\n")).toContain("Terminal too small · Esc"); + viewer.handleInput(LEGACY_ESCAPE); + expect(viewer.render(80).join("\n")).not.toContain("Terminal too small"); }); - describe("handleInput()", () => { - it("escape closes the viewer (calls done with undefined)", () => { - const { viewer, done } = makeViewer(); - viewer.handleInput(ESC); - expect(done).toHaveBeenCalledWith(undefined); - }); + it("forwards wrapper focus to a composer opened afterwards", () => { + const { viewer } = makeViewer({ onSteer: vi.fn() }); + viewer.focused = true; + viewer.handleInput(CSI_U_ENTER); + expect(viewer.render(80).join("\n")).toContain(CURSOR_MARKER); + viewer.focused = false; + expect(viewer.render(80).join("\n")).not.toContain(CURSOR_MARKER); + viewer.focused = true; + expect(viewer.render(80).join("\n")).toContain(CURSOR_MARKER); + }); - it("x once arms stop but does not call onStop", () => { - const onStop = vi.fn(); - const { viewer } = makeViewer({ onStop }); - viewer.handleInput("x"); - expect(onStop).not.toHaveBeenCalled(); - // stopArmed is visible via the footer hint - const all = viewer.render(80).join("\n"); - expect(all).toContain("x again to STOP"); - }); + it("routes legacy and CSI-u composer input, while release events do nothing", () => { + const onSteer = vi.fn(); + const onStop = vi.fn(); + const { viewer } = makeViewer({ onSteer, onStop }); + viewer.focused = true; + viewer.handleInput(CSI_U_ENTER_RELEASE); + viewer.handleInput(CSI_U_X_RELEASE); + viewer.handleInput(CSI_U_X_RELEASE); + expect(viewer.render(80).join("\n")).not.toContain("Steer agent"); + expect(viewer.render(80).join("\n")).not.toContain("Again to STOP"); + expect(onSteer).not.toHaveBeenCalled(); + expect(onStop).not.toHaveBeenCalled(); + }); - it("x twice calls onStop", () => { - const onStop = vi.fn(); - const { viewer } = makeViewer({ onStop }); - viewer.handleInput("x"); - viewer.handleInput("x"); - expect(onStop).toHaveBeenCalledOnce(); + it.each([ + ["legacy", LEGACY_ENTER, "a", LEGACY_ENTER], + ["CSI-u", CSI_U_ENTER, "\x1b[97u", CSI_U_ENTER], + ])("submits trimmed %s composer input", (_name, open, input, submit) => { + const onSteer = vi.fn(); + const { viewer } = makeViewer({ onSteer }); + + viewer.handleInput(open); + viewer.handleInput(" "); + viewer.handleInput(input); + viewer.handleInput(" "); + viewer.handleInput(submit); + expect(onSteer).toHaveBeenCalledWith("a"); + expect(onSteer).toHaveBeenCalledOnce(); + }); + + it.each([ + ["legacy", LEGACY_ENTER, LEGACY_ESCAPE], + ["CSI-u", CSI_U_ENTER, CSI_U_ESCAPE], + ])("cancels a %s composer without steering or closing", (_name, open, cancel) => { + const onSteer = vi.fn(); + const { viewer, done } = makeViewer({ onSteer }); + + viewer.handleInput(open); + viewer.handleInput("a"); + viewer.handleInput(cancel); + expect(viewer.render(80).join("\n")).not.toContain("Steer agent"); + expect(onSteer).not.toHaveBeenCalled(); + expect(done).not.toHaveBeenCalled(); + }); + + it("ignores an empty composer submission", () => { + const onSteer = vi.fn(); + const { viewer } = makeViewer({ onSteer }); + + viewer.handleInput(LEGACY_ENTER); + viewer.handleInput(" "); + viewer.handleInput(CSI_U_ENTER); + expect(onSteer).not.toHaveBeenCalled(); + expect(viewer.render(80).join("\n")).not.toContain("Steer agent"); + }); + + it("keeps composer keys from scrolling and renders once for input, submit, and cancel", () => { + const onSteer = vi.fn(); + const { viewer, tui, done } = makeViewer({ messages: messages(30), onSteer }); + viewer.handleInput(LEGACY_ENTER); + tui.requestRender.mockClear(); + viewer.handleInput("j"); + expect(tui.requestRender).toHaveBeenCalledOnce(); + tui.requestRender.mockClear(); + viewer.handleInput("k"); + expect(tui.requestRender).toHaveBeenCalledOnce(); + tui.requestRender.mockClear(); + viewer.handleInput(CSI_U_ENTER); + expect(tui.requestRender).toHaveBeenCalledOnce(); + expect(onSteer).toHaveBeenCalledWith("jk"); + + viewer.handleInput(LEGACY_ENTER); + tui.requestRender.mockClear(); + viewer.handleInput(CSI_U_ESCAPE); + expect(tui.requestRender).toHaveBeenCalledOnce(); + expect(viewer.render(80).join("\n")).not.toContain("Steer agent"); + expect(done).not.toHaveBeenCalled(); + }); + + it.each([ + ["legacy Escape", LEGACY_ESCAPE], + ["CSI-u Escape", CSI_U_ESCAPE], + ["q", "q"], + ])("closes once for outer %s", (_name, key) => { + const { viewer, done } = makeViewer(); + + viewer.handleInput(key); + viewer.handleInput(key); + expect(done).toHaveBeenCalledOnce(); + }); + + it("preserves chronological transcript order", () => { + const transcript = [ + { role: "user" as const, content: "first", timestamp: Date.now() }, + { role: "assistant" as const, content: [{ type: "text", text: "second" }] }, + { role: "toolResult" as const, content: [{ type: "text", text: "third" }] }, + ]; + const { viewer } = makeViewer({ messages: transcript }); + const ordered = viewer.render(100).join("\n"); + expect(ordered.indexOf("first")).toBeLessThan(ordered.indexOf("second")); + expect(ordered.indexOf("second")).toBeLessThan(ordered.indexOf("third")); + }); + + it("moves and clamps legacy, Kitty, and page scrolling at both transcript bounds", () => { + const { viewer } = makeViewer({ messages: messages(30) }); + + expect(viewer.render(100).join("\n")).toContain("message 29"); + for (const [up, down] of [ + [LEGACY_UP, LEGACY_DOWN], + [KITTY_UP, KITTY_DOWN], + [LEGACY_PAGE_UP, LEGACY_PAGE_DOWN], + ]) { + viewer.handleInput(up); + expect(viewer.render(100).join("\n")).not.toContain("100%"); + viewer.handleInput(down); + expect(viewer.render(100).join("\n")).toContain("100%"); + } + + viewer.handleInput(LEGACY_HOME); + for (let index = 0; index < 100; index++) viewer.handleInput(KITTY_UP); + for (let index = 0; index < 10; index++) viewer.handleInput(LEGACY_PAGE_UP); + expect(viewer.render(100).join("\n")).toContain("message 0"); + + viewer.handleInput(LEGACY_END); + for (let index = 0; index < 100; index++) viewer.handleInput(KITTY_DOWN); + for (let index = 0; index < 10; index++) viewer.handleInput(LEGACY_PAGE_DOWN); + expect(viewer.render(100).join("\n")).toContain("100%"); + expect(viewer.render(100).join("\n")).toContain("message 29"); + }); + + it("honors custom scroll and page bindings while retaining j/k fallbacks", () => { + const bindings = new Map([ + ["w", "tui.select.up"], + ["s", "tui.select.down"], + ["u", "tui.select.pageUp"], + ["d", "tui.select.pageDown"], + ]); + const keybindings = { matches: (data: string, id: string) => bindings.get(data) === id }; + const { viewer } = makeViewer({ messages: messages(30), keybindings }); + + viewer.render(100); + for (const [up, down] of [ + ["w", "s"], + ["u", "d"], + ["k", "j"], + ]) { + viewer.handleInput(up); + expect(viewer.render(100).join("\n")).not.toContain("100%"); + viewer.handleInput(down); + expect(viewer.render(100).join("\n")).toContain("100%"); + } + }); + + it("preserves manual position on updates and auto-follows new messages after End", () => { + const { viewer, session, tui } = makeViewer({ messages: messages(30) }); + + viewer.render(100); + viewer.handleInput(KITTY_UP); + tui.requestRender.mockClear(); + session.messages.push({ + role: "user" as const, + content: "newest", + timestamp: Date.now(), }); + session.emit(); + expect(tui.requestRender).toHaveBeenCalledOnce(); + expect(viewer.render(100).join("\n")).not.toContain("100%"); - it("enter opens composer when canSteer is true (running + onSteer provided)", () => { - const onSteer = vi.fn(); - const { viewer } = makeViewer({ - onSteer, - record: makeRecord({ status: "running" }), - }); - viewer.handleInput(ENTER); - const all = viewer.render(80).join("\n"); - expect(all).toContain("✎ steer"); + viewer.handleInput(LEGACY_END); + session.messages.push({ + role: "user" as const, + content: "after End", + timestamp: Date.now(), }); + session.emit(); + const followed = viewer.render(100).join("\n"); + expect(followed).toContain("after End"); + expect(followed).toContain("100%"); + }); - it("up arrow decrements scroll offset when not at the top", () => { - // 10 user messages → 29 content lines, viewportHeight 22 → maxScroll 7 - const messages = Array.from({ length: 10 }, (_, i) => ({ - role: "user", - content: `message ${i}`, - timestamp: Date.now(), - })); - const { viewer } = makeViewer({ messages }); + it("reads mutable record state and keeps completed viewers open", () => { + const record = makeRecord({ live: { activeTools: [], responseText: "working" } }); + const { viewer, session, tui, done } = makeViewer({ + record, + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }); + expect(viewer.render(80).join("\n")).toContain("working"); + record.live.responseText = "still working"; + expect(viewer.render(80).join("\n")).toContain("still working"); + record.status = "completed"; + record.live.responseText = "finished"; + tui.requestRender.mockClear(); + session.emit(); + expect(tui.requestRender).toHaveBeenCalledOnce(); + expect(viewer.render(80).join("\n")).toContain("✓"); + expect(done).not.toHaveBeenCalled(); + }); - // Initial render: autoScroll brings us to the bottom (100%) - const lines1 = viewer.render(80); - expect(lines1[lines1.length - 2]).toContain("100%"); + it.each([ + ["completed", makeRecord({ status: "completed", completedAt: Date.now() })], + ["read-only", makeRecord()], + ])("hides and disables actions for a %s viewer", (_name, record) => { + const onSteer = vi.fn(); + const onStop = vi.fn(); + const callbacks = _name === "read-only" ? {} : { onSteer, onStop }; + const { viewer } = makeViewer({ record, ...callbacks }); + const rendered = viewer.render(80).join("\n"); - // Press up once — scrollOffset should decrease - viewer.handleInput(UP); + expect(rendered).not.toContain("Enter Steer"); + expect(rendered).not.toContain("x Stop"); + viewer.handleInput(LEGACY_ENTER); + viewer.handleInput(CSI_U_X); + viewer.handleInput(CSI_U_X); + expect(onSteer).not.toHaveBeenCalled(); + expect(onStop).not.toHaveBeenCalled(); + }); - const lines2 = viewer.render(80); - expect(lines2[lines2.length - 2]).not.toContain("100%"); - }); + it("keeps stop confirmation and cleanup behavior", () => { + const onStop = vi.fn(); + const { viewer, session, done, tui } = makeViewer({ onStop }); + viewer.handleInput(CSI_U_X); + expect(viewer.render(80).join("\n")).toContain("x Again to STOP"); + viewer.handleInput("a"); + expect(viewer.render(80).join("\n")).not.toContain("x Again to STOP"); + expect(onStop).not.toHaveBeenCalled(); + viewer.handleInput(CSI_U_X); + viewer.handleInput(CSI_U_X); + expect(onStop).toHaveBeenCalledOnce(); + viewer.handleInput("q"); + expect(done).toHaveBeenCalledOnce(); + viewer.dispose(); + viewer.dispose(); + expect(session._unsubscribe).toHaveBeenCalledOnce(); + tui.requestRender.mockClear(); + session.emit(); + expect(tui.requestRender).not.toHaveBeenCalled(); }); });