@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,489 @@
|
|
|
1
|
+
/** Plan 22 — the Org browser. One docked mode, two scopes (teams / agents), built on the exact shape the
|
|
2
|
+
* artifact browser proved: ALL UI state lives in App's `orgState` (passed as `st` + `onChange`) so a
|
|
3
|
+
* pending approval card can unmount and remount it losslessly; keys arrive via a browser-OWNED `keyRef`
|
|
4
|
+
* (App dispatches pending card → operation view → artifact browser → ORG → chat, a fixed order).
|
|
5
|
+
*
|
|
6
|
+
* Surfaces:
|
|
7
|
+
* - SHELF: the list + a live preview, scope tabs, single-key verbs.
|
|
8
|
+
* - DETAIL (⏎): the whole team/agent on one screen; `o` opens its file in $EDITOR.
|
|
9
|
+
* - WIZARD (`a`): Team Add (id → charter → members → lead) or Hire Agent (id → role → identity → teams).
|
|
10
|
+
* - PICKER (`m`/`t`): a checkbox multi-select to (re)staff a team or (re)team an agent.
|
|
11
|
+
* - CONFIRM (`d`): a guarded delete.
|
|
12
|
+
* Mutations are App-provided async actions (they own the store wiring + roster refresh); the component
|
|
13
|
+
* drives them and shows the result on a transient `note` line. */
|
|
14
|
+
import { useEffect, type ReactNode } from "react";
|
|
15
|
+
import { Box, Text, useApp } from "ink";
|
|
16
|
+
import { editFileInTerminal } from "./editor-handoff";
|
|
17
|
+
import type { CardKeyHandler } from "./ProposalCard";
|
|
18
|
+
import { paths } from "@taicho-ai/framework/store/files";
|
|
19
|
+
import { hasWorkflow, loadWorkflow, orchestrationSlice, laneFor, seatsOf } from "@taicho-ai/framework/store/workflows";
|
|
20
|
+
import { teamRows, agentRows, isProtectedAgent, isProtectedTeam, clampSel, type OrgScope } from "./org-browser-model";
|
|
21
|
+
|
|
22
|
+
type WizardState =
|
|
23
|
+
| { kind: "team"; step: 1 | 2 | 3; field: "id" | "charter"; id: string; charter: string; selected: string[]; cursor: number; lead: string | null; leadCursor: number }
|
|
24
|
+
| { kind: "agent"; step: 1 | 2; field: "id" | "role" | "identity"; id: string; role: string; identity: string; selected: string[]; cursor: number };
|
|
25
|
+
type PickerState = { kind: "team-members" | "agent-teams"; targetId: string; options: string[]; selected: string[]; cursor: number };
|
|
26
|
+
type ConfirmState = { kind: "delete-team" | "delete-agent"; id: string };
|
|
27
|
+
|
|
28
|
+
export interface OrgUiState {
|
|
29
|
+
scope: OrgScope;
|
|
30
|
+
sel: number;
|
|
31
|
+
reading: boolean;
|
|
32
|
+
/** Plan 23: the id of the team whose WORKFLOW is being viewed full-screen (null = not in that view). */
|
|
33
|
+
workflow: string | null;
|
|
34
|
+
wizard: WizardState | null;
|
|
35
|
+
picker: PickerState | null;
|
|
36
|
+
confirm: ConfirmState | null;
|
|
37
|
+
note?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function initialOrgUi(scope: OrgScope = "teams"): OrgUiState {
|
|
41
|
+
return { scope, sel: 0, reading: false, workflow: null, wizard: null, picker: null, confirm: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type OrgActionResult = { ok: boolean; error?: string };
|
|
45
|
+
export interface OrgActions {
|
|
46
|
+
createTeam: (draft: { id: string; charter: string; lead?: string }, members: string[]) => Promise<OrgActionResult>;
|
|
47
|
+
createAgent: (draft: { id: string; role: string; identity: string; teams: string[] }) => Promise<OrgActionResult>;
|
|
48
|
+
deleteTeam: (id: string) => Promise<OrgActionResult>;
|
|
49
|
+
deleteAgent: (id: string) => Promise<OrgActionResult>;
|
|
50
|
+
setTeamMembers: (teamId: string, members: string[]) => Promise<OrgActionResult>;
|
|
51
|
+
setAgentTeams: (agentId: string, teams: string[]) => Promise<OrgActionResult>;
|
|
52
|
+
scaffoldWorkflow: (teamId: string, members: string[]) => Promise<OrgActionResult>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ID_RE = /^[a-z][a-z0-9-]*$/;
|
|
56
|
+
|
|
57
|
+
export function OrgBrowser(props: {
|
|
58
|
+
ws: string;
|
|
59
|
+
db: import("bun:sqlite").Database;
|
|
60
|
+
width: number;
|
|
61
|
+
rows: number;
|
|
62
|
+
bump?: number;
|
|
63
|
+
st: OrgUiState;
|
|
64
|
+
onChange: (next: OrgUiState | ((prev: OrgUiState) => OrgUiState)) => void;
|
|
65
|
+
keyRef: React.MutableRefObject<CardKeyHandler | null>;
|
|
66
|
+
onClose: () => void;
|
|
67
|
+
actions: OrgActions;
|
|
68
|
+
}) {
|
|
69
|
+
const { ws, db, st, onChange } = props;
|
|
70
|
+
const { suspendTerminal } = useApp(); // Ink 7.1.0: hand the tty to the captain's editor, then repaint
|
|
71
|
+
|
|
72
|
+
const teams = teamRows(ws, db);
|
|
73
|
+
const agents = agentRows(db);
|
|
74
|
+
const teamIds = teams.map((t) => t.id).filter((id) => !isProtectedTeam(id)); // wizards/pickers offer non-default teams
|
|
75
|
+
const agentIds = agents.map((a) => a.id);
|
|
76
|
+
|
|
77
|
+
const list = st.scope === "teams" ? teams : agents;
|
|
78
|
+
const sel = clampSel(st.sel, list.length);
|
|
79
|
+
const selTeam = st.scope === "teams" ? teams[sel] : undefined;
|
|
80
|
+
const selAgent = st.scope === "agents" ? agents[sel] : undefined;
|
|
81
|
+
|
|
82
|
+
const setNote = (note: string) => onChange((prev) => ({ ...prev, note }));
|
|
83
|
+
const run = (p: Promise<OrgActionResult>, ok: string) =>
|
|
84
|
+
void p.then((r) => onChange((prev) => ({ ...prev, note: r.ok ? ok : `⚠ ${r.error ?? "failed"}`, confirm: null, wizard: null, picker: null })));
|
|
85
|
+
|
|
86
|
+
// Plan 23: hand the terminal to the captain's editor ($EDITOR/nano), wait for it to quit, then Ink
|
|
87
|
+
// resumes and the view re-reads the file. Everyone keeps their own editor; no bespoke in-TUI editor.
|
|
88
|
+
const openInEditor = (path: string) => {
|
|
89
|
+
void editFileInTerminal(suspendTerminal, path).then((r) => setNote(r.note));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ── keyboard ──────────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
const handler: CardKeyHandler = (input, key) => {
|
|
94
|
+
// WIZARD owns the keyboard while open.
|
|
95
|
+
if (st.wizard) return handleWizard(input, key, st.wizard);
|
|
96
|
+
// PICKER (checkbox multi-select).
|
|
97
|
+
if (st.picker) return handlePicker(input, key, st.picker);
|
|
98
|
+
// WORKFLOW VIEW — see the team's workflow, hand-edit it in $EDITOR, or scaffold a starter.
|
|
99
|
+
if (st.workflow) {
|
|
100
|
+
const wid = st.workflow;
|
|
101
|
+
if (key.escape) { onChange({ ...st, workflow: null, note: undefined }); return; }
|
|
102
|
+
if (input === "e" || input === "o") { openInEditor(paths.teamWorkflowFile(ws, wid)); return; }
|
|
103
|
+
if (input === "n" && !hasWorkflow(ws, wid)) {
|
|
104
|
+
const members = teams.find((t) => t.id === wid)?.members ?? [];
|
|
105
|
+
run(props.actions.scaffoldWorkflow(wid, members), `✓ scaffolded the ${wid} workflow — press e to hand-edit it`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// CONFIRM delete.
|
|
111
|
+
if (st.confirm) {
|
|
112
|
+
if (input === "y") {
|
|
113
|
+
const c = st.confirm;
|
|
114
|
+
if (c.kind === "delete-team") run(props.actions.deleteTeam(c.id), `✓ deleted team ${c.id}`);
|
|
115
|
+
else run(props.actions.deleteAgent(c.id), `✓ retired ${c.id}`);
|
|
116
|
+
} else if (key.escape || input === "n") onChange({ ...st, confirm: null });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// DETAIL.
|
|
120
|
+
if (st.reading) {
|
|
121
|
+
if (key.escape) { onChange({ ...st, reading: false }); return; }
|
|
122
|
+
if (input === "o") {
|
|
123
|
+
if (selTeam) openInEditor(paths.teamFile(ws, selTeam.id));
|
|
124
|
+
else if (selAgent) openInEditor(paths.agentFile(ws, selAgent.id));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (input === "w" && selTeam) { onChange({ ...st, workflow: selTeam.id, note: undefined }); return; } // Plan 23: open the workflow view
|
|
128
|
+
if (input === "m" && selTeam && !isProtectedTeam(selTeam.id)) return openMembers(selTeam.id, selTeam.members);
|
|
129
|
+
if (input === "t" && selAgent) return openAgentTeams(selAgent.id, selAgent.teams);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// SHELF.
|
|
133
|
+
if (key.escape) { props.onClose(); return; }
|
|
134
|
+
if (key.tab || input === "1" || input === "2") {
|
|
135
|
+
const scope: OrgScope = input === "1" ? "teams" : input === "2" ? "agents" : st.scope === "teams" ? "agents" : "teams";
|
|
136
|
+
onChange({ ...st, scope, sel: 0, note: undefined });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (key.upArrow) { onChange({ ...st, sel: clampSel(sel - 1, list.length) }); return; }
|
|
140
|
+
if (key.downArrow) { onChange({ ...st, sel: clampSel(sel + 1, list.length) }); return; }
|
|
141
|
+
if (key.return) { if (list.length) onChange({ ...st, reading: true, note: undefined }); return; }
|
|
142
|
+
if (input === "a") { onChange({ ...st, wizard: freshWizard(st.scope), note: undefined }); return; }
|
|
143
|
+
if (input === "o") { if (selTeam) openInEditor(paths.teamFile(ws, selTeam.id)); else if (selAgent) openInEditor(paths.agentFile(ws, selAgent.id)); return; }
|
|
144
|
+
if (input === "w" && selTeam) { onChange({ ...st, workflow: selTeam.id, note: undefined }); return; } // Plan 23: open the workflow view
|
|
145
|
+
if (input === "m" && selTeam && !isProtectedTeam(selTeam.id)) return openMembers(selTeam.id, selTeam.members);
|
|
146
|
+
if (input === "t" && selAgent) return openAgentTeams(selAgent.id, selAgent.teams);
|
|
147
|
+
if (input === "d") {
|
|
148
|
+
if (selTeam && !isProtectedTeam(selTeam.id)) onChange({ ...st, confirm: { kind: "delete-team", id: selTeam.id } });
|
|
149
|
+
else if (selAgent && !isProtectedAgent(selAgent.id)) onChange({ ...st, confirm: { kind: "delete-agent", id: selAgent.id } });
|
|
150
|
+
else setNote(selTeam ? "the default team can't be deleted" : "root and the librarian can't be retired");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
function freshWizard(scope: OrgScope): WizardState {
|
|
156
|
+
return scope === "teams"
|
|
157
|
+
? { kind: "team", step: 1, field: "id", id: "", charter: "", selected: [], cursor: 0, lead: null, leadCursor: 0 }
|
|
158
|
+
: { kind: "agent", step: 1, field: "id", id: "", role: "", identity: "", selected: [], cursor: 0 };
|
|
159
|
+
}
|
|
160
|
+
function openMembers(teamId: string, current: string[]) {
|
|
161
|
+
onChange({ ...st, picker: { kind: "team-members", targetId: teamId, options: agentIds, selected: current.filter((m) => agentIds.includes(m)), cursor: 0 } });
|
|
162
|
+
}
|
|
163
|
+
function openAgentTeams(agentId: string, current: string[]) {
|
|
164
|
+
onChange({ ...st, picker: { kind: "agent-teams", targetId: agentId, options: teamIds, selected: current.filter((t) => teamIds.includes(t)), cursor: 0 } });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function handlePicker(input: string, key: { escape?: boolean; upArrow?: boolean; downArrow?: boolean; return?: boolean }, p: PickerState) {
|
|
168
|
+
if (key.escape) { onChange({ ...st, picker: null }); return; }
|
|
169
|
+
if (key.upArrow) { onChange({ ...st, picker: { ...p, cursor: clampSel(p.cursor - 1, p.options.length) } }); return; }
|
|
170
|
+
if (key.downArrow) { onChange({ ...st, picker: { ...p, cursor: clampSel(p.cursor + 1, p.options.length) } }); return; }
|
|
171
|
+
if (input === " ") {
|
|
172
|
+
const opt = p.options[p.cursor];
|
|
173
|
+
if (!opt) return;
|
|
174
|
+
const selected = p.selected.includes(opt) ? p.selected.filter((x) => x !== opt) : [...p.selected, opt];
|
|
175
|
+
onChange({ ...st, picker: { ...p, selected } });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (key.return) {
|
|
179
|
+
if (p.kind === "team-members") run(props.actions.setTeamMembers(p.targetId, p.selected), `✓ ${p.targetId}: ${p.selected.length} member(s)`);
|
|
180
|
+
else run(props.actions.setAgentTeams(p.targetId, p.selected), `✓ ${p.targetId}: ${p.selected.length} team(s)`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function handleWizard(input: string, key: { escape?: boolean; return?: boolean; backspace?: boolean; delete?: boolean; upArrow?: boolean; downArrow?: boolean; tab?: boolean; ctrl?: boolean; meta?: boolean }, w: WizardState) {
|
|
186
|
+
const cancel = () => onChange({ ...st, wizard: null });
|
|
187
|
+
const type = (cur: string) => {
|
|
188
|
+
if (key.backspace || key.delete) return cur.slice(0, -1);
|
|
189
|
+
if (input && !key.ctrl && !key.meta && !key.tab && !key.upArrow && !key.downArrow && !key.return) return cur + input;
|
|
190
|
+
return cur;
|
|
191
|
+
};
|
|
192
|
+
if (w.kind === "team") {
|
|
193
|
+
// step 1: identity (id → charter)
|
|
194
|
+
if (w.step === 1) {
|
|
195
|
+
if (key.escape) return cancel();
|
|
196
|
+
if (key.return || key.tab) {
|
|
197
|
+
if (w.field === "id") { onChange({ ...st, wizard: { ...w, field: "charter" } }); return; }
|
|
198
|
+
if (!ID_RE.test(w.id)) { setNote("id must be lowercase letters/digits/-, starting with a letter"); return; }
|
|
199
|
+
onChange({ ...st, wizard: { ...w, step: 2, cursor: 0 } });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
onChange({ ...st, wizard: { ...w, [w.field]: type(w.field === "id" ? w.id : w.charter) } as WizardState });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// step 2: members (checkbox over agents)
|
|
206
|
+
if (w.step === 2) {
|
|
207
|
+
if (key.escape) { onChange({ ...st, wizard: { ...w, step: 1, field: "charter" } }); return; }
|
|
208
|
+
if (key.upArrow) { onChange({ ...st, wizard: { ...w, cursor: clampSel(w.cursor - 1, agentIds.length) } }); return; }
|
|
209
|
+
if (key.downArrow) { onChange({ ...st, wizard: { ...w, cursor: clampSel(w.cursor + 1, agentIds.length) } }); return; }
|
|
210
|
+
if (input === " ") {
|
|
211
|
+
const a = agentIds[w.cursor];
|
|
212
|
+
if (a) onChange({ ...st, wizard: { ...w, selected: w.selected.includes(a) ? w.selected.filter((x) => x !== a) : [...w.selected, a] } });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (input === "a") { onChange({ ...st, wizard: { ...w, selected: [...agentIds] } }); return; }
|
|
216
|
+
if (input === "n") { onChange({ ...st, wizard: { ...w, selected: [] } }); return; }
|
|
217
|
+
if (key.return) { onChange({ ...st, wizard: { ...w, step: 3, leadCursor: 0 } }); return; }
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// step 3: lead (leadless + each selected member)
|
|
221
|
+
const leadChoices = [null, ...w.selected];
|
|
222
|
+
if (key.escape) { onChange({ ...st, wizard: { ...w, step: 2 } }); return; }
|
|
223
|
+
if (key.upArrow) { onChange({ ...st, wizard: { ...w, leadCursor: clampSel(w.leadCursor - 1, leadChoices.length) } }); return; }
|
|
224
|
+
if (key.downArrow) { onChange({ ...st, wizard: { ...w, leadCursor: clampSel(w.leadCursor + 1, leadChoices.length) } }); return; }
|
|
225
|
+
if (key.return) {
|
|
226
|
+
const lead = leadChoices[clampSel(w.leadCursor, leadChoices.length)] ?? undefined;
|
|
227
|
+
run(props.actions.createTeam({ id: w.id, charter: w.charter || w.id, lead: lead ?? undefined }, w.selected), `✓ created team ${w.id}`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// AGENT wizard
|
|
233
|
+
if (w.step === 1) {
|
|
234
|
+
if (key.escape) return cancel();
|
|
235
|
+
const order: ("id" | "role" | "identity")[] = ["id", "role", "identity"];
|
|
236
|
+
if (key.return || key.tab) {
|
|
237
|
+
const i = order.indexOf(w.field);
|
|
238
|
+
if (i < order.length - 1) { onChange({ ...st, wizard: { ...w, field: order[i + 1]! } }); return; }
|
|
239
|
+
if (!ID_RE.test(w.id)) { setNote("id must be lowercase letters/digits/-, starting with a letter"); return; }
|
|
240
|
+
if (!w.role.trim() || !w.identity.trim()) { setNote("role and identity are required"); return; }
|
|
241
|
+
onChange({ ...st, wizard: { ...w, step: 2, cursor: 0 } });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
onChange({ ...st, wizard: { ...w, [w.field]: type(w[w.field]) } as WizardState });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// step 2: teams (checkbox over non-default teams)
|
|
248
|
+
if (key.escape) { onChange({ ...st, wizard: { ...w, step: 1, field: "identity" } }); return; }
|
|
249
|
+
if (key.upArrow) { onChange({ ...st, wizard: { ...w, cursor: clampSel(w.cursor - 1, teamIds.length) } }); return; }
|
|
250
|
+
if (key.downArrow) { onChange({ ...st, wizard: { ...w, cursor: clampSel(w.cursor + 1, teamIds.length) } }); return; }
|
|
251
|
+
if (input === " ") {
|
|
252
|
+
const t = teamIds[w.cursor];
|
|
253
|
+
if (t) onChange({ ...st, wizard: { ...w, selected: w.selected.includes(t) ? w.selected.filter((x) => x !== t) : [...w.selected, t] } });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (key.return) { run(props.actions.createAgent({ id: w.id, role: w.role, identity: w.identity, teams: w.selected }), `✓ hired ${w.id}`); return; }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Publish the handler DURING render (the ink registration race the artifact browser/ProposalCard fixed).
|
|
260
|
+
props.keyRef.current = handler;
|
|
261
|
+
useEffect(() => () => { props.keyRef.current = null; }, []);
|
|
262
|
+
|
|
263
|
+
// ── render ──────────────────────────────────────────────────────────────────────────────────────
|
|
264
|
+
if (st.wizard) return <Wizard w={st.wizard} agentIds={agentIds} teamIds={teamIds} width={props.width} note={st.note} />;
|
|
265
|
+
if (st.picker) return <Picker p={st.picker} width={props.width} note={st.note} />;
|
|
266
|
+
if (st.workflow) return renderWorkflowView();
|
|
267
|
+
|
|
268
|
+
const border = (
|
|
269
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
|
|
270
|
+
<Box>
|
|
271
|
+
<Text color="cyan" bold>ORG </Text>
|
|
272
|
+
<Text color={st.scope === "teams" ? "cyan" : "gray"} bold={st.scope === "teams"} inverse={st.scope === "teams"}>{" 1 teams "}</Text>
|
|
273
|
+
<Text> </Text>
|
|
274
|
+
<Text color={st.scope === "agents" ? "cyan" : "gray"} bold={st.scope === "agents"} inverse={st.scope === "agents"}>{" 2 agents "}</Text>
|
|
275
|
+
<Text dimColor> {st.scope === "teams" ? `${teams.length} team${teams.length === 1 ? "" : "s"}` : `${agents.length} agent${agents.length === 1 ? "" : "s"}`}</Text>
|
|
276
|
+
</Box>
|
|
277
|
+
{st.confirm && (
|
|
278
|
+
<Text color="yellow">
|
|
279
|
+
{st.confirm.kind === "delete-team" ? `delete team ${st.confirm.id}? it detaches every member` : `retire ${st.confirm.id}? this removes the agent`} — y / n
|
|
280
|
+
</Text>
|
|
281
|
+
)}
|
|
282
|
+
{st.note && <Text color={st.note.startsWith("⚠") ? "red" : "green"}>{st.note}</Text>}
|
|
283
|
+
<Text> </Text>
|
|
284
|
+
{st.reading ? renderDetail() : renderShelf()}
|
|
285
|
+
<Text> </Text>
|
|
286
|
+
<Text dimColor>{footer()}</Text>
|
|
287
|
+
</Box>
|
|
288
|
+
);
|
|
289
|
+
return border;
|
|
290
|
+
|
|
291
|
+
function footer(): string {
|
|
292
|
+
if (st.reading) return "esc back · o $EDITOR" + (st.scope === "teams" ? " · m members · w workflow" : " · t teams");
|
|
293
|
+
return st.scope === "teams" ? "↑↓ · ⏎ open · tab scope · a add · m members · w workflow · d delete · o edit · esc" : "↑↓ · ⏎ open · tab scope · a hire · t teams · d retire · o edit · esc";
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function renderShelf() {
|
|
297
|
+
if (!list.length) return <Text dimColor> (nothing here yet — press `a` to add)</Text>;
|
|
298
|
+
return (
|
|
299
|
+
<Box>
|
|
300
|
+
<Box flexDirection="column" width={Math.floor(props.width * 0.5)}>
|
|
301
|
+
{list.map((row, i) => {
|
|
302
|
+
const on = i === sel;
|
|
303
|
+
if (st.scope === "teams") {
|
|
304
|
+
const t = row as (typeof teams)[number];
|
|
305
|
+
return <Text key={t.id} color={on ? "cyan" : undefined} bold={on}>{on ? "▸ " : " "}{t.id} <Text dimColor>{t.members.length} · {t.lead ? `lead ${t.lead}` : "by capability"}</Text></Text>;
|
|
306
|
+
}
|
|
307
|
+
const a = row as (typeof agents)[number];
|
|
308
|
+
return <Text key={a.id} color={on ? "cyan" : undefined} bold={on}>{on ? "▸ " : " "}{a.isRoot ? "*" : " "}{a.id} <Text dimColor>{a.teams.length ? a.teams.join(" · ") : "—"}</Text></Text>;
|
|
309
|
+
})}
|
|
310
|
+
</Box>
|
|
311
|
+
<Box flexDirection="column" marginLeft={2} flexGrow={1}>{renderPreview()}</Box>
|
|
312
|
+
</Box>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderPreview() {
|
|
317
|
+
if (selTeam) return [
|
|
318
|
+
<Text key="c" color="cyan">{selTeam.id}</Text>,
|
|
319
|
+
<Text key="ch" wrap="truncate">{selTeam.charter}</Text>,
|
|
320
|
+
<Text key="l" dimColor>{selTeam.lead ? `lead: ${selTeam.lead}` : "routed by capability"}</Text>,
|
|
321
|
+
<Text key="m" dimColor wrap="truncate">members: {selTeam.members.join(", ") || "(none)"}</Text>,
|
|
322
|
+
];
|
|
323
|
+
if (selAgent) return [
|
|
324
|
+
<Text key="c" color="cyan">{selAgent.id}{selAgent.isRoot ? " *" : ""}</Text>,
|
|
325
|
+
<Text key="r" wrap="truncate">{selAgent.role}</Text>,
|
|
326
|
+
<Text key="t" dimColor wrap="truncate">teams: {selAgent.teams.length ? selAgent.teams.join(", ") : "default only"}</Text>,
|
|
327
|
+
];
|
|
328
|
+
return [<Text key="e" dimColor>—</Text>];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Plan 23: the WORKFLOW is a first-class citizen — see every seat's lane + the orchestration here, and
|
|
332
|
+
// hand-edit the file ($EDITOR) or scaffold a starter, without leaving the browser.
|
|
333
|
+
function renderWorkflowView() {
|
|
334
|
+
const wid = st.workflow!;
|
|
335
|
+
const team = teams.find((t) => t.id === wid);
|
|
336
|
+
const wf = loadWorkflow(ws, wid);
|
|
337
|
+
const rows: ReactNode[] = [];
|
|
338
|
+
if (!wf) {
|
|
339
|
+
rows.push(<Text key="none" dimColor> no workflow yet — {wid} runs on the agentic brief. Author one to script how work moves through the team.</Text>);
|
|
340
|
+
} else {
|
|
341
|
+
const orch = orchestrationSlice(wf);
|
|
342
|
+
if (orch) {
|
|
343
|
+
rows.push(<Text key="oh" color="cyan">orchestration <Text dimColor>· the lead's sequence & hand-offs</Text></Text>);
|
|
344
|
+
orch.split("\n").slice(0, 6).forEach((l, i) => rows.push(<Text key={`o${i}`} wrap="truncate"> {l}</Text>));
|
|
345
|
+
rows.push(<Text key="osp"> </Text>);
|
|
346
|
+
}
|
|
347
|
+
for (const seat of seatsOf(wf).filter((s) => s !== "orchestration")) {
|
|
348
|
+
const drift = team && !team.members.includes(seat) ? " · not a current member" : "";
|
|
349
|
+
rows.push(<Text key={`h-${seat}`} color="cyan">{seat}<Text dimColor>{drift}</Text></Text>);
|
|
350
|
+
(laneFor(wf, seat) ?? "").split("\n").slice(0, 4).forEach((l, i) => rows.push(<Text key={`${seat}-${i}`} wrap="truncate"> {l}</Text>));
|
|
351
|
+
rows.push(<Text key={`sp-${seat}`}> </Text>);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return (
|
|
355
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
|
|
356
|
+
<Text color="cyan" bold>WORKFLOW · {wid}</Text>
|
|
357
|
+
{st.note && <Text color={st.note.startsWith("⚠") ? "red" : "green"}>{st.note}</Text>}
|
|
358
|
+
<Text> </Text>
|
|
359
|
+
{rows}
|
|
360
|
+
<Text dimColor>{wf ? "e/o edit in $EDITOR · esc back" : "n scaffold a starter from the members · o author in $EDITOR · esc back"}</Text>
|
|
361
|
+
</Box>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function renderDetail() {
|
|
366
|
+
if (selTeam) {
|
|
367
|
+
const t = selTeam;
|
|
368
|
+
return (
|
|
369
|
+
<Box flexDirection="column">
|
|
370
|
+
<Text color="cyan" bold>{t.id} · {t.charter}</Text>
|
|
371
|
+
<Text> </Text>
|
|
372
|
+
<Text color="cyan">ROUTING</Text>
|
|
373
|
+
<Text> {t.lead ? `lead: ${t.lead}` : "leadless — routed to the best-ranked member"}</Text>
|
|
374
|
+
<Text> </Text>
|
|
375
|
+
<Text color="cyan">MEMBERS ({t.members.length})</Text>
|
|
376
|
+
{t.members.length ? t.members.map((m) => <Text key={m}> {m === t.lead ? "* " : "- "}{m}</Text>) : <Text dimColor> (none — press m to staff)</Text>}
|
|
377
|
+
<Text> </Text>
|
|
378
|
+
<Text color="cyan">WORKFLOW</Text>
|
|
379
|
+
{(() => {
|
|
380
|
+
const wf = loadWorkflow(ws, t.id);
|
|
381
|
+
if (!wf) return <Text dimColor> none — press w to view/author (optional)</Text>;
|
|
382
|
+
const seats = seatsOf(wf).filter((s) => s !== "orchestration");
|
|
383
|
+
return <Text dimColor> {orchestrationSlice(wf) ? "orchestration + " : ""}{seats.length} seat{seats.length === 1 ? "" : "s"} — press w to view/edit</Text>;
|
|
384
|
+
})()}
|
|
385
|
+
</Box>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (selAgent) {
|
|
389
|
+
const a = selAgent;
|
|
390
|
+
return (
|
|
391
|
+
<Box flexDirection="column">
|
|
392
|
+
<Text color="cyan" bold>{a.id} · {a.role}</Text>
|
|
393
|
+
<Text> </Text>
|
|
394
|
+
<Text color="cyan">TEAMS</Text>
|
|
395
|
+
<Text> {a.teams.length ? ["default", ...a.teams].join(" · ") : "default (only)"}</Text>
|
|
396
|
+
<Text> </Text>
|
|
397
|
+
<Text dimColor>open the agent.md in $EDITOR (o) to edit its persona, tools, and budgets</Text>
|
|
398
|
+
</Box>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
return <Text dimColor>—</Text>;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── wizard + picker sub-views (pure presentational) ─────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
function Wizard(props: { w: WizardState; agentIds: string[]; teamIds: string[]; width: number; note?: string }) {
|
|
408
|
+
const { w } = props;
|
|
409
|
+
const cursorMark = <Text color="cyan">▏</Text>;
|
|
410
|
+
if (w.kind === "team") {
|
|
411
|
+
if (w.step === 1)
|
|
412
|
+
return (
|
|
413
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
|
|
414
|
+
<Text color="cyan" bold>NEW TEAM · step 1/3 — identity</Text>
|
|
415
|
+
<Text> </Text>
|
|
416
|
+
<Text><Text dimColor>id </Text>{w.field === "id" ? <>{w.id}{cursorMark}</> : w.id || <Text dimColor>—</Text>}</Text>
|
|
417
|
+
<Text><Text dimColor>charter </Text>{w.field === "charter" ? <>{w.charter}{cursorMark}</> : w.charter || <Text dimColor>—</Text>}</Text>
|
|
418
|
+
{props.note && <Text color="red">{props.note}</Text>}
|
|
419
|
+
<Text> </Text>
|
|
420
|
+
<Text dimColor>type · ⏎/tab next field · ⏎ on charter → members · esc cancel</Text>
|
|
421
|
+
</Box>
|
|
422
|
+
);
|
|
423
|
+
if (w.step === 2)
|
|
424
|
+
return (
|
|
425
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
|
|
426
|
+
<Text color="cyan" bold>NEW TEAM · step 2/3 — members (space toggles)</Text>
|
|
427
|
+
<Text> </Text>
|
|
428
|
+
{props.agentIds.length ? props.agentIds.map((a, i) => (
|
|
429
|
+
<Text key={a} color={i === w.cursor ? "cyan" : undefined}>{i === w.cursor ? "▸ " : " "}<Text color={w.selected.includes(a) ? "green" : undefined}>[{w.selected.includes(a) ? "x" : " "}]</Text> {a}</Text>
|
|
430
|
+
)) : <Text dimColor> (no agents yet — hire one first)</Text>}
|
|
431
|
+
<Text> </Text>
|
|
432
|
+
<Text dimColor>{w.selected.length} selected · space toggle · a all · n none · ⏎ next · esc back</Text>
|
|
433
|
+
</Box>
|
|
434
|
+
);
|
|
435
|
+
const leadChoices = [null, ...w.selected];
|
|
436
|
+
return (
|
|
437
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
|
|
438
|
+
<Text color="cyan" bold>NEW TEAM · step 3/3 — lead (optional)</Text>
|
|
439
|
+
<Text> </Text>
|
|
440
|
+
{leadChoices.map((c, i) => (
|
|
441
|
+
<Text key={c ?? "__leadless"} color={i === w.leadCursor ? "cyan" : undefined}>{i === w.leadCursor ? "▸ " : " "}{c === null ? <Text dimColor>leadless — routed by capability (default)</Text> : c}</Text>
|
|
442
|
+
))}
|
|
443
|
+
<Text> </Text>
|
|
444
|
+
<Text dimColor>writes teams/{w.id || "…"}/team.md + teams: on {w.selected.length} agent(s) · ↑↓ · ⏎ create · esc back</Text>
|
|
445
|
+
</Box>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
// agent wizard
|
|
449
|
+
if (w.step === 1)
|
|
450
|
+
return (
|
|
451
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
|
|
452
|
+
<Text color="cyan" bold>HIRE AGENT · step 1/2 — identity</Text>
|
|
453
|
+
<Text> </Text>
|
|
454
|
+
<Text><Text dimColor>id </Text>{w.field === "id" ? <>{w.id}{cursorMark}</> : w.id || <Text dimColor>—</Text>}</Text>
|
|
455
|
+
<Text><Text dimColor>role </Text>{w.field === "role" ? <>{w.role}{cursorMark}</> : w.role || <Text dimColor>—</Text>}</Text>
|
|
456
|
+
<Text><Text dimColor>persona </Text>{w.field === "identity" ? <>{w.identity}{cursorMark}</> : w.identity || <Text dimColor>—</Text>}</Text>
|
|
457
|
+
{props.note && <Text color="red">{props.note}</Text>}
|
|
458
|
+
<Text> </Text>
|
|
459
|
+
<Text dimColor>type · ⏎/tab next field · ⏎ on persona → teams · esc cancel</Text>
|
|
460
|
+
</Box>
|
|
461
|
+
);
|
|
462
|
+
return (
|
|
463
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
|
|
464
|
+
<Text color="cyan" bold>HIRE AGENT · step 2/2 — teams (space toggles; the artifact floor is always granted)</Text>
|
|
465
|
+
<Text> </Text>
|
|
466
|
+
{props.teamIds.length ? props.teamIds.map((t, i) => (
|
|
467
|
+
<Text key={t} color={i === w.cursor ? "cyan" : undefined}>{i === w.cursor ? "▸ " : " "}<Text color={w.selected.includes(t) ? "green" : undefined}>[{w.selected.includes(t) ? "x" : " "}]</Text> {t}</Text>
|
|
468
|
+
)) : <Text dimColor> (no teams yet — the agent will be default-only)</Text>}
|
|
469
|
+
<Text> </Text>
|
|
470
|
+
<Text dimColor>{w.selected.length} selected · space toggle · ⏎ hire · esc back</Text>
|
|
471
|
+
</Box>
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function Picker(props: { p: PickerState; width: number; note?: string }) {
|
|
476
|
+
const { p } = props;
|
|
477
|
+
const title = p.kind === "team-members" ? `MEMBERS of ${p.targetId}` : `TEAMS for ${p.targetId}`;
|
|
478
|
+
return (
|
|
479
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
|
|
480
|
+
<Text color="cyan" bold>{title} (space toggles)</Text>
|
|
481
|
+
<Text> </Text>
|
|
482
|
+
{p.options.length ? p.options.map((o, i) => (
|
|
483
|
+
<Text key={o} color={i === p.cursor ? "cyan" : undefined}>{i === p.cursor ? "▸ " : " "}<Text color={p.selected.includes(o) ? "green" : undefined}>[{p.selected.includes(o) ? "x" : " "}]</Text> {o}</Text>
|
|
484
|
+
)) : <Text dimColor> (nothing to choose)</Text>}
|
|
485
|
+
<Text> </Text>
|
|
486
|
+
<Text dimColor>{p.selected.length} selected · space toggle · ↑↓ move · ⏎ save · esc cancel</Text>
|
|
487
|
+
</Box>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Plan 18: the pinned plan panel — a checklist that redraws in place while a plan is live and
|
|
2
|
+
* collapses to nothing when there isn't one.
|
|
3
|
+
*
|
|
4
|
+
* Display-only. The REPL always owns the keyboard, exactly as SquadPanes does; there is no focus
|
|
5
|
+
* state, no key handler, nothing to steal ⏎ from the input. Rail colours are lifted from AgentBlock's
|
|
6
|
+
* railColor so the two live surfaces read as one system rather than two. */
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { Box, Text } from "ink";
|
|
9
|
+
import type { PlanState, PlanItemStatus, FoldedItem } from "@taicho-ai/contracts/plan";
|
|
10
|
+
|
|
11
|
+
/** Fixed height, like AgentBlock's body: a long plan must not push the input off the screen. */
|
|
12
|
+
export const PLAN_PANEL_MAX_ROWS = 8;
|
|
13
|
+
|
|
14
|
+
const GLYPH: Record<PlanItemStatus, string> = {
|
|
15
|
+
pending: "○",
|
|
16
|
+
in_progress: "◐",
|
|
17
|
+
done: "✓",
|
|
18
|
+
failed: "✗",
|
|
19
|
+
blocked: "✗",
|
|
20
|
+
interrupted: "?",
|
|
21
|
+
dropped: "–",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Same vocabulary as AgentBlock: green done, amber live, red failed, magenta delegated. */
|
|
25
|
+
function itemColor(s: PlanItemStatus): string | undefined {
|
|
26
|
+
switch (s) {
|
|
27
|
+
case "done": return "green";
|
|
28
|
+
case "in_progress": return "yellow";
|
|
29
|
+
case "failed":
|
|
30
|
+
case "blocked": return "red";
|
|
31
|
+
case "interrupted": return "red";
|
|
32
|
+
case "dropped": return "gray";
|
|
33
|
+
default: return "gray";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `3/7 · 1 failed` — the summary a captain reads before the detail. Only mention failures when there
|
|
38
|
+
* are any: a clean plan should not carry a "0 failed" the eye has to discard. */
|
|
39
|
+
export function planSummary(s: PlanState): string {
|
|
40
|
+
const base = `${s.counts.done}/${s.counts.total}`;
|
|
41
|
+
return s.counts.failed ? `${base} · ${s.counts.failed} failed` : base;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Which rows to show when the plan is taller than the panel: never hide a failure, and never hide
|
|
45
|
+
* what is running. Those are the two things the captain is watching for. */
|
|
46
|
+
export function visibleItems(items: FoldedItem[], max: number): { rows: FoldedItem[]; hidden: number } {
|
|
47
|
+
if (items.length <= max) return { rows: items, hidden: 0 };
|
|
48
|
+
const urgent = items.filter((i) => i.status === "in_progress" || i.status === "failed" || i.status === "blocked" || i.status === "interrupted");
|
|
49
|
+
const rest = items.filter((i) => !urgent.includes(i));
|
|
50
|
+
const rows = [...urgent, ...rest].slice(0, max);
|
|
51
|
+
// restore the plan's own order — the captain wrote it in that order for a reason
|
|
52
|
+
const ordered = items.filter((i) => rows.includes(i));
|
|
53
|
+
return { rows: ordered, hidden: items.length - ordered.length };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function PlanPanel({ plan, width }: { plan: PlanState; width: number }) {
|
|
57
|
+
const { rows, hidden } = visibleItems(plan.items, PLAN_PANEL_MAX_ROWS);
|
|
58
|
+
const cap = Math.max(20, width - 8);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Box flexDirection="column" marginTop={1}>
|
|
62
|
+
<Text>
|
|
63
|
+
<Text color="cyan">▎plan</Text>
|
|
64
|
+
<Text color="gray"> · {plan.handle} · </Text>
|
|
65
|
+
<Text color={plan.counts.failed ? "red" : "gray"}>{planSummary(plan)}</Text>
|
|
66
|
+
</Text>
|
|
67
|
+
{rows.map((i) => {
|
|
68
|
+
const who = i.assignee ? ` @${i.assignee}` : "";
|
|
69
|
+
const why = i.status === "failed" && i.note ? ` — ${i.note}` : "";
|
|
70
|
+
const line = `${i.text}${who}${why}`;
|
|
71
|
+
return (
|
|
72
|
+
<Text key={i.id}>
|
|
73
|
+
<Text color={itemColor(i.status)}>{" "}{GLYPH[i.status]} </Text>
|
|
74
|
+
<Text color={i.status === "done" || i.status === "dropped" ? "gray" : undefined} dimColor={i.status === "pending"}>
|
|
75
|
+
{line.slice(0, cap)}
|
|
76
|
+
</Text>
|
|
77
|
+
</Text>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
{hidden > 0 && <Text color="gray">{" "}+{hidden} more</Text>}
|
|
81
|
+
</Box>
|
|
82
|
+
);
|
|
83
|
+
}
|