diff --git a/src/cli/tui/components.tsx b/src/cli/tui/components.tsx
index a273859..418c729 100644
--- a/src/cli/tui/components.tsx
+++ b/src/cli/tui/components.tsx
@@ -78,7 +78,210 @@ function fmtUsdFromTicks(ticks: number): string {
return `$${usd.toFixed(2)}`;
}
-// ─── Header ─────────────────────────────────────────────────────────────────
+// ─── ASCII bar helper ────────────────────────────────────────────────────────
+
+function asciiBar(value: number, max: number, width = 10): string {
+ if (max <= 0) return `[${"─".repeat(width)}]`;
+ const ratio = Math.min(1, Math.max(0, value / max));
+ const filled = Math.round(ratio * width);
+ return `[${"#".repeat(filled)}${"─".repeat(width - filled)}]`;
+}
+
+// ─── Cockpit banner ─────────────────────────────────────────────────────────
+
+export function CockpitBanner(): ReactElement {
+ return (
+
+
+ Strand
+
+ — operator cockpit
+
+ );
+}
+
+// ─── Mission panel ──────────────────────────────────────────────────────────
+
+export interface MissionPanelProps {
+ mode: string;
+ halt: string;
+ tier: string;
+ provider: string;
+ model: string;
+}
+
+export function MissionPanel(props: MissionPanelProps): ReactElement {
+ const modeColor = props.mode === "live" ? "red" : props.mode === "gated" ? "yellow" : "green";
+ const haltOn = props.halt === "true";
+ return (
+
+
+ {" "}
+ MISSION
+
+
+ MODE
+
+ {props.mode.padEnd(9)}
+
+ HALT
+
+ {haltOn ? "\u25CF ON" : "\u25CF off"}
+
+
+
+ TIER
+ {props.tier}
+
+
+
+
+ {props.provider}/{props.model.slice(0, 28)}
+
+
+
+ );
+}
+
+// ─── Safety Shield panel ────────────────────────────────────────────────────
+
+export interface SafetyShieldPanelProps {
+ reviewQueued: number;
+ reviewActive: number;
+ dlqFailed: number;
+ totalRuns: number;
+ completedRuns: number;
+}
+
+export function SafetyShieldPanel(props: SafetyShieldPanelProps): ReactElement {
+ const queueColor = props.reviewQueued > 0 ? "yellow" : "green";
+ const dlqColor = props.dlqFailed > 0 ? "red" : "green";
+ const healthMax = Math.max(props.totalRuns, 1);
+ const healthBar = asciiBar(props.completedRuns, healthMax, 10);
+ return (
+
+
+ {" "}
+ SAFETY SHIELD
+
+
+ Review
+ {String(props.reviewQueued).padStart(3)} queued
+
+ {" "}
+ {String(props.reviewActive).padStart(2)} active
+
+
+
+ DLQ
+ {String(props.dlqFailed).padStart(3)} failed
+
+
+ Health
+ {healthBar}
+
+ {" "}
+ {props.totalRuns} runs
+
+
+
+ );
+}
+
+// ─── Pulse panel ────────────────────────────────────────────────────────────
+
+export interface PulsePanelProps {
+ ticks: number;
+ candidates: number;
+ toolCalls: number;
+ costUsdTicks: number;
+}
+
+export function PulsePanel(props: PulsePanelProps): ReactElement {
+ const tickMax = Math.max(props.ticks, 100);
+ const toolMax = Math.max(props.toolCalls, 50);
+ return (
+
+
+ {" "}
+ PULSE
+
+
+ Ticks
+ {String(props.ticks).padStart(5)} ticks
+ {asciiBar(props.ticks, tickMax, 12)}
+
+
+ Cands
+ {String(props.candidates).padStart(5)} candidates
+
+
+ Tools
+ {String(props.toolCalls).padStart(5)} calls
+ {asciiBar(props.toolCalls, toolMax, 12)}
+
+
+ Cost
+
+ {fmtUsdFromTicks(props.costUsdTicks)}
+
+
+
+ );
+}
+
+// ─── Reach panel ────────────────────────────────────────────────────────────
+
+export interface ReachPanelProps {
+ followers?: number | null;
+ delta24h?: number | null;
+ xUsage?: string | null;
+ xHealth?: string | null;
+}
+
+export function ReachPanel(props: ReachPanelProps): ReactElement {
+ const val = (v: number | string | null | undefined, suffix = ""): string =>
+ v != null ? `${v}${suffix}` : "\u2014";
+ const healthColor =
+ props.xHealth === "ok" ? "green" : props.xHealth === "degraded" ? "yellow" : "gray";
+ return (
+
+
+ {" "}
+ REACH
+
+
+ Followers
+ {val(props.followers)}
+
+
+ 24h delta
+ 0
+ ? "green"
+ : props.delta24h != null && props.delta24h < 0
+ ? "red"
+ : "gray"
+ }
+ >
+ {props.delta24h != null && props.delta24h > 0 ? "+" : ""}
+ {val(props.delta24h)}
+
+
+
+ X usage
+ {val(props.xUsage)}
+
+
+ X health
+ {val(props.xHealth)}
+
+
+ );
+}
+
+// ─── Header (legacy) ────────────────────────────────────────────────────────
export interface HeaderProps {
provider: string;
diff --git a/src/cli/tui/dashboard.tsx b/src/cli/tui/dashboard.tsx
index 43a255c..1e3f8ea 100644
--- a/src/cli/tui/dashboard.tsx
+++ b/src/cli/tui/dashboard.tsx
@@ -1,5 +1,5 @@
/**
- * Strand dashboard — the live view behind `strand tui --dashboard`.
+ * Strand cockpit — the operator command center behind `strand tui --dashboard`.
*
* Single-screen read-only tree over the local SQLite ops DB:
* - polls `agent_task_graphs` / `_steps` for active graphs
@@ -13,8 +13,17 @@
import { env } from "@/config";
import { Box, Text, useApp, useInput, useStdin } from "ink";
import type { ReactElement } from "react";
-import { useCallback, useMemo, useState } from "react";
-import { Footer, Header, InvocationsPane, RunSummaryPane, TaskGraphsPane } from "./components";
+import { useCallback, useState } from "react";
+import {
+ CockpitBanner,
+ Footer,
+ InvocationsPane,
+ MissionPanel,
+ PulsePanel,
+ ReachPanel,
+ SafetyShieldPanel,
+ TaskGraphsPane,
+} from "./components";
import { useRecentInvocations, useRunSummary, useTaskGraphs } from "./hooks";
export interface DashboardProps {
@@ -92,20 +101,12 @@ export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactEl
{ isActive: Boolean(isRawModeSupported) },
);
- const header = useMemo(
- () => ({
- provider: env.LLM_PROVIDER,
- model: env.LLM_MODEL_REASONER,
- mode: env.STRAND_MODE,
- credentialStore: process.env["STRAND_CREDENTIAL_STORE"] ?? "env",
- tenant: process.env["STRAND_TENANT"] ?? null,
- }),
- [],
- );
+ const r = summary.data.reasoner;
+ const c = summary.data.consolidator;
return (
-
+
{!isRawModeSupported ? (
@@ -118,6 +119,34 @@ export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactEl
{"[paused] — press p to resume, r to refresh once"}
) : null}
+ {/* ── Cockpit top row ── */}
+
+
+
+
+ {/* ── Cockpit bottom row ── */}
+
+
+
+
+ {/* ── Live data panes ── */}
-
{
- it("renders a non-empty frame with mocked data", () => {
+ it("renders cockpit panels with mocked data", () => {
const source = makeStubSource();
const tree = createElement(
DataSourceContext.Provider,
@@ -106,20 +106,37 @@ describe("strand tui dashboard", () => {
const frame = lastFrame() ?? "";
expect(frame.length).toBeGreaterThan(0);
- // Header renders provider + mode
- expect(frame).toContain("Strand TUI");
+
+ // Cockpit banner
+ expect(frame).toContain("Strand");
+ expect(frame).toContain("operator cockpit");
+
+ // Mission panel
+ expect(frame).toContain("MISSION");
expect(frame).toContain("shadow");
- // A fake graph appears
+ expect(frame).toContain("HALT");
+
+ // Safety Shield panel
+ expect(frame).toContain("SAFETY SHIELD");
+ expect(frame).toContain("queued");
+ expect(frame).toContain("7 runs");
+
+ // Pulse panel
+ expect(frame).toContain("PULSE");
+ expect(frame).toContain("42 ticks");
+ expect(frame).toContain("$0.18");
+
+ // Reach panel
+ expect(frame).toContain("REACH");
+
+ // Task graph still renders
expect(frame).toContain("crawl site X and summarize");
expect(frame).toContain("fetch home page");
- // Invocations pane shows a tool line
+
+ // Invocations pane shows tool lines
expect(frame).toContain("http_fetch");
expect(frame).toContain("fs_read");
- // Run summary pane shows reasoner ticks + cost
- expect(frame).toContain("42 ticks");
- expect(frame).toContain("$0.18");
- // Consolidator counts
- expect(frame).toContain("7 runs");
+
// Footer hint
expect(frame).toContain("[q] quit");
@@ -148,9 +165,44 @@ describe("strand tui dashboard", () => {
);
const { lastFrame, unmount } = render(tree);
const frame = lastFrame() ?? "";
- expect(frame).toContain("Strand TUI");
+ // Cockpit panels still render
+ expect(frame).toContain("MISSION");
+ expect(frame).toContain("SAFETY SHIELD");
+ expect(frame).toContain("PULSE");
+ expect(frame).toContain("REACH");
+ // Empty state for data panes
expect(frame).toContain("(no active graphs)");
expect(frame).toContain("(no invocations yet)");
unmount();
});
+
+ it("renders ASCII bars in pulse panel", () => {
+ const source = makeStubSource();
+ const tree = createElement(
+ DataSourceContext.Provider,
+ { value: source },
+ createElement(Dashboard, { pollMs: 10_000 }),
+ );
+ const { lastFrame, unmount } = render(tree);
+ const frame = lastFrame() ?? "";
+ // ASCII bars use # and ─ characters
+ expect(frame).toMatch(/\[#+─*\]/);
+ unmount();
+ });
+
+ it("shows safety indicators with correct severity", () => {
+ const source = makeStubSource();
+ const tree = createElement(
+ DataSourceContext.Provider,
+ { value: source },
+ createElement(Dashboard, { pollMs: 10_000 }),
+ );
+ const { lastFrame, unmount } = render(tree);
+ const frame = lastFrame() ?? "";
+ // DLQ count appears
+ expect(frame).toContain("1 failed");
+ // Queued count appears
+ expect(frame).toContain("4 queued");
+ unmount();
+ });
});