@kal-elsam/kairo-runtime 0.16.0 → 0.18.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +2 -1
  3. package/scripts/cockpit-smoke.mjs +1 -1
  4. package/scripts/ux-smoke-test.sh +3 -3
  5. package/src/cli.js +96 -11
  6. package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
  7. package/src/global/architect/architect-cli.js +76 -0
  8. package/src/global/architect/architect-codex.js +146 -0
  9. package/src/global/architect/architect-manager.js +125 -0
  10. package/src/global/architect/architect-store.js +377 -0
  11. package/src/global/architect/architect-types.js +47 -0
  12. package/src/global/cli-help.js +10 -1
  13. package/src/global/cockpit/app.js +493 -0
  14. package/src/global/cockpit/card.js +111 -0
  15. package/src/global/cockpit/cli.js +33 -0
  16. package/src/global/cockpit/gauge.js +31 -0
  17. package/src/global/cockpit/project-overlay.js +693 -0
  18. package/src/global/cockpit/rows.js +148 -0
  19. package/src/global/cockpit/theme.js +118 -0
  20. package/src/global/cockpit/view.js +1298 -0
  21. package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
  22. package/src/global/conversation/cli.js +53 -0
  23. package/src/global/conversation/codex-sandbox.js +230 -0
  24. package/src/global/conversation/cursor-sandbox.js +215 -0
  25. package/src/global/conversation/project-analysis.js +204 -0
  26. package/src/global/conversation/project-profile.js +178 -0
  27. package/src/global/conversation/project-router.js +149 -0
  28. package/src/global/conversation/project-strategy-store.js +64 -0
  29. package/src/global/conversation/project-strategy.js +514 -0
  30. package/src/global/conversation/sanitized-snapshot.js +169 -0
  31. package/src/global/conversation/secret-scanner.js +71 -0
  32. package/src/global/conversation/service.js +1090 -0
  33. package/src/global/conversation/session-store.js +75 -0
  34. package/src/global/conversation/transcript-store.js +79 -0
  35. package/src/global/conversation/ui.js +195 -0
  36. package/src/global/intelligence/capability-scoring.js +480 -0
  37. package/src/global/intelligence/execution-router.js +466 -0
  38. package/src/global/intelligence/kairo-telemetry-source.js +59 -0
  39. package/src/global/intelligence/kairobench-runner.js +85 -0
  40. package/src/global/intelligence/kairobench-source.js +34 -0
  41. package/src/global/intelligence/kairobench-tasks.js +47 -0
  42. package/src/global/intelligence/model-candidate-catalog.js +456 -0
  43. package/src/global/intelligence/model-capability-registry-sources.js +145 -0
  44. package/src/global/intelligence/model-capability-registry.js +125 -0
  45. package/src/global/intelligence/model-intelligence.js +1646 -0
  46. package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
  47. package/src/global/intelligence/quick-ask.js +149 -0
  48. package/src/global/intelligence/role-profiles.js +251 -0
  49. package/src/global/intelligence/skill-catalog.js +67 -0
  50. package/src/global/intelligence/subscription-pressure-source.js +41 -0
  51. package/src/global/mcp/kairo-mcp.js +51 -18
  52. package/src/global/mcp/work-snapshot-rule.js +4 -2
  53. package/src/global/mcp/workspace-binding.js +88 -0
  54. package/src/global/mcp/workspace-mcp-entry.js +74 -0
  55. package/src/global/mcp-install.js +8 -1
  56. package/src/global/observability/artificial-analysis-models.js +118 -0
  57. package/src/global/observability/claude-models.js +31 -0
  58. package/src/global/observability/claude-usage.js +112 -0
  59. package/src/global/observability/codex-models.js +96 -0
  60. package/src/global/observability/codex-usage.js +160 -0
  61. package/src/global/observability/cursor-auth.js +88 -0
  62. package/src/global/observability/cursor-models.js +101 -0
  63. package/src/global/observability/huggingface-leaderboard.js +97 -0
  64. package/src/global/observability/opencode-models.js +101 -0
  65. package/src/global/observability/opencode-usage.js +162 -0
  66. package/src/global/paths.js +49 -2
  67. package/src/global/profile.js +23 -1
  68. package/src/global/runtime/execution-adapters/claude.js +63 -30
  69. package/src/global/runtime/execution-adapters/codex.js +9 -2
  70. package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
  71. package/src/global/runtime/execution-adapters/opencode.js +83 -18
  72. package/src/global/runtime/execution-worktree-manager.js +924 -0
  73. package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
  74. package/src/global/runtime/execution-worktree-store.js +83 -0
  75. package/src/global/runtime/execution-worktree-types.js +45 -0
  76. package/src/global/runtime/run-events.js +38 -0
  77. package/src/global/runtime/run-manager.js +22 -6
  78. package/src/global/runtime/run-supervisor.js +41 -12
  79. package/src/global/runtime/usage-manager.js +96 -0
  80. package/src/global/runtime/usage-store.js +69 -0
  81. package/src/global/runtime/usage-types.js +62 -0
@@ -0,0 +1,493 @@
1
+ import { Editor, ProcessTerminal, ScrollView, TuiAltScreen, VStack, isViewportTUI, matchesKey } from "@earendil-works/pi-tui";
2
+ import { createConversationService } from "../conversation/service.js";
3
+ import { CockpitView } from "./view.js";
4
+ import { editorTheme, theme } from "./theme.js";
5
+ import { CARD_TONE, cardTop } from "./card.js";
6
+ import { openProjectOverlay } from "./project-overlay.js";
7
+
8
+ // The composer is otherwise just two flat, muted rules from pi-tui's Editor
9
+ // (no title, no corners) — easy to miss right under the cockpit's colorful
10
+ // framed cards. This title bar gives it the same rounded-corner, titled
11
+ // look as the rest of the cockpit so it reads as its own zone: a blank
12
+ // spacer row separates it from whatever is above (SESSION), then the title
13
+ // sits directly on top of the editor's own rule with no gap, so the two
14
+ // read as one continuous framed box rather than two disconnected pieces.
15
+ function composerHeader(width, workMode) {
16
+ const label = workMode ? ` · ${workMode.toUpperCase()}` : "";
17
+ return ["", cardTop(`Message Kairo${label}`, CARD_TONE.SUCCESS, theme, width)];
18
+ }
19
+
20
+ const DEFAULT_POLL_MS = 2000;
21
+ // Fast enough to read as a live "thinking" indicator (a real spinner plus
22
+ // a ticking elapsed-time count), slow enough to never matter for CPU/
23
+ // battery on a terminal app that's otherwise idle between actions.
24
+ const DEFAULT_SPINNER_MS = 120;
25
+
26
+ /**
27
+ * Builds the real-scroll layout tree: dashboard (fixed) + conversation
28
+ * (the only scrollable zone, via pi-tui's own ScrollView) + composer
29
+ * header (fixed) + editor (fixed). Extracted as a pure function so the
30
+ * exact shape/options can be unit tested directly — pi-tui's
31
+ * isViewportTUI() gate checks an internal, non-exported symbol that test
32
+ * doubles can't fake, so this can't be exercised end-to-end through
33
+ * runCockpitApp() in a unit test the way the addChild() fallback can.
34
+ * `view` still owns all cockpit state; dashboardComponent/
35
+ * conversationComponent are thin render adapters, not new state.
36
+ * @param {import("./view.js").CockpitView} view
37
+ * @param {object} editor
38
+ * @returns {object} a VStack ready for tui.setLayoutRoot()
39
+ */
40
+ export function buildViewportLayoutRoot(view, editor) {
41
+ const dashboardComponent = { render: (width) => view.renderDashboard(width) };
42
+ const conversationComponent = { render: (width) => view.renderConversation(width) };
43
+ const conversationScroll = new ScrollView(conversationComponent, {
44
+ follow: "end", primary: true, overscroll: "contain", scrollbar: "auto"
45
+ });
46
+ return new VStack([
47
+ { component: dashboardComponent, shrink: 0 },
48
+ { component: conversationScroll, grow: 1, minSize: 4 },
49
+ { component: { render: (width) => composerHeader(width, view.workMode) }, basis: 2, shrink: 0 },
50
+ { component: editor, basis: 3, shrink: 0 }
51
+ ], { gap: 0 });
52
+ }
53
+
54
+ /**
55
+ * Boots the interactive `kairo start` cockpit: a full-screen pi-tui app wired
56
+ * to the real conversation service (no mocks). Every dependency is
57
+ * injectable so this can run headless in tests.
58
+ *
59
+ * @param {object} [options]
60
+ * @param {string} options.cwd
61
+ * @param {object} [options.service] - conversation service (defaults to the real one)
62
+ * @param {() => object} [options.terminalFactory] - defaults to `() => new ProcessTerminal()`
63
+ * @param {(terminal: object) => object} [options.tuiFactory] - defaults to `(t) => new TuiAltScreen(t)`
64
+ * @param {number} [options.pollIntervalMs]
65
+ * @param {(fn: () => void, ms: number) => any} [options.setIntervalImpl]
66
+ * @param {(handle: any) => void} [options.clearIntervalImpl]
67
+ * @returns {Promise<{ tui: object, view: CockpitView, stop: () => void, done: Promise<void>, refresh: () => Promise<void> }>}
68
+ */
69
+ export async function runCockpitApp({
70
+ cwd,
71
+ service = createConversationService({ enableProviderProbes: true }),
72
+ terminalFactory = () => new ProcessTerminal(),
73
+ tuiFactory = (terminal) => new TuiAltScreen(terminal),
74
+ editorFactory = (tui) => new Editor(tui, editorTheme),
75
+ pollIntervalMs = DEFAULT_POLL_MS,
76
+ spinnerIntervalMs = DEFAULT_SPINNER_MS,
77
+ setIntervalImpl = setInterval,
78
+ clearIntervalImpl = clearInterval
79
+ } = {}) {
80
+ const terminal = terminalFactory();
81
+ const tui = tuiFactory(terminal);
82
+ const editor = editorFactory(tui);
83
+
84
+ let timer = null;
85
+ let spinnerTimer = null;
86
+ let stopped = false;
87
+ let resolveDone;
88
+ const done = new Promise((resolvePromise) => { resolveDone = resolvePromise; });
89
+
90
+ function stop() {
91
+ if (stopped) return;
92
+ stopped = true;
93
+ if (timer) clearIntervalImpl(timer);
94
+ if (spinnerTimer) clearIntervalImpl(spinnerTimer);
95
+ tui.stop();
96
+ resolveDone();
97
+ }
98
+
99
+ // The real nextIndex already shown per active runId (see
100
+ // service.readRunTranscript's own doc) — in-memory only, never
101
+ // persisted: a restart just starts tailing from 0 again, which is fine
102
+ // since the run's own real event log still has everything.
103
+ const runTranscriptState = new Map();
104
+
105
+ /**
106
+ * Tails every real active run's own transcript for new lines since the
107
+ * last poll and pushes them into the chat, each one tagged with its own
108
+ * real provider (never a generic "Kairo" label) — Kairo can have
109
+ * multiple real runs active across different real providers (Codex,
110
+ * Claude, OpenCode) at once, and each line must stay attributed to
111
+ * whichever one actually produced it. Runs one final tail pass after a
112
+ * run stops being active (to catch its last lines), then stops tracking
113
+ * it. Best-effort: a single run's tail failing must never break the
114
+ * rest of the poll loop.
115
+ * @param {Array<object>} timeline
116
+ */
117
+ async function tailActiveRunTranscripts(timeline) {
118
+ for (const entry of timeline ?? []) {
119
+ const runId = entry.execution?.runId;
120
+ if (!runId) continue;
121
+ if (!entry.execution.active && !runTranscriptState.has(runId)) continue;
122
+ const sinceIndex = runTranscriptState.get(runId) ?? 0;
123
+ try {
124
+ const result = await service.readRunTranscript({ runId, sinceIndex });
125
+ for (const line of result.entries) {
126
+ const tag = line.provider ? `${theme.fg("muted", `[${line.provider}]`)} ` : "";
127
+ pushTranscript("kairo", `${tag}${line.text}`);
128
+ }
129
+ runTranscriptState.set(runId, result.nextIndex);
130
+ } catch {
131
+ // Best-effort — see this function's own doc.
132
+ }
133
+ if (!entry.execution.active) runTranscriptState.delete(runId);
134
+ }
135
+ }
136
+
137
+ async function refresh() {
138
+ try {
139
+ const snapshot = await service.snapshot({ cwd });
140
+ view.setSnapshot(snapshot);
141
+ view.setRowsFromTimeline(snapshot.timeline);
142
+ await tailActiveRunTranscripts(snapshot.timeline);
143
+ } catch (error) {
144
+ view.setStatus(`Refresh failed: ${error.message ?? String(error)}`);
145
+ }
146
+ }
147
+
148
+ async function runAction(label, fn) {
149
+ view.beginAction(label);
150
+ try {
151
+ await fn();
152
+ view.endAction();
153
+ await refresh();
154
+ } catch (error) {
155
+ view.endAction();
156
+ view.setStatus(`${label} failed: ${error.message ?? String(error)}`);
157
+ }
158
+ }
159
+
160
+ const view = new CockpitView({
161
+ requestRender: () => tui.requestRender(),
162
+ // Composer header (basis:2, includes its own spacer row) + editor
163
+ // (basis:3) reserve 5 rows (the VStack below has no gap) — the rest of
164
+ // the terminal is the view's.
165
+ getViewportRows: () => {
166
+ const rows = Number(terminal?.rows);
167
+ return Number.isFinite(rows) && rows > 0 ? Math.max(10, rows - 5) : undefined;
168
+ },
169
+ actions: {
170
+ onShowPlan: (taskId) => {
171
+ return runAction("Loading plan", async () => {
172
+ const plan = await service.showPlan({ cwd, taskId });
173
+ view.showDetail(taskId, plan.planMarkdown || plan.taskMarkdown || "(no plan markdown yet)");
174
+ });
175
+ },
176
+ onApprove: (taskId) => {
177
+ return runAction("Approving", () => service.decidePlan({ cwd, taskId, decision: "approved" }));
178
+ },
179
+ onReject: (taskId) => {
180
+ return runAction("Rejecting", () => service.decidePlan({ cwd, taskId, decision: "rejected" }));
181
+ },
182
+ onRequestExecute: (taskId, role) => {
183
+ // `role` comes from the view's own role picker (view.js's
184
+ // projectTeamRoles()/showRoleSelect) — always the user's own
185
+ // explicit choice from the active ProjectStrategy's real team,
186
+ // never inferred here or anywhere else from the task's text.
187
+ // PROJECT TEAM is the sole authority for execution: this is only
188
+ // ever called once a real role has been picked (see view.js's
189
+ // handleListInput — 'x' never calls this without one).
190
+ return runAction("Asking PROJECT TEAM who should execute this", async () => {
191
+ const decision = await service.planExecution({ cwd, taskId, role });
192
+ view.showExecuteConfirm(taskId, decision);
193
+ });
194
+ },
195
+ onExecute: (taskId, decision) => {
196
+ const label = decision?.provider ? `Executing with ${decision.provider}` : "Executing";
197
+ // executePlan revalidates the confirmationTarget against a
198
+ // freshly recomputed route before ever reserving quota or
199
+ // launching (see service.js's own doc) — there is no free-form
200
+ // agentId/model override anymore.
201
+ return runAction(label, () => service.executePlan({ cwd, taskId, confirmationTarget: decision?.confirmationTarget }));
202
+ },
203
+ onCancel: (taskId) => {
204
+ return runAction("Cancelling run", () => service.cancelExecution({ cwd, taskId }));
205
+ },
206
+ onRefresh: () => { return refresh(); },
207
+ onQuit: () => { stop(); }
208
+ }
209
+ });
210
+
211
+ // Every transcript entry a real chat CLI shows is worth keeping across a
212
+ // restart the same way plan/task state already is — so every addition
213
+ // goes through this instead of view.addTranscript directly, and gets
214
+ // persisted to `.ai/kairo/transcript.json`. A save failure surfaces on
215
+ // the status line rather than silently losing the message.
216
+ function pushTranscript(role, text) {
217
+ view.addTranscript(role, text);
218
+ service.appendTranscript?.({ cwd, role, text })?.catch((error) => {
219
+ view.setStatus(`Transcript save failed: ${error.message ?? String(error)}`);
220
+ });
221
+ }
222
+
223
+ editor.onSubmit = (text) => {
224
+ const task = text.trim();
225
+ if (!task) return;
226
+ if (task.startsWith("/")) {
227
+ const command = task.split(/\s+/)[0].toLowerCase();
228
+ // Echo the command itself into the transcript before acting on it —
229
+ // without this, scrolling back through history is a wall of Kairo-only
230
+ // blocks with no indication of which command produced which one.
231
+ pushTranscript("user", task);
232
+ if (command === "/help") {
233
+ pushTranscript("kairo", "Shift+Tab cycles ASK/PLAN/AGENT · /project interactive overlay (or analyze/analyst/approve/refresh/status/cursor exhausted|available subcommands for scripted use) · /plan <task> force a plan · /usage automatic-provider status (Codex/Claude/Go) · /providers all connections incl. Zen/Cursor (manual) · /models CAPABILITY + EFFICIENT picks (--evidence for raw metrics) · /why eligibility detail · /clear · /quit");
234
+ } else if (command === "/usage") {
235
+ for (const line of view.usageLines()) pushTranscript("kairo", line);
236
+ } else if (command === "/providers") {
237
+ for (const line of view.providerLines()) pushTranscript("kairo", line);
238
+ } else if (command === "/status") {
239
+ for (const line of view.providerLines()) pushTranscript("kairo", line);
240
+ pushTranscript("kairo", view.integrationsLine());
241
+ } else if (command === "/models") {
242
+ // The widgets only show Role -> effective model; /models writes
243
+ // the plain-language why (capability, efficient alternative,
244
+ // fallback), never raw metrics/percentages/ids/sources. Those
245
+ // stay behind the explicit --evidence flag for technical audit.
246
+ const flag = task.slice(command.length).trim();
247
+ const explainLines = flag === "--evidence" ? view.aiTeamDetailLines() : view.modelsExplainLines();
248
+ for (const line of explainLines) pushTranscript("kairo", line);
249
+ } else if (command === "/why") {
250
+ // Drill-down for FIT: which providers were excluded and the exact
251
+ // real reason (quota, availability, PAYG/manual-only policy).
252
+ for (const line of view.fitWhyLines()) pushTranscript("kairo", line);
253
+ } else if (command === "/project") {
254
+ const args = task.slice(command.length).trim().split(/\s+/).filter(Boolean);
255
+ const sub = args[0]?.toLowerCase() ?? "";
256
+ if (!sub) {
257
+ // Bare `/project`: the interactive overlay — real preflight ->
258
+ // select analyst -> confirm -> analyze -> result -> approve,
259
+ // driven by the exact same service calls as the subcommands
260
+ // below. The subcommands themselves stay untouched for scripted/
261
+ // non-interactive use.
262
+ openProjectOverlay({ tui, service, view, cwd, onNarrate: (text) => pushTranscript("kairo", text) });
263
+ editor.setText("");
264
+ return;
265
+ }
266
+ if (sub === "status") {
267
+ const strategy = view.snapshot?.projectStrategy;
268
+ if (!strategy) {
269
+ pushTranscript("kairo", view.pendingProjectAnalysis
270
+ ? "AWAITING_ANALYST — pick a real Project Analyst with /project analyst quality|efficient --confirm."
271
+ : "Project not analyzed. Use /project analyze for a real, project-specific team.");
272
+ } else {
273
+ const approvedNote = strategy.approvedAt ? ` (approved ${strategy.approvedAt})` : "";
274
+ pushTranscript("kairo", `Status: ${strategy.status.toUpperCase()}${approvedNote}`);
275
+ // The real operational team, same as the dashboard panel and
276
+ // the /project overlay — never qualityTeam (comparative
277
+ // reference only), so /project status never disagrees with
278
+ // what's actually running.
279
+ for (const entry of strategy.projectTeam ?? strategy.qualityTeam ?? []) {
280
+ pushTranscript("kairo", `${entry.role}: ${entry.model ? view.aiTeamLabel(entry.model) : "no eligible option"}`);
281
+ }
282
+ }
283
+ } else if (sub === "analyze") {
284
+ // LOCAL_PREFLIGHT: real, read-only evidence + real Project
285
+ // Analyst alternatives — no provider call yet, no ProjectStrategy
286
+ // created yet (AWAITING_ANALYST). The human still has to pick
287
+ // and confirm before anything runs.
288
+ editor.disableSubmit = true;
289
+ editor.setText("");
290
+ return runAction("Analyzing project locally (read-only)", async () => {
291
+ const preflight = await service.preflightProject({ cwd });
292
+ view.pendingProjectAnalysis = preflight;
293
+ if (!preflight.alternatives.length) {
294
+ pushTranscript("kairo", "No real Project Analyst candidate is available right now (ASK only supports Codex/Claude today).");
295
+ return;
296
+ }
297
+ const lines = preflight.alternatives.map((alt) => ` ${alt.choice}: ${view.aiTeamLabel(alt.model)}`).join("\n");
298
+ pushTranscript("kairo", `Select Project Analyst — real alternatives:\n${lines}\nUse /project analyst quality|efficient --confirm to run it (consumes real quota).`);
299
+ }).finally(() => { editor.disableSubmit = false; });
300
+ } else if (sub === "analyst") {
301
+ const choice = args[1]?.toLowerCase();
302
+ const confirmed = args.includes("--confirm");
303
+ if (choice !== "quality" && choice !== "efficient") {
304
+ pushTranscript("kairo", "Usage: /project analyst quality|efficient [--confirm]");
305
+ editor.setText("");
306
+ return;
307
+ }
308
+ if (!view.pendingProjectAnalysis) {
309
+ pushTranscript("kairo", "Nothing awaiting a Project Analyst choice. Run /project analyze first.");
310
+ editor.setText("");
311
+ return;
312
+ }
313
+ const alternative = view.pendingProjectAnalysis.alternatives.find((alt) => alt.choice === choice);
314
+ if (!alternative) {
315
+ pushTranscript("kairo", `"${choice}" is not one of the real available alternatives right now.`);
316
+ editor.setText("");
317
+ return;
318
+ }
319
+ if (!confirmed) {
320
+ pushTranscript("kairo", `This will run ${view.aiTeamLabel(alternative.model)} read-only against your project and consume real quota from that provider. Run again with --confirm to proceed: /project analyst ${choice} --confirm`);
321
+ editor.setText("");
322
+ return;
323
+ }
324
+ editor.disableSubmit = true;
325
+ editor.setText("");
326
+ return runAction(`Analyzing with ${view.aiTeamLabel(alternative.model)} (read-only)`, async () => {
327
+ const { profile, candidates } = view.pendingProjectAnalysis;
328
+ const result = await service.runBootstrapAnalysis({ cwd, profile, candidates, analyst: alternative });
329
+ view.pendingProjectAnalysis = null;
330
+ pushTranscript("kairo", `Suggested project team ready (${result.activeRoles.length} real role${result.activeRoles.length === 1 ? "" : "s"}). Use /project approve to activate.`);
331
+ }).finally(() => { editor.disableSubmit = false; });
332
+ } else if (sub === "approve") {
333
+ editor.disableSubmit = true;
334
+ editor.setText("");
335
+ return runAction("Approving project strategy", async () => {
336
+ await service.approveProjectStrategy({ cwd });
337
+ pushTranscript("kairo", "Project team is now ACTIVE.");
338
+ }).finally(() => { editor.disableSubmit = false; });
339
+ } else if (sub === "refresh") {
340
+ editor.disableSubmit = true;
341
+ editor.setText("");
342
+ return runAction("Refreshing project strategy", async () => {
343
+ const result = await service.refreshProjectStrategy({ cwd });
344
+ pushTranscript("kairo", result ? `Project strategy is now ${result.status.toUpperCase()}.` : "Nothing to refresh yet — use /project analyze first.");
345
+ }).finally(() => { editor.disableSubmit = false; });
346
+ } else if (sub === "cursor") {
347
+ // Cursor exposes no real, zero-cost local usage read (see
348
+ // execution-router.js's checkCandidate doc) — this manual toggle
349
+ // is the only way Kairo learns its quota state, ever. Never
350
+ // auto-detected, never inferred from a failed run.
351
+ const state = args[1]?.toLowerCase();
352
+ if (state !== "exhausted" && state !== "available") {
353
+ pushTranscript("kairo", "Usage: /project cursor exhausted|available");
354
+ editor.setText("");
355
+ return;
356
+ }
357
+ editor.setText("");
358
+ return runAction(`Marking Cursor ${state}`, async () => {
359
+ await service.setCursorManualQuota({ exhausted: state === "exhausted" });
360
+ pushTranscript("kairo", state === "exhausted"
361
+ ? "Cursor marked out of credits — excluded from team suggestions until you run /project cursor available."
362
+ : "Cursor marked available again — back in team suggestions.");
363
+ });
364
+ } else {
365
+ pushTranscript("kairo", "Usage: /project status|analyze|analyst quality|efficient [--confirm]|approve|refresh|cursor exhausted|available");
366
+ }
367
+ } else if (command === "/plan") {
368
+ const planTask = task.slice(command.length).trim();
369
+ if (!planTask) {
370
+ pushTranscript("kairo", "Usage: /plan <task description>");
371
+ editor.setText("");
372
+ return;
373
+ }
374
+ // Backward-compatible shortcut: /plan switches WorkMode to PLAN
375
+ // (so subsequent plain messages stay in PLAN too, never silently
376
+ // dropping back to whatever mode was active before) and sends the
377
+ // message immediately — real behavior, never just a label change.
378
+ if (view.workMode !== "plan") {
379
+ view.setWorkMode("plan");
380
+ service.setMode?.({ cwd, mode: "plan" })?.catch((error) => {
381
+ view.setStatus(`Mode change not saved: ${error.message ?? String(error)}`);
382
+ });
383
+ }
384
+ editor.disableSubmit = true;
385
+ editor.setText("");
386
+ return runAction("Asking Codex for a plan", async () => {
387
+ await service.submitArchitecture({ cwd, task: planTask });
388
+ pushTranscript("kairo", "Plan requested from Codex. Review it below, then press a to approve.");
389
+ editor.addToHistory(task);
390
+ }).finally(() => { editor.disableSubmit = false; });
391
+ } else if (command === "/clear") {
392
+ view.clearTranscript();
393
+ service.clearTranscript?.({ cwd })?.catch((error) => {
394
+ view.setStatus(`Transcript clear failed: ${error.message ?? String(error)}`);
395
+ });
396
+ } else if (command === "/quit" || command === "/exit") {
397
+ stop();
398
+ } else {
399
+ pushTranscript("kairo", `Unknown command: ${command}. Try /help.`);
400
+ }
401
+ editor.setText("");
402
+ return;
403
+ }
404
+ pushTranscript("user", task);
405
+ editor.disableSubmit = true;
406
+ // The real WorkMode decides outright — ASK always answers read-only,
407
+ // PLAN/AGENT always create a plan (see service.submitTask) — replacing
408
+ // the old isLikelyQuestion guess with what the user explicitly told
409
+ // Kairo they're doing (Shift+Tab / /plan).
410
+ return runAction("Asking Kairo", async () => {
411
+ const result = await service.submitTask({ cwd, task, mode: view.workMode });
412
+ if (result.kind === "answer") {
413
+ pushTranscript("kairo", `${result.provider}${result.model ? ` · ${result.model}` : ""}: ${result.answer}`);
414
+ } else {
415
+ pushTranscript("kairo", "Plan requested from Codex. Review it below, then press a to approve.");
416
+ }
417
+ editor.setText("");
418
+ editor.addToHistory(task);
419
+ }).finally(() => { editor.disableSubmit = false; });
420
+ };
421
+
422
+ // view.hasListFocus gates whether the footer claims "a approve · j reject
423
+ // · x implement" — those keys only actually do that while the list has
424
+ // focus; while typing, the same letters just become message text (e.g.
425
+ // pressing "j" to reject, with the composer focused, submits a task
426
+ // literally named "j" instead of rejecting anything).
427
+ function focusEditor() { tui.setFocus(editor); view.hasListFocus = false; }
428
+ function focusList() { tui.setFocus(view); view.hasListFocus = true; }
429
+
430
+ if (isViewportTUI(tui)) {
431
+ // Real scroll: the dashboard (USAGE/AI TEAM/EFFICIENT TEAM + footer)
432
+ // and composer/editor stay fixed; only the conversation scrolls, via
433
+ // pi-tui's own ScrollView — it owns viewport windowing, PageUp/
434
+ // PageDown/Home/End, mouse wheel, scrollbar drag, and follow-the-end
435
+ // behavior natively (see scroll-view.js / layout.js), so none of that
436
+ // is reimplemented here.
437
+ tui.setLayoutRoot(buildViewportLayoutRoot(view, editor));
438
+ } else {
439
+ // Test doubles and older pi-tui versions retain the stacked,
440
+ // monolithic-render fallback (view.render() -> renderWorkspace()).
441
+ tui.addChild(view);
442
+ tui.addChild(editor);
443
+ }
444
+ focusEditor();
445
+ // Safety net: raw mode intercepts Ctrl+C before it becomes SIGINT, so make
446
+ // sure the cockpit always exits cleanly even if focus is ever lost. Tab
447
+ // toggles focus between the task input and the plan/run list, ahead of
448
+ // whichever component is currently focused.
449
+ tui.addInputListener?.((data) => {
450
+ if (matchesKey(data, "ctrl+c")) { stop(); return { consume: true }; }
451
+ // Shift+Tab cycles the real WorkMode (ASK -> PLAN -> AGENT -> ASK);
452
+ // plain Tab keeps its existing job (focus toggle) — reserved for
453
+ // autocomplete later, per the plan's own assumption, never repurposed
454
+ // here. Persisting the new mode is pure local state (no provider I/O),
455
+ // so this stays instant even if the write is still in flight.
456
+ if (matchesKey(data, "shift+tab")) {
457
+ const next = CockpitView.nextWorkMode(view.workMode);
458
+ view.setWorkMode(next);
459
+ service.setMode?.({ cwd, mode: next })?.catch((error) => {
460
+ view.setStatus(`Mode change not saved: ${error.message ?? String(error)}`);
461
+ });
462
+ return { consume: true };
463
+ }
464
+ if (matchesKey(data, "tab")) {
465
+ if (tui.getFocusedComponent?.() === editor) focusList(); else focusEditor();
466
+ tui.requestRender();
467
+ return { consume: true };
468
+ }
469
+ return undefined;
470
+ });
471
+
472
+ // Load persisted chat history and the real KairoSession (currently just
473
+ // WorkMode) before the first render, so a restart never shows an empty
474
+ // chat or silently resets back to ASK while STATUS still shows a task
475
+ // from before it.
476
+ try {
477
+ view.loadTranscript(await service.loadTranscript?.({ cwd }));
478
+ } catch (error) {
479
+ view.setStatus(`Transcript load failed: ${error.message ?? String(error)}`);
480
+ }
481
+ try {
482
+ const session = await service.getSession?.({ cwd });
483
+ if (session?.mode) view.setWorkMode(session.mode);
484
+ } catch (error) {
485
+ view.setStatus(`Session load failed: ${error.message ?? String(error)}`);
486
+ }
487
+ await refresh();
488
+ tui.start();
489
+ timer = setIntervalImpl(() => refresh(), pollIntervalMs);
490
+ spinnerTimer = setIntervalImpl(() => view.tickSpinner(), spinnerIntervalMs);
491
+
492
+ return { tui, view, editor, stop, done, refresh };
493
+ }
@@ -0,0 +1,111 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ // Rounded-frame card rendering, ported from gentle-pi's lib/shell-card.ts
4
+ // (MIT) and simplified for the cockpit's plain-JS, single-frame use case:
5
+ // one open card per screen, each line able to carry its own tone so a row's
6
+ // left rail reflects its own state (e.g. a failed run's rail reads red).
7
+
8
+ export const CARD_TONE = {
9
+ INFO: "info",
10
+ SUCCESS: "success",
11
+ WARNING: "warning",
12
+ ERROR: "error"
13
+ };
14
+
15
+ const FRAME_ROLE = "border";
16
+ const FRAME_COLUMNS = 4;
17
+
18
+ function rule(length) {
19
+ return "─".repeat(Math.max(0, length));
20
+ }
21
+
22
+ /**
23
+ * @param {string} title
24
+ * @param {string} tone - a CARD_TONE value
25
+ * @param {{fg(role:string,text:string):string}} theme
26
+ * @param {number} width
27
+ * @returns {string}
28
+ */
29
+ export function cardTop(title, tone, theme, width) {
30
+ const targetWidth = Math.max(0, Math.floor(width));
31
+ if (targetWidth === 0) return "";
32
+ if (targetWidth < 5) {
33
+ const left = theme.fg(tone, "╭");
34
+ if (targetWidth === 1) return left;
35
+ return left + theme.fg(FRAME_ROLE, `${rule(targetWidth - 2)}╮`);
36
+ }
37
+ const head = `✿ ${title}`;
38
+ const headWidth = visibleWidth(head);
39
+ const maxTitleWidth = Math.max(0, targetWidth - 5);
40
+ const clippedHead = headWidth <= maxTitleWidth ? head : truncateToWidth(head, maxTitleWidth, "");
41
+ const clippedWidth = headWidth <= maxTitleWidth ? headWidth : visibleWidth(clippedHead);
42
+ const fill = rule(Math.max(0, targetWidth - clippedWidth - 5));
43
+ return (
44
+ theme.fg(tone, "╭")
45
+ + theme.fg(FRAME_ROLE, "─ ")
46
+ + theme.fg(tone, clippedHead)
47
+ + theme.fg(FRAME_ROLE, ` ${fill}╮`)
48
+ );
49
+ }
50
+
51
+ /**
52
+ * @param {string} text - may already carry ANSI styling
53
+ * @param {string} tone - colors this line's left rail
54
+ * @param {{fg(role:string,text:string):string}} theme
55
+ * @param {number} width
56
+ * @returns {string}
57
+ */
58
+ export function cardLine(text, tone, theme, width) {
59
+ const targetWidth = Math.max(0, Math.floor(width));
60
+ if (targetWidth === 0) return "";
61
+ const left = theme.fg(tone, "│");
62
+ if (targetWidth === 1) return left;
63
+ if (targetWidth === 2) return left + theme.fg(FRAME_ROLE, "│");
64
+ if (targetWidth === 3) return `${left} ${theme.fg(FRAME_ROLE, "│")}`;
65
+
66
+ const innerWidth = targetWidth - FRAME_COLUMNS;
67
+ const clipped = innerWidth === 0 ? "" : truncateToWidth(text, innerWidth, "…");
68
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
69
+ return `${left} ${clipped}${padding} ${theme.fg(FRAME_ROLE, "│")}`;
70
+ }
71
+
72
+ /**
73
+ * @param {string} tone
74
+ * @param {{fg(role:string,text:string):string}} theme
75
+ * @param {number} width
76
+ * @returns {string}
77
+ */
78
+ export function cardBottom(tone, theme, width) {
79
+ const targetWidth = Math.max(0, Math.floor(width));
80
+ if (targetWidth === 0) return "";
81
+ const left = theme.fg(tone, "╰");
82
+ if (targetWidth === 1) return left;
83
+ return left + theme.fg(FRAME_ROLE, `${rule(targetWidth - 2)}╯`);
84
+ }
85
+
86
+ /** @param {number} width */
87
+ export function cardInnerWidth(width) {
88
+ return Math.max(1, width - FRAME_COLUMNS);
89
+ }
90
+
91
+ /**
92
+ * A complete rounded-frame panel — top rule, one framed line per content
93
+ * line (padded to `targetLineCount` when given), bottom rule. The one real
94
+ * bordered-panel primitive every cockpit surface (the dashboard's cards,
95
+ * the /project overlay) should build on, rather than each screen inventing
96
+ * its own frame or going borderless.
97
+ * @param {string} title
98
+ * @param {string} tone - a CARD_TONE value
99
+ * @param {{fg(role:string,text:string):string}} theme
100
+ * @param {number} width
101
+ * @param {string[]} contentLines - lines already sized to cardInnerWidth(width)
102
+ * @param {number} [targetLineCount]
103
+ * @returns {string[]}
104
+ */
105
+ export function renderPanel(title, tone, theme, width, contentLines, targetLineCount = contentLines.length) {
106
+ const padded = Array.from({ length: targetLineCount }, (_, i) => contentLines[i] ?? "");
107
+ const lines = [cardTop(title, tone, theme, width)];
108
+ for (const line of padded) lines.push(cardLine(line, tone, theme, width));
109
+ lines.push(cardBottom(tone, theme, width));
110
+ return lines;
111
+ }
@@ -0,0 +1,33 @@
1
+ import { stdin as input, stdout as output } from "node:process";
2
+ import { runCockpitApp as defaultRunCockpitApp } from "./app.js";
3
+
4
+ /**
5
+ * `kairo start` entrypoint: boots the interactive cockpit and waits for it to
6
+ * exit (Ctrl+C, `q`, or SIGTERM).
7
+ *
8
+ * @param {object} options - parsed CLI options (uses options.cwd)
9
+ * @param {object} [deps]
10
+ * @param {typeof defaultRunCockpitApp} [deps.runCockpitApp]
11
+ * @param {boolean} [deps.interactive] - overrides the TTY auto-detection (for tests)
12
+ */
13
+ export async function runCockpitCli(options, deps = {}) {
14
+ const interactive = deps.interactive ?? Boolean(input.isTTY && output.isTTY);
15
+ if (!interactive) {
16
+ throw new Error(
17
+ "kairo start requires an interactive terminal (TTY). Run it directly in your shell."
18
+ );
19
+ }
20
+
21
+ const factory = deps.runCockpitApp ?? defaultRunCockpitApp;
22
+ const app = await factory({ cwd: options.cwd });
23
+
24
+ const onSignal = () => app.stop();
25
+ process.on("SIGINT", onSignal);
26
+ process.on("SIGTERM", onSignal);
27
+ try {
28
+ await app.done;
29
+ } finally {
30
+ process.off("SIGINT", onSignal);
31
+ process.off("SIGTERM", onSignal);
32
+ }
33
+ }
@@ -0,0 +1,31 @@
1
+ // Small progress-bar primitive ported from gentle-pi's lib/shell-gauge.ts
2
+ // (MIT) — block-character gauges colored by how much of a budget is used,
3
+ // so the AGENTS panel reads like a real usage meter instead of bare text.
4
+
5
+ const GAUGE_CELLS = 8;
6
+ const GAUGE_FILLED = "▰";
7
+ const GAUGE_EMPTY = "▱";
8
+ const GAUGE_EMPTY_ROLE = "border";
9
+ const WARNING_THRESHOLD = 80;
10
+ const ERROR_THRESHOLD = 95;
11
+
12
+ /** @param {number|null} usedPercent */
13
+ export function gaugeTone(usedPercent) {
14
+ if (usedPercent == null) return "muted";
15
+ if (usedPercent >= ERROR_THRESHOLD) return "error";
16
+ if (usedPercent >= WARNING_THRESHOLD) return "warning";
17
+ return "success";
18
+ }
19
+
20
+ /**
21
+ * @param {number|null} usedPercent - 0-100, how much of the budget is used
22
+ * @param {{fg(role:string,text:string):string}} theme
23
+ * @param {number} [cells]
24
+ */
25
+ export function paintGauge(usedPercent, theme, cells = GAUGE_CELLS) {
26
+ const clamped = Math.max(0, Math.min(100, usedPercent ?? 0));
27
+ const filled = Math.round((clamped / 100) * cells);
28
+ const filledCells = GAUGE_FILLED.repeat(filled);
29
+ const emptyCells = GAUGE_EMPTY.repeat(cells - filled);
30
+ return theme.fg(gaugeTone(usedPercent), filledCells) + theme.fg(GAUGE_EMPTY_ROLE, emptyCells);
31
+ }