@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,170 @@
|
|
|
1
|
+
/** Plan 21 — the artifact browser's PURE model: scope resolution, filters, badges, run-grouping,
|
|
2
|
+
* sorts, and the "N of M" honesty line. No Ink, no state — everything here is unit-testable with a
|
|
3
|
+
* seeded workspace. The Shelf/Reader (ArtifactBrowser.tsx) render what this module returns.
|
|
4
|
+
*
|
|
5
|
+
* Scopes (spec §2):
|
|
6
|
+
* - "run" — the delegation subtree of one foreground turn (gatherConversationArtifacts).
|
|
7
|
+
* - "conversation" — the union over EVERY agent's conversation ledger (ledgers are per-agent; a
|
|
8
|
+
* foreground @agent turn audits to the TARGET agent's ledger, so reading root's
|
|
9
|
+
* alone would miss exactly the turns that docked the browser).
|
|
10
|
+
* - "all" — the whole store (latest version per id, by the manifest's construction),
|
|
11
|
+
* grouped by producing run so the widest scope reads as execution history. */
|
|
12
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { type Artifact, artifactHandle } from "@taicho-ai/contracts/artifact";
|
|
15
|
+
import { listArtifacts } from "@taicho-ai/framework/store/artifacts";
|
|
16
|
+
import { listAnnotations } from "@taicho-ai/framework/store/annotations";
|
|
17
|
+
import { loadLedger } from "@taicho-ai/framework/store/conversation";
|
|
18
|
+
import { gatherConversationArtifacts } from "@taicho-ai/framework/core/conversation-artifacts";
|
|
19
|
+
|
|
20
|
+
export type BrowserScope = "run" | "conversation" | "all";
|
|
21
|
+
export type BrowserSort = "run" | "time" | "producer";
|
|
22
|
+
|
|
23
|
+
export interface BrowserFilters {
|
|
24
|
+
producer?: string;
|
|
25
|
+
type?: string;
|
|
26
|
+
feedback?: "open" | "any";
|
|
27
|
+
verdict?: "pass" | "fail" | "any";
|
|
28
|
+
since?: "24h" | "7d" | "30d" | "all";
|
|
29
|
+
q?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RowBadges { openFeedback: number; approved: boolean; verdict?: "pass" | "fail"; }
|
|
33
|
+
export type ShelfRow =
|
|
34
|
+
| { kind: "header"; label: string }
|
|
35
|
+
| { kind: "artifact"; artifact: Artifact; badges: RowBadges };
|
|
36
|
+
|
|
37
|
+
const SINCE_MS: Record<Exclude<NonNullable<BrowserFilters["since"]>, "all">, number> = {
|
|
38
|
+
"24h": 24 * 3600_000,
|
|
39
|
+
"7d": 7 * 24 * 3600_000,
|
|
40
|
+
"30d": 30 * 24 * 3600_000,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function conversationAgents(ws: string): string[] {
|
|
44
|
+
const dir = join(ws, "conversations");
|
|
45
|
+
if (!existsSync(dir)) return [];
|
|
46
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Merge, keeping the LATEST version per logical id, ordered created desc — the same discipline
|
|
50
|
+
* gatherConversationArtifacts applies inside one run tree, applied across trees. */
|
|
51
|
+
function mergeLatest(lists: Artifact[][]): Artifact[] {
|
|
52
|
+
const byId = new Map<string, Artifact>();
|
|
53
|
+
for (const list of lists) for (const a of list) {
|
|
54
|
+
const cur = byId.get(a.id);
|
|
55
|
+
if (!cur || a.version > cur.version) byId.set(a.id, a);
|
|
56
|
+
}
|
|
57
|
+
return [...byId.values()].sort((x, y) => y.created.localeCompare(x.created));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveScope(ws: string, scope: BrowserScope, opts: { rootRunId?: string } = {}): Artifact[] {
|
|
61
|
+
if (scope === "run") return opts.rootRunId ? gatherConversationArtifacts(ws, opts.rootRunId) : [];
|
|
62
|
+
if (scope === "conversation") {
|
|
63
|
+
const runIds = new Set<string>();
|
|
64
|
+
for (const agent of conversationAgents(ws))
|
|
65
|
+
for (const turn of loadLedger(ws, agent)) if (turn.runId) runIds.add(turn.runId);
|
|
66
|
+
return mergeLatest([...runIds].map((id) => gatherConversationArtifacts(ws, id)));
|
|
67
|
+
}
|
|
68
|
+
return listArtifacts(ws);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Cold-start "latest run": the newest ledger turn across every agent's conversation — a foreground
|
|
72
|
+
* turn by definition (only user turns are audited). Undefined on a fresh workspace. */
|
|
73
|
+
export function latestRunFallback(ws: string): string | undefined {
|
|
74
|
+
let best: { ts: string; runId: string } | undefined;
|
|
75
|
+
for (const agent of conversationAgents(ws))
|
|
76
|
+
for (const turn of loadLedger(ws, agent)) {
|
|
77
|
+
if (!turn.runId || !turn.timestamp) continue;
|
|
78
|
+
if (!best || turn.timestamp > best.ts) best = { ts: turn.timestamp, runId: turn.runId };
|
|
79
|
+
}
|
|
80
|
+
return best?.runId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function badgesFor(ws: string, a: Artifact): RowBadges {
|
|
84
|
+
const anns = listAnnotations(ws, artifactHandle(a));
|
|
85
|
+
let verdict: "pass" | "fail" | undefined;
|
|
86
|
+
for (const an of anns) if (an.verdict) verdict = an.verdict.pass ? "pass" : "fail"; // last verdict wins
|
|
87
|
+
return {
|
|
88
|
+
// "Open feedback" means ACTIONABLE: open feedback text or a FAILING verdict. A passing checker
|
|
89
|
+
// verdict is an open annotation too, but flagging it would tell the captain to act on a pass —
|
|
90
|
+
// and an approval (open by ledger mechanics) is state, not feedback.
|
|
91
|
+
openFeedback: anns.filter((an) => an.status === "open" && an.kind !== "approval" && (!an.verdict || !an.verdict.pass)).length,
|
|
92
|
+
approved: anns.some((an) => an.kind === "approval"),
|
|
93
|
+
verdict,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function applyFilters(ws: string, arts: Artifact[], f: BrowserFilters, nowMs = Date.now()): Artifact[] {
|
|
98
|
+
const ql = f.q?.trim().toLowerCase();
|
|
99
|
+
return arts.filter((a) => {
|
|
100
|
+
if (f.producer && a.producer !== f.producer) return false;
|
|
101
|
+
if (f.type && a.type !== f.type) return false;
|
|
102
|
+
if (ql && !`${a.id} ${a.title} ${a.summary ?? ""}`.toLowerCase().includes(ql)) return false;
|
|
103
|
+
if (f.since && f.since !== "all") {
|
|
104
|
+
const age = nowMs - Date.parse(a.created);
|
|
105
|
+
if (!(age <= SINCE_MS[f.since])) return false;
|
|
106
|
+
}
|
|
107
|
+
if (f.feedback === "open" || (f.verdict && f.verdict !== "any")) {
|
|
108
|
+
const b = badgesFor(ws, a);
|
|
109
|
+
if (f.feedback === "open" && b.openFeedback === 0) return false;
|
|
110
|
+
if (f.verdict && f.verdict !== "any" && b.verdict !== f.verdict) return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function shortRun(runId: string): string {
|
|
117
|
+
const slash = runId.indexOf("/");
|
|
118
|
+
return slash === -1 ? runId : runId.slice(slash + 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function ageLabel(created: string, nowMs = Date.now()): string {
|
|
122
|
+
const ms = nowMs - Date.parse(created);
|
|
123
|
+
if (isNaN(ms)) return "";
|
|
124
|
+
const secs = Math.floor(ms / 1000);
|
|
125
|
+
if (secs < 60) return `${Math.max(secs, 0)}s`;
|
|
126
|
+
const mins = Math.floor(secs / 60);
|
|
127
|
+
if (mins < 60) return `${mins}m`;
|
|
128
|
+
const hours = Math.floor(mins / 60);
|
|
129
|
+
if (hours < 24) return `${hours}h`;
|
|
130
|
+
return `${Math.floor(hours / 24)}d`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Rows the Shelf renders. Only the all-runs scope with the default "run" sort gets group headers
|
|
134
|
+
* (execution history); every flat sort/scope is artifact rows only, and `sel` indexes ONLY the
|
|
135
|
+
* artifact rows — headers are never selectable. */
|
|
136
|
+
export function shelfRows(ws: string, arts: Artifact[], scope: BrowserScope, sort: BrowserSort, nowMs = Date.now()): ShelfRow[] {
|
|
137
|
+
const row = (a: Artifact): ShelfRow => ({ kind: "artifact", artifact: a, badges: badgesFor(ws, a) });
|
|
138
|
+
if (scope !== "all" || sort === "time")
|
|
139
|
+
return [...arts].sort((x, y) => y.created.localeCompare(x.created)).map(row);
|
|
140
|
+
if (sort === "producer")
|
|
141
|
+
return [...arts].sort((x, y) => x.producer.localeCompare(y.producer) || y.created.localeCompare(x.created)).map(row);
|
|
142
|
+
// sort === "run": group by producing run, groups ordered by their newest member.
|
|
143
|
+
const groups = new Map<string, Artifact[]>();
|
|
144
|
+
for (const a of arts) {
|
|
145
|
+
const g = groups.get(a.runId) ?? [];
|
|
146
|
+
g.push(a);
|
|
147
|
+
groups.set(a.runId, g);
|
|
148
|
+
}
|
|
149
|
+
const ordered = [...groups.entries()].map(([runId, list]) => {
|
|
150
|
+
list.sort((x, y) => y.created.localeCompare(x.created));
|
|
151
|
+
return { runId, list, newest: list[0]!.created };
|
|
152
|
+
}).sort((x, y) => y.newest.localeCompare(x.newest));
|
|
153
|
+
const out: ShelfRow[] = [];
|
|
154
|
+
for (const g of ordered) {
|
|
155
|
+
out.push({ kind: "header", label: `── ${shortRun(g.runId)} · ${g.list.length} artifact${g.list.length === 1 ? "" : "s"} · ${ageLabel(g.newest, nowMs)}` });
|
|
156
|
+
for (const a of g.list) out.push(row(a));
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The honesty line (spec §3): a filtered shelf announces itself, never impersonates the store. */
|
|
162
|
+
export function countLine(matched: number, total: number): string {
|
|
163
|
+
if (matched === total) return `${total} artifact${total === 1 ? "" : "s"}`;
|
|
164
|
+
return `${matched} of ${total} match`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The artifact rows in shelf order (what `sel` indexes). */
|
|
168
|
+
export function artifactRows(rows: ShelfRow[]): Extract<ShelfRow, { kind: "artifact" }>[] {
|
|
169
|
+
return rows.filter((r): r is Extract<ShelfRow, { kind: "artifact" }> => r.kind === "artifact");
|
|
170
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Plan 23: hand the terminal to the captain's OWN editor to edit a file, then return control to Ink.
|
|
2
|
+
*
|
|
3
|
+
* The old approach (spawn detached, stdio:"ignore") could never work for a TERMINAL editor — Ink owns
|
|
4
|
+
* the tty (raw mode + full-screen render loop), so nano/vim never got it, and with no $EDITOR set it
|
|
5
|
+
* just dead-ended on "no $EDITOR". Ink 7.1.0's `suspendTerminal()` fixes this properly: it releases the
|
|
6
|
+
* terminal to a child process, then restores Ink's state and repaints. We wait for the editor to EXIT
|
|
7
|
+
* before resuming, so the browser (which re-reads files on render) shows the saved changes on return.
|
|
8
|
+
*
|
|
9
|
+
* Editor precedence: $EDITOR → $VISUAL → nano → vi. In a non-interactive context (tests, pipes) there
|
|
10
|
+
* is no tty to hand off, so we skip the spawn and report the path — a test never launches a real editor. */
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
/** The callback form of Ink 7.1.0's `useApp().suspendTerminal` — restores the terminal even on throw. */
|
|
14
|
+
export type SuspendTerminal = (callback: () => void | Promise<void>) => Promise<void>;
|
|
15
|
+
|
|
16
|
+
/** Runs the chosen editor over the file and resolves when it EXITS (rejects on spawn error, e.g. ENOENT).
|
|
17
|
+
* Injectable so tests never launch a real process — the real one inherits the tty (stdio:"inherit"). */
|
|
18
|
+
export type RunEditor = (cmd: string, args: string[]) => Promise<void>;
|
|
19
|
+
|
|
20
|
+
const spawnEditor: RunEditor = (cmd, args) =>
|
|
21
|
+
new Promise<void>((resolve, reject) => {
|
|
22
|
+
const child = spawn(cmd, args, { stdio: "inherit" });
|
|
23
|
+
child.on("error", reject);
|
|
24
|
+
child.on("exit", () => resolve());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export interface EditResult { ok: boolean; note: string }
|
|
28
|
+
|
|
29
|
+
/** The editor to hand off to: $EDITOR → $VISUAL → nano (the near-universal terminal default). May carry
|
|
30
|
+
* flags (e.g. "code --wait"); the caller splits it. */
|
|
31
|
+
export function pickEditor(): string {
|
|
32
|
+
return process.env.EDITOR || process.env.VISUAL || "nano";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function editFileInTerminal(suspendTerminal: SuspendTerminal, path: string, run: RunEditor = spawnEditor): Promise<EditResult> {
|
|
36
|
+
const editor = pickEditor();
|
|
37
|
+
const name = path.split("/").pop() ?? path;
|
|
38
|
+
// No interactive terminal to hand off (tests, headless, a pipe) → don't launch anything.
|
|
39
|
+
if (!process.stdin.isTTY) return { ok: false, note: `not an interactive terminal — open it yourself: ${path}` };
|
|
40
|
+
try {
|
|
41
|
+
await suspendTerminal(() => {
|
|
42
|
+
// $EDITOR may carry flags ("code --wait", "code -w"); split so the command + its args are honored.
|
|
43
|
+
const [cmd, ...args] = editor.split(/\s+/).filter(Boolean);
|
|
44
|
+
return run(cmd!, [...args, path]);
|
|
45
|
+
});
|
|
46
|
+
return { ok: true, note: `✓ ${name} saved — reloaded` };
|
|
47
|
+
} catch (e) {
|
|
48
|
+
const code = (e as NodeJS.ErrnoException)?.code;
|
|
49
|
+
return { ok: false, note: `⚠ couldn't open "${editor}"${code ? ` (${code})` : ""} — set $EDITOR, or open: ${path}` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Plan 24: session message history + a ↑/↓ navigator. `idx` is an offset from the NEWEST entry: -1 means
|
|
2
|
+
* "on the live draft" (not in history yet), 0 = newest, list.length-1 = oldest. Stepping up the first
|
|
3
|
+
* time stashes the current draft so stepping all the way back down restores it (shell-style). Pure +
|
|
4
|
+
* deterministic. loadHistory/appendHistory persist across sessions in a per-workspace dot-file. */
|
|
5
|
+
import { existsSync, readFileSync, appendFileSync } from "node:fs";
|
|
6
|
+
import { paths } from "@taicho-ai/framework/store/files";
|
|
7
|
+
|
|
8
|
+
export const HISTORY_CAP = 500;
|
|
9
|
+
|
|
10
|
+
export function pushHistory(list: string[], entry: string, cap = HISTORY_CAP): string[] {
|
|
11
|
+
const e = entry.trim();
|
|
12
|
+
if (!e) return list;
|
|
13
|
+
if (list.length && list[list.length - 1] === e) return list; // ignore consecutive dupes
|
|
14
|
+
const next = [...list, e];
|
|
15
|
+
return next.length > cap ? next.slice(next.length - cap) : next;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HistNav { idx: number; draft: string }
|
|
19
|
+
export const histStart = (): HistNav => ({ idx: -1, draft: "" });
|
|
20
|
+
|
|
21
|
+
/** Step to an OLDER entry. `current` is the buffer's current text (stashed as the draft on the first step). */
|
|
22
|
+
export function histPrev(nav: HistNav, list: string[], current: string): { nav: HistNav; value: string } | null {
|
|
23
|
+
if (!list.length) return null;
|
|
24
|
+
const draft = nav.idx === -1 ? current : nav.draft;
|
|
25
|
+
const idx = Math.min(nav.idx + 1, list.length - 1);
|
|
26
|
+
if (idx === nav.idx) return null; // already at the oldest
|
|
27
|
+
return { nav: { idx, draft }, value: list[list.length - 1 - idx]! };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Step to a NEWER entry, or back to the stashed draft when leaving history. */
|
|
31
|
+
export function histNext(nav: HistNav, list: string[]): { nav: HistNav; value: string } | null {
|
|
32
|
+
if (nav.idx === -1) return null; // already on the draft
|
|
33
|
+
const idx = nav.idx - 1;
|
|
34
|
+
if (idx === -1) return { nav: { idx: -1, draft: "" }, value: nav.draft };
|
|
35
|
+
return { nav: { idx, draft: nav.draft }, value: list[list.length - 1 - idx]! };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── persistence (per-workspace dot-file; gitignored) ─────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export function loadHistory(ws: string): string[] {
|
|
41
|
+
const f = paths.inputHistoryFile(ws);
|
|
42
|
+
if (!existsSync(f)) return [];
|
|
43
|
+
const lines = readFileSync(f, "utf8").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
44
|
+
const out: string[] = [];
|
|
45
|
+
for (const l of lines) if (out[out.length - 1] !== l) out.push(l); // collapse consecutive dupes
|
|
46
|
+
return out.length > HISTORY_CAP ? out.slice(out.length - HISTORY_CAP) : out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function appendHistory(ws: string, entry: string): void {
|
|
50
|
+
const e = entry.trim();
|
|
51
|
+
if (!e) return;
|
|
52
|
+
const cur = loadHistory(ws);
|
|
53
|
+
if (cur[cur.length - 1] === e) return;
|
|
54
|
+
appendFileSync(paths.inputHistoryFile(ws), e + "\n");
|
|
55
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Plan 24: map one Ink keypress to a semantic editing action. This is the ONE place the cross-platform
|
|
2
|
+
* key detection lives. Ink 7.1.0 normalizes Option/Alt into `key.meta` (via ESC-prefix like `\x1b\x7f`,
|
|
3
|
+
* and via the kitty keyboard protocol). We also bind Ctrl+W → delete-word-back, the readline-universal
|
|
4
|
+
* fallback that works even on macOS Terminal.app/iTerm2 when "Use Option as Meta" is OFF. Word MOVE is
|
|
5
|
+
* bound to meta+arrow and meta+b / meta+f; word DELETE to meta+backspace / meta+delete, Alt+d, Ctrl+W. */
|
|
6
|
+
import type { Key } from "ink";
|
|
7
|
+
|
|
8
|
+
export type InputAction =
|
|
9
|
+
| { kind: "insert"; text: string } | { kind: "newline" }
|
|
10
|
+
| { kind: "backspace" } | { kind: "del" }
|
|
11
|
+
| { kind: "left" } | { kind: "right" } | { kind: "home" } | { kind: "end" }
|
|
12
|
+
| { kind: "wordLeft" } | { kind: "wordRight" } | { kind: "deleteWordBack" } | { kind: "deleteWordForward" }
|
|
13
|
+
| { kind: "submit" } | { kind: "historyPrev" } | { kind: "historyNext" } | { kind: "noop" };
|
|
14
|
+
|
|
15
|
+
export function classifyKey(input: string, key: Key): InputAction {
|
|
16
|
+
// Newline (multi-line): Shift+Enter (kitty-protocol terminals), Alt/Option+Enter, or Ctrl+J. Ctrl+J is
|
|
17
|
+
// the terminal-agnostic fallback: legacy terminals send a raw linefeed `\n`; under the kitty protocol it
|
|
18
|
+
// arrives as `input:"j", key.ctrl` (a CSI-u sequence), so accept both so it behaves identically either way.
|
|
19
|
+
if (input === "\n" || (key.ctrl && input === "j") || (key.return && (key.shift || key.meta)))
|
|
20
|
+
return { kind: "newline" };
|
|
21
|
+
if (key.return) return { kind: "submit" };
|
|
22
|
+
if (key.tab || key.escape) return { kind: "noop" }; // owned by the suggester / higher dispatch
|
|
23
|
+
|
|
24
|
+
// Word DELETE (check before plain backspace/delete).
|
|
25
|
+
if (key.ctrl && (input === "w" || input === "\x17")) return { kind: "deleteWordBack" }; // Ctrl+W
|
|
26
|
+
if (key.meta && key.backspace) return { kind: "deleteWordBack" };
|
|
27
|
+
if (key.meta && key.delete) return { kind: "deleteWordForward" };
|
|
28
|
+
if (key.meta && input === "d") return { kind: "deleteWordForward" };
|
|
29
|
+
|
|
30
|
+
// Word MOVE (check before plain arrows).
|
|
31
|
+
if (key.meta && key.leftArrow) return { kind: "wordLeft" };
|
|
32
|
+
if (key.meta && key.rightArrow) return { kind: "wordRight" };
|
|
33
|
+
if (key.meta && input === "b") return { kind: "wordLeft" };
|
|
34
|
+
if (key.meta && input === "f") return { kind: "wordRight" };
|
|
35
|
+
|
|
36
|
+
// Plain motions + deletes.
|
|
37
|
+
if (key.backspace) return { kind: "backspace" };
|
|
38
|
+
if (key.delete) return { kind: "del" };
|
|
39
|
+
if (key.leftArrow) return { kind: "left" };
|
|
40
|
+
if (key.rightArrow) return { kind: "right" };
|
|
41
|
+
if (key.home || (key.ctrl && input === "a")) return { kind: "home" };
|
|
42
|
+
if (key.end || (key.ctrl && input === "e")) return { kind: "end" };
|
|
43
|
+
if (key.upArrow) return { kind: "historyPrev" };
|
|
44
|
+
if (key.downArrow) return { kind: "historyNext" };
|
|
45
|
+
|
|
46
|
+
// Any remaining ctrl/meta combo is a shortcut we don't own — never insert control chars.
|
|
47
|
+
if (key.ctrl || key.meta) return { kind: "noop" };
|
|
48
|
+
// Printable text (Ink hands a paste as one multi-char `input`).
|
|
49
|
+
if (input && input !== "\r" && input !== "\n") return { kind: "insert", text: input };
|
|
50
|
+
return { kind: "noop" };
|
|
51
|
+
}
|
package/src/ui/input.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Pure REPL line classifier: slash command, @address, or bare chat (-> root). */
|
|
2
|
+
export type ParsedInput =
|
|
3
|
+
| { kind: "slash"; cmd: string; arg: string }
|
|
4
|
+
| { kind: "address"; to: string; text: string }
|
|
5
|
+
| { kind: "chat"; text: string };
|
|
6
|
+
|
|
7
|
+
export function parseInput(value: string): ParsedInput {
|
|
8
|
+
const t = value.trim();
|
|
9
|
+
if (t.startsWith("/")) {
|
|
10
|
+
const [cmd, ...rest] = t.slice(1).split(/\s+/);
|
|
11
|
+
return { kind: "slash", cmd, arg: rest.join(" ") };
|
|
12
|
+
}
|
|
13
|
+
if (t.startsWith("@")) {
|
|
14
|
+
const m = /^@([a-z][a-z0-9-]*)\s*([\s\S]*)$/.exec(t);
|
|
15
|
+
if (m) return { kind: "address", to: m[1], text: m[2] };
|
|
16
|
+
}
|
|
17
|
+
return { kind: "chat", text: t };
|
|
18
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Incremental markdown streaming: given the accumulated buffer, report which top-level markdown
|
|
2
|
+
* blocks are COMPLETE (safe to render) vs the still-growing tail. Uses marked's lexer — every
|
|
3
|
+
* content token except the last is stable; the last may still grow (an open code fence / list is a
|
|
4
|
+
* single token that stays in `tail` until it closes). Pure + deterministic. */
|
|
5
|
+
import { Marked } from "marked";
|
|
6
|
+
|
|
7
|
+
const lexerMd = new Marked();
|
|
8
|
+
|
|
9
|
+
// Block types that can still GROW by absorbing a following block across a blank line (a loose list
|
|
10
|
+
// gains items; a blockquote gains lazy lines; html gains lines). Committing one on a trailing blank
|
|
11
|
+
// line would let a later delta shift its boundary — so these stay in the tail until a different
|
|
12
|
+
// block begins after them. Every other type (heading, paragraph, closed code fence, hr, table) is
|
|
13
|
+
// final the moment a blank line follows it.
|
|
14
|
+
const MERGEABLE = new Set(["list", "blockquote", "html"]);
|
|
15
|
+
|
|
16
|
+
export interface BlockSplit { blocks: string[]; tail: string }
|
|
17
|
+
|
|
18
|
+
export function splitCompletedBlocks(buffer: string): BlockSplit {
|
|
19
|
+
if (!buffer.trim()) return { blocks: [], tail: buffer };
|
|
20
|
+
const tokens = lexerMd.lexer(buffer).filter((t) => t.type !== "space"); // drop blank-line tokens
|
|
21
|
+
if (tokens.length === 0) return { blocks: [], tail: buffer };
|
|
22
|
+
const last = tokens[tokens.length - 1]!;
|
|
23
|
+
// A block is complete when a later block already follows it — OR when it is the last block but a
|
|
24
|
+
// trailing blank line proves it closed AND its type can't later absorb the next block. This lets a
|
|
25
|
+
// finished heading/paragraph/fence reveal as soon as its blank line lands, instead of waiting for
|
|
26
|
+
// the next block's first token (so the stream breaks on blocks, never showing a raw partial line).
|
|
27
|
+
if (/\n[ \t]*\n[ \t]*$/.test(buffer) && !MERGEABLE.has(last.type))
|
|
28
|
+
return { blocks: tokens.map((t) => t.raw), tail: "" };
|
|
29
|
+
if (tokens.length === 1) return { blocks: [], tail: buffer };
|
|
30
|
+
return { blocks: tokens.slice(0, -1).map((t) => t.raw), tail: last.raw };
|
|
31
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Markdown → ANSI for the terminal REPL. A thin, owned wrapper over marked + marked-terminal
|
|
2
|
+
* (we don't use the dormant ink-markdown package). Pure + synchronous + memoized, so it's cheap to
|
|
3
|
+
* call from render. One Marked instance per width (marked-terminal wraps to a fixed width). */
|
|
4
|
+
import { Marked } from "marked";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
// @ts-ignore marked-terminal has no types, but the API is stable
|
|
8
|
+
import { markedTerminal } from "marked-terminal";
|
|
9
|
+
|
|
10
|
+
const byWidth = new Map<number, Marked>();
|
|
11
|
+
function mdFor(width: number): Marked {
|
|
12
|
+
let m = byWidth.get(width);
|
|
13
|
+
if (!m) {
|
|
14
|
+
// reflowText makes paragraphs wrap to `width`; unescape keeps entities readable.
|
|
15
|
+
// showSectionPrefix: false removes the markdown markers from output (e.g., # from headings).
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
|
+
m = new Marked(markedTerminal({ width, reflowText: true, unescape: true, showSectionPrefix: false }) as any);
|
|
18
|
+
byWidth.set(width, m);
|
|
19
|
+
}
|
|
20
|
+
return m;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const cache = new Map<string, string>();
|
|
24
|
+
|
|
25
|
+
export function renderMarkdown(text: string, width = 80): string {
|
|
26
|
+
const key = `${width}:${text}`;
|
|
27
|
+
const hit = cache.get(key);
|
|
28
|
+
if (hit !== undefined) return hit;
|
|
29
|
+
// marked-terminal both styles text AND strips markdown markers (**bold**, `-` lists, `#`) via chalk.
|
|
30
|
+
// When chalk auto-detects NO color (level 0 — common in a compiled binary / non-TTY / the Ink
|
|
31
|
+
// runtime) it returns the markdown almost RAW, markers intact — so agent replies show as literal
|
|
32
|
+
// `**text**`. taicho is a color TUI, so force a color level for the SYNCHRONOUS render and restore
|
|
33
|
+
// it immediately (a scoped, self-undoing bump — not a permanent global mutation). Respect NO_COLOR.
|
|
34
|
+
const prevLevel = chalk.level;
|
|
35
|
+
if (prevLevel === 0 && !process.env.NO_COLOR) chalk.level = 3;
|
|
36
|
+
let out: string;
|
|
37
|
+
try {
|
|
38
|
+
out = (mdFor(width).parse(text) as string).replace(/\n+$/, "");
|
|
39
|
+
} finally {
|
|
40
|
+
chalk.level = prevLevel;
|
|
41
|
+
}
|
|
42
|
+
if (cache.size > 500) cache.clear(); // bound the memo; a REPL session shouldn't hoard forever
|
|
43
|
+
cache.set(key, out);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Plan 22 — the Org browser's PURE model: the rows each scope renders, and the small guards the UI
|
|
2
|
+
* needs. No Ink, no state — everything here reads the store (like browser-model.ts) and is unit-testable
|
|
3
|
+
* with a seeded workspace. OrgBrowser.tsx renders what this returns. */
|
|
4
|
+
import type { Database } from "bun:sqlite";
|
|
5
|
+
import { listTeams, membersOf } from "@taicho-ai/framework/store/teams";
|
|
6
|
+
import { loadIndex } from "@taicho-ai/framework/store/roster";
|
|
7
|
+
import { DEFAULT_TEAM_ID } from "@taicho-ai/contracts/team";
|
|
8
|
+
|
|
9
|
+
export type OrgScope = "teams" | "agents";
|
|
10
|
+
|
|
11
|
+
export interface TeamRow { id: string; charter: string; lead?: string; members: string[]; }
|
|
12
|
+
export interface AgentRow { id: string; role: string; isRoot: boolean; teams: string[]; }
|
|
13
|
+
|
|
14
|
+
/** Every team, with its members resolved from the join. The default team is included — it IS the squad,
|
|
15
|
+
* and the Org browser is where the captain sees the whole org (unlike the terse /agents list). */
|
|
16
|
+
export function teamRows(ws: string, db: Database): TeamRow[] {
|
|
17
|
+
return listTeams(ws).map((t) => ({ id: t.id, charter: t.charter, lead: t.lead, members: membersOf(db, t.id).map((m) => m.id) }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Every agent, with its EXPLICIT teams (default dropped — every agent is on it, so it carries no signal
|
|
21
|
+
* in a per-row list). Sorted by id, root first is NOT forced — id order keeps navigation deterministic. */
|
|
22
|
+
export function agentRows(db: Database): AgentRow[] {
|
|
23
|
+
return loadIndex(db)
|
|
24
|
+
.map((r) => ({ id: r.id, role: r.role, isRoot: !!r.is_root, teams: (r.teams ?? []).filter((t) => t !== DEFAULT_TEAM_ID) }))
|
|
25
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Root and the librarian are the squad's fixtures — they cannot be retired (deleteAgent enforces it too;
|
|
29
|
+
* this is the UI-side guard that greys the verb). */
|
|
30
|
+
export function isProtectedAgent(id: string): boolean {
|
|
31
|
+
return id === "root" || id === "librarian";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The default team cannot be deleted or have its membership set — everyone belongs to it. */
|
|
35
|
+
export function isProtectedTeam(id: string): boolean {
|
|
36
|
+
return id === DEFAULT_TEAM_ID;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Wrap a selection index into [0, len) — shared by every list/cursor in the browser. */
|
|
40
|
+
export function clampSel(sel: number, len: number): number {
|
|
41
|
+
if (len <= 0) return 0;
|
|
42
|
+
return Math.max(0, Math.min(sel, len - 1));
|
|
43
|
+
}
|