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();