From 5aef7550607cbe976c09f294334a0c21a736ef9a Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 26 Aug 2026 22:37:00 -0400 Subject: [PATCH 1/9] docs: update phase 3 plan --- ...shboard-ui-phase-3-dashboard-foundation.md | 465 ++++++++++++++++-- 1 file changed, 436 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-3-dashboard-foundation.md b/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-3-dashboard-foundation.md index 5c06919..69f6f2f 100644 --- a/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-3-dashboard-foundation.md +++ b/docs/superpowers/plans/2026-08-26-run-chain-dashboard-ui-phase-3-dashboard-foundation.md @@ -2,58 +2,465 @@ > **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:** Introduce one local dashboard renderer and use it for the chain preview, establishing the visual system without changing other surfaces. +**Goal:** Add one local dashboard renderer and use it for every `ChainClarifyComponent` presentation without changing chain execution or editing behavior. -**Architecture:** A dependency-free `dashboard-style.ts` owns overlay sizing, ANSI-safe frame width, selected-row viewport fitting, and the small-terminal fallback. The chain preview is the first consumer. +**Architecture:** A dependency-free `dashboard-style.ts` owns shared overlay options, an ANSI-safe heavy frame, selected-row viewport fitting, and a bounded small-terminal fallback. `ChainClarifyComponent` remains the single preview/editor used by slash chains and structured subagent clarification; only the slash-chain caller supplies overlay options. -**Tech Stack:** TypeScript, Pi/TUI `0.84.3`, Vitest. +**Tech Stack:** TypeScript, `@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 2 chain input repair. +**Prerequisite:** Phase 2 chain input repair is merged at `3408875`. -**Usable result:** The chain preview has a responsive pi-status-inspired frame and viewport while retaining all repaired interactions. +**Usable result:** Sequential, static-parallel, and dynamic-parallel chain previews render inside a responsive pi-status-inspired frame while retaining Phase 2 input, focus, editing, cancellation, and dispatch behavior. -## Constraints +## Global Constraints -- Use only Pi's active theme; add no palettes, settings, config, pi-status dependency, or shared package. -- Overlay options are centered, `width: "92%"`, `maxHeight: "85%"`. -- Preserve chain behavior and readonly parallel steps. -- ANSI-aware width/truncation must use the TUI utilities already available in Pi/TUI. +- Keep the three Pi development packages and lockfile resolved at `0.84.3`. +- Use only Pi's active theme; add no palette, setting, config file, pi-status dependency, or shared package. +- Preserve `/run-chain`, `/chain`, prompt workflows, `--yes`, `--bg`, chain schemas, tool contracts, agent definitions, and settings formats. +- Preserve `ChainClarifyResult = { action: "run" | "cancel" | "bg"; steps: ChainStep[] }`. +- Preserve Phase 2's `matchesKey()`, key-release guard, native `Input`, focus forwarding, cursor positioning, and preview-failure behavior. +- Keep static and dynamic parallel steps read-only. +- Center slash-chain overlays at `92%` width and `85%` maximum height. +- Use Pi/TUI's `truncateToWidth()` and `visibleWidth()` for terminal width calculations. +- Do not replace the renderer with `Box` or `ScrollView`; neither owns this component's selected logical-step mapping or required heavy-frame geometry. -### Task 1: Specify the renderer with failing tests +--- -**Files:** Create `tests/dashboard-style.test.ts` +### Task 1: Add ANSI-safe dashboard primitives -- [ ] Add exact tests for: a frame rendered at width `24`; `dashboardContentWidth(24) === 18`; a selected row scrolled into view with a stable offset; and `renderDashboardTooSmall(30, 3, theme)` containing `Esc`. Run `pnpm vitest run tests/dashboard-style.test.ts`; expected: module-not-found failure. +**Files:** -### Task 2: Add the minimal shared renderer +- Create: `src/tui/dashboard-style.ts` +- Create: `tests/dashboard-style.test.ts` -**Files:** Create `src/tui/dashboard-style.ts` +**Interfaces:** -- [ ] Export exactly: +- Consumes: `OverlayOptions`, `truncateToWidth()`, and `visibleWidth()` from `@earendil-works/pi-tui`; `Theme` from `src/tui/agent-widget.ts`. +- Produces: ```ts export const DASHBOARD_MAX_HEIGHT_RATIO = 0.85; export const DASHBOARD_OVERLAY_OPTIONS: OverlayOptions; export const MIN_DASHBOARD_FRAME_WIDTH = 7; export function dashboardContentWidth(width: number): number; - export function renderDashboardFrame(lines: readonly string[], width: number, theme: Theme): string[]; - export function fitDashboardViewport(lines: readonly string[], selectedLine: number | undefined, height: number, offset: number): { lines: string[]; offset: number }; - export function renderDashboardTooSmall(width: number, height: number, theme: Theme): string[]; + export function renderDashboardFrame( + lines: readonly string[], + width: number, + theme: Theme, + ): string[]; + export function fitDashboardViewport( + lines: readonly string[], + selectedLine: number | undefined, + height: number, + offset: number, + ): { lines: string[]; offset: number }; + export function renderDashboardTooSmall( + width: number, + height: number, + theme: Theme, + ): string[]; ``` -- [ ] Use horizontal padding `2`, heavy frame glyphs `┏ ┓ ┗ ┛ ━ ┃`, and `{ anchor: "center", width: "92%", maxHeight: "85%" }`. Clamp every width/height calculation; keep the selected line visible without unnecessary offset jumps. -- [ ] Run `pnpm vitest run tests/dashboard-style.test.ts`; expected: all exact-width, viewport, and narrow-terminal tests pass. +- [ ] **Step 1: Write the failing primitive tests.** -### Task 3: Apply it only to chain preview + Create `tests/dashboard-style.test.ts` with a plain theme and an ANSI theme. Cover these exact contracts: -**Files:** `src/tui/chain-clarify.ts`, `tests/chain-clarify.test.ts`, `tests/core/chain-clarify-integration.test.ts` + ```ts + import { visibleWidth } from "@earendil-works/pi-tui"; + import { describe, expect, test } from "vitest"; + import { + dashboardContentWidth, + fitDashboardViewport, + MIN_DASHBOARD_FRAME_WIDTH, + renderDashboardFrame, + renderDashboardTooSmall, + } from "../src/tui/dashboard-style.js"; + + const plainTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + const ansiTheme = { + fg: (_color: string, text: string) => `\x1b[31m${text}\x1b[39m`, + bold: (text: string) => `\x1b[1m${text}\x1b[22m`, + }; + + test("renders a heavy frame at the requested visible width", () => { + const lines = renderDashboardFrame(["Header", "Body"], 24, plainTheme); + expect(lines).toHaveLength(6); + expect(lines[0]).toBe(`┏${"━".repeat(22)}┓`); + expect(lines.at(-1)).toBe(`┗${"━".repeat(22)}┛`); + expect(lines.every((line) => visibleWidth(line) === 24)).toBe(true); + expect(dashboardContentWidth(24)).toBe(18); + }); + + test("preserves visible width with ANSI and embedded newlines", () => { + const lines = renderDashboardFrame(["one\r\ntwo"], 24, ansiTheme); + expect(lines.some((line) => line.includes("\x1b[31m"))).toBe(true); + expect(lines.every((line) => visibleWidth(line) === 24)).toBe(true); + expect(lines.every((line) => !/[\r\n]/.test(line))).toBe(true); + }); + + test("preserves the complete seven-column minimum", () => { + expect(MIN_DASHBOARD_FRAME_WIDTH).toBe(7); + expect(renderDashboardFrame(["x"], 7, plainTheme)).toEqual([ + "┏━━━━━┓", + "┃ ┃", + "┃ x ┃", + "┃ ┃", + "┗━━━━━┛", + ]); + }); + + test("keeps selection visible without unnecessary offset jumps", () => { + const lines = ["0", "1", "2", "3", "4"]; + expect(fitDashboardViewport(lines, 4, 3, 0)).toEqual({ + lines: ["2", "3", "4"], + offset: 2, + }); + expect(fitDashboardViewport(lines, 2, 3, 1)).toEqual({ + lines: ["1", "2", "3"], + offset: 1, + }); + expect(fitDashboardViewport(["0", "1"], 1, 3, 99)).toEqual({ + lines: ["0", "1", ""], + offset: 0, + }); + expect(fitDashboardViewport(lines, 2, 0, 1)).toEqual({ + lines: [], + offset: 0, + }); + }); + + test("renders a bounded small-terminal escape message", () => { + const lines = renderDashboardTooSmall(30, 3, plainTheme); + expect(lines).toHaveLength(3); + expect(lines.every((line) => visibleWidth(line) === 30)).toBe(true); + expect(lines.join("\n")).toContain("Esc"); + }); + ``` + +- [ ] **Step 2: Run the tests and confirm the missing-module failure.** + + ```bash + pnpm vitest run tests/dashboard-style.test.ts + ``` + + Expected: FAIL because `src/tui/dashboard-style.ts` does not exist. + +- [ ] **Step 3: Implement the minimal shared renderer.** + + In `src/tui/dashboard-style.ts`, use two columns of horizontal padding and these exact fixed values: + + ```ts + const PADDING_X = 2; + const FRAME = { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" } as const; + + export const DASHBOARD_MAX_HEIGHT_RATIO = 0.85; + export const MIN_DASHBOARD_FRAME_WIDTH = 7; + export const DASHBOARD_OVERLAY_OPTIONS: OverlayOptions = { + anchor: "center", + width: "92%", + maxHeight: "85%", + }; + ``` + + `dashboardContentWidth(width)` returns `max(1, floor(width) - 6)`. The internal padding helper replaces embedded CR/LF runs with one space, truncates with an empty ellipsis, and pads using `visibleWidth()`. + + `renderDashboardFrame()` emits the top border, one blank row, all content rows, one blank row, and the bottom border. Color frame glyphs with `theme.fg("border", glyph)` and clamp every row to the requested visible width. + + `fitDashboardViewport()` follows the pi-status algorithm: clamp height and offset, move only when the selected line is above or below the visible range, slice the visible lines, and pad short content with empty rows. + + `renderDashboardTooSmall()` clamps dimensions to at least `1`, returns exactly that many fixed-width rows, and places `theme.fg("accent", "Terminal too small · Esc")` on the middle row. + +- [ ] **Step 4: Run the primitive tests and typecheck.** + + ```bash + pnpm vitest run tests/dashboard-style.test.ts + pnpm typecheck + git diff --check + ``` + + Expected: all primitive tests pass and every rendered row has the requested ANSI-visible width. + +- [ ] **Step 5: Commit the reusable foundation.** + + ```bash + git add src/tui/dashboard-style.ts tests/dashboard-style.test.ts + git commit -m "feat: add dashboard ui primitives" + ``` + +--- -- [ ] Add failing render tests for the heavy frame, visible `▸` selection marker, footer hints, narrow fallback, and selected-row scrolling. -- [ ] Render the preview through the shared frame and viewport. Keep parallel steps readonly and retain native input/focus behavior from Phase 2. -- [ ] Run the three focused test files, then `pnpm check` and `git diff --check`. +### Task 2: Render the shared chain preview and editor + +**Files:** + +- Modify: `src/tui/chain-clarify.ts` +- Modify: `tests/chain-clarify.test.ts` + +**Interfaces:** + +- Consumes: all exports from `src/tui/dashboard-style.ts` except `DASHBOARD_OVERLAY_OPTIONS`. +- Preserves: the `ChainClarifyComponent` constructor, `Component`/`Focusable` behavior, `ChainClarifyResult`, native `Input`, and all Phase 2 keys and callbacks. +- Produces: a framed list/editor bounded by `tui.terminal.rows` whose selected logical step remains visible. + +- [ ] **Step 1: Give each test component a configurable terminal height.** + + Replace the shared partial `mockTui` with a `makeComponent()`-local TUI containing: + + ```ts + const tui = { + requestRender, + terminal: { columns: 80, rows: terminalRows }, + } as unknown as TUI; + ``` + + Add `terminalRows = 40` as the final `makeComponent()` parameter so resize cases do not mutate shared test state. + +- [ ] **Step 2: Add failing dashboard render regressions.** + + Keep all current tests and add cases that assert: + + ```ts + test("renders a framed preview with footer and visible selection", () => { + const { component } = makeComponent([ + { agent: "scout", task: "analyze" }, + { agent: "worker", task: "change" }, + ]); + const lines = component.render(80); + expect(lines[0]).toContain("┏"); + expect(lines.at(-1)).toContain("┗"); + expect(lines.find((line) => line.includes("scout"))).toContain("▸"); + expect(lines.join("\n")).toContain("Enter Run"); + expect(lines.every((line) => visibleWidth(line) === 80)).toBe(true); + }); + + test("scrolls the complete selected sequential step into view", () => { + const steps = Array.from({ length: 8 }, (_, index) => ({ + agent: `agent-${index}`, + task: `task-${index}`, + model: `model-${index}`, + })); + const { component } = makeComponent(steps, undefined, 13); + for (let index = 1; index < steps.length; index++) + component.handleInput("j"); + const output = component.render(80).join("\n"); + expect(output).toContain("▸ [8/8] agent-7"); + expect(output).toContain("task-7"); + expect(output).toContain("model-7"); + expect(output).not.toContain("agent-0"); + }); + + test("renders bounded width and height fallbacks", () => { + expect( + makeComponent(undefined, undefined, 10).component.render(30).join("\n"), + ).toContain("Esc"); + expect(makeComponent().component.render(6).join("\n")).not.toContain("┏"); + }); + + test("renders static and dynamic parallel agents read-only", () => { + const { component } = makeComponent([ + { parallel: [{ agent: "scout" }, { agent: "worker" }] }, + { + expand: { from: { output: "items", path: "$.items" } }, + parallel: { agent: "reviewer", task: "review {{item}}" }, + collect: { as: "reviews" }, + }, + ]); + const output = component.render(100).join("\n"); + expect(output).toContain("Parallel · scout, worker"); + expect(output).toContain("Dynamic parallel · reviewer"); + component.handleInput("e"); + expect(component.render(100).join("\n")).not.toContain("Edit Task"); + }); + ``` + + Also assert task/model edit modes retain the heavy frame and native input, and a task containing `"first\nsecond"` produces no embedded CR/LF in any returned row. Update existing marker assertions from `>` to `▸`. + +- [ ] **Step 3: Run the component test and confirm only new layout assertions fail.** + + ```bash + pnpm vitest run tests/chain-clarify.test.ts + ``` + + Expected: Phase 2 interaction tests pass; frame, viewport, marker, parallel-label, and fallback assertions fail. + +- [ ] **Step 4: Build logical list rows and selected-line metadata.** + + Add a component-level `viewportOffset = 0`. Render sequential steps as three-row blocks: + + ```text + ▸ [1/N] scout + Task analyze + Model (inherit) + ``` + + Preserve the existing `* ` prefix for task/model overrides. Separate logical steps with one blank row between steps, with no trailing separator. For the selected sequential step, pass the model row index to `fitDashboardViewport()` so a three-row viewport contains its marker, task, and model together. + + Render static parallel groups as one row: + + ```text + ▸ [2/N] Parallel · scout, worker + ``` + + Render dynamic parallel groups as one row: + + ```text + ▸ [3/N] Dynamic parallel · reviewer + ``` + + Discriminate dynamic groups first with `"expand" in step`; otherwise an object with `"parallel" in step` is the static group whose `parallel` value is an array. Both variants ignore `e` and `m`, preserving read-only behavior. + +- [ ] **Step 5: Apply the chain-specific height budget.** + + Keep these values private to `chain-clarify.ts`: + + ```ts + const DASHBOARD_CHROME_ROWS = 8; + const MIN_CHAIN_DASHBOARD_ROWS = 11; + ``` + + Calculate: + + ```ts + const maxRows = Math.max( + 1, + Math.floor(this.tui.terminal.rows * DASHBOARD_MAX_HEIGHT_RATIO), + ); + const targetRows = + maxRows < MIN_CHAIN_DASHBOARD_ROWS + ? maxRows + : Math.min( + maxRows, + DASHBOARD_CHROME_ROWS + Math.max(3, logicalBody.lines.length), + ); + ``` + + Return `renderDashboardTooSmall(width, targetRows, theme)` when `width < 7` or `targetRows < 11`. Otherwise give `targetRows - 8` rows to `fitDashboardViewport()`, store its returned offset, and frame: + + ```text + Chain Preview · N steps + + + + ↑/↓ Select • e Edit task • m Edit model • Enter Run • b Background • q/Esc Cancel + ``` + +- [ ] **Step 6: Frame native edit mode without changing input behavior.** + + Render `Edit Task` or `Edit Model`, a three-row viewport containing `input.render(dashboardContentWidth(width))`, and `Enter Submit • Esc Cancel` through the same height/fallback/frame path. Do not replace the native input or change its submit, escape, focus, or disposal callbacks. + +- [ ] **Step 7: Run component and primitive verification.** + + ```bash + pnpm vitest run tests/dashboard-style.test.ts tests/chain-clarify.test.ts + pnpm typecheck + git diff --check + ``` + + Expected: all existing input/edit tests and all new responsive-layout tests pass. + +- [ ] **Step 8: Commit the chain renderer migration.** + + ```bash + git add src/tui/chain-clarify.ts tests/chain-clarify.test.ts + git commit -m "feat: render chain preview as dashboard" + ``` + +--- + +### Task 3: Use shared slash-chain overlay options + +**Files:** + +- Modify: `src/core/slash-chain.ts` +- Modify: `tests/slash-chain.test.ts` + +**Interfaces:** + +- Consumes: `DASHBOARD_OVERLAY_OPTIONS` from `src/tui/dashboard-style.ts`. +- Preserves: the Phase 2 condition `ctx.mode === "tui" && !bg && !yes`, cancellation, rejected-preview reporting, and manager dispatch. +- Leaves unchanged: `src/core/subagent.ts`; its structured clarification path shares the component renderer but remains a non-overlay custom UI with the same result contract. + +- [ ] **Step 1: Update the existing overlay-options regression.** + + In `confirms a TUI preview through CSI-u input`, replace the literal expectation with: + + ```ts + expect(seenOptions).toEqual({ + overlay: true, + overlayOptions: { + anchor: "center", + width: "92%", + maxHeight: "85%", + }, + }); + ``` + +- [ ] **Step 2: Run the slash-chain test and confirm the old dimensions fail.** + + ```bash + pnpm vitest run tests/slash-chain.test.ts + ``` + + Expected: the overlay-options assertion receives `{ width: 84, maxHeight: "80%" }`; confirmation, cancellation, rejection, RPC, `--yes`, and `--bg` tests remain green. + +- [ ] **Step 3: Replace literal dimensions with the shared constant.** + + Import `DASHBOARD_OVERLAY_OPTIONS` and pass: + + ```ts + { + overlay: true, + overlayOptions: DASHBOARD_OVERLAY_OPTIONS, + } + ``` + + Do not change the preview condition, error boundary, normalization, or dispatch flow. + +- [ ] **Step 4: Run focused verification.** + + ```bash + pnpm vitest run \ + tests/dashboard-style.test.ts \ + tests/chain-clarify.test.ts \ + tests/slash-chain.test.ts \ + tests/core/chain-clarify-integration.test.ts + pnpm typecheck + git diff --check + ``` + + Expected: all focused tests pass and production changes are limited to the renderer, chain presentation, and shared slash-chain overlay options. + +- [ ] **Step 5: Run the full repository check.** + + In a sandbox where inherited Git signing blocks temporary test repositories, run: + + ```bash + env GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=commit.gpgsign \ + GIT_CONFIG_VALUE_0=false \ + pnpm check + ``` + + Otherwise run `pnpm check` normally. Expected: 56 existing test files and 1,293 existing tests plus the new Phase 3 tests pass; existing non-fatal Biome warnings may remain unchanged. + +- [ ] **Step 6: Perform interactive TUI verification.** + + Open `/run-chain` with one sequential step, a long sequential chain, a static parallel group, and a dynamic parallel group. At normal and undersized terminal dimensions verify selection, task/model editing, run, background, cancel, resize, and fallback behavior. If no interactive TUI is available, record that manual visual verification was not run. + +- [ ] **Step 7: Commit the overlay wiring.** + + ```bash + git add src/core/slash-chain.ts tests/slash-chain.test.ts + git commit -m "feat: use dashboard chain overlay" + ``` -### Task 4: Commit the atomic result +## Out of Scope -- [ ] Commit with `git add src/tui/dashboard-style.ts src/tui/chain-clarify.ts tests/dashboard-style.test.ts tests/chain-clarify.test.ts tests/core/chain-clarify-integration.test.ts && git commit -m "feat: add dashboard chain preview"`. +- Do not modify `tests/core/chain-clarify-integration.test.ts`; Phase 2 already placed command-to-component coverage in `tests/slash-chain.test.ts`. +- Do not alter the structured subagent clarification call site, chain preflight, execution, managers, widgets, `/agents`, fleet, inline renderers, or conversation viewer. +- Do not add height-budget exports for later phases; each later surface has different fixed chrome and can consume the shared ratio, frame, viewport, and fallback directly. From 5324677732c5ca2d3f16353b44a17734af6004fd Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 26 Aug 2026 22:40:14 -0400 Subject: [PATCH 2/9] feat: add dashboard ui primitives --- src/tui/dashboard-style.ts | 66 +++++++++++++++++++++++++++++++ tests/dashboard-style.test.ts | 74 +++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/tui/dashboard-style.ts create mode 100644 tests/dashboard-style.test.ts diff --git a/src/tui/dashboard-style.ts b/src/tui/dashboard-style.ts new file mode 100644 index 0000000..e845b38 --- /dev/null +++ b/src/tui/dashboard-style.ts @@ -0,0 +1,66 @@ +import { truncateToWidth, visibleWidth, type OverlayOptions } from "@earendil-works/pi-tui"; +import type { Theme } from "./agent-widget.js"; + +const PADDING_X = 2; +const FRAME = { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" } as const; + +export const DASHBOARD_MAX_HEIGHT_RATIO = 0.85; +export const MIN_DASHBOARD_FRAME_WIDTH = 7; +export const DASHBOARD_OVERLAY_OPTIONS: OverlayOptions = { + anchor: "center", + width: "92%", + maxHeight: "85%", +}; + +export function dashboardContentWidth(width: number): number { + return Math.max(1, Math.floor(width) - 6); +} + +function fixedWidth(text: string, width: number): string { + const truncated = truncateToWidth(text.replace(/[\r\n]+/g, " "), width, ""); + return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated))); +} + +export function renderDashboardFrame(lines: readonly string[], width: number, theme: Theme): string[] { + const frameWidth = Math.max(MIN_DASHBOARD_FRAME_WIDTH, Math.floor(width)); + const contentWidth = dashboardContentWidth(frameWidth); + const border = (glyph: string) => theme.fg("border", glyph); + const row = (content: string) => + fixedWidth(`${border(FRAME.v)}${" ".repeat(PADDING_X)}${fixedWidth(content, contentWidth)}${" ".repeat(PADDING_X)}${border(FRAME.v)}`, frameWidth); + + return [ + fixedWidth(`${border(FRAME.tl)}${border(FRAME.h.repeat(frameWidth - 2))}${border(FRAME.tr)}`, frameWidth), + row(""), + ...lines.map(row), + row(""), + fixedWidth(`${border(FRAME.bl)}${border(FRAME.h.repeat(frameWidth - 2))}${border(FRAME.br)}`, frameWidth), + ]; +} + +export function fitDashboardViewport( + lines: readonly string[], + selectedLine: number | undefined, + height: number, + offset: number, +): { lines: string[]; offset: number } { + const viewportHeight = Math.max(0, Math.floor(height)); + if (viewportHeight === 0) return { lines: [], offset: 0 }; + + const maxOffset = Math.max(0, lines.length - viewportHeight); + let nextOffset = Math.max(0, Math.min(Math.floor(offset), maxOffset)); + if (selectedLine !== undefined) { + if (selectedLine < nextOffset) nextOffset = selectedLine; + else if (selectedLine >= nextOffset + viewportHeight) nextOffset = selectedLine - viewportHeight + 1; + nextOffset = Math.max(0, Math.min(nextOffset, maxOffset)); + } + + const visibleLines = lines.slice(nextOffset, nextOffset + viewportHeight); + return { lines: [...visibleLines, ...Array(viewportHeight - visibleLines.length).fill("")], offset: nextOffset }; +} + +export function renderDashboardTooSmall(width: number, height: number, theme: Theme): string[] { + const safeWidth = Math.max(1, Math.floor(width)); + const safeHeight = Math.max(1, Math.floor(height)); + const message = theme.fg("accent", "Terminal too small · Esc"); + return Array.from({ length: safeHeight }, (_, index) => fixedWidth(index === Math.floor(safeHeight / 2) ? message : "", safeWidth)); +} diff --git a/tests/dashboard-style.test.ts b/tests/dashboard-style.test.ts new file mode 100644 index 0000000..9bde7d0 --- /dev/null +++ b/tests/dashboard-style.test.ts @@ -0,0 +1,74 @@ +import { visibleWidth } from "@earendil-works/pi-tui"; +import { describe, expect, test } from "vitest"; +import { + dashboardContentWidth, + fitDashboardViewport, + MIN_DASHBOARD_FRAME_WIDTH, + renderDashboardFrame, + renderDashboardTooSmall, +} from "../src/tui/dashboard-style.js"; + +const plainTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +}; +const ansiTheme = { + fg: (_color: string, text: string) => `\x1b[31m${text}\x1b[39m`, + bold: (text: string) => `\x1b[1m${text}\x1b[22m`, +}; + +describe("dashboard style", () => { + test("renders a heavy frame at the requested visible width", () => { + const lines = renderDashboardFrame(["Header", "Body"], 24, plainTheme); + expect(lines).toHaveLength(6); + expect(lines[0]).toBe(`┏${"━".repeat(22)}┓`); + expect(lines.at(-1)).toBe(`┗${"━".repeat(22)}┛`); + expect(lines.every((line) => visibleWidth(line) === 24)).toBe(true); + expect(dashboardContentWidth(24)).toBe(18); + }); + + test("preserves visible width with ANSI and embedded newlines", () => { + const lines = renderDashboardFrame(["one\r\ntwo"], 24, ansiTheme); + expect(lines.some((line) => line.includes("\x1b[31m"))).toBe(true); + expect(lines.every((line) => visibleWidth(line) === 24)).toBe(true); + expect(lines.every((line) => !/[\r\n]/.test(line))).toBe(true); + }); + + test("preserves the complete seven-column minimum", () => { + expect(MIN_DASHBOARD_FRAME_WIDTH).toBe(7); + expect(renderDashboardFrame(["x"], 7, plainTheme)).toEqual([ + "┏━━━━━┓", + "┃ ┃", + "┃ x ┃", + "┃ ┃", + "┗━━━━━┛", + ]); + }); + + test("keeps selection visible without unnecessary offset jumps", () => { + const lines = ["0", "1", "2", "3", "4"]; + expect(fitDashboardViewport(lines, 4, 3, 0)).toEqual({ + lines: ["2", "3", "4"], + offset: 2, + }); + expect(fitDashboardViewport(lines, 2, 3, 1)).toEqual({ + lines: ["1", "2", "3"], + offset: 1, + }); + expect(fitDashboardViewport(["0", "1"], 1, 3, 99)).toEqual({ + lines: ["0", "1", ""], + offset: 0, + }); + expect(fitDashboardViewport(lines, 2, 0, 1)).toEqual({ + lines: [], + offset: 0, + }); + }); + + test("renders a bounded small-terminal escape message", () => { + const lines = renderDashboardTooSmall(30, 3, plainTheme); + expect(lines).toHaveLength(3); + expect(lines.every((line) => visibleWidth(line) === 30)).toBe(true); + expect(lines.join("\n")).toContain("Esc"); + }); +}); From 590a42110c4b728c639845171154dcbb015cce35 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 26 Aug 2026 22:48:40 -0400 Subject: [PATCH 3/9] feat: render chain preview as dashboard --- src/tui/chain-clarify.ts | 106 ++++++++++++++++++++++++++++-------- tests/chain-clarify.test.ts | 88 ++++++++++++++++++++++++++---- 2 files changed, 159 insertions(+), 35 deletions(-) diff --git a/src/tui/chain-clarify.ts b/src/tui/chain-clarify.ts index cc9db86..7702527 100644 --- a/src/tui/chain-clarify.ts +++ b/src/tui/chain-clarify.ts @@ -9,6 +9,17 @@ import { } from "@earendil-works/pi-tui"; import type { ChainStep, SequentialStep } from "../shared/types.js"; import type { Theme } from "./agent-widget.js"; +import { + DASHBOARD_MAX_HEIGHT_RATIO, + MIN_DASHBOARD_FRAME_WIDTH, + dashboardContentWidth, + fitDashboardViewport, + renderDashboardFrame, + renderDashboardTooSmall, +} from "./dashboard-style.js"; + +const DASHBOARD_CHROME_ROWS = 8; +const MIN_CHAIN_DASHBOARD_ROWS = 11; export interface ChainClarifyResult { action: "run" | "cancel" | "bg"; @@ -41,6 +52,7 @@ export class ChainClarifyComponent implements Component, Focusable { private mode: EditMode = "list"; private modelOverrides = new Map(); private taskOverrides = new Map(); + private viewportOffset = 0; constructor( private tui: TUI, @@ -113,37 +125,39 @@ export class ChainClarifyComponent implements Component, Focusable { } private renderListMode(width: number): string[] { - const th = this.theme; const lines: string[] = []; - const header = `Chain Preview (${this.steps.length} step${this.steps.length === 1 ? "" : "s"})`; - const hint = "[Enter] Run [b] Background [Esc] Cancel"; - lines.push(`${th.fg("accent", header)} ${th.fg("dim", hint)}`); - lines.push(th.fg("dim", "─".repeat(Math.min(width, 60)))); + let selectedLine: number | undefined; for (let i = 0; i < this.steps.length; i++) { const step = this.steps[i]!; - const cursor = i === this.selectedIndex ? ">" : " "; - const isSeq = !("parallel" in step); - - if (isSeq) { + const cursor = i === this.selectedIndex ? "▸" : " "; + if ("expand" in step) { + lines.push(`${cursor} [${i + 1}/${this.steps.length}] Dynamic parallel · ${step.parallel.agent}`); + if (i === this.selectedIndex) selectedLine = lines.length - 1; + } else if ("parallel" in step) { + lines.push(`${cursor} [${i + 1}/${this.steps.length}] Parallel · ${step.parallel.map((item) => item.agent).join(", ")}`); + if (i === this.selectedIndex) selectedLine = lines.length - 1; + } else { const seq = step as SequentialStep; const taskText = this.taskOverrides.get(i) ?? seq.task ?? "(no task)"; const modelText = this.modelOverrides.get(i) ?? seq.model ?? "(inherit)"; const taskMarker = this.taskOverrides.has(i) ? "* " : ""; const modelMarker = this.modelOverrides.has(i) ? "* " : ""; - lines.push( - ` ${cursor} [${i + 1}] ${th.fg("accent", seq.agent ?? "?")}`, - ); - lines.push(` Task: ${taskMarker}${taskText}`); - lines.push(` Model: ${modelMarker}${modelText}`); - } else { - lines.push(` ${cursor} [${i + 1}] ${th.fg("dim", "(parallel step)")}`); + lines.push(`${cursor} [${i + 1}/${this.steps.length}] ${seq.agent ?? "?"}`); + lines.push(` Task ${taskMarker}${taskText}`); + lines.push(` Model ${modelMarker}${modelText}`); + if (i === this.selectedIndex) selectedLine = lines.length - 1; } + if (i < this.steps.length - 1) lines.push(""); } - lines.push(th.fg("dim", "─".repeat(Math.min(width, 60)))); - lines.push(th.fg("dim", "[e] Edit task [m] Model [j/k] Navigate")); - return lines; + return this.renderDashboard( + width, + lines, + selectedLine, + `Chain Preview · ${this.steps.length} steps`, + "↑/↓ Select • e Edit task • m Edit model • Enter Run • b Background • q/Esc Cancel", + ); } // --------------------------------------------------------------------------- @@ -178,12 +192,56 @@ export class ChainClarifyComponent implements Component, Focusable { } private renderEditMode(width: number): string[] { - const th = this.theme; const label = this.mode === "edit-task" ? "Task" : "Model"; - return [ - `${th.fg("accent", `Edit ${label}`)} ${th.fg("dim", "[Enter] Confirm [Esc] Cancel")}`, - ...this.input.render(width), - ]; + return this.renderDashboard( + width, + this.input.render(dashboardContentWidth(width)), + undefined, + `Edit ${label}`, + "Enter Submit • Esc Cancel", + ); + } + + private renderDashboard( + width: number, + lines: string[], + selectedLine: number | undefined, + header: string, + footer: string, + ): string[] { + const maxRows = Math.max( + 1, + Math.floor(this.tui.terminal.rows * DASHBOARD_MAX_HEIGHT_RATIO), + ); + const targetRows = + maxRows < MIN_CHAIN_DASHBOARD_ROWS + ? maxRows + : Math.min( + maxRows, + DASHBOARD_CHROME_ROWS + Math.max(3, lines.length), + ); + if (width < MIN_DASHBOARD_FRAME_WIDTH || targetRows < MIN_CHAIN_DASHBOARD_ROWS) { + return renderDashboardTooSmall(width, targetRows, this.theme); + } + + const viewport = fitDashboardViewport( + lines, + selectedLine, + targetRows - DASHBOARD_CHROME_ROWS, + this.viewportOffset, + ); + this.viewportOffset = viewport.offset; + return renderDashboardFrame( + [ + this.theme.fg("accent", header), + "", + ...viewport.lines, + "", + this.theme.fg("dim", footer), + ], + width, + this.theme, + ); } // --------------------------------------------------------------------------- diff --git a/tests/chain-clarify.test.ts b/tests/chain-clarify.test.ts index 1c21145..bbb15a3 100644 --- a/tests/chain-clarify.test.ts +++ b/tests/chain-clarify.test.ts @@ -1,15 +1,14 @@ -import { CURSOR_MARKER } from "@earendil-works/pi-tui"; +import { CURSOR_MARKER, type TUI, visibleWidth } from "@earendil-works/pi-tui"; import { describe, expect, test, vi } from "vitest"; import type { ChainStep } from "../src/shared/types.js"; import type { ChainClarifyResult } from "../src/tui/chain-clarify.js"; import { ChainClarifyComponent } from "../src/tui/chain-clarify.js"; // --------------------------------------------------------------------------- -// Minimal mocks — the component only calls tui.requestRender() +// Minimal mocks // --------------------------------------------------------------------------- const requestRender = vi.fn(); -const mockTui = { requestRender } as unknown as import("@earendil-works/pi-tui").TUI; // Minimal theme-compatible object — the component uses theme.fg() for styling const mockTheme = { fg: (_name: string, text: string) => text, @@ -19,10 +18,15 @@ const mockTheme = { function makeComponent( steps: ChainStep[] = [{ agent: "scout", task: "analyze" }], done?: (r: ChainClarifyResult) => void, + terminalRows = 40, ): { component: ChainClarifyComponent; result: { value: ChainClarifyResult | undefined } } { const result: { value: ChainClarifyResult | undefined } = { value: undefined }; + const tui = { + requestRender, + terminal: { columns: 80, rows: terminalRows }, + } as unknown as TUI; const component = new ChainClarifyComponent( - mockTui, + tui, mockTheme, steps, done ?? ((r) => { result.value = r; }), @@ -63,6 +67,62 @@ describe("ChainClarifyComponent — render", () => { const text = component.render(80).join("\n"); expect(text.toLowerCase()).toMatch(/enter|run/); }); + + test("renders a framed preview with footer and visible selection", () => { + const { component } = makeComponent([ + { agent: "scout", task: "analyze" }, + { agent: "worker", task: "change" }, + ]); + const lines = component.render(80); + expect(lines[0]).toContain("┏"); + expect(lines.at(-1)).toContain("┗"); + expect(lines.find((line) => line.includes("scout"))).toContain("▸"); + expect(lines.join("\n")).toContain("Enter Run"); + expect(lines.every((line) => visibleWidth(line) === 80)).toBe(true); + }); + + test("scrolls the complete selected sequential step into view", () => { + const steps = Array.from({ length: 8 }, (_, index) => ({ + agent: `agent-${index}`, + task: `task-${index}`, + model: `model-${index}`, + })); + const { component } = makeComponent(steps, undefined, 13); + for (let index = 1; index < steps.length; index++) component.handleInput("j"); + const output = component.render(80).join("\n"); + expect(output).toContain("▸ [8/8] agent-7"); + expect(output).toContain("task-7"); + expect(output).toContain("model-7"); + expect(output).not.toContain("agent-0"); + }); + + test("renders bounded width and height fallbacks", () => { + expect( + makeComponent(undefined, undefined, 10).component.render(30).join("\n"), + ).toContain("Esc"); + expect(makeComponent().component.render(6).join("\n")).not.toContain("┏"); + }); + + test("renders static and dynamic parallel agents read-only", () => { + const { component } = makeComponent([ + { parallel: [{ agent: "scout" }, { agent: "worker" }] }, + { + expand: { from: { output: "items", path: "$.items" } }, + parallel: { agent: "reviewer", task: "review {{item}}" }, + collect: { as: "reviews" }, + }, + ]); + const output = component.render(100).join("\n"); + expect(output).toContain("Parallel · scout, worker"); + expect(output).toContain("Dynamic parallel · reviewer"); + component.handleInput("e"); + expect(component.render(100).join("\n")).not.toContain("Edit Task"); + }); + + test("does not return embedded newlines for task text", () => { + const { component } = makeComponent([{ agent: "scout", task: "first\nsecond" }]); + expect(component.render(80).every((line) => !/[\r\n]/.test(line))).toBe(true); + }); }); // --------------------------------------------------------------------------- @@ -106,7 +166,7 @@ describe("ChainClarifyComponent — input", () => { expect( component .render(80) - .some((line) => line.includes(">") && line.includes("planner")), + .some((line) => line.includes("▸") && line.includes("planner")), ).toBe(true); }); @@ -131,10 +191,10 @@ describe("ChainClarifyComponent — input", () => { ]); component.handleInput("\x1b[1;1B"); expect( - component.render(80).some((line) => line.includes(">") && line.includes("planner")), + component.render(80).some((line) => line.includes("▸") && line.includes("planner")), ).toBe(true); component.handleInput("\x1b[1;1A"); - expect(component.render(80).some((line) => line.includes(">") && line.includes("scout"))).toBe( + expect(component.render(80).some((line) => line.includes("▸") && line.includes("scout"))).toBe( true, ); }); @@ -154,7 +214,7 @@ describe("ChainClarifyComponent — input", () => { const lines = component.render(80); // Second step should now be selected (cursor on planner) const hasCursorOnPlanner = lines.some( - (l) => l.includes(">") && l.includes("planner"), + (l) => l.includes("▸") && l.includes("planner"), ); expect(hasCursorOnPlanner).toBe(true); }); @@ -168,7 +228,7 @@ describe("ChainClarifyComponent — input", () => { component.handleInput("k"); // back to scout const lines = component.render(80); const hasCursorOnScout = lines.some( - (l) => l.includes(">") && l.includes("scout"), + (l) => l.includes("▸") && l.includes("scout"), ); expect(hasCursorOnScout).toBe(true); }); @@ -223,18 +283,24 @@ describe("ChainClarifyComponent — edit mode", () => { const { component } = makeComponent([{ agent: "scout", task: "analyze" }]); component.handleInput("e"); component.handleInput("\x1b[F"); + component.focused = true; const lines = component.render(80); + expect(lines[0]).toContain("┏"); + expect(lines.at(-1)).toContain("┗"); expect(lines.some((l) => l.includes("Edit Task"))).toBe(true); - expect(lines.some((l) => l.includes("analyze"))).toBe(true); + expect(lines.some((l) => l.includes("analyze") && l.includes(CURSOR_MARKER))).toBe(true); }); test("m key enters edit-model mode", () => { const { component } = makeComponent([{ agent: "scout", task: "analyze", model: "gpt-4" }]); component.handleInput("m"); component.handleInput("\x1b[F"); + component.focused = true; const lines = component.render(80); + expect(lines[0]).toContain("┏"); + expect(lines.at(-1)).toContain("┗"); expect(lines.some((l) => l.includes("Edit Model"))).toBe(true); - expect(lines.some((l) => l.includes("gpt-4"))).toBe(true); + expect(lines.some((l) => l.includes("gpt-4") && l.includes(CURSOR_MARKER))).toBe(true); }); test("typing in edit mode appends to buffer", () => { From fa9fadf3e7598d85b572110487bdea26ec3ed485 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 26 Aug 2026 22:54:06 -0400 Subject: [PATCH 4/9] fix: keep selected chain block visible --- src/tui/chain-clarify.ts | 10 ++++++++++ tests/chain-clarify.test.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/tui/chain-clarify.ts b/src/tui/chain-clarify.ts index 7702527..653f653 100644 --- a/src/tui/chain-clarify.ts +++ b/src/tui/chain-clarify.ts @@ -151,6 +151,16 @@ export class ChainClarifyComponent implements Component, Focusable { if (i < this.steps.length - 1) lines.push(""); } + const selectedStep = this.steps[this.selectedIndex]; + if ( + selectedLine !== undefined && + selectedStep && + !("parallel" in selectedStep) && + selectedLine < this.viewportOffset + ) { + this.viewportOffset = selectedLine - 2; + } + return this.renderDashboard( width, lines, diff --git a/tests/chain-clarify.test.ts b/tests/chain-clarify.test.ts index bbb15a3..83e5184 100644 --- a/tests/chain-clarify.test.ts +++ b/tests/chain-clarify.test.ts @@ -96,6 +96,22 @@ describe("ChainClarifyComponent — render", () => { expect(output).not.toContain("agent-0"); }); + test("keeps the complete selected sequential step visible when navigating upward", () => { + const steps = Array.from({ length: 8 }, (_, index) => ({ + agent: `agent-${index}`, + task: `task-${index}`, + model: `model-${index}`, + })); + const { component } = makeComponent(steps, undefined, 13); + for (let index = 1; index < steps.length; index++) component.handleInput("j"); + component.render(80); + component.handleInput("k"); + const output = component.render(80).join("\n"); + expect(output).toContain("▸ [7/8] agent-6"); + expect(output).toContain("task-6"); + expect(output).toContain("model-6"); + }); + test("renders bounded width and height fallbacks", () => { expect( makeComponent(undefined, undefined, 10).component.render(30).join("\n"), From 7604e620b8f49c631dbd7b5cb944072726f5ff40 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 26 Aug 2026 22:59:08 -0400 Subject: [PATCH 5/9] fix: retain selected chain block at viewport boundary --- src/tui/chain-clarify.ts | 2 +- tests/chain-clarify.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/tui/chain-clarify.ts b/src/tui/chain-clarify.ts index 653f653..1efa600 100644 --- a/src/tui/chain-clarify.ts +++ b/src/tui/chain-clarify.ts @@ -156,7 +156,7 @@ export class ChainClarifyComponent implements Component, Focusable { selectedLine !== undefined && selectedStep && !("parallel" in selectedStep) && - selectedLine < this.viewportOffset + selectedLine <= this.viewportOffset ) { this.viewportOffset = selectedLine - 2; } diff --git a/tests/chain-clarify.test.ts b/tests/chain-clarify.test.ts index 83e5184..e700193 100644 --- a/tests/chain-clarify.test.ts +++ b/tests/chain-clarify.test.ts @@ -112,6 +112,25 @@ describe("ChainClarifyComponent — render", () => { expect(output).toContain("model-6"); }); + test("keeps a preceding sequential step visible when navigating up from parallel", () => { + const { component } = makeComponent( + [ + { agent: "scout", task: "analyze", model: "model-scout" }, + { parallel: [{ agent: "worker" }, { agent: "reviewer" }] }, + ], + undefined, + 13, + ); + component.render(80); + component.handleInput("j"); + component.render(80); + component.handleInput("k"); + const output = component.render(80).join("\n"); + expect(output).toContain("▸ [1/2] scout"); + expect(output).toContain("Task analyze"); + expect(output).toContain("Model model-scout"); + }); + test("renders bounded width and height fallbacks", () => { expect( makeComponent(undefined, undefined, 10).component.render(30).join("\n"), From 098b61dc2d95b4e5835af5d177dd46902b07d77e Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 26 Aug 2026 23:03:35 -0400 Subject: [PATCH 6/9] feat: use dashboard chain overlay --- src/core/slash-chain.ts | 3 ++- tests/slash-chain.test.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/core/slash-chain.ts b/src/core/slash-chain.ts index 1bb038c..acb4eba 100644 --- a/src/core/slash-chain.ts +++ b/src/core/slash-chain.ts @@ -9,6 +9,7 @@ import { findAgentByName } from "./subagent.js"; import { resolveModelSelection, validateModelThinking } from "./model-resolver.js"; import { preflightChainModels, validateChainAgents } from "./chain-preflight.js"; import { normalizeThinkingLevel, ChainThinkingLevelError } from "../shared/thinking.js"; +import { DASHBOARD_OVERLAY_OPTIONS } from "../tui/dashboard-style.js"; export class SlashParseError extends Error {} @@ -619,7 +620,7 @@ export async function executeSlashChain( (tui, theme, _kb, done) => new ChainClarifyComponent(tui, theme, chain, done), { overlay: true, - overlayOptions: { anchor: "center", width: 84, maxHeight: "80%" }, + overlayOptions: DASHBOARD_OVERLAY_OPTIONS, }, ); } catch (error) { diff --git a/tests/slash-chain.test.ts b/tests/slash-chain.test.ts index d669943..23445d3 100644 --- a/tests/slash-chain.test.ts +++ b/tests/slash-chain.test.ts @@ -349,7 +349,11 @@ describe("executeSlashChain validation", () => { expect(spawn).toHaveBeenCalledTimes(1); expect(seenOptions).toEqual({ overlay: true, - overlayOptions: { anchor: "center", width: 84, maxHeight: "80%" }, + overlayOptions: { + anchor: "center", + width: "92%", + maxHeight: "85%", + }, }); manager.dispose(); }); From 5f30fd858496d548e1ce688b72406d7a3f3f0ef0 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 26 Aug 2026 23:15:42 -0400 Subject: [PATCH 7/9] fix: retain sequential dashboard selection header --- src/tui/chain-clarify.ts | 2 +- tests/chain-clarify.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/tui/chain-clarify.ts b/src/tui/chain-clarify.ts index 1efa600..1c90256 100644 --- a/src/tui/chain-clarify.ts +++ b/src/tui/chain-clarify.ts @@ -156,7 +156,7 @@ export class ChainClarifyComponent implements Component, Focusable { selectedLine !== undefined && selectedStep && !("parallel" in selectedStep) && - selectedLine <= this.viewportOffset + selectedLine - 2 <= this.viewportOffset ) { this.viewportOffset = selectedLine - 2; } diff --git a/tests/chain-clarify.test.ts b/tests/chain-clarify.test.ts index e700193..7963f9d 100644 --- a/tests/chain-clarify.test.ts +++ b/tests/chain-clarify.test.ts @@ -131,6 +131,25 @@ describe("ChainClarifyComponent — render", () => { expect(output).toContain("Model model-scout"); }); + test("keeps a preceding sequential header and marker in a four-row viewport", () => { + const { component } = makeComponent( + [ + { agent: "scout", task: "analyze", model: "model-scout" }, + { parallel: [{ agent: "worker" }, { agent: "reviewer" }] }, + ], + undefined, + 15, + ); + component.render(80); + component.handleInput("j"); + component.render(80); + component.handleInput("k"); + const output = component.render(80).join("\n"); + expect(output).toContain("▸ [1/2] scout"); + expect(output).toContain("Task analyze"); + expect(output).toContain("Model model-scout"); + }); + test("renders bounded width and height fallbacks", () => { expect( makeComponent(undefined, undefined, 10).component.render(30).join("\n"), From 5eda560a6581ee5891fd7f09a96861edc9ab0b68 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Thu, 27 Aug 2026 00:14:52 -0400 Subject: [PATCH 8/9] test: add selection preservation tests and update helper to expose component TUI state --- tests/chain-clarify.test.ts | 71 ++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/tests/chain-clarify.test.ts b/tests/chain-clarify.test.ts index 7963f9d..163eafa 100644 --- a/tests/chain-clarify.test.ts +++ b/tests/chain-clarify.test.ts @@ -19,19 +19,23 @@ function makeComponent( steps: ChainStep[] = [{ agent: "scout", task: "analyze" }], done?: (r: ChainClarifyResult) => void, terminalRows = 40, -): { component: ChainClarifyComponent; result: { value: ChainClarifyResult | undefined } } { +): { + component: ChainClarifyComponent; + result: { value: ChainClarifyResult | undefined }; + tui: { requestRender: typeof requestRender; terminal: { columns: number; rows: number } }; +} { const result: { value: ChainClarifyResult | undefined } = { value: undefined }; const tui = { requestRender, terminal: { columns: 80, rows: terminalRows }, - } as unknown as TUI; + }; const component = new ChainClarifyComponent( - tui, + tui as unknown as TUI, mockTheme, steps, done ?? ((r) => { result.value = r; }), ); - return { component, result }; + return { component, result, tui }; } function clearAndType(component: ChainClarifyComponent, clear: number, text: string): void { @@ -96,6 +100,65 @@ describe("ChainClarifyComponent — render", () => { expect(output).not.toContain("agent-0"); }); + test.each([ + { + name: "sequential", + selectedIndex: 3, + expected: ["▸ [4/4] final", "final task", "final model"], + steps: [ + { agent: "scout", task: "analyze" }, + { parallel: [{ agent: "worker" }, { agent: "reviewer" }] }, + { + expand: { from: { output: "items", path: "$.items" } }, + parallel: { agent: "planner", task: "plan {{item}}" }, + collect: { as: "plans" }, + }, + { agent: "final", task: "final task", model: "final model" }, + ] satisfies ChainStep[], + }, + { + name: "static parallel", + selectedIndex: 1, + expected: ["▸ [2/4] Parallel · worker, reviewer"], + steps: [ + { agent: "scout", task: "analyze" }, + { parallel: [{ agent: "worker" }, { agent: "reviewer" }] }, + { agent: "planner", task: "plan" }, + { agent: "final", task: "finish" }, + ] satisfies ChainStep[], + }, + { + name: "dynamic parallel", + selectedIndex: 2, + expected: ["▸ [3/4] Dynamic parallel · planner"], + steps: [ + { agent: "scout", task: "analyze" }, + { parallel: [{ agent: "worker" }, { agent: "reviewer" }] }, + { + expand: { from: { output: "items", path: "$.items" } }, + parallel: { agent: "planner", task: "plan {{item}}" }, + collect: { as: "plans" }, + }, + { agent: "final", task: "finish" }, + ] satisfies ChainStep[], + }, + ])("preserves the selected $name step across fallback and resize", ({ + expected, + selectedIndex, + steps, + }) => { + const { component, tui } = makeComponent(steps, undefined, 13); + for (let index = 0; index < selectedIndex; index++) component.handleInput("j"); + component.render(80); + + tui.terminal.rows = 10; + expect(component.render(30).join("\n")).toContain("Terminal too small · Esc"); + + tui.terminal.rows = 13; + const output = component.render(80).join("\n"); + for (const text of expected) expect(output).toContain(text); + }); + test("keeps the complete selected sequential step visible when navigating upward", () => { const steps = Array.from({ length: 8 }, (_, index) => ({ agent: `agent-${index}`, From 5d7d216e6e71e141ebd57c64006abe00d5a5216e Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Thu, 27 Aug 2026 10:43:33 -0400 Subject: [PATCH 9/9] refactor: simplify ChainClarifyComponent test cases using a shared step definition and data-driven approach --- tests/chain-clarify.test.ts | 63 ++++++++++--------------------------- 1 file changed, 16 insertions(+), 47 deletions(-) diff --git a/tests/chain-clarify.test.ts b/tests/chain-clarify.test.ts index 163eafa..cedecac 100644 --- a/tests/chain-clarify.test.ts +++ b/tests/chain-clarify.test.ts @@ -49,6 +49,17 @@ function clearAndType(component: ChainClarifyComponent, clear: number, text: str // --------------------------------------------------------------------------- describe("ChainClarifyComponent — render", () => { + const resizeSteps: ChainStep[] = [ + { agent: "scout", task: "analyze" }, + { parallel: [{ agent: "worker" }, { agent: "reviewer" }] }, + { + expand: { from: { output: "items", path: "$.items" } }, + parallel: { agent: "planner", task: "plan {{item}}" }, + collect: { as: "plans" }, + }, + { agent: "final", task: "final task", model: "final model" }, + ]; + test("renders step list with agent names", () => { const { component } = makeComponent([ { agent: "scout", task: "analyze" }, @@ -101,53 +112,11 @@ describe("ChainClarifyComponent — render", () => { }); test.each([ - { - name: "sequential", - selectedIndex: 3, - expected: ["▸ [4/4] final", "final task", "final model"], - steps: [ - { agent: "scout", task: "analyze" }, - { parallel: [{ agent: "worker" }, { agent: "reviewer" }] }, - { - expand: { from: { output: "items", path: "$.items" } }, - parallel: { agent: "planner", task: "plan {{item}}" }, - collect: { as: "plans" }, - }, - { agent: "final", task: "final task", model: "final model" }, - ] satisfies ChainStep[], - }, - { - name: "static parallel", - selectedIndex: 1, - expected: ["▸ [2/4] Parallel · worker, reviewer"], - steps: [ - { agent: "scout", task: "analyze" }, - { parallel: [{ agent: "worker" }, { agent: "reviewer" }] }, - { agent: "planner", task: "plan" }, - { agent: "final", task: "finish" }, - ] satisfies ChainStep[], - }, - { - name: "dynamic parallel", - selectedIndex: 2, - expected: ["▸ [3/4] Dynamic parallel · planner"], - steps: [ - { agent: "scout", task: "analyze" }, - { parallel: [{ agent: "worker" }, { agent: "reviewer" }] }, - { - expand: { from: { output: "items", path: "$.items" } }, - parallel: { agent: "planner", task: "plan {{item}}" }, - collect: { as: "plans" }, - }, - { agent: "final", task: "finish" }, - ] satisfies ChainStep[], - }, - ])("preserves the selected $name step across fallback and resize", ({ - expected, - selectedIndex, - steps, - }) => { - const { component, tui } = makeComponent(steps, undefined, 13); + ["sequential", 3, ["▸ [4/4] final", "final task", "final model"]], + ["static parallel", 1, ["▸ [2/4] Parallel · worker, reviewer"]], + ["dynamic parallel", 2, ["▸ [3/4] Dynamic parallel · planner"]], + ] as const)("preserves the selected %s step across fallback and resize", (_name, selectedIndex, expected) => { + const { component, tui } = makeComponent(resizeSteps, undefined, 13); for (let index = 0; index < selectedIndex; index++) component.handleInput("j"); component.render(80);