From 6f0ab12cd43925a3552b8bf5aa8db7d2bf03b5b3 Mon Sep 17 00:00:00 2001 From: R4vager Date: Fri, 24 Apr 2026 05:26:29 -0400 Subject: [PATCH 1/8] build strand cockpit tui --- README.md | 1 + config/policies.yaml | 4 +- docs/RUNBOOK.md | 123 ++++++++- src/cli/commands/review.ts | 102 ++++++-- src/cli/commands/status.ts | 62 ++++- src/cli/commands/tui.ts | 12 + src/cli/tui/components.tsx | 423 +++++++++++++++++++++---------- src/cli/tui/dashboard.tsx | 117 +++++++-- src/cli/tui/hooks.ts | 190 ++++++++++++++ src/cli/tui/layout.ts | 71 ++++++ src/cli/tui/welcome.tsx | 1 + src/clients/x.ts | 29 +++ src/db/schema.sql | 55 ++++ src/loops/actor.ts | 12 +- src/metrics/index.ts | 220 ++++++++++++++++ src/orchestrator.ts | 153 ++++++++--- tests/cli/cli.test.ts | 1 + tests/cli/tui.test.ts | 99 +++++++- tests/loops/actor-phase3.test.ts | 276 ++++++++++++++++++++ 19 files changed, 1719 insertions(+), 232 deletions(-) create mode 100644 src/cli/tui/layout.ts create mode 100644 src/metrics/index.ts create mode 100644 tests/loops/actor-phase3.test.ts diff --git a/README.md b/README.md index c73c70b..afd0d62 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ strand init # first-run wizard — pick provider, store key strand doctor # preflight health check strand run "summarize the README and commit a rewrite" # one-shot agentic plan strand tui # welcome splash · [d] live dashboard +strand cockpit # live operator cockpit for a pinned terminal strand status # orchestrator + reasoner/consolidator summary strand tasks list # persisted TaskGraphs strand tasks show # graph + steps + reflections diff --git a/config/policies.yaml b/config/policies.yaml index eb4ac29..db24413 100644 --- a/config/policies.yaml +++ b/config/policies.yaml @@ -1,8 +1,10 @@ # Must match PoliciesConfigSchema in src/config.ts. # Values here are the *ceiling*; `effectiveCap` multiplies by ramp_multiplier. +# Phase 3 configuration: like/bookmark live, all other actions shadow +# ramp_multiplier 0.5 = half-caps during ramp-up (100 likes/day, 15 bookmarks/day) mode: shadow -ramp_multiplier: 0.25 +ramp_multiplier: 0.5 caps_per_day: posts: 6 diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index cd8a1f4..ca8b87d 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -159,11 +159,132 @@ can attribute label quality to a specific prompt version. Before enabling low-risk live actions (`like` + `bookmark`): +- [ ] `pnpm strand review gate-check` exits 0 (≥100 labeled, ≥80% agreement) - [ ] `pnpm strand review agreement --json | jq '.gate.met'` → `true` - (≥100 labeled candidates AND ≥80% agreement in `mode=shadow`) + (confirms same criteria with full confusion matrix) - [ ] Confusion matrix shows no systematic `false_approve` bias (i.e. policy approves ≤5 actions the operator would reject) - [ ] No `reasoner.candidate_cap_enforced` warnings sustained over ≥48h (model consistently emits ≤5 — overrun implies prompt drift) - [ ] Actor dry-run verified: `action_log` rows show `status='executed'` in shadow mode with the write path short-circuited before the X API call + +## Phase 3: Low-risk actions live + +In Phase 3 the Actor enables **only** `like` and `bookmark` in live mode. All +other actions (`reply`, `quote`, `post`, `follow`, `dm`) remain in shadow mode +even when `STRAND_MODE=live`. + +### Phase 3 gate checklist + +Before enabling live actions: + +```bash +# 1. Verify gate criteria met (≥100 labeled, ≥80% agreement) +pnpm strand review gate-check + +# 2. Verify half-caps configured (ramp_multiplier should be 0.5) +cat config/policies.yaml | grep ramp_multiplier # should be 0.5 + +# 3. Check metrics baseline (run for 24h in shadow with metrics enabled) +pnpm strand status --metrics +``` + +### Enabling live mode + +```bash +# 1. Confirm readiness +pnpm strand review gate-check --json | jq '.ready' # should be true + +# 2. Set live mode (only like/bookmark will actually go live) +export STRAND_MODE=live +export STRAND_HALT=false + +# 3. Restart orchestrator +pkill -SIGTERM -f "strand start" +pnpm strand start & + +# 4. Record transition +echo "Phase 3 live start: $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> ./data/phase3.log +``` + +### Phase 3 kill switch (drain mode) + +If anything goes wrong, the kill switch implements drain semantics: + +```bash +# STRAND_HALT stops the Reasoner (no new candidates) +# Perceiver continues (reads are safe) +# In-flight actions complete; new actions are rejected +export STRAND_HALT=true +``` + +Verify drain state: +```bash +# Check halt is active +pnpm strand status --json | jq '.env.strand_halt' # should be "true" + +# Check no new candidates being emitted (reasoner_runs should stop growing) +pnpm strand status | grep reasoner_runs +``` + +### Monitoring Phase 3 health + +Check metrics dashboard every 4 hours: + +```bash +# Full metrics dashboard +pnpm strand status --metrics + +# Key metrics to watch: +# - X API health: rate limits healthy, monthly cap < 50% +# - Follower delta: no sudden negative spikes (>10% drop) +# - Error rates: < 5% failure rate on like/bookmark +``` + +### Cap enforcement (half-caps during ramp-up) + +Phase 3 uses `ramp_multiplier: 0.5` in `policies.yaml`: + +| Action | Daily Cap (full) | Phase 3 Cap (0.5x) | +|--------|------------------|-------------------| +| likes | 200 | 100 | +| bookmarks | 50 | 25 | + +Verify caps in effect: +```bash +# Check action_log for cap enforcement +sqlite3 ./data/strand.db "SELECT kind, COUNT(*) FROM action_log WHERE status='executed' AND created_at > datetime('now', '-24 hours') GROUP BY kind" +``` + +### Rollback to shadow + +If you need to revert: + +```bash +# 1. Halt first (drain in-flight) +export STRAND_HALT=true +sleep 30 # wait for drain + +# 2. Switch back to shadow +export STRAND_MODE=shadow +export STRAND_HALT=false + +# 3. Restart +pkill -SIGTERM -f "strand start" +pnpm strand start & + +# 4. Record rollback +echo "Phase 3 rollback: $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> ./data/phase3.log +``` + +### Gate to Phase 4 + +Before enabling `reply` live: + +- [ ] Phase 3 ran clean for ≥ 72 hours +- [ ] `like` and `bookmark` error rate < 1% +- [ ] No X rate limit 429s sustained +- [ ] Follower delta stable (no negative trend) +- [ ] `pnpm strand review agreement --mode=live` shows ≥90% agreement +- [ ] Human review queue shows manageable volume diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index ebe9290..66afe66 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -28,11 +28,11 @@ export function registerReviewCmd(program: Command, _ctx: CliContext): void { "SELECT id, decision_id, payload_json, reasons_json FROM human_review_queue WHERE decided_at IS NULL ORDER BY created_at ASC LIMIT 50", ) .all() as Array<{ - id: number; - decision_id: string; - payload_json: string; - reasons_json: string | null; - }>; + id: number; + decision_id: string; + payload_json: string; + reasons_json: string | null; + }>; if (rows.length === 0) { printLine("no pending reviews"); @@ -77,17 +77,17 @@ export function registerReviewCmd(program: Command, _ctx: CliContext): void { LIMIT ?`, ) .all(opts.mode, limit) as Array<{ - id: number; - decision_id: string; - kind: string; - status: string; - payload_json: string; - rationale: string | null; - confidence: number | null; - relevance: number | null; - reasons_json: string | null; - created_at: string; - }>; + id: number; + decision_id: string; + kind: string; + status: string; + payload_json: string; + rationale: string | null; + confidence: number | null; + relevance: number | null; + reasons_json: string | null; + created_at: string; + }>; if (rows.length === 0) { printLine(`no unlabeled candidates in mode=${opts.mode}`); @@ -156,11 +156,11 @@ export function registerReviewCmd(program: Command, _ctx: CliContext): void { WHERE operator_label IS NOT NULL AND mode = ?`, ) .all(opts.mode) as Array<{ - status: string; - operator_label: string; - confidence: number | null; - relevance: number | null; - }>; + status: string; + operator_label: string; + confidence: number | null; + relevance: number | null; + }>; const total = rows.length; let agree = 0; @@ -254,4 +254,64 @@ export function registerReviewCmd(program: Command, _ctx: CliContext): void { } } }); + + // ─── Phase 2: `review gate-check` ───────────────────────────── + review + .command("gate-check") + .description("programmatic Phase 3 gate check — exits 0 if ready, 1 if not") + .option("--min-labeled ", "minimum labeled candidates", "100") + .option("--min-agreement ", "minimum agreement %", "80") + .option("--mode ", "filter by mode", "shadow") + .option("--json", "emit JSON result to stdout") + .action(async (opts: { minLabeled: string; minAgreement: string; mode: string; json?: boolean }) => { + const { db } = await import("@/db"); + + const minLabeled = Number.parseInt(opts.minLabeled, 10) || 100; + const minAgreement = Number.parseInt(opts.minAgreement, 10) || 80; + + const rows = db() + .prepare( + `SELECT status, operator_label + FROM action_log + WHERE operator_label IS NOT NULL AND mode = ?`, + ) + .all(opts.mode) as Array<{ status: string; operator_label: string }>; + + const total = rows.length; + let agree = 0; + let disagree = 0; + + for (const r of rows) { + const policyApproved = r.status === "approved" || r.status === "executed"; + if (r.operator_label === "unclear") continue; + const operatorGood = r.operator_label === "good"; + if (policyApproved === operatorGood) { + agree++; + } else { + disagree++; + } + } + + const decisive = agree + disagree; + const agreementPct = decisive > 0 ? (agree / decisive) * 100 : 0; + const gateMet = total >= minLabeled && agreementPct >= minAgreement; + + if (opts.json) { + const result = { + ready: gateMet, + mode: opts.mode, + total_labeled: total, + min_labeled: minLabeled, + agreement_pct: Number(agreementPct.toFixed(2)), + min_agreement_pct: minAgreement, + }; + printLine(JSON.stringify(result, null, 2)); + } else { + printLine(gateMet ? "READY" : "NOT_READY"); + printLine(` labeled: ${total}/${minLabeled}`); + printLine(` agreement: ${agreementPct.toFixed(2)}% (min ${minAgreement}%)`); + } + + process.exit(gateMet ? 0 : 1); + }); } diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index cb92639..61133bf 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -7,10 +7,50 @@ export function registerStatusCmd(program: Command, _ctx: CliContext): void { .command("status") .description("orchestrator status + recent events / actions / reasoner / consolidator rows") .option("--json", "emit status as JSON for programmatic checks") - .action(async (opts: { json?: boolean }) => { + .option("--metrics", "show Phase 3 health metrics dashboard") + .action(async (opts: { json?: boolean; metrics?: boolean }) => { const { db } = await import("@/db"); const dbh = db(); + if (opts.metrics) { + // Phase 3: Health metrics dashboard + const { getHealthSummary } = await import("@/metrics"); + const metrics = getHealthSummary(); + + printLine("=== Phase 3 Health Metrics ==="); + printLine(""); + + printLine("--- X API Health (last hour) ---"); + if (metrics.xHealth.length === 0) { + printLine(" No health snapshots recorded yet"); + } else { + for (const h of metrics.xHealth.slice(0, 5)) { + printLine(` [${h.sampledAt}] ${h.endpoint}: ${h.healthy ? "healthy" : "degraded"}`); + } + } + printLine(""); + + printLine("--- Follower Delta ---"); + if (metrics.followerDelta) { + printLine(` Current: ${metrics.followerDelta.followersCount}`); + printLine(` 24h change: ${metrics.followerDelta.delta24h ?? 0}`); + printLine(` Last sampled: ${metrics.followerDelta.sampledAt}`); + } else { + printLine(" No follower data recorded yet"); + } + printLine(""); + + printLine("--- Error Rates (last 24h) ---"); + if (metrics.errorRates.length === 0) { + printLine(" No errors recorded"); + } else { + for (const e of metrics.errorRates.slice(0, 10)) { + printLine(` [${e.hourBucket}] ${e.kind}.${e.errorCode}: ${e.count}`); + } + } + return; + } + if (opts.json) { // JSON output for 48h sanity checks const eventCounts = dbh @@ -100,11 +140,11 @@ export function registerStatusCmd(program: Command, _ctx: CliContext): void { "SELECT tick_at, candidate_count, tool_call_count, cost_in_usd_ticks FROM reasoner_runs ORDER BY tick_at DESC LIMIT 5", ) .all() as Array<{ - tick_at: string; - candidate_count: number; - tool_call_count: number; - cost_in_usd_ticks: number | null; - }>; + tick_at: string; + candidate_count: number; + tool_call_count: number; + cost_in_usd_ticks: number | null; + }>; printLine(`=== last ${reasoner.length} reasoner_runs ===`); for (const r of reasoner) { printLine( @@ -118,11 +158,11 @@ export function registerStatusCmd(program: Command, _ctx: CliContext): void { "SELECT status, batch_id, completed_at, created_at FROM consolidator_runs ORDER BY created_at DESC LIMIT 5", ) .all() as Array<{ - status: string; - batch_id: string | null; - completed_at: string | null; - created_at: string; - }>; + status: string; + batch_id: string | null; + completed_at: string | null; + created_at: string; + }>; printLine(`=== last ${consolidator.length} consolidator_runs ===`); for (const c of consolidator) { printLine( diff --git a/src/cli/commands/tui.ts b/src/cli/commands/tui.ts index 6a572bf..829ab77 100644 --- a/src/cli/commands/tui.ts +++ b/src/cli/commands/tui.ts @@ -15,4 +15,16 @@ export function registerTuiCmd(program: Command, _ctx: CliContext): void { pollMs: Number.isFinite(n) && n > 0 ? n : 2000, }); }); + + program + .command("cockpit") + .description("open the live Strand operator cockpit") + .option("--poll-ms ", "dashboard poll cadence in ms", "2000") + .action(async (opts: { pollMs: string }) => { + const n = Number(opts.pollMs); + await launchTui({ + dashboard: true, + pollMs: Number.isFinite(n) && n > 0 ? n : 2000, + }); + }); } diff --git a/src/cli/tui/components.tsx b/src/cli/tui/components.tsx index a273859..b00f9d2 100644 --- a/src/cli/tui/components.tsx +++ b/src/cli/tui/components.tsx @@ -1,33 +1,33 @@ /** * Stateless presentational components for the Strand TUI. * - * Every piece of data is passed in as props — no hook calls here, no side - * effects. Makes these trivial to render in tests with whatever mock data we - * want. + * Every visible row is sized before Ink sees it. That keeps the cockpit stable + * in 80-column terminals and avoids flex-row wrapping between adjacent Text + * nodes. */ import type { PlanStep, StepStatus, TaskGraph } from "@/agent/types"; import { Box, Text } from "ink"; -import Spinner from "ink-spinner"; -import type { ReactElement } from "react"; -import type { InvocationRow, RunSummary } from "./hooks"; +import type { ReactElement, ReactNode } from "react"; +import type { InvocationRow, OperatorSnapshot, RunSummary } from "./hooks"; +import { fit, kv, pad, panelInnerWidth, ratioBar, sign, truncate } from "./layout"; -// ─── Visual helpers ───────────────────────────────────────────────────────── +// --- Visual helpers --------------------------------------------------------- function statusGlyph(s: StepStatus): string { switch (s) { case "completed": - return "\u2713"; + return "ok"; case "running": - return "\u27F3"; + return ">>"; case "failed": - return "\u2717"; + return "!!"; case "skipped": - return "\u2192"; + return "--"; case "abandoned": - return "\u00D7"; + return "xx"; case "pending": - return "·"; + return ".."; } } @@ -49,75 +49,202 @@ function statusColor(s: StepStatus): string { } function shortId(id: string): string { - return id.length > 4 ? `${id.slice(0, 4)}\u2026` : id; + return id.length > 6 ? `${id.slice(0, 6)}...` : id; } function fmtTime(iso: string): string { try { const d = new Date(iso); + if (!Number.isFinite(d.getTime())) return iso.slice(11, 19) || iso; const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); const ss = String(d.getSeconds()).padStart(2, "0"); return `${hh}:${mm}:${ss}`; } catch { - return iso.slice(11, 19); + return iso.slice(11, 19) || iso; } } function fmtDuration(ms: number | null): string { - if (ms == null) return "\u2014"; + if (ms == null) return "-"; if (ms < 1000) return `${ms}ms`; return `${(ms / 1000).toFixed(1)}s`; } function fmtUsdFromTicks(ticks: number): string { - // 1 tick = 1e-10 USD. const usd = ticks / 1e10; if (usd === 0) return "$0.00"; if (usd < 0.01) return `$${usd.toFixed(4)}`; return `$${usd.toFixed(2)}`; } -// ─── Header ───────────────────────────────────────────────────────────────── +function fmtMinutes(minutes: number | null): string { + if (minutes == null) return "-"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (hours < 48) return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`; + return `${Math.floor(hours / 24)}d`; +} + +function fmtMaybeCount(n: number | null): string { + return n == null ? "-" : String(n); +} + +function fmtNumber(n: number): string { + return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +function panelColor(issueCount: number): string { + if (issueCount > 0) return "red"; + return "green"; +} + +function Panel({ + title, + width, + color = "gray", + children, +}: { + title: string; + width: number; + color?: string; + children: ReactNode; +}): ReactElement { + const inner = panelInnerWidth(width); + return ( + + + {fit(title, inner)} + + {children} + + ); +} + +function PanelLine({ + width, + color, + children, +}: { + width: number; + color?: string; + children: string; +}): ReactElement { + const line = fit(children, panelInnerWidth(width)); + if (color) return {line}; + return {line}; +} + +// --- Header ---------------------------------------------------------------- export interface HeaderProps { provider: string; model: string; mode: string; + halt: string; + tier: string; credentialStore: string; tenant: string | null; + width?: number; } export function Header(props: HeaderProps): ReactElement { + const width = props.width ?? 80; + const inner = Math.max(20, width - 2); + const modelBudget = Math.max(14, inner - 42); + const model = truncate(`${props.provider}/${props.model}`, modelBudget); + const halt = props.halt === "true" ? "HALTED" : "armed"; return ( - - - - Strand TUI - - — live agent harness - - - provider: - - {props.provider}/{props.model} - - mode: - - {props.mode} - - - - credential store: - {props.credentialStore} - tenant: - {props.tenant ?? "\u2014"} - + + + {fit("STRAND COCKPIT - live agent harness", inner)} + + + {fit(`model ${model} | mode ${props.mode} | halt ${halt} | tier ${props.tier}`, inner)} + + + {fit(`credential store ${props.credentialStore} | tenant ${props.tenant ?? "-"}`, inner)} + ); } -// ─── TaskGraphsPane ───────────────────────────────────────────────────────── +// --- Operator cockpit ------------------------------------------------------- + +export interface OperatorPaneProps { + snapshot: OperatorSnapshot; + loading: boolean; + width: number; +} + +function healthColor(row: OperatorSnapshot["x"]["latestHealth"][number]): string { + if (row.healthy === 0) return "red"; + if (row.remaining != null && row.limit != null && row.limit > 0) { + const ratio = row.remaining / row.limit; + if (ratio < 0.1) return "red"; + if (ratio < 0.25) return "yellow"; + } + return "green"; +} + +function healthText(row: OperatorSnapshot["x"]["latestHealth"][number]): string { + const state = healthColor(row) === "green" ? "ok" : healthColor(row); + return `${row.endpoint} ${fmtMaybeCount(row.remaining)}/${fmtMaybeCount(row.limit)} ${state}`; +} + +function actionsByKindText(rows: OperatorSnapshot["actions24h"]["byKind"]): string { + if (rows.length === 0) return "none"; + return rows.map((r) => `${r.kind}:${r.count}`).join(" "); +} + +export function OperatorPane({ snapshot, loading, width }: OperatorPaneProps): ReactElement { + const inner = panelInnerWidth(width); + const barWidth = Math.max(8, Math.min(18, Math.floor(inner / 5))); + const actionTotal = Math.max(1, snapshot.actions24h.total); + const executedBar = ratioBar(snapshot.actions24h.executed, actionTotal, barWidth); + const guardIssueCount = + snapshot.guardrails.dlqOpen + snapshot.actions24h.failed + snapshot.actions24h.rejected; + const usageBar = + snapshot.x.monthlyUsed == null || snapshot.x.monthlyCap == null + ? "[-]" + : ratioBar(snapshot.x.monthlyUsed, snapshot.x.monthlyCap, barWidth); + const monthly = + snapshot.x.monthlyUsed == null || snapshot.x.monthlyCap == null + ? "-" + : `${snapshot.x.monthlyUsed}/${snapshot.x.monthlyCap}`; + const followers = snapshot.followers + ? `${fmtNumber(snapshot.followers.count)} (${sign(snapshot.followers.delta24h)} 24h)` + : "-"; + const latestHealth = snapshot.x.latestHealth.slice(0, 3).map(healthText).join(" | "); + const title = loading ? "operator cockpit / syncing" : "operator cockpit"; + + return ( + + 0 ? "yellow" : "green"}> + {`MISSION ${kv("review", snapshot.review.open)} open | oldest ${fmtMinutes( + snapshot.review.oldestMinutes, + )} | actions ${snapshot.actions24h.total}`} + + 0 ? "red" : "cyan"}> + {`PULSE exec ${executedBar} ${snapshot.actions24h.executed}/${snapshot.actions24h.total} | approved ${snapshot.actions24h.approved} | kinds ${actionsByKindText( + snapshot.actions24h.byKind, + )}`} + + + {`SHIELD cooldowns ${snapshot.guardrails.activeCooldowns} | dlq ${snapshot.guardrails.dlqOpen} | dedup ${snapshot.guardrails.recentDuplicateHashes} | rejected ${snapshot.actions24h.rejected} | failed ${snapshot.actions24h.failed}`} + + + {`REACH x usage ${usageBar} ${monthly} | followers ${followers}`} + + + {`HEALTH ${latestHealth.length === 0 ? "no snapshots" : latestHealth}`} + + + ); +} + +// --- Task graphs ------------------------------------------------------------ export interface TaskGraphsPaneProps { graphs: TaskGraph[]; @@ -125,118 +252,96 @@ export interface TaskGraphsPaneProps { selectedIdx: number; expanded: boolean; focused: boolean; + width: number; } -function StepLine({ step }: { step: PlanStep }): ReactElement { - const glyph = statusGlyph(step.status); - const color = statusColor(step.status); +function stepLine(step: PlanStep, width: number): string { const duration = step.startedAt && step.completedAt ? fmtDuration(new Date(step.completedAt).getTime() - new Date(step.startedAt).getTime()) : step.startedAt ? fmtDuration(Date.now() - new Date(step.startedAt).getTime()) - : null; - return ( - - {glyph} - {step.status.padEnd(9, " ")} - {step.goal.slice(0, 48)} - {duration ? ({duration}) : null} - {step.error ? error: {step.error.slice(0, 32)} : null} - - ); + : "-"; + const prefix = `${statusGlyph(step.status)} ${pad(step.status, 9)} ${duration.padStart(8)} `; + const suffix = step.error ? ` | error ${step.error}` : ""; + return `${prefix}${truncate(step.goal, Math.max(12, width - prefix.length - suffix.length))}${suffix}`; } -function GraphLine({ g, selected }: { g: TaskGraph; selected: boolean }): ReactElement { +function graphLine(g: TaskGraph, selected: boolean, width: number): string { const total = g.steps.length; const done = g.steps.filter((s) => s.status === "completed").length; const running = g.steps.some((s) => s.status === "running"); const cursor = selected ? ">" : " "; - return ( - - {cursor} - {shortId(g.id)} - {g.status.padEnd(10, " ")} - "{g.rootGoal.slice(0, 42)}" - - {" "} - {done} / {total} steps - - {running ? ( - - {" "} - - - ) : null} - - ); + const prefix = `${cursor} ${shortId(g.id)} ${pad(g.status, 10)} `; + const suffix = ` ${done}/${total} steps${running ? " running" : ""}`; + return `${prefix}${truncate(g.rootGoal, Math.max(10, width - prefix.length - suffix.length))}${suffix}`; } export function TaskGraphsPane(props: TaskGraphsPaneProps): ReactElement { + const inner = panelInnerWidth(props.width); + const title = `active task graphs${props.focused ? " / focused" : ""}`; return ( - - - {"─── active task graphs "} - {props.focused ? "[focused]" : ""} - + {props.loading && props.graphs.length === 0 ? ( - - - loading… - - + + {"loading active graphs"} + ) : props.graphs.length === 0 ? ( - (no active graphs) + + {"(no active graphs)"} + ) : ( props.graphs.map((g, i) => ( - + + {graphLine(g, i === props.selectedIdx, inner)} + {props.expanded && i === props.selectedIdx - ? g.steps.map((s) => ) + ? g.steps.map((s) => ( + + {stepLine(s, inner)} + + )) : null} )) )} - + ); } -// ─── RunSummaryPane ───────────────────────────────────────────────────────── +// --- Run summary ------------------------------------------------------------ export interface RunSummaryPaneProps { summary: RunSummary; loading: boolean; + width: number; } export function RunSummaryPane(props: RunSummaryPaneProps): ReactElement { const r = props.summary.reasoner; const c = props.summary.consolidator; + const inner = panelInnerWidth(props.width); + const barWidth = Math.max(8, Math.min(18, Math.floor(inner / 5))); + const title = props.loading ? "run pulse 24h / syncing" : "run pulse 24h"; return ( - - {"─── recent runs (24h)"} - - reasoner: - {r.ticks} ticks · - {r.candidates} candidates · - {r.toolCalls} tool calls · - {fmtUsdFromTicks(r.costUsdTicks)} - - - consolidator: - {c.total} runs · - {c.completed} completed - · - {c.failed} failed - · - {c.inProgress} in-progress - · - {c.queued} queued - - + 0 ? "yellow" : "green"}> + + {`reasoner ${r.ticks} ticks | ${r.candidates} candidates | ${r.toolCalls} tool calls | ${fmtUsdFromTicks( + r.costUsdTicks, + )}`} + + 0 ? "yellow" : "green"}> + {`consolidator ${ratioBar(c.completed, Math.max(1, c.total), barWidth)} ${c.total} runs | ok ${c.completed} | fail ${c.failed} | wip ${c.inProgress} | queue ${c.queued}`} + + ); } -// ─── InvocationsPane ──────────────────────────────────────────────────────── +// --- Invocations ------------------------------------------------------------ export interface InvocationsPaneProps { rows: InvocationRow[]; @@ -244,6 +349,7 @@ export interface InvocationsPaneProps { focused: boolean; scrollOffset: number; maxRows?: number; + width: number; } export function InvocationsPane(props: InvocationsPaneProps): ReactElement { @@ -251,46 +357,99 @@ export function InvocationsPane(props: InvocationsPaneProps): ReactElement { const total = props.rows.length; const start = Math.min(Math.max(0, props.scrollOffset), Math.max(0, total - 1)); const visible = props.rows.slice(start, start + maxRows); + const inner = panelInnerWidth(props.width); + const title = `tool invocations${props.focused ? " / focused" : ""} (${visible.length}/${total})`; return ( - - - {"─── tool invocations "} - {props.focused ? "[focused] " : ""} - (showing {visible.length}/{total}) - + {total === 0 ? ( - (no invocations yet) + + {"(no invocations yet)"} + ) : ( - visible.map((r) => ( - - {fmtTime(r.at)} - {r.toolName.padEnd(16, " ")} - {fmtDuration(r.durationMs).padStart(8, " ")} - {r.error ? {r.error.slice(0, 40)} : null} - - )) + visible.map((r) => { + const prefix = `${fmtTime(r.at)} ${pad(truncate(r.toolName, 18), 18)} ${pad( + fmtDuration(r.durationMs), + 8, + )}`; + const error = r.error ? ` error ${r.error}` : ""; + return ( + + {fit(`${prefix}${truncate(error, Math.max(0, inner - prefix.length))}`, inner)} + + ); + }) )} - + ); } -// ─── Footer ───────────────────────────────────────────────────────────────── +// --- Help + footer ---------------------------------------------------------- + +export interface HelpEntry { + key: string; + description: string; +} + +export const HELP_ENTRIES: HelpEntry[] = [ + { key: "?", description: "toggle this help menu" }, + { key: "tab", description: "switch focus between graphs and tools" }, + { key: "up/down", description: "move graph selection or invocation scroll" }, + { key: "enter", description: "expand or collapse the selected graph" }, + { key: "r", description: "refresh every data panel once" }, + { key: "p", description: "pause or resume polling" }, + { key: "w", description: "return to the welcome screen" }, + { key: "q / ctrl-c", description: "quit Strand cockpit" }, + { key: "esc", description: "close help" }, +]; + +export interface HelpPanelProps { + width: number; + focusedPane: "graphs" | "invocations"; + paused: boolean; +} + +export function HelpPanel(props: HelpPanelProps): ReactElement { + return ( + + + {`state focus ${props.focusedPane} | polling ${props.paused ? "paused" : "live"}`} + + {HELP_ENTRIES.map((entry) => ( + + {`${pad(`[${entry.key}]`, 12)} ${entry.description}`} + + ))} + + ); +} export interface FooterProps { focusedPane: "graphs" | "invocations"; lastRefreshAt: number; + paused: boolean; + width: number; } export function Footer(props: FooterProps): ReactElement { + const inner = Math.max(20, props.width - 2); + const verb = props.paused ? "resume" : "pause"; + const focusHint = + props.focusedPane === "graphs" ? "[up/down] select [enter] expand" : "[up/down] scroll tools"; return ( - {"[↑↓] select · [enter] expand · [tab] switch pane ("} - {props.focusedPane} - {") · [r] refresh · [p] pause · [q] quit"} + {fit( + `[?] help [tab] focus ${props.focusedPane} [r] refresh [p] ${verb} [q] quit`, + inner, + )} + + + {fit( + `${focusHint} [w] welcome refreshed ${fmtTime(new Date(props.lastRefreshAt).toISOString())}`, + inner, + )} - last refresh: {fmtTime(new Date(props.lastRefreshAt).toISOString())} ); } diff --git a/src/cli/tui/dashboard.tsx b/src/cli/tui/dashboard.tsx index 43a255c..f4927f0 100644 --- a/src/cli/tui/dashboard.tsx +++ b/src/cli/tui/dashboard.tsx @@ -11,37 +11,51 @@ */ import { env } from "@/config"; -import { Box, Text, useApp, useInput, useStdin } from "ink"; +import { Box, Text, useApp, useInput, useStdin, useStdout } from "ink"; import type { ReactElement } from "react"; import { useCallback, useMemo, useState } from "react"; -import { Footer, Header, InvocationsPane, RunSummaryPane, TaskGraphsPane } from "./components"; -import { useRecentInvocations, useRunSummary, useTaskGraphs } from "./hooks"; +import { + Footer, + Header, + HelpPanel, + InvocationsPane, + OperatorPane, + RunSummaryPane, + TaskGraphsPane, +} from "./components"; +import { useOperatorSnapshot, useRecentInvocations, useRunSummary, useTaskGraphs } from "./hooks"; +import { splitWidths, terminalWidth } from "./layout"; export interface DashboardProps { pollMs?: number; onWelcome?: () => void; + width?: number; } -export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactElement { +export function Dashboard({ pollMs = 2000, onWelcome, width }: DashboardProps): ReactElement { const app = useApp(); const { isRawModeSupported } = useStdin(); + const { stdout } = useStdout(); const [selectedIdx, setSelectedIdx] = useState(0); const [expanded, setExpanded] = useState(true); const [focusedPane, setFocusedPane] = useState<"graphs" | "invocations">("graphs"); const [scrollOffset, setScrollOffset] = useState(0); const [lastRefreshAt, setLastRefreshAt] = useState(Date.now()); const [paused, setPaused] = useState(false); + const [showHelp, setShowHelp] = useState(false); const graphs = useTaskGraphs(paused ? 10 * 60_000 : pollMs); + const operator = useOperatorSnapshot(paused ? 10 * 60_000 : Math.max(pollMs, 3000)); const summary = useRunSummary(paused ? 10 * 60_000 : Math.max(pollMs * 2, 5000)); const invocations = useRecentInvocations(50, paused ? 10 * 60_000 : Math.max(pollMs / 2, 1000)); const refreshAll = useCallback((): void => { graphs.refresh(); + operator.refresh(); summary.refresh(); invocations.refresh(); setLastRefreshAt(Date.now()); - }, [graphs, summary, invocations]); + }, [graphs, operator, summary, invocations]); useInput( (input, key) => { @@ -49,6 +63,14 @@ export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactEl app.exit(); return; } + if (input === "?") { + setShowHelp((v) => !v); + return; + } + if (key.escape) { + if (showHelp) setShowHelp(false); + return; + } if (input === "w" && onWelcome) { onWelcome(); return; @@ -57,14 +79,17 @@ export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactEl refreshAll(); return; } - if (key.tab) { - setFocusedPane((p) => (p === "graphs" ? "invocations" : "graphs")); - return; - } if (input === "p") { setPaused((p) => !p); return; } + if (showHelp) { + return; + } + if (key.tab) { + setFocusedPane((p) => (p === "graphs" ? "invocations" : "graphs")); + return; + } if (focusedPane === "graphs") { if (key.upArrow) { setSelectedIdx((i) => Math.max(0, i - 1)); @@ -97,15 +122,20 @@ export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactEl provider: env.LLM_PROVIDER, model: env.LLM_MODEL_REASONER, mode: env.STRAND_MODE, + halt: env.STRAND_HALT, + tier: env.TIER, credentialStore: process.env["STRAND_CREDENTIAL_STORE"] ?? "env", tenant: process.env["STRAND_TENANT"] ?? null, }), [], ); + const viewportWidth = terminalWidth(width ?? stdout.columns); + const layout = splitWidths(viewportWidth); + return ( -
+
{!isRawModeSupported ? ( @@ -118,21 +148,60 @@ export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactEl {"[paused] — press p to resume, r to refresh once"} ) : null} - - - + ) : ( + <> + + {layout.stacked ? ( + <> + + + + ) : ( + + + + + + )} + + + )} +