From bfcfc08f7c47e940e0335d0f0514ed4e0e01cc65 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:45:41 +0000 Subject: [PATCH] feat(tui): add fixed-width layout system for cockpit rows - Add reusable layout helpers: truncate, pad, formatKV, badge, sectionLine - Replace fragile inline sequences with nested spans (no longer depends on Ink flex preserving spaces between adjacent nodes) - Header rows stable at 80 columns; provider/model truncated with ellipsis - Section dividers fill to COLS (78) with box-drawing characters - RunSummaryPane uses compact badges instead of dot-separated flex children - Add 10 new tests: 7 for layout helpers, 3 for cockpit header rendering Co-Authored-By: Terrence Schonleber --- src/cli/tui/components.tsx | 120 +++++++++++++++++++++++-------------- tests/cli/tui.test.ts | 114 +++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 46 deletions(-) diff --git a/src/cli/tui/components.tsx b/src/cli/tui/components.tsx index a273859..8df01d5 100644 --- a/src/cli/tui/components.tsx +++ b/src/cli/tui/components.tsx @@ -78,6 +78,41 @@ function fmtUsdFromTicks(ticks: number): string { return `$${usd.toFixed(2)}`; } +// ─── Layout helpers (fixed-width cockpit) ─────────────────────────────────── + +/** Cockpit content width: 80 cols minus paddingX=1 on each side. */ +export const COLS = 78; + +/** Truncate to n chars; append ellipsis if clipped. */ +export function truncate(s: string, n: number): string { + if (s.length <= n) return s; + return `${s.slice(0, Math.max(0, n - 1))}\u2026`; +} + +/** Right-pad to exactly n chars (no-op if already wider). */ +export function pad(s: string, n: number): string { + if (s.length >= n) return s; + return s + " ".repeat(n - s.length); +} + +/** "label: value" with value truncated+padded to valueWidth. */ +export function formatKV(label: string, value: string, valueWidth: number): string { + return `${label}: ${pad(truncate(value, valueWidth), valueWidth)}`; +} + +/** Compact badge: "count label" padded to width. */ +export function badge(count: number | string, label: string, width: number): string { + return pad(`${count} ${label}`, width); +} + +/** Section divider filling width: "─── title ───────…" */ +export function sectionLine(title: string, width: number = COLS): string { + if (!title) return "\u2500".repeat(width); + const prefix = `\u2500\u2500\u2500 ${title} `; + if (prefix.length >= width) return prefix.slice(0, width); + return prefix + "\u2500".repeat(width - prefix.length); +} + // ─── Header ───────────────────────────────────────────────────────────────── export interface HeaderProps { @@ -89,30 +124,29 @@ export interface HeaderProps { } export function Header(props: HeaderProps): ReactElement { + const pv = truncate(`${props.provider}/${props.model}`, 28); + const modeColor = props.mode === "live" ? "red" : props.mode === "gated" ? "yellow" : "green"; return ( - + - Strand TUI - - — live agent harness - - - provider: - - {props.provider}/{props.model} - - mode: - - {props.mode} + {"Strand TUI"} - - - credential store: - {props.credentialStore} - tenant: + {" \u2014 live agent harness"} + + + {"provider: "} + {pad(pv, 28)} + {" mode: "} + {props.mode} + + + {"store: "} + {pad(props.credentialStore, 8)} + {" tenant: "} {props.tenant ?? "\u2014"} - + + {sectionLine("")} ); } @@ -173,12 +207,10 @@ function GraphLine({ g, selected }: { g: TaskGraph; selected: boolean }): ReactE } export function TaskGraphsPane(props: TaskGraphsPaneProps): ReactElement { + const heading = `active task graphs${props.focused ? " [focused]" : ""}`; return ( - - {"─── active task graphs "} - {props.focused ? "[focused]" : ""} - + {sectionLine(heading)} {props.loading && props.graphs.length === 0 ? ( @@ -213,25 +245,22 @@ export function RunSummaryPane(props: RunSummaryPaneProps): ReactElement { const c = props.summary.consolidator; return ( - {"─── recent runs (24h)"} - - reasoner: - {r.ticks} ticks · - {r.candidates} candidates · - {r.toolCalls} tool calls · + {sectionLine("recent runs (24h)")} + + {pad("reasoner", 13)} + {badge(r.ticks, "ticks", 12)} + {badge(r.candidates, "cands", 12)} + {badge(r.toolCalls, "tools", 12)} {fmtUsdFromTicks(r.costUsdTicks)} - - - consolidator: - {c.total} runs · - {c.completed} completed - · - {c.failed} failed - · - {c.inProgress} in-progress - · - {c.queued} queued - + + + {pad("consolidator", 13)} + {badge(c.total, "runs", 10)} + {badge(c.completed, "ok", 7)} + {badge(c.failed, "fail", 8)} + {badge(c.inProgress, "wip", 7)} + {badge(c.queued, "queued", 10)} + ); } @@ -252,13 +281,12 @@ export function InvocationsPane(props: InvocationsPaneProps): ReactElement { const start = Math.min(Math.max(0, props.scrollOffset), Math.max(0, total - 1)); const visible = props.rows.slice(start, start + maxRows); + const focusTag = props.focused ? " [focused]" : ""; + const invTitle = `tool invocations${focusTag} (${visible.length}/${total})`; + return ( - - {"─── tool invocations "} - {props.focused ? "[focused] " : ""} - (showing {visible.length}/{total}) - + {sectionLine(invTitle)} {total === 0 ? ( (no invocations yet) ) : ( diff --git a/tests/cli/tui.test.ts b/tests/cli/tui.test.ts index f13ca94..fde0072 100644 --- a/tests/cli/tui.test.ts +++ b/tests/cli/tui.test.ts @@ -10,6 +10,7 @@ */ import type { TaskGraph } from "@/agent/types"; +import { COLS, Header, badge, formatKV, pad, sectionLine, truncate } from "@/cli/tui/components"; import { DataSourceContext, type InvocationRow, @@ -94,6 +95,119 @@ function makeStubSource(): TuiDataSource { }; } +// ─── Layout helper unit tests ───────────────────────────────────────────── + +describe("layout helpers", () => { + it("truncate clips long strings with ellipsis", () => { + expect(truncate("hello world", 8)).toBe("hello w\u2026"); + expect(truncate("short", 10)).toBe("short"); + expect(truncate("exact", 5)).toBe("exact"); + }); + + it("pad right-pads to target width", () => { + expect(pad("hi", 5)).toBe("hi "); + expect(pad("hello", 3)).toBe("hello"); + }); + + it("formatKV builds fixed-width key-value pairs", () => { + const kv = formatKV("mode", "shadow", 10); + expect(kv).toBe("mode: shadow "); + expect(kv).toHaveLength(16); + }); + + it("formatKV truncates long values", () => { + const kv = formatKV("provider", "xai/grok-4.20-reasoning-super-long-model-name", 20); + expect(kv).toHaveLength(30); + expect(kv).toContain("\u2026"); + }); + + it("badge builds compact count+label", () => { + expect(badge(42, "ticks", 12)).toBe("42 ticks "); + expect(badge(0, "wip", 7)).toBe("0 wip "); + }); + + it("sectionLine fills to COLS with dashes", () => { + const line = sectionLine("test"); + expect(line).toHaveLength(COLS); + expect(line).toMatch(/^\u2500\u2500\u2500 test \u2500+$/); + }); + + it("sectionLine with empty title produces plain rule", () => { + const line = sectionLine(""); + expect(line).toHaveLength(COLS); + expect(line).toMatch(/^\u2500+$/); + }); +}); + +// ─── Cockpit header rendering ───────────────────────────────────────────── + +describe("cockpit header", () => { + it("renders provider and mode on a single stable line", () => { + const tree = createElement(Header, { + provider: "xai", + model: "grok-4.20-reasoning", + mode: "shadow", + credentialStore: "env", + tenant: null, + }); + const { lastFrame, unmount } = render(tree); + const frame = lastFrame() ?? ""; + + expect(frame).toContain("Strand TUI"); + expect(frame).toContain("provider:"); + expect(frame).toContain("xai/grok-4.20-reasoning"); + expect(frame).toContain("mode:"); + expect(frame).toContain("shadow"); + expect(frame).toContain("store:"); + + const lines = frame.split("\n"); + const providerLine = lines.find((l) => l.includes("provider:")); + expect(providerLine).toBeDefined(); + expect(providerLine).toContain("mode:"); + + unmount(); + }); + + it("truncates long provider/model with ellipsis", () => { + const tree = createElement(Header, { + provider: "openai-compatible", + model: "some-very-long-model-name-that-exceeds-budget", + mode: "gated", + credentialStore: "file", + tenant: "acme", + }); + const { lastFrame, unmount } = render(tree); + const frame = lastFrame() ?? ""; + + expect(frame).toContain("\u2026"); + expect(frame).not.toContain("some-very-long-model-name-that-exceeds-budget"); + expect(frame).toContain("gated"); + expect(frame).toContain("acme"); + + unmount(); + }); + + it("section dividers span full width", () => { + const tree = createElement(Header, { + provider: "xai", + model: "grok", + mode: "shadow", + credentialStore: "env", + tenant: null, + }); + const { lastFrame, unmount } = render(tree); + const frame = lastFrame() ?? ""; + + const lines = frame.split("\n"); + const ruleLine = lines.find((l) => /^\u2500{10,}$/.test(l.trim())); + expect(ruleLine).toBeDefined(); + + unmount(); + }); +}); + +// ─── Dashboard smoke tests ──────────────────────────────────────────────── + describe("strand tui dashboard", () => { it("renders a non-empty frame with mocked data", () => { const source = makeStubSource();