Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 204 additions & 1 deletion src/cli/tui/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Box paddingX={1}>
<Text bold color="magenta">
Strand
</Text>
<Text color="gray"> — operator cockpit</Text>
</Box>
);
}

// ─── 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 (
<Box width={40} borderStyle="single" borderColor="cyan" flexDirection="column">
<Text bold color="cyan">
{" "}
MISSION
</Text>
<Box>
<Text color="gray"> MODE </Text>
<Text bold color={modeColor}>
{props.mode.padEnd(9)}
</Text>
<Text color="gray">HALT </Text>
<Text bold color={haltOn ? "red" : "green"}>
{haltOn ? "\u25CF ON" : "\u25CF off"}
</Text>
</Box>
<Box>
<Text color="gray"> TIER </Text>
<Text color="white">{props.tier}</Text>
</Box>
<Box>
<Text color="gray"> </Text>
<Text color="white">
{props.provider}/{props.model.slice(0, 28)}
</Text>
</Box>
</Box>
);
}

// ─── 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 (
<Box width={40} borderStyle="single" borderColor="green" flexDirection="column">
<Text bold color="green">
{" "}
SAFETY SHIELD
</Text>
<Box>
<Text color="gray"> Review </Text>
<Text color={queueColor}>{String(props.reviewQueued).padStart(3)} queued</Text>
<Text color="cyan">
{" "}
{String(props.reviewActive).padStart(2)} active
</Text>
</Box>
<Box>
<Text color="gray"> DLQ </Text>
<Text color={dlqColor}>{String(props.dlqFailed).padStart(3)} failed</Text>
</Box>
<Box>
<Text color="gray"> Health </Text>
<Text color="green">{healthBar}</Text>
<Text color="gray">
{" "}
{props.totalRuns} runs
</Text>
</Box>
</Box>
);
}

// ─── 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 (
<Box width={40} borderStyle="single" borderColor="yellow" flexDirection="column">
<Text bold color="yellow">
{" "}
PULSE
</Text>
<Box>
<Text color="gray"> Ticks </Text>
<Text>{String(props.ticks).padStart(5)} ticks </Text>
<Text color="cyan">{asciiBar(props.ticks, tickMax, 12)}</Text>
</Box>
<Box>
<Text color="gray"> Cands </Text>
<Text>{String(props.candidates).padStart(5)} candidates</Text>
</Box>
<Box>
<Text color="gray"> Tools </Text>
<Text>{String(props.toolCalls).padStart(5)} calls </Text>
<Text color="cyan">{asciiBar(props.toolCalls, toolMax, 12)}</Text>
</Box>
<Box>
<Text color="gray"> Cost </Text>
<Text bold color="yellow">
{fmtUsdFromTicks(props.costUsdTicks)}
</Text>
</Box>
</Box>
);
}

// ─── 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 (
<Box width={40} borderStyle="single" borderColor="magenta" flexDirection="column">
<Text bold color="magenta">
{" "}
REACH
</Text>
<Box>
<Text color="gray"> Followers </Text>
<Text>{val(props.followers)}</Text>
</Box>
<Box>
<Text color="gray"> 24h delta </Text>
<Text
color={
props.delta24h != null && props.delta24h > 0
? "green"
: props.delta24h != null && props.delta24h < 0
? "red"
: "gray"
}
>
{props.delta24h != null && props.delta24h > 0 ? "+" : ""}
{val(props.delta24h)}
</Text>
</Box>
<Box>
<Text color="gray"> X usage </Text>
<Text color="cyan">{val(props.xUsage)}</Text>
</Box>
<Box>
<Text color="gray"> X health </Text>
<Text color={healthColor}>{val(props.xHealth)}</Text>
</Box>
</Box>
);
}

// ─── Header (legacy) ────────────────────────────────────────────────────────

export interface HeaderProps {
provider: string;
Expand Down
58 changes: 43 additions & 15 deletions src/cli/tui/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 (
<Box flexDirection="column">
<Header {...header} />
<CockpitBanner />
{!isRawModeSupported ? (
<Box paddingX={1}>
<Text color="yellow">
Expand All @@ -118,14 +119,41 @@ export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactEl
<Text color="yellow">{"[paused] — press p to resume, r to refresh once"}</Text>
</Box>
) : null}
{/* ── Cockpit top row ── */}
<Box flexDirection="row">
<MissionPanel
mode={env.STRAND_MODE}
halt={env.STRAND_HALT}
tier={env.TIER}
provider={env.LLM_PROVIDER}
model={env.LLM_MODEL_REASONER}
/>
<SafetyShieldPanel
reviewQueued={c.queued}
reviewActive={c.inProgress}
dlqFailed={c.failed}
totalRuns={c.total}
completedRuns={c.completed}
/>
</Box>
{/* ── Cockpit bottom row ── */}
<Box flexDirection="row">
<PulsePanel
ticks={r.ticks}
candidates={r.candidates}
toolCalls={r.toolCalls}
costUsdTicks={r.costUsdTicks}
/>
<ReachPanel />
</Box>
{/* ── Live data panes ── */}
<TaskGraphsPane
graphs={graphs.data}
loading={graphs.loading}
selectedIdx={Math.min(selectedIdx, Math.max(0, graphs.data.length - 1))}
expanded={expanded}
focused={focusedPane === "graphs"}
/>
<RunSummaryPane summary={summary.data} loading={summary.loading} />
<InvocationsPane
rows={invocations.data}
loading={invocations.loading}
Expand Down
Loading
Loading