@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,270 @@
1
+ /** Pure REPL slash-command dispatch. Returns lines to print; no I/O of its own (store fns injected). */
2
+ import type { RegistryRow } from "@taicho-ai/framework/store/roster";
3
+ import type { RunTrace } from "@taicho-ai/contracts/trace";
4
+ import type { PolicyNote } from "@taicho-ai/contracts/policy";
5
+ import { McpServerConfig } from "@taicho-ai/framework/store/config";
6
+ import type { McpServerStatus } from "@taicho-ai/framework/core/mcp/manager";
7
+ import { rollupCosts, formatCostRollup } from "@taicho-ai/framework/core/costs";
8
+ import { DEFAULT_TEAM_ID } from "@taicho-ai/contracts/team";
9
+
10
+ export type Line = { kind: "user" | "agent" | "system"; from?: string; text: string; rendered?: boolean };
11
+
12
+ export interface SlashCommand { name: string; summary: string; usage?: string; requiresArg?: boolean; }
13
+
14
+ /** Single source of truth for slash commands — drives both /help and the live suggester. */
15
+ export const COMMANDS: SlashCommand[] = [
16
+ { name: "help", summary: "list commands" },
17
+ { name: "agents", summary: "list the squad", usage: "[reindex]" },
18
+ { name: "teams", summary: "list teams, their leads, and their members" },
19
+ { name: "workflows", summary: "browse team workflows and their run state" },
20
+ { name: "costs", summary: "cross-session spend rollup (agent / day / model)", usage: "[agent]" },
21
+ { name: "tasks", summary: "list / cancel background tasks", usage: "[cancel <id>]" },
22
+ { name: "schedules", summary: "scheduled/triggered runs (cron / interval / watch)", usage: "list | add <goal> --every … | remove <id> | run <id>" },
23
+ { name: "view", summary: "switch the live view (persists)", usage: "bar|panes|both" },
24
+ { name: "plan", summary: "show/hide the pinned plan panel (persists)", usage: "[on|off]" },
25
+ { name: "teach", summary: "teach an agent a standing instruction", usage: "<agent> <correction>", requiresArg: true },
26
+ { name: "policies", summary: "list an agent's coaching notes; approve a proposed one", usage: "<agent> | approve <pol_id>", requiresArg: true },
27
+ { name: "forget", summary: "remove a coaching note", usage: "<agent> <pol_id>", requiresArg: true },
28
+ { name: "mcp", summary: "manage MCP servers", usage: "[list|add|remove|login] …" },
29
+ { name: "kb", summary: "manage the knowledgebase", usage: "sync | list [filter] | forget <filter> | reindex" },
30
+ { name: "skills", summary: "manage agent skills", usage: "list | show <id|name> | remove <id> | reindex" },
31
+ { name: "artifacts", summary: "browse the squad's artifacts — runs also end here", usage: "" },
32
+ { name: "clear", summary: "clear the conversation — wipe the screen and forget history" },
33
+ { name: "status", summary: "show the auth source" },
34
+ { name: "login", summary: "sign in with a ChatGPT subscription", usage: "openai" },
35
+ { name: "logout", summary: "sign out", usage: "openai" },
36
+ ];
37
+
38
+ /** Wrap an index by delta within [0, len); returns 0 for an empty list. */
39
+ export function cycleIndex(current: number, len: number, delta: number): number {
40
+ if (len <= 0) return 0;
41
+ return (((current + delta) % len) + len) % len;
42
+ }
43
+
44
+ /** Commands matching what the captain is typing, while still on the command NAME (before a space). */
45
+ export function suggestCommands(buffer: string): SlashCommand[] {
46
+ if (!buffer.startsWith("/")) return [];
47
+ const rest = buffer.slice(1);
48
+ if (rest.includes(" ")) return [];
49
+ return COMMANDS.filter((c) => c.name.startsWith(rest.toLowerCase()));
50
+ }
51
+
52
+ export interface SlashDeps {
53
+ roster: RegistryRow[];
54
+ /** Plan 19: the squad's teams, captain-owned files. Empty on a squad that has never made one. */
55
+ teams: { id: string; charter: string; lead?: string }[];
56
+ listTraces: (agentId?: string) => RunTrace[]; // still the source /costs rolls up
57
+ listPolicies: (agentId: string) => PolicyNote[];
58
+ deletePolicy: (agentId: string, polId: string) => boolean;
59
+ /** Approve a `proposed` note by id (search is caller-scoped across agents). Null ⇒ no such note. */
60
+ approvePolicy: (polId: string) => PolicyNote | null;
61
+ }
62
+
63
+ const sys = (text: string): Line => ({ kind: "system", text });
64
+
65
+ export function runSlash(cmd: string, arg: string, deps: SlashDeps): Line[] {
66
+ if (cmd === "help")
67
+ return [
68
+ sys("commands (type / to see these; Tab completes):"),
69
+ ...COMMANDS.map((c) => sys(` /${c.name}${c.usage ? " " + c.usage : ""} — ${c.summary}`)),
70
+ sys(" @<agent> <task> — address an agent · ESC to quit"),
71
+ ];
72
+ if (cmd === "agents")
73
+ return deps.roster.map((r) => {
74
+ // Show only EXPLICIT teams — every agent is on `default`, so printing it on each line is noise.
75
+ // (The Org browser shows default; this flat list stays terse.)
76
+ const teams = (r.teams ?? []).filter((t) => t !== DEFAULT_TEAM_ID);
77
+ return sys(` ${r.is_root ? "*" : "-"} ${r.id}: ${r.role}${teams.length ? ` (${teams.join(" · ")})` : ""}`);
78
+ });
79
+ if (cmd === "teams") {
80
+ if (!deps.teams.length)
81
+ return [sys(" (no teams) — a team is a captain-owned file at teams/<id>/team.md")];
82
+ const out: Line[] = [];
83
+ for (const t of deps.teams) {
84
+ const members = deps.roster.filter((r) => r.teams?.includes(t.id));
85
+ const how = t.lead ? `lead: ${t.lead}` : "routed by capability";
86
+ out.push(sys(` ${t.id}: ${t.charter}`));
87
+ out.push(sys(` ${how} · ${members.length} agent${members.length === 1 ? "" : "s"}`));
88
+ for (const m of members) out.push(sys(` ${m.id === t.lead ? "*" : "-"} ${m.id}: ${m.role}`));
89
+ }
90
+ return out;
91
+ }
92
+ if (cmd === "costs") {
93
+ // Cross-session spend rollup from the per-run RunTrace records. Honest about subscription
94
+ // (costUsd:null) runs — reports their tokens, never a fabricated $0. Optional [agent] scopes it.
95
+ return formatCostRollup(rollupCosts(deps.listTraces(arg || undefined))).map(sys);
96
+ }
97
+ if (cmd === "policies") {
98
+ const parts = arg.split(/\s+/).filter(Boolean);
99
+ // `/policies approve <pol_id>` — the captain gate that flips a `proposed` note (e.g. a repeated-
100
+ // failure coaching proposal) to `approved`, the only status run.ts applies. Id-only: the ⚑ proposal
101
+ // message hands the captain the id, and approvePolicy locates its owning agent.
102
+ if (parts[0] === "approve") {
103
+ const polId = parts[1];
104
+ if (!polId) return [sys(" usage: /policies approve <pol_id>")];
105
+ const note = deps.approvePolicy(polId);
106
+ if (!note) return [sys(` no proposed policy "${polId}" — check the id (⚑ message or /policies <agent>)`)];
107
+ return [sys(` ✓ approved ${note.id} for ${note.agent} — WHEN ${note.when}: ${note.do}`)];
108
+ }
109
+ const agentId = parts[0];
110
+ if (!agentId) return [sys(" usage: /policies <agent> · /policies approve <pol_id>")];
111
+ const notes = deps.listPolicies(agentId);
112
+ if (!notes.length) return [sys(" (no policies)")];
113
+ return notes.map((n) => sys(` [${n.id}] (${n.status}) WHEN ${n.when}: ${n.do}`));
114
+ }
115
+ if (cmd === "forget") {
116
+ const [agentId, polId] = arg.split(/\s+/);
117
+ if (!agentId || !polId) return [sys(" usage: /forget <agentId> <pol_id>")];
118
+ return [sys(deps.deletePolicy(agentId, polId) ? ` forgot ${polId}` : ` no such policy: ${polId}`)];
119
+ }
120
+ return [sys(` unknown command: /${cmd}`)];
121
+ }
122
+
123
+ // ---- /mcp parsing & formatting (pure; the async handler lives in App.tsx) -------------------------
124
+
125
+ export type McpCommand =
126
+ | { kind: "list" }
127
+ | { kind: "add"; name: string; spec: McpServerConfig }
128
+ | { kind: "remove"; name: string }
129
+ | { kind: "login"; name: string }
130
+ | { kind: "reconnect"; name: string }
131
+ | { kind: "error"; message: string };
132
+
133
+ /** Split on whitespace but keep "double quoted" runs together (so --header "K: V" survives, and a
134
+ * cron expr `--cron "0 9 * * *"` stays one token). Shared by /mcp and /schedules parsing. */
135
+ export function tokenize(s: string): string[] {
136
+ const out: string[] = [];
137
+ let cur = "", q = false, has = false;
138
+ for (const c of s) {
139
+ if (c === '"') { q = !q; has = true; continue; }
140
+ if (!q && /\s/.test(c)) { if (has) { out.push(cur); cur = ""; has = false; } continue; }
141
+ cur += c; has = true;
142
+ }
143
+ if (has) out.push(cur);
144
+ return out;
145
+ }
146
+
147
+ /** Parse the argument string of `/mcp …` into a command. Supports:
148
+ * (bare) | list
149
+ * add <name> <command> [args…] [--env K=V …] (stdio)
150
+ * add <name> <https://url> [--oauth] [--header "K: V" …] (http)
151
+ * remove|login|reconnect <name> */
152
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/i; // no `/` (it delimits server/tool refs), no spaces
153
+
154
+ export function parseMcpCommand(arg: string): McpCommand {
155
+ const tokens = tokenize(arg.trim());
156
+ const sub = (tokens[0] ?? "list").toLowerCase();
157
+ if (sub === "list" || sub === "ls") return { kind: "list" };
158
+
159
+ if (sub === "remove" || sub === "rm" || sub === "login" || sub === "reconnect") {
160
+ const name = tokens[1];
161
+ if (!name) return { kind: "error", message: `usage: /mcp ${sub} <name>` };
162
+ if (!NAME_RE.test(name)) return { kind: "error", message: `invalid server name "${name}" (use letters, digits, _ or -)` };
163
+ const kind = sub === "rm" ? "remove" : (sub as "remove" | "login" | "reconnect");
164
+ return { kind, name };
165
+ }
166
+
167
+ if (sub === "add") {
168
+ const name = tokens[1];
169
+ if (!name) return { kind: "error", message: "usage: /mcp add <name> <command…|https://url> [flags]" };
170
+ if (!NAME_RE.test(name)) return { kind: "error", message: `invalid server name "${name}" (use letters, digits, _ or -)` };
171
+ const rest = tokens.slice(2);
172
+ const positionals: string[] = [];
173
+ const headers: Record<string, string> = {};
174
+ const env: Record<string, string> = {};
175
+ let oauth = false;
176
+ for (let i = 0; i < rest.length; i++) {
177
+ const t = rest[i];
178
+ if (t === "--oauth") oauth = true;
179
+ else if (t === "--header") { const kv = rest[++i] ?? ""; const j = kv.indexOf(":"); if (j > 0) headers[kv.slice(0, j).trim()] = kv.slice(j + 1).trim(); }
180
+ else if (t === "--env") { const kv = rest[++i] ?? ""; const j = kv.indexOf("="); if (j > 0) env[kv.slice(0, j)] = kv.slice(j + 1); }
181
+ else positionals.push(t);
182
+ }
183
+ if (!positionals.length) return { kind: "error", message: "usage: /mcp add <name> <command…|https://url> [flags]" };
184
+ const head = positionals[0];
185
+ const isHttp = /^https?:\/\//.test(head);
186
+ if (isHttp && Object.keys(env).length) return { kind: "error", message: "--env applies to stdio servers, not an http url" };
187
+ if (!isHttp && (Object.keys(headers).length || oauth)) return { kind: "error", message: "--header/--oauth apply to http servers, not a stdio command" };
188
+ const raw: unknown = isHttp
189
+ ? { url: head, ...(Object.keys(headers).length ? { headers } : {}), ...(oauth ? { auth: "oauth" } : {}) }
190
+ : { command: head, ...(positionals.length > 1 ? { args: positionals.slice(1) } : {}), ...(Object.keys(env).length ? { env } : {}) };
191
+ const parsed = McpServerConfig.safeParse(raw);
192
+ if (!parsed.success) return { kind: "error", message: `invalid server spec: ${parsed.error.issues[0]?.message ?? "bad input"}` };
193
+ return { kind: "add", name, spec: parsed.data };
194
+ }
195
+
196
+ return { kind: "error", message: `unknown /mcp subcommand "${sub}" (try list, add, remove, login, reconnect)` };
197
+ }
198
+
199
+ const statusIcon = (s: McpServerStatus["status"]): string => (s === "connected" ? "●" : s === "needs-auth" ? "◌" : "✗");
200
+
201
+ export function formatMcpStatus(servers: McpServerStatus[]): string[] {
202
+ if (!servers.length) return [" (no MCP servers — add one with /mcp add <name> npx -y <server> …)"];
203
+ return servers.map((s) =>
204
+ ` ${statusIcon(s.status)} ${s.name} [${s.kind}] ${s.status}` +
205
+ (s.status === "connected" ? ` · ${s.toolCount} tool(s)` : "") +
206
+ (s.error ? ` · ${s.error}` : ""));
207
+ }
208
+
209
+ // ---- /kb parsing (pure; the async handler lives in App.tsx) ---------------------------------------
210
+
211
+ export interface KbFilter { ids?: string[]; kind?: string; sourcePrefix?: string }
212
+ export type KbCommand =
213
+ | { kind: "sync" }
214
+ | { kind: "reindex" }
215
+ | { kind: "list"; filter: KbFilter }
216
+ | { kind: "forget"; filter: KbFilter }
217
+ | { kind: "error"; message: string };
218
+
219
+ /** Parse `kind=…`, `source=…` (→ sourcePrefix), and repeatable `id=…` tokens into a filter. */
220
+ function parseKbFilter(tokens: string[]): KbFilter {
221
+ const filter: KbFilter = {};
222
+ const ids: string[] = [];
223
+ for (const tok of tokens) {
224
+ const [k, ...rest] = tok.split("=");
225
+ const v = rest.join("=");
226
+ if (!v) continue;
227
+ if (k === "kind") filter.kind = v;
228
+ else if (k === "source") filter.sourcePrefix = v;
229
+ else if (k === "id") ids.push(v);
230
+ }
231
+ if (ids.length) filter.ids = ids;
232
+ return filter;
233
+ }
234
+
235
+ export function parseKbCommand(arg: string): KbCommand {
236
+ const parts = arg.trim().split(/\s+/).filter(Boolean);
237
+ const sub = parts[0];
238
+ const rest = parts.slice(1);
239
+ if (sub === "sync") return { kind: "sync" };
240
+ if (sub === "reindex") return { kind: "reindex" };
241
+ if (sub === "list") return { kind: "list", filter: parseKbFilter(rest) };
242
+ if (sub === "forget") {
243
+ const filter = parseKbFilter(rest);
244
+ if (!filter.ids && !filter.kind && !filter.sourcePrefix)
245
+ return { kind: "error", message: "usage: /kb forget kind=… | source=… | id=… (at least one)" };
246
+ return { kind: "forget", filter };
247
+ }
248
+ return { kind: "error", message: `unknown /kb subcommand "${sub ?? ""}" (try sync, list, forget, reindex)` };
249
+ }
250
+
251
+ // ---- /skills parsing (pure; the async handler lives in App.tsx) -----------------------------------
252
+
253
+ export type SkillCommand =
254
+ | { kind: "list" }
255
+ | { kind: "reindex" }
256
+ | { kind: "show"; arg: string }
257
+ | { kind: "remove"; id: string }
258
+ | { kind: "error"; message: string };
259
+
260
+ export function parseSkillCommand(arg: string): SkillCommand {
261
+ const parts = arg.trim().split(/\s+/).filter(Boolean);
262
+ const sub = parts[0];
263
+ if (!sub || sub === "list") return { kind: "list" };
264
+ if (sub === "reindex") return { kind: "reindex" };
265
+ if (sub === "show") return parts[1] ? { kind: "show", arg: parts[1] } : { kind: "error", message: "usage: /skills show <id|name>" };
266
+ if (sub === "remove") return parts[1] ? { kind: "remove", id: parts[1] } : { kind: "error", message: "usage: /skills remove <id>" };
267
+ return { kind: "error", message: `unknown /skills subcommand "${sub}" (try list, show, remove, reindex)` };
268
+ }
269
+ // Plan 21 Ph4: parseArtifactsCommand + ArtifactsCommand retired — /artifacts opens the browser;
270
+ // the verbs live inside it (⏎ read · a annotate · y approve · v versions · o $EDITOR · g gc).
@@ -0,0 +1,92 @@
1
+ /** Plan 24: pure cursor/edit model for the message buffer (may contain `\n` — see the multi-line section
2
+ * at the bottom). `cursor` is a code-unit index in [0, value.length]. The word-boundary algorithm
3
+ * (wordLeftIndex/wordRightIndex) is the heart of the
4
+ * Option/Alt word-nav: skip whitespace in the direction of travel, then skip the maximal run of
5
+ * same-CLASS chars (a run of word chars, or a run of non-space non-word chars). That matches how most
6
+ * editors stop at punctuation. No Ink, no React — fully unit-testable. */
7
+ export interface Buf { value: string; cursor: number }
8
+
9
+ const WORD = /[\p{L}\p{N}_]/u;
10
+ const isWord = (ch: string): boolean => WORD.test(ch);
11
+ const isSpace = (ch: string): boolean => ch === " " || ch === "\t";
12
+
13
+ export function insert(b: Buf, s: string): Buf {
14
+ return { value: b.value.slice(0, b.cursor) + s + b.value.slice(b.cursor), cursor: b.cursor + s.length };
15
+ }
16
+ export function backspace(b: Buf): Buf {
17
+ if (b.cursor === 0) return b;
18
+ return { value: b.value.slice(0, b.cursor - 1) + b.value.slice(b.cursor), cursor: b.cursor - 1 };
19
+ }
20
+ export function del(b: Buf): Buf {
21
+ if (b.cursor >= b.value.length) return b;
22
+ return { value: b.value.slice(0, b.cursor) + b.value.slice(b.cursor + 1), cursor: b.cursor };
23
+ }
24
+ export const left = (b: Buf): Buf => ({ ...b, cursor: Math.max(0, b.cursor - 1) });
25
+ export const right = (b: Buf): Buf => ({ ...b, cursor: Math.min(b.value.length, b.cursor + 1) });
26
+ export const home = (b: Buf): Buf => ({ ...b, cursor: 0 });
27
+ export const end = (b: Buf): Buf => ({ ...b, cursor: b.value.length });
28
+
29
+ export function wordLeftIndex(value: string, cursor: number): number {
30
+ let i = cursor;
31
+ while (i > 0 && isSpace(value[i - 1]!)) i--;
32
+ if (i === 0) return 0;
33
+ const cls = isWord(value[i - 1]!);
34
+ while (i > 0 && !isSpace(value[i - 1]!) && isWord(value[i - 1]!) === cls) i--;
35
+ return i;
36
+ }
37
+ export function wordRightIndex(value: string, cursor: number): number {
38
+ const n = value.length;
39
+ let i = cursor;
40
+ while (i < n && isSpace(value[i]!)) i++;
41
+ if (i === n) return n;
42
+ const cls = isWord(value[i]!);
43
+ while (i < n && !isSpace(value[i]!) && isWord(value[i]!) === cls) i++;
44
+ return i;
45
+ }
46
+ export const wordLeft = (b: Buf): Buf => ({ ...b, cursor: wordLeftIndex(b.value, b.cursor) });
47
+ export const wordRight = (b: Buf): Buf => ({ ...b, cursor: wordRightIndex(b.value, b.cursor) });
48
+ export function deleteWordBack(b: Buf): Buf {
49
+ const j = wordLeftIndex(b.value, b.cursor);
50
+ return { value: b.value.slice(0, j) + b.value.slice(b.cursor), cursor: j };
51
+ }
52
+ export function deleteWordForward(b: Buf): Buf {
53
+ const j = wordRightIndex(b.value, b.cursor);
54
+ return { value: b.value.slice(0, b.cursor) + b.value.slice(j), cursor: b.cursor };
55
+ }
56
+
57
+ // ── multi-line (Plan 24) — line motion for ↑/↓ inside a message with newlines ────────────────────
58
+
59
+ /** Start index of the line the cursor is on (0, or one past the previous newline). */
60
+ function lineStart(value: string, cursor: number): number {
61
+ return value.lastIndexOf("\n", cursor - 1) + 1;
62
+ }
63
+ /** End index of the line the cursor is on (the next newline, or end of value). */
64
+ function lineEnd(value: string, cursor: number): number {
65
+ const nl = value.indexOf("\n", cursor);
66
+ return nl === -1 ? value.length : nl;
67
+ }
68
+ export function isOnFirstLine(value: string, cursor: number): boolean {
69
+ return value.lastIndexOf("\n", cursor - 1) === -1;
70
+ }
71
+ export function isOnLastLine(value: string, cursor: number): boolean {
72
+ return value.indexOf("\n", cursor) === -1;
73
+ }
74
+
75
+ /** Move the cursor up one line, keeping its column (clamped to the shorter line). No-op on the first line. */
76
+ export function lineUp(b: Buf): Buf {
77
+ const start = lineStart(b.value, b.cursor);
78
+ if (start === 0) return b;
79
+ const col = b.cursor - start;
80
+ const prevStart = lineStart(b.value, start - 1);
81
+ const prevLen = start - 1 - prevStart; // chars on the previous line (excluding its trailing \n)
82
+ return { ...b, cursor: prevStart + Math.min(col, prevLen) };
83
+ }
84
+ /** Move the cursor down one line, keeping its column. No-op on the last line. */
85
+ export function lineDown(b: Buf): Buf {
86
+ const end = lineEnd(b.value, b.cursor);
87
+ if (end === b.value.length) return b;
88
+ const col = b.cursor - lineStart(b.value, b.cursor);
89
+ const nextStart = end + 1;
90
+ const nextLen = lineEnd(b.value, nextStart) - nextStart;
91
+ return { ...b, cursor: nextStart + Math.min(col, nextLen) };
92
+ }
@@ -0,0 +1,51 @@
1
+ /** Boot hydration: turn the replayed conversation thread — the SAME `rootThread` the model receives on
2
+ * startup — into visible scrollback, so a resumed session opens on its history instead of a blank
3
+ * screen. Pure + tested; App seeds `lines` from this at boot. What you SEE matches what the model
4
+ * REMEMBERS: the replay is compacted (Plan 05 Ph3), so folded older turns arrive as one condensed
5
+ * marker rather than verbatim. */
6
+ import type { ModelMessage } from "ai";
7
+ import type { Line } from "./slash";
8
+
9
+ // The pinned summary head the replay cache leads with once older turns were folded (see store/thread.ts).
10
+ const COMPACTION_HEAD_MARKER = "[CONVERSATION COMPACTION]";
11
+
12
+ /** Flatten a message's content to plain text. A replayed turn is usually a string; be defensive about
13
+ * the structured content-part form (extract the text parts, ignore the rest). */
14
+ function textOf(content: ModelMessage["content"]): string {
15
+ if (typeof content === "string") return content;
16
+ if (Array.isArray(content)) {
17
+ return content
18
+ .map((part) => {
19
+ if (typeof part === "string") return part;
20
+ if (part && typeof part === "object" && "text" in part && typeof (part as { text?: unknown }).text === "string") {
21
+ return (part as { text: string }).text;
22
+ }
23
+ return "";
24
+ })
25
+ .join("");
26
+ }
27
+ return "";
28
+ }
29
+
30
+ /** Map the replay thread to scrollback lines: the compaction head becomes a "condensed" marker, user and
31
+ * assistant turns become their normal lines, bracketed by a `resumed` header and a closing rule so the
32
+ * prior conversation reads as clearly distinct from the live session below it. Empty in → empty out
33
+ * (a fresh conversation hydrates nothing). */
34
+ export function threadToLines(messages: ModelMessage[]): Line[] {
35
+ const body: Line[] = [];
36
+ let condensed = false;
37
+ for (const m of messages) {
38
+ const text = textOf(m.content).trim();
39
+ if (!text) continue;
40
+ if (m.role === "user") {
41
+ if (text.startsWith(COMPACTION_HEAD_MARKER)) { condensed = true; continue; } // the pinned summary head
42
+ body.push({ kind: "user", text });
43
+ } else if (m.role === "assistant") {
44
+ body.push({ kind: "agent", from: "root", text, rendered: true });
45
+ }
46
+ // system / tool roles were never part of the visible conversation — skip them
47
+ }
48
+ if (body.length === 0) return [];
49
+ const header = condensed ? " ↩ resumed conversation · earlier turns condensed" : " ↩ resumed conversation";
50
+ return [{ kind: "system", text: header }, ...body, { kind: "system", text: " ───────────────" }];
51
+ }
@@ -0,0 +1,124 @@
1
+ /** Scrollback readability (transcript hierarchy). Pure helpers that decide how a `Line` is spaced and
2
+ * coloured so the REPL reads as distinct turns instead of a low-contrast wall. Three tiers:
3
+ *
4
+ * content the user's turn (a full-width inverse ` you ` bar) and agent replies (a bold speaker).
5
+ * activity operation breadcrumbs (tool calls, delegations) — a dim `│` rail; routine ops recede.
6
+ * problems warnings / refusals / failures — coloured and NEVER dim, so they can't hide in the noise.
7
+ *
8
+ * Kept pure + separate from App.tsx so the spacing/colour rules are unit-testable (Ink strips styling
9
+ * from the test frame, but these decisions are plain data). */
10
+ import type { Line } from "./slash";
11
+
12
+ /** Which logical block a scrollback line belongs to. Consecutive lines in the same group are ONE block
13
+ * (an agent reply is several rendered markdown blocks that share a speaker); a group change is a
14
+ * turn/speaker/op-stream boundary that earns a blank line above it. */
15
+ export function lineGroup(l: Line): string {
16
+ if (l.kind === "agent") return `agent:${l.from ?? ""}`;
17
+ return l.kind; // "user" | "system"
18
+ }
19
+
20
+ /** Blank line above a line: 1 when it OPENS a new block (a turn, a new speaker, or the start of an op
21
+ * stream), and also between the paragraphs of one agent reply so a long answer breathes. 0 keeps a run
22
+ * of operation breadcrumbs tight, so they read as a single column rather than sprawling. */
23
+ export function marginTopFor(prev: Line | undefined, l: Line): 0 | 1 {
24
+ if (!prev) return 0;
25
+ if (lineGroup(prev) !== lineGroup(l)) return 1; // block boundary
26
+ return l.kind === "agent" ? 1 : 0; // same-reply paragraph spacing; op streams stay tight
27
+ }
28
+
29
+ export interface SpacedLine {
30
+ line: Line;
31
+ marginTop: 0 | 1;
32
+ /** True when this line opens a new block — used to show a speaker label once per agent reply. */
33
+ newBlock: boolean;
34
+ }
35
+
36
+ /** Precompute each line's spacing + block-boundary flag from its predecessor, so a write-once renderer
37
+ * (Ink `<Static>`, whose callback sees one item at a time with no neighbours) needs no lookup. Prior
38
+ * lines are frozen, so appending a line never changes an earlier line's result — safe for the log. */
39
+ export function annotateSpacing(lines: Line[]): SpacedLine[] {
40
+ return lines.map((line, i) => {
41
+ const prev = lines[i - 1];
42
+ return { line, marginTop: marginTopFor(prev, line), newBlock: !prev || lineGroup(prev) !== lineGroup(line) };
43
+ });
44
+ }
45
+
46
+ export interface SystemLineStyle {
47
+ /** True for an activity breadcrumb (tool call, delegation, warning…) — it gets the `│` rail. False
48
+ * for a plain notice (boot message, run summary), which renders flush with no rail. */
49
+ isOp: boolean;
50
+ /** Ink colour name; undefined = default foreground. */
51
+ color?: string;
52
+ dim: boolean;
53
+ /** Text to display: op lines are left-trimmed (the rail supplies the indent); notices are verbatim. */
54
+ text: string;
55
+ }
56
+
57
+ interface GlyphStyle {
58
+ color?: string;
59
+ dim: boolean;
60
+ }
61
+
62
+ // Leading glyph → treatment. Problems are bright (never dim) so they surface; routine tool activity
63
+ // recedes to dim gray; a delegation keeps a dim magenta tint so it stays scannable amid the tool stream.
64
+ const GLYPH_STYLES: Record<string, GlyphStyle> = {
65
+ "⚠": { color: "yellow", dim: false }, // warning / failure — must stay visible
66
+ "⊘": { color: "red", dim: false }, // refused
67
+ "✗": { color: "red", dim: false }, // failed
68
+ "⇢": { color: "magenta", dim: true }, // dispatch / delegation
69
+ "↳": { color: "gray", dim: true }, // tool breadcrumb — routine, recede hard
70
+ "✓": { color: "green", dim: true }, // success
71
+ "⊗": { color: "yellow", dim: true }, // cancelling
72
+ "⏰": { color: "blue", dim: true }, // schedule fired
73
+ };
74
+
75
+ /** Classify a `kind: "system"` line by its leading activity glyph. A line with no known glyph is a
76
+ * plain notice (kept as the previous medium-gray). The glyph itself is left IN the text — the colour
77
+ * categorises it, the rail groups it. */
78
+ export function classifySystemLine(text: string): SystemLineStyle {
79
+ const trimmed = text.replace(/^\s+/, "");
80
+ for (const glyph in GLYPH_STYLES) {
81
+ if (trimmed.startsWith(glyph)) {
82
+ const s = GLYPH_STYLES[glyph];
83
+ return { isOp: true, color: s.color, dim: s.dim, text: trimmed };
84
+ }
85
+ }
86
+ return { isOp: false, color: "gray", dim: false, text };
87
+ }
88
+
89
+ const USER_BAR_LABEL = " you ";
90
+
91
+ /** Greedy word-wrap to `width` columns; a word longer than a line is hard-split so nothing overflows.
92
+ * Always returns at least one line (an empty string for empty input). */
93
+ export function wrapText(text: string, width: number): string[] {
94
+ const w = Math.max(1, width);
95
+ const lines: string[] = [];
96
+ for (const para of text.split("\n")) {
97
+ let cur = "";
98
+ for (let word of para.split(" ")) {
99
+ while (word.length > w) {
100
+ if (cur) { lines.push(cur); cur = ""; }
101
+ lines.push(word.slice(0, w));
102
+ word = word.slice(w);
103
+ }
104
+ if (cur === "") cur = word;
105
+ else if (cur.length + 1 + word.length <= w) cur += " " + word;
106
+ else { lines.push(cur); cur = word; }
107
+ }
108
+ lines.push(cur);
109
+ }
110
+ return lines.length ? lines : [""];
111
+ }
112
+
113
+ /** The user's turn as a FULL-WIDTH highlight bar: a ` you ` label on the first line, the message wrapped
114
+ * and aligned under it, every line padded to `width` so an inverse render fills the row edge-to-edge
115
+ * (an inverse `<Text>` only paints the characters it's given — the padding is what makes the bar solid). */
116
+ export function userBarLines(text: string, width: number): string[] {
117
+ const label = USER_BAR_LABEL;
118
+ const indent = " ".repeat(label.length);
119
+ const w = Math.max(label.length + 1, width);
120
+ const body = wrapText(text, w - label.length);
121
+ return body
122
+ .map((ln, i) => (i === 0 ? label : indent) + ln)
123
+ .map((ln) => (ln.length >= w ? ln.slice(0, w) : ln.padEnd(w)));
124
+ }
@@ -0,0 +1,65 @@
1
+ /** Plan 25: the /workflows browser's PURE model — the rows each screen renders, read from the stores.
2
+ * No Ink here, so it's unit-testable (mirrors org-browser-model.ts). The component renders these. */
3
+ import { listTeams } from "@taicho-ai/framework/store/teams";
4
+ import { DEFAULT_TEAM_ID } from "@taicho-ai/contracts/team";
5
+ import { loadWorkflowDef, loadWorkflow } from "@taicho-ai/framework/store/workflows";
6
+ import { listWorkflowRunIds, foldWorkflowRun } from "@taicho-ai/graph";
7
+ import type { WorkflowDef } from "@taicho-ai/graph";
8
+
9
+ export type WorkflowKind = "structured" | "prose" | "none";
10
+
11
+ export interface WorkflowRow {
12
+ team: string;
13
+ kind: WorkflowKind;
14
+ name?: string; // structured: the workflow name (def.id)
15
+ steps: number; // structured: step count · prose: seat count · none: 0
16
+ gates: number; // structured: human-gate count
17
+ runs: number; // past run count
18
+ lastStatus?: string; // last run's overall status
19
+ }
20
+
21
+ /** Run ids are `wr_<wf>_<n>`; sort by the numeric suffix (a lexical sort mis-orders _10 before _2). */
22
+ const runNumber = (id: string): number => Number(/_(\d+)$/.exec(id)?.[1] ?? 0);
23
+
24
+ /** One row per team (excluding `default`), classifying its workflow and summarizing its runs. */
25
+ export function listWorkflowRows(ws: string): WorkflowRow[] {
26
+ return listTeams(ws)
27
+ .filter((t) => t.id !== DEFAULT_TEAM_ID)
28
+ .map((t): WorkflowRow => {
29
+ const def = loadWorkflowDef(ws, t.id);
30
+ if (def) {
31
+ const runIds = listWorkflowRunIds(ws, def.id).sort((a, b) => runNumber(a) - runNumber(b));
32
+ const last = runIds.length ? foldWorkflowRun(ws, def, runIds[runIds.length - 1]!) : null;
33
+ return {
34
+ team: t.id,
35
+ kind: "structured",
36
+ name: def.id,
37
+ steps: def.steps.length,
38
+ gates: def.steps.filter((s) => s.kind === "human").length,
39
+ runs: runIds.length,
40
+ lastStatus: last?.status,
41
+ };
42
+ }
43
+ const prose = loadWorkflow(ws, t.id);
44
+ if (prose) return { team: t.id, kind: "prose", steps: prose.sections.size, gates: 0, runs: 0 };
45
+ return { team: t.id, kind: "none", steps: 0, gates: 0, runs: 0 };
46
+ })
47
+ .sort((a, b) => a.team.localeCompare(b.team));
48
+ }
49
+
50
+ export interface RunRow {
51
+ runId: string;
52
+ status: string;
53
+ done: number;
54
+ total: number;
55
+ }
56
+
57
+ /** Past runs of a workflow, newest-first, with folded status + counts. */
58
+ export function workflowRunRows(ws: string, def: WorkflowDef): RunRow[] {
59
+ return listWorkflowRunIds(ws, def.id)
60
+ .sort((a, b) => runNumber(b) - runNumber(a)) // newest first
61
+ .map((runId): RunRow => {
62
+ const s = foldWorkflowRun(ws, def, runId);
63
+ return { runId, status: s.status, done: s.counts.done, total: s.counts.total };
64
+ });
65
+ }