@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.
- package/README.md +4 -0
- package/package.json +46 -0
- package/src/index.tsx +334 -0
- package/src/ui/AgentBlock.tsx +183 -0
- package/src/ui/App.tsx +1404 -0
- package/src/ui/ArtifactBrowser.tsx +451 -0
- package/src/ui/ChatInput.tsx +146 -0
- package/src/ui/OperationView.tsx +185 -0
- package/src/ui/OrgBrowser.tsx +489 -0
- package/src/ui/PlanPanel.tsx +83 -0
- package/src/ui/ProposalCard.tsx +96 -0
- package/src/ui/QuestionCard.tsx +61 -0
- package/src/ui/SquadPanes.tsx +155 -0
- package/src/ui/StatusBar.tsx +72 -0
- package/src/ui/WorkflowBrowser.tsx +141 -0
- package/src/ui/banner.ts +10 -0
- package/src/ui/browser-model.ts +170 -0
- package/src/ui/editor-handoff.ts +51 -0
- package/src/ui/input-history.ts +55 -0
- package/src/ui/input-keys.ts +51 -0
- package/src/ui/input.ts +18 -0
- package/src/ui/markdown-stream.ts +31 -0
- package/src/ui/markdown.ts +45 -0
- package/src/ui/org-browser-model.ts +43 -0
- package/src/ui/slash.ts +270 -0
- package/src/ui/text-buffer.ts +92 -0
- package/src/ui/transcript-hydrate.ts +51 -0
- package/src/ui/transcript-style.ts +124 -0
- package/src/ui/workflow-browser-model.ts +65 -0
|
@@ -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
|
+
}
|
package/src/ui/banner.ts
ADDED
|
@@ -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
|
+
`;
|