@taicho-ai/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ import { useState, type MutableRefObject } from "react";
2
+ import { Box, Text, type Key } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import type { ApprovalDecision } from "@taicho-ai/framework/core/run";
5
+
6
+ /** The one interaction grammar: propose -> approve. Used for agent creation,
7
+ * coaching notes, exemplar promotion. */
8
+ export interface CardField { label: string; value: string; }
9
+
10
+ /** A card's keyboard handler. Cards publish this to App's single, boot-registered useInput (via a
11
+ * ref) instead of owning a useInput of their own — see ProposalCard/QuestionCard for why. */
12
+ export type CardKeyHandler = (input: string, key: Key) => void;
13
+
14
+ export function ProposalCard(props: {
15
+ title: string;
16
+ fields: CardField[];
17
+ supersedes?: string; // "this replaces: ..." line
18
+ keyHandlerRef: MutableRefObject<CardKeyHandler | null>;
19
+ onDecision: (d: ApprovalDecision) => void;
20
+ }) {
21
+ const [editing, setEditing] = useState(false);
22
+ const [editValues, setEditValues] = useState<Record<string, string>>(() =>
23
+ Object.fromEntries(props.fields.map((f) => [f.label, f.value]))
24
+ );
25
+ const [fieldIndex, setFieldIndex] = useState(0);
26
+
27
+ // Published during render so App's boot-registered useInput forwards the captain's first keystroke
28
+ // (a card-owned useInput registers a beat late and would drop it — see QuestionCard). In edit mode
29
+ // we only handle Esc here; field text is typed by the TextInput, whose input handling fires on its own.
30
+ props.keyHandlerRef.current = (input, key) => {
31
+ if (editing) {
32
+ // Esc from edit mode returns to y/n/e prompt
33
+ if (key.escape) { setEditing(false); setFieldIndex(0); }
34
+ return;
35
+ }
36
+ if (input === "y") props.onDecision({ type: "approve" });
37
+ else if (input === "n") props.onDecision({ type: "reject" });
38
+ else if (input === "e") { setEditing(true); setFieldIndex(0); }
39
+ };
40
+
41
+ if (editing) {
42
+ const currentField = props.fields[fieldIndex];
43
+ const isLast = fieldIndex === props.fields.length - 1;
44
+
45
+ const handleSubmit = (value: string) => {
46
+ const updated = { ...editValues, [currentField.label]: value };
47
+ setEditValues(updated);
48
+ if (isLast) {
49
+ // Collect only changed fields (non-empty overrides)
50
+ const changed: Record<string, string> = {};
51
+ for (const f of props.fields) {
52
+ if (updated[f.label] !== f.value && updated[f.label] !== "") {
53
+ changed[f.label] = updated[f.label];
54
+ }
55
+ }
56
+ props.onDecision({ type: "edit", draft: changed });
57
+ } else {
58
+ setFieldIndex(fieldIndex + 1);
59
+ }
60
+ };
61
+
62
+ return (
63
+ <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
64
+ <Text color="yellow" bold>Editing — Tab/Enter to advance, Esc to cancel</Text>
65
+ {props.fields.map((f, i) => (
66
+ <Box key={f.label}>
67
+ <Text color="gray">{f.label.padEnd(7)}</Text>
68
+ {i === fieldIndex ? (
69
+ <TextInput
70
+ value={editValues[f.label]}
71
+ onChange={(v) => setEditValues({ ...editValues, [f.label]: v })}
72
+ onSubmit={handleSubmit}
73
+ />
74
+ ) : (
75
+ <Text>{editValues[f.label]}</Text>
76
+ )}
77
+ </Box>
78
+ ))}
79
+ <Text color="gray">field {fieldIndex + 1}/{props.fields.length} — Enter to advance, Enter on last to submit</Text>
80
+ </Box>
81
+ );
82
+ }
83
+
84
+ return (
85
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
86
+ <Text color="cyan" bold>{props.title}</Text>
87
+ {props.fields.map((f) => (
88
+ <Text key={f.label}>
89
+ <Text color="gray">{f.label.padEnd(7)}</Text>{f.value}
90
+ </Text>
91
+ ))}
92
+ {props.supersedes && <Text color="yellow">replaces: {props.supersedes}</Text>}
93
+ <Text color="gray">approve? [y]es [n]o [e]dit</Text>
94
+ </Box>
95
+ );
96
+ }
@@ -0,0 +1,61 @@
1
+ import { useState, type MutableRefObject } from "react";
2
+ import { Box, Text } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import type { ApprovalDecision } from "@taicho-ai/framework/core/run";
5
+ import type { CardKeyHandler } from "./ProposalCard";
6
+ import { cycleIndex } from "./slash";
7
+
8
+ /** An agent's question to the captain: numbered options (pick with 1-N or ↑↓+Enter) plus a free-text
9
+ * escape hatch ("type your own"). Mirrors ProposalCard's keyHandlerRef + typing-mode-with-TextInput. */
10
+ export function QuestionCard(props: {
11
+ question: string;
12
+ options: string[];
13
+ keyHandlerRef: MutableRefObject<CardKeyHandler | null>;
14
+ onDecision: (d: ApprovalDecision) => void;
15
+ }) {
16
+ const [selected, setSelected] = useState(0);
17
+ const [typing, setTyping] = useState(false);
18
+ const [draft, setDraft] = useState("");
19
+ const total = props.options.length + 1; // options + the "type your own" row
20
+ const answer = (a: string) => props.onDecision({ type: "answered", answer: a });
21
+
22
+ // Published synchronously during render (NOT via a card-owned useInput) so App's boot-registered
23
+ // useInput can forward the captain's very FIRST keystroke. A card-owned listener registers via a
24
+ // useEffect that runs a beat after the render commits, so the first key would arrive before the
25
+ // card is listening and be silently dropped — the hang this fixes. In typing mode we only handle
26
+ // Esc; the chars are typed by the TextInput, whose own input handling fires independently.
27
+ props.keyHandlerRef.current = (input, key) => {
28
+ if (typing) { if (key.escape) setTyping(false); return; } // typing handled by the TextInput
29
+ if (key.escape) { props.onDecision({ type: "reject" }); return; }
30
+ if (key.upArrow) { setSelected((s) => cycleIndex(s, total, -1)); return; }
31
+ if (key.downArrow) { setSelected((s) => cycleIndex(s, total, +1)); return; }
32
+ if (key.return) { selected < props.options.length ? answer(props.options[selected]) : setTyping(true); return; }
33
+ const n = Number(input); // 1-N fast path
34
+ if (Number.isInteger(n) && n >= 1 && n <= props.options.length) answer(props.options[n - 1]);
35
+ };
36
+
37
+ if (typing)
38
+ return (
39
+ <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
40
+ <Text color="yellow" bold>{props.question}</Text>
41
+ <Box>
42
+ <Text color="gray">your answer: </Text>
43
+ <TextInput value={draft} onChange={setDraft} onSubmit={(v) => (v.trim() ? answer(v.trim()) : setTyping(false))} />
44
+ </Box>
45
+ <Text color="gray">Enter to submit · Esc to go back</Text>
46
+ </Box>
47
+ );
48
+
49
+ return (
50
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
51
+ <Text color="cyan" bold>{props.question}</Text>
52
+ {props.options.map((o, i) => (
53
+ <Text key={i} color={i === selected ? "cyan" : undefined}>{`${i === selected ? "›" : " "} ${i + 1}. ${o}`}</Text>
54
+ ))}
55
+ <Text color={selected === props.options.length ? "cyan" : "gray"}>
56
+ {`${selected === props.options.length ? "›" : " "} ✎ type your own answer`}
57
+ </Text>
58
+ <Text color="gray">{`1-${props.options.length} or ↑↓ + Enter · esc to cancel`}</Text>
59
+ </Box>
60
+ );
61
+ }
@@ -0,0 +1,155 @@
1
+ /** Plan 10 Phase 4 — the split-pane live view. One pane per live agent: a status line (glyph ·
2
+ * agent · state · current tool+argsPreview · elapsed) plus its live stream (recent tool lines with
3
+ * argsPreview and the streamed/final text). Renders from the SAME AgentStatus model the bar does
4
+ * (agent-status.ts) plus a lightweight per-run activity feed the App builds off the one event
5
+ * stream — nothing is invented or re-derived here.
6
+ *
7
+ * Lifecycle: a pane appears when an agent goes live and lingers in a dim `done` settle state for a
8
+ * beat after its run leaves the status map (so the captain sees the result land) before collapsing.
9
+ * Visible panes are capped by terminal height with a "+N more" overflow — the bar stays the
10
+ * complete summary. Below a minimum terminal size the panes degrade to bar-only (see resolveLayout).
11
+ * Display-only: the REPL always owns the keyboard (Plan 10 scope). */
12
+ import { useState, useEffect, useRef, type ReactNode } from "react";
13
+ import { Box, Text } from "ink";
14
+ import type { AgentStatus, AgentState } from "@taicho-ai/framework/core/agent-status";
15
+ import type { ViewMode } from "@taicho-ai/framework/store/prefs";
16
+
17
+ /** Per-run activity feed the App accumulates from the event stream and hands to the panes: recent
18
+ * tool lines (each already a redacted `→ tool argsPreview`). The streamed/final REPLY text is NOT
19
+ * duplicated here — it renders in the main scrollback (the reply channel); echoing it in the pane
20
+ * raced that channel (the pane showed the reply before it committed / the trace persisted). The
21
+ * pane's live `writing`/`working` state + tool lines carry the transparency; the bar + scrollback
22
+ * carry the text. */
23
+ export interface PaneEntry { lines: string[] }
24
+ export type PaneFeedMap = ReadonlyMap<string, PaneEntry>;
25
+
26
+ const GLYPH: Record<AgentState, string> = {
27
+ idle: "·", thinking: "…", writing: "✎", working: "●", delegating: "⇢", waiting: "✋",
28
+ };
29
+
30
+ /** Below either bound the panes collapse to bar-only (the bar always fits one line). */
31
+ export const MIN_PANE_COLS = 50;
32
+ export const MIN_PANE_ROWS = 10;
33
+
34
+ const SETTLE_MS = 1200; // how long a completed pane lingers in its `done` state before collapsing
35
+ const PANE_BODY = 3; // max body (activity) lines shown per pane
36
+ const PANE_HEIGHT = 5; // header + body + spacer budget, for the terminal-height pane cap
37
+ const RESERVED_ROWS = 6; // rows kept for the banner/scrollback/bar/input when budgeting pane space
38
+ const RULE = "▎"; // the left-accent that reads as a pane's column edge (cheap vs an Ink border)
39
+
40
+ /** Pure layout decision shared by the App and its tests: which surfaces show for a mode + terminal
41
+ * size. Panes hide in `bar` mode and whenever the terminal is too small (degrade
42
+ * to bar-only); the bar stays only in `bar`/`both` (and everywhere when too small to render panes).
43
+ * Plan 13's consistent-block view is the DEFAULT render — it replaces the panes as the primary squad view. */
44
+ export function resolveLayout(viewMode: ViewMode, columns: number, rows: number): { showPanes: boolean; showBar: boolean } {
45
+ const tooSmall = columns < MIN_PANE_COLS || rows < MIN_PANE_ROWS;
46
+ return {
47
+ showPanes: viewMode !== "bar" && !tooSmall,
48
+ // Bar is the complete summary; it owns the surface in bar/both, and is the fallback whenever the
49
+ // panes can't render because the terminal is too small.
50
+ showBar: viewMode === "bar" || viewMode === "both" || tooSmall,
51
+ };
52
+ }
53
+
54
+ /** Collapse to a single line: strip the inline markdown markers the REPL is careful to hide (so a
55
+ * mid-stream tail can never surface raw `**bold**` / `# heading` / `` `code` ``) and cap. Kept
56
+ * narrow (backtick/asterisk/hash) so tool names + args like `delegate_task` survive intact. */
57
+ export function paneOneLine(s: string, cap = 72): string {
58
+ const flat = s.replace(/[`*#]/g, "").replace(/\s+/g, " ").trim();
59
+ return flat.length > cap ? flat.slice(0, cap - 1) + "…" : flat;
60
+ }
61
+
62
+ interface LivePane {
63
+ runId: string; agent: string; state: AgentState; tool?: string; argsPreview?: string;
64
+ since: number; waiting: boolean; lines: string[]; done: boolean;
65
+ }
66
+
67
+ function paneColor(p: LivePane): string {
68
+ if (p.done) return "gray";
69
+ if (p.waiting) return "red";
70
+ if (p.state === "delegating") return "magenta";
71
+ if (p.state === "writing") return "green";
72
+ return "cyan";
73
+ }
74
+
75
+ function headerText(p: LivePane, now: number, width: number): string {
76
+ const secs = Math.max(0, Math.round((now - p.since) / 1000));
77
+ const label = p.done
78
+ ? "done"
79
+ : p.waiting
80
+ ? `waiting: ${p.tool ?? ""}`.trim()
81
+ : `${p.state}${p.tool ? ` ${p.tool}${p.argsPreview ? ` ${p.argsPreview}` : ""}` : ""}`;
82
+ return paneOneLine(`${GLYPH[p.state]} ${p.agent} ${label} · ${secs}s`, width);
83
+ }
84
+
85
+ /** One pane's rows (header + indented body), as a flat list of plain colored Text. Deliberately
86
+ * border- and Box-free: an Ink bordered/nested Box re-lays-out its yoga tree on every event, which
87
+ * is measurably slower and can perturb timing-sensitive flows. A left-accent rule (the color-coded
88
+ * column edge) reads as the pane's edge for cheap. */
89
+ function paneRows(pane: LivePane, width: number, now: number, prefix: string): ReactNode[] {
90
+ const color = paneColor(pane);
91
+ const body = pane.lines.map((l) => paneOneLine(l, width - 4)).slice(-PANE_BODY);
92
+ return [
93
+ <Text key={`${prefix}-h`} color={color} bold={pane.waiting}>{`${RULE} ${headerText(pane, now, width - 2)}`}</Text>,
94
+ ...body.map((l, i) => <Text key={`${prefix}-b${i}`} color={color} dimColor>{`${RULE} ${l}`}</Text>),
95
+ ];
96
+ }
97
+
98
+ export function SquadPanes({ statuses, feed, columns, rows }: {
99
+ statuses: AgentStatus[]; feed: PaneFeedMap; columns: number; rows: number;
100
+ }) {
101
+ const [, setTick] = useState(0);
102
+ // settleRef: runs that just left the status map, kept briefly in a `done` snapshot. prevRef: the
103
+ // panes rendered last frame, so a disappearance is detected against a self-contained snapshot
104
+ // (the App may have already cleared the feed by the time a pane collapses).
105
+ const settleRef = useRef<Map<string, { pane: LivePane; at: number }>>(new Map());
106
+ const prevRef = useRef<Map<string, LivePane>>(new Map());
107
+
108
+ const live: LivePane[] = statuses.map((s) => ({
109
+ runId: s.runId, agent: s.agent, state: s.state, tool: s.tool, argsPreview: s.argsPreview,
110
+ since: s.since, waiting: s.waiting,
111
+ lines: feed.get(s.runId)?.lines ?? [], done: false,
112
+ }));
113
+ const liveIds = new Set(live.map((p) => p.runId));
114
+ const liveById = new Map(live.map((p) => [p.runId, p]));
115
+
116
+ const now = Date.now();
117
+ const settle = settleRef.current;
118
+ for (const id of liveIds) settle.delete(id); // a reappeared run cancels its settle
119
+ for (const [id, pane] of prevRef.current) // newly disappeared → snapshot in `done`
120
+ if (!liveIds.has(id) && !settle.has(id)) settle.set(id, { pane: { ...pane, done: true }, at: now });
121
+ for (const [id, ent] of settle) if (now - ent.at > SETTLE_MS) settle.delete(id); // expire
122
+ prevRef.current = liveById;
123
+ const settling = [...settle.values()].map((e) => e.pane);
124
+
125
+ const all = [...live, ...settling];
126
+ // A light ticker advances the elapsed readouts + expires settle panes — but ONLY while panes are
127
+ // up. Left always-on it re-renders the whole App every tick even at rest, which measurably slows
128
+ // the REPL (and perturbed a timing-sensitive steering test); gated, an idle REPL pays nothing.
129
+ const hasPanes = all.length > 0;
130
+ useEffect(() => {
131
+ if (!hasPanes) return;
132
+ const t = setInterval(() => setTick((n) => n + 1), 500);
133
+ return () => clearInterval(t);
134
+ }, [hasPanes]);
135
+
136
+ if (!all.length) return null;
137
+ if (columns < MIN_PANE_COLS || rows < MIN_PANE_ROWS) return null; // degrade to bar-only
138
+
139
+ const avail = Math.max(PANE_HEIGHT, rows - RESERVED_ROWS);
140
+ const maxPanes = Math.max(1, Math.floor(avail / PANE_HEIGHT));
141
+ const shown = all.slice(0, maxPanes);
142
+ const hidden = all.length - shown.length;
143
+ const paneWidth = Math.min(columns - 4, 96);
144
+
145
+ // One flat column of Text lines — a spacer row between panes gives the split-pane separation
146
+ // without any nested Box (keeps the yoga tree, and so per-event render cost, minimal).
147
+ const out: ReactNode[] = [];
148
+ shown.forEach((p, i) => {
149
+ if (i > 0) out.push(<Text key={`sp-${p.runId}`}> </Text>);
150
+ out.push(...paneRows(p, paneWidth, now, p.runId));
151
+ });
152
+ if (hidden > 0) out.push(<Text key="more" dimColor>{` +${hidden} more (the bar lists them all)`}</Text>);
153
+
154
+ return <Box flexDirection="column">{out}</Box>;
155
+ }
@@ -0,0 +1,72 @@
1
+ /** Plan 10 Phase 3 — the live status bar. One compact segment per active run (glyph · agent · state
2
+ * · tool+argsPreview · elapsed), pinned at the bottom directly above the input. `waiting` is rendered
3
+ * loud (the "the squad is stalled on YOU" signal). Hidden when nothing runs; "+N more" past width.
4
+ * Renders purely from the AgentStatus model (agent-status.ts) — nothing invented here. */
5
+ import { useState, useEffect } from "react";
6
+ import { Box, Text } from "ink";
7
+ import type { AgentStatus } from "@taicho-ai/framework/core/agent-status";
8
+
9
+ const GLYPH: Record<AgentStatus["state"], string> = {
10
+ idle: "·", thinking: "…", writing: "✎", working: "●", delegating: "⇢", waiting: "✋",
11
+ };
12
+
13
+ /** One segment's plain text (used for both rendering and width budgeting). */
14
+ export function segmentText(s: AgentStatus, now: number, cap = 64): string {
15
+ const secs = Math.max(0, Math.round((now - s.since) / 1000));
16
+ const tool = s.tool ? ` ${s.tool}${s.argsPreview ? `(${s.argsPreview})` : ""}` : "";
17
+ const label = s.waiting ? `waiting: ${s.tool ?? ""}`.trim() : `${s.state}${tool}`;
18
+ const text = `${GLYPH[s.state]} ${s.agent} ${label} ${secs}s`;
19
+ return text.length > cap ? text.slice(0, cap - 1) + "…" : text;
20
+ }
21
+
22
+ export function StatusBar({ statuses, width }: { statuses: AgentStatus[]; width: number }) {
23
+ // A light ticker advances the elapsed readouts without any engine event.
24
+ const [, setTick] = useState(0);
25
+ useEffect(() => {
26
+ const t = setInterval(() => setTick((n) => n + 1), 500);
27
+ return () => clearInterval(t);
28
+ }, []);
29
+ if (!statuses.length) return null;
30
+
31
+ const now = Date.now();
32
+ // Worst-case " +N more" tail width. Reserve it for the FIRST segment too (else a narrow terminal
33
+ // <~72 cols or ≥10 hidden agents pushes the tail past the single line and soft-wraps). Cap each
34
+ // segment to leave tail room so even one segment + tail fits.
35
+ const tailLen = ` +${statuses.length} more`.length;
36
+ const segCap = Math.min(64, Math.max(8, width - tailLen));
37
+ const segs = statuses.map((s) => ({ s, text: segmentText(s, now, segCap) }));
38
+ const SEP = 3; // " · "
39
+ // Fast path: if everything fits on the line with NO tail, show all (no wasted tail reservation).
40
+ const totalAll = segs.reduce((sum, seg, i) => sum + seg.text.length + (i ? SEP : 0), 0);
41
+ let shown: typeof segs;
42
+ if (totalAll <= width) {
43
+ shown = segs;
44
+ } else {
45
+ shown = [];
46
+ let used = 0;
47
+ for (const seg of segs) {
48
+ const add = seg.text.length + (shown.length ? SEP : 0);
49
+ if (shown.length && used + add > width - tailLen) break; // reserve room for the "+N more" tail
50
+ shown.push(seg);
51
+ used += add;
52
+ }
53
+ }
54
+ const hidden = segs.length - shown.length;
55
+
56
+ return (
57
+ <Box>
58
+ {shown.map((seg, i) => (
59
+ <Text key={seg.s.runId}>
60
+ {i > 0 ? <Text dimColor> · </Text> : null}
61
+ <Text
62
+ color={seg.s.waiting ? "red" : seg.s.state === "delegating" ? "magenta" : seg.s.state === "writing" ? "green" : "cyan"}
63
+ bold={seg.s.waiting}
64
+ >
65
+ {seg.text}
66
+ </Text>
67
+ </Text>
68
+ ))}
69
+ {hidden > 0 && <Text dimColor>{` +${hidden} more`}</Text>}
70
+ </Box>
71
+ );
72
+ }
@@ -0,0 +1,141 @@
1
+ /** Plan 25 — the /workflows browser. A docked, read-only inspection mode over the chat, mirroring the Org
2
+ * browser's grammar (round cyan border, ▸ selection, dim ·-separated footer). Three screens: the LIST of
3
+ * teams and their workflow status, a team's STEPS (the graph), and a workflow's past RUNS. Running and
4
+ * proposing stay conversational (root's run_workflow / propose_workflow tools); this surface is for
5
+ * seeing what a team's process is and how its runs went. Display-only — the REPL owns the keyboard; this
6
+ * component just publishes a key handler on props.keyRef, exactly like OrgBrowser. */
7
+ import { Box, Text } from "ink";
8
+ import { useEffect, type MutableRefObject } from "react";
9
+ import type { CardKeyHandler } from "./ProposalCard";
10
+ import { listWorkflowRows, workflowRunRows, type WorkflowRow } from "./workflow-browser-model";
11
+ import { loadWorkflowDef } from "@taicho-ai/framework/store/workflows";
12
+ import type { WorkflowNode } from "@taicho-ai/graph";
13
+
14
+ export interface WorkflowUiState {
15
+ sel: number; // selected team index in the list
16
+ view?: string; // team id whose steps are shown (drill-in)
17
+ runsFor?: string; // team id whose past runs are shown
18
+ }
19
+
20
+ export function initialWorkflowUi(): WorkflowUiState {
21
+ return { sel: 0 };
22
+ }
23
+
24
+ export interface WorkflowBrowserProps {
25
+ ws: string;
26
+ width: number;
27
+ st: WorkflowUiState;
28
+ onChange: (next: WorkflowUiState | ((s: WorkflowUiState) => WorkflowUiState)) => void;
29
+ keyRef: MutableRefObject<CardKeyHandler | null>;
30
+ onClose: () => void;
31
+ bump?: number; // bump to force a re-read after a mutation elsewhere
32
+ }
33
+
34
+ const clamp = (i: number, len: number): number => (len === 0 ? 0 : Math.max(0, Math.min(i, len - 1)));
35
+
36
+ const runGlyph = (status: string): { g: string; color: string } =>
37
+ status === "done" ? { g: "✓", color: "green" }
38
+ : status === "failed" ? { g: "✗", color: "red" }
39
+ : status === "interrupted" ? { g: "?", color: "yellow" }
40
+ : status === "parked" ? { g: "✋", color: "red" }
41
+ : { g: "◐", color: "yellow" };
42
+
43
+ /** A compact one-line label for a step in the graph view. */
44
+ function stepLabel(s: WorkflowNode): { glyph: string; color: string; who: string; io: string } {
45
+ switch (s.kind) {
46
+ case "agent": return { glyph: "▸", color: "cyan", who: s.run, io: [s.consumes.length ? s.consumes.join(",") : "", s.produces ? `→ ${s.produces}` : ""].filter(Boolean).join(" ") };
47
+ case "check": return { glyph: "✓", color: "green", who: `check`, io: s.check };
48
+ case "human": return { glyph: "✋", color: "red", who: `you`, io: s.choices.join(" │ ") };
49
+ case "branch": return { glyph: "⑂", color: "yellow", who: s.branch, io: Object.keys(s.routes).join(" / ") };
50
+ case "parallel": return { glyph: "⇉", color: "magenta", who: "parallel", io: s.produces ? `→ ${s.produces}` : "" };
51
+ }
52
+ }
53
+
54
+ export function WorkflowBrowser(props: WorkflowBrowserProps): React.ReactElement {
55
+ const rows = listWorkflowRows(props.ws);
56
+ const st = props.st;
57
+
58
+ const handler: CardKeyHandler = (input, key) => {
59
+ if (st.view || st.runsFor) {
60
+ if (key.escape) props.onChange((s) => ({ ...s, view: undefined, runsFor: undefined }));
61
+ return;
62
+ }
63
+ if (key.escape) { props.onClose(); return; }
64
+ if (key.upArrow) { props.onChange((s) => ({ ...s, sel: clamp(s.sel - 1, rows.length) })); return; }
65
+ if (key.downArrow) { props.onChange((s) => ({ ...s, sel: clamp(s.sel + 1, rows.length) })); return; }
66
+ const row = rows[clamp(st.sel, rows.length)];
67
+ if (key.return) { if (row?.kind === "structured") props.onChange((s) => ({ ...s, view: row.team })); return; }
68
+ if (input === "h") { if (row?.kind === "structured") props.onChange((s) => ({ ...s, runsFor: row.team })); return; }
69
+ };
70
+ props.keyRef.current = handler;
71
+ useEffect(() => () => { props.keyRef.current = null; }, []); // publish during render; clear on unmount
72
+
73
+ // ── STEPS view ──────────────────────────────────────────────────────────────────────────────────
74
+ if (st.view) {
75
+ const def = loadWorkflowDef(props.ws, st.view);
76
+ return (
77
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
78
+ <Text color="cyan" bold>WORKFLOW · {st.view} <Text dimColor>— {def ? `${def.id} · ${def.steps.length} steps` : "no structured workflow"}</Text></Text>
79
+ <Box height={1} />
80
+ {def?.steps.map((s, i) => {
81
+ const l = stepLabel(s);
82
+ return (
83
+ <Text key={s.id}>
84
+ <Text color={l.color}>{l.glyph}</Text> {String(i + 1).padStart(2)} <Text color="cyan">{s.id}</Text> <Text dimColor>{l.who}{l.io ? ` ${l.io}` : ""}</Text>
85
+ </Text>
86
+ );
87
+ })}
88
+ <Box height={1} />
89
+ <Text dimColor>← esc back{def ? " · h past runs" : ""}</Text>
90
+ </Box>
91
+ );
92
+ }
93
+
94
+ // ── RUNS view ───────────────────────────────────────────────────────────────────────────────────
95
+ if (st.runsFor) {
96
+ const def = loadWorkflowDef(props.ws, st.runsFor);
97
+ const runs = def ? workflowRunRows(props.ws, def) : [];
98
+ return (
99
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
100
+ <Text color="cyan" bold>WORKFLOWS · {st.runsFor} runs <Text dimColor>— {runs.length} run{runs.length === 1 ? "" : "s"}</Text></Text>
101
+ <Box height={1} />
102
+ {runs.length === 0 && <Text dimColor>never run</Text>}
103
+ {runs.map((r) => {
104
+ const g = runGlyph(r.status);
105
+ return <Text key={r.runId}><Text color={g.color}>{g.g}</Text> <Text color="cyan">{r.runId}</Text> <Text dimColor>{r.status} · {r.done}/{r.total}</Text></Text>;
106
+ })}
107
+ <Box height={1} />
108
+ <Text dimColor>← esc back</Text>
109
+ </Box>
110
+ );
111
+ }
112
+
113
+ // ── LIST screen ─────────────────────────────────────────────────────────────────────────────────
114
+ const structured = rows.filter((r) => r.kind === "structured").length;
115
+ const sel = clamp(st.sel, rows.length);
116
+ return (
117
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
118
+ <Text><Text color="cyan" bold>WORKFLOWS </Text><Text dimColor> {rows.length} team{rows.length === 1 ? "" : "s"} · {structured} structured</Text></Text>
119
+ <Box height={1} />
120
+ {rows.length === 0 && <Text dimColor>no teams yet — create one, then propose a workflow for it</Text>}
121
+ {rows.map((r, i) => <WorkflowListRow key={r.team} row={r} on={i === sel} />)}
122
+ <Box height={1} />
123
+ <Text dimColor>↑↓ · ⏎ steps · h past runs · esc · (run/propose via root)</Text>
124
+ </Box>
125
+ );
126
+ }
127
+
128
+ function WorkflowListRow({ row, on }: { row: WorkflowRow; on: boolean }): React.ReactElement {
129
+ const summary =
130
+ row.kind === "structured" ? `${row.steps} steps${row.gates ? ` · ${row.gates} gate${row.gates === 1 ? "" : "s"}` : ""}`
131
+ : row.kind === "prose" ? "prose (runs on the agentic brief)"
132
+ : "— no workflow";
133
+ const last = row.kind === "structured" && row.runs > 0 ? runGlyph(row.lastStatus ?? "") : null;
134
+ return (
135
+ <Text>
136
+ <Text color={on ? "cyan" : undefined} bold={on}>{on ? "▸ " : " "}{row.team.padEnd(14)}</Text>
137
+ <Text dimColor>{summary}</Text>
138
+ {last && <Text> <Text color={last.color}>{last.g}</Text><Text dimColor> {row.runs} run{row.runs === 1 ? "" : "s"}</Text></Text>}
139
+ </Text>
140
+ );
141
+ }
@@ -0,0 +1,10 @@
1
+ /** The taicho launch banner — ANSI Shadow figlet, pre-rendered so there is no figlet runtime
2
+ * dependency (bundles cleanly into the single binary). Shown once at the top on launch. */
3
+ export const BANNER = `
4
+ ████████╗ █████╗ ██╗ ██████╗██╗ ██╗ ██████╗
5
+ ╚══██╔══╝██╔══██╗██║██╔════╝██║ ██║██╔═══██╗
6
+ ██║ ███████║██║██║ ███████║██║ ██║
7
+ ██║ ██╔══██║██║██║ ██╔══██║██║ ██║
8
+ ██║ ██║ ██║██║╚██████╗██║ ██║╚██████╔╝
9
+ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝
10
+ `;