@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,1298 @@
1
+ import { matchesKey, Key, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+ import { buildTaskRows, clampSelection, isActionAvailable } from "./rows.js";
3
+ import { CARD_TONE, cardBottom, cardInnerWidth, cardLine, cardTop, renderPanel as renderPanelWithTheme } from "./card.js";
4
+ import { theme } from "./theme.js";
5
+ import { LOW_QUOTA_WARN_PERCENT } from "../intelligence/execution-router.js";
6
+
7
+ /**
8
+ * A real, early heads-up — never fabricated, never re-deriving its own
9
+ * threshold (see execution-router.js's LOW_QUOTA_WARN_PERCENT, the same
10
+ * canonical policy checkCandidate itself uses for its harder exclusion
11
+ * cutoff). `alreadyFlagged` skips this for a window a caller already
12
+ * tagged some other way (e.g. Go's own "RATE LIMITED"), so a single
13
+ * window is never double-tagged.
14
+ * @param {number|null|undefined} remainingPercent
15
+ * @param {boolean} [alreadyFlagged]
16
+ */
17
+ function quotaWarnSuffix(remainingPercent, alreadyFlagged = false) {
18
+ if (alreadyFlagged || remainingPercent == null) return "";
19
+ return remainingPercent < LOW_QUOTA_WARN_PERCENT ? " LOW" : "";
20
+ }
21
+
22
+ /** view.js's own local binding for card.js's real renderPanel, fixed to this module's theme. */
23
+ function renderPanel(title, tone, width, contentLines, targetLineCount = contentLines.length) {
24
+ return renderPanelWithTheme(title, tone, theme, width, contentLines, targetLineCount);
25
+ }
26
+
27
+ function compactNumber(value) {
28
+ const n = Number(value);
29
+ if (!Number.isFinite(n)) return "unknown";
30
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
31
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
32
+ return String(Math.round(n));
33
+ }
34
+
35
+ /**
36
+ * CockpitView is a plain-object pi-tui Component: it exposes render(width) and
37
+ * handleInput(data), and owns the small amount of UI state the `kairo start`
38
+ * cockpit needs (selected row, list/detail/confirm mode, status line).
39
+ *
40
+ * It never talks to the conversation service directly — all side effects are
41
+ * delegated to the injected `actions` so this class stays cheap to unit test.
42
+ */
43
+ export class CockpitView {
44
+ /** Real Braille spinner frames — the same family real terminal CLIs (Claude Code, Codex) use for a live "in progress" indicator. */
45
+ static SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
46
+
47
+ /**
48
+ * @param {object} deps
49
+ * @param {object} deps.actions
50
+ * @param {(taskId: string) => void} deps.actions.onShowPlan
51
+ * @param {(taskId: string) => void} deps.actions.onApprove
52
+ * @param {(taskId: string) => void} deps.actions.onReject
53
+ * @param {(taskId: string, role: string) => void} deps.actions.onRequestExecute - asks for the
54
+ * real routing decision (via service.planExecution) before showing the confirm prompt. `role` is
55
+ * always the user's own explicit choice from the role picker below — PROJECT TEAM is the sole
56
+ * authority for execution, so this is only ever called once a real role has been picked; a
57
+ * project with no active team has no roles to pick and 'x' never calls this at all (see
58
+ * handleListInput's own doc).
59
+ * @param {(taskId: string, decision: object) => void} deps.actions.onExecute - confirmed; decision
60
+ * is the same one shown in the prompt, so the cockpit and the actual launch never disagree
61
+ * @param {(taskId: string) => void} deps.actions.onCancel
62
+ * @param {() => void} deps.actions.onRefresh
63
+ * @param {() => void} deps.actions.onQuit
64
+ * @param {() => void} [deps.requestRender]
65
+ * @param {() => number|undefined} [deps.getViewportRows] - rows available to this
66
+ * component (terminal height minus whatever else the layout reserves, e.g. the
67
+ * editor). Used only to pad the session card so it reaches the bottom of the
68
+ * screen instead of leaving dead space below a short frame; omit in tests.
69
+ */
70
+ constructor({ actions, requestRender = () => {}, getViewportRows = () => undefined }) {
71
+ this.actions = actions;
72
+ this.requestRender = requestRender;
73
+ this.getViewportRows = getViewportRows;
74
+ this.rows = [];
75
+ this.selectedIndex = 0;
76
+ this.mode = "list"; // "list" | "detail" | "select-role" | "confirm-execute" — UI screen, never confused with workMode below
77
+ // The cockpit's real WorkMode ("ask" | "plan" | "agent" — see
78
+ // rows.js's isActionAvailable) — deliberately a SEPARATE field from
79
+ // `this.mode` above, which is the UI screen state (list/detail/
80
+ // confirm-execute), a completely different axis. Defaults to "ask",
81
+ // the strictly read-only default every session (new or pre-WorkMode)
82
+ // starts from until app.js loads the real persisted KairoSession.
83
+ this.workMode = "ask";
84
+ this.detailTaskId = null;
85
+ this.detailText = "";
86
+ this.statusMessage = "";
87
+ // Live "in progress" indicator state — see beginAction/endAction/
88
+ // tickSpinner/actionStatusLine's own docs. null actionLabel means no
89
+ // action is currently running.
90
+ this.actionLabel = null;
91
+ this.actionStartedAt = null;
92
+ this.spinnerFrame = 0;
93
+ this.snapshot = null;
94
+ this.transcript = [];
95
+ this.executeDecision = null;
96
+ // Pending role picker state (mode "select-role") — the real project
97
+ // team's roles for THIS task's project, held only in memory between
98
+ // pressing 'x' and the user's explicit role choice. Never persisted
99
+ // and never defaulted to a role the user didn't pick: role selection
100
+ // is skipped entirely (falls straight to the legacy no-role
101
+ // onRequestExecute) whenever the project has no active team yet.
102
+ this.roleSelectTaskId = null;
103
+ this.roleOptions = [];
104
+ this.roleSelectedIndex = 0;
105
+ // AWAITING_ANALYST: real preflightProject() output (profile,
106
+ // alternatives, candidates) held here between /project analyze and a
107
+ // confirmed /project analyst choice — deliberately in-memory only,
108
+ // never persisted: a restart mid-flow should just start over from
109
+ // NOT_ANALYZED, not resurrect a stale, unconfirmed selection.
110
+ this.pendingProjectAnalysis = null;
111
+ // Real, monotonic per-entry ids (never re-derived from array index,
112
+ // which shifts under the 500-entry cap) so each transcript entry's
113
+ // expensive ANSI-aware wrap (wrapTextWithAnsi) can be cached by
114
+ // `${id}:${width}` in renderConversation() instead of re-wrapping the
115
+ // ENTIRE transcript on every keystroke's render — with a real
116
+ // conversation, that's the actual cost behind "escritura lenta". A
117
+ // cached entry never needs invalidating: transcript entries are
118
+ // immutable once pushed, so the only real cache key that matters is
119
+ // width (a terminal resize), which the Map key already captures.
120
+ this._nextEntryId = 0;
121
+ this._wrapCache = new Map();
122
+ // Set by app.js's focus toggling — the "a approve · j reject ·
123
+ // x implement" hint is only true while the list actually has focus;
124
+ // with the composer focused those same letters just become message
125
+ // text instead of triggering an action.
126
+ this.hasListFocus = false;
127
+ }
128
+
129
+ /** @param {"ask"|"plan"|"agent"} workMode */
130
+ setWorkMode(workMode) {
131
+ this.workMode = workMode;
132
+ this.requestRender();
133
+ }
134
+
135
+ /** Cycles ASK -> PLAN -> AGENT -> ASK (Shift+Tab); Tab stays reserved for focus/autocomplete. */
136
+ static nextWorkMode(workMode) {
137
+ const order = ["ask", "plan", "agent"];
138
+ return order[(order.indexOf(workMode) + 1) % order.length];
139
+ }
140
+
141
+ /**
142
+ * Shows the confirm-execute prompt with the real routing decision
143
+ * (provider/model/why) already resolved — never a placeholder while
144
+ * waiting, since the decision is fetched by the caller (app.js, via
145
+ * service.planExecution) before this is called.
146
+ * @param {string} taskId
147
+ * @param {object} decision - a service.planExecution() result
148
+ */
149
+ showExecuteConfirm(taskId, decision) {
150
+ this.mode = "confirm-execute";
151
+ this.executeDecision = decision;
152
+ this.requestRender();
153
+ }
154
+
155
+ /**
156
+ * The real, project-specific roles execution can be requested for right
157
+ * now — read straight off the active ProjectStrategy's own projectTeam
158
+ * (never a fixed global role list: a project only ever offers the roles
159
+ * its own analysis actually required). Empty whenever there's no active
160
+ * strategy yet, which is exactly when 'x' should skip role selection
161
+ * entirely and fall back to the legacy no-role routing.
162
+ * @returns {string[]}
163
+ */
164
+ projectTeamRoles() {
165
+ const strategy = this.snapshot?.projectStrategy;
166
+ if (!strategy || strategy.status !== "active" || !Array.isArray(strategy.projectTeam)) return [];
167
+ return strategy.projectTeam.map((entry) => entry.role);
168
+ }
169
+
170
+ /**
171
+ * Opens the role picker for a pending execute request — the user's own
172
+ * explicit choice of which real project-team role this task falls
173
+ * under, never inferred from the task's text.
174
+ * @param {string} taskId
175
+ * @param {string[]} roles
176
+ */
177
+ showRoleSelect(taskId, roles) {
178
+ this.mode = "select-role";
179
+ this.roleSelectTaskId = taskId;
180
+ this.roleOptions = roles;
181
+ this.roleSelectedIndex = 0;
182
+ this.requestRender();
183
+ }
184
+
185
+ /** Store the latest control-plane snapshot for the dashboard header. */
186
+ setSnapshot(snapshot) {
187
+ this.snapshot = snapshot ?? null;
188
+ this.requestRender();
189
+ }
190
+
191
+ /** Add a short, user-visible event without retaining provider transcripts. */
192
+ addTranscript(role, text) {
193
+ const value = String(text ?? "").trim();
194
+ if (!value) return;
195
+ this.transcript.push({ id: this._nextEntryId++, role: role === "user" ? "You" : "Kairo", text: value });
196
+ // A generous safety cap, not a display constraint — what actually shows
197
+ // on screen is decided per-render by the real viewport budget
198
+ // (see chatLines()), not by how much history this array retains.
199
+ if (this.transcript.length > 500) {
200
+ const evicted = this.transcript.shift();
201
+ for (const key of this._wrapCache.keys()) {
202
+ if (key.startsWith(`${evicted.id}:`)) this._wrapCache.delete(key);
203
+ }
204
+ }
205
+ this.requestRender();
206
+ }
207
+
208
+ /**
209
+ * Seeds the transcript from persisted history (service.loadTranscript())
210
+ * at cockpit startup — one render for the whole batch, and never
211
+ * re-persists what was just loaded back from disk.
212
+ * @param {Array<{role: "user"|"kairo", text: string}>} entries
213
+ */
214
+ loadTranscript(entries) {
215
+ this.transcript = (entries ?? [])
216
+ .map((entry) => ({ id: this._nextEntryId++, role: entry.role === "user" ? "You" : "Kairo", text: String(entry.text ?? "").trim() }))
217
+ .filter((entry) => entry.text)
218
+ .slice(-500);
219
+ this._wrapCache.clear();
220
+ this.requestRender();
221
+ }
222
+
223
+ clearTranscript() {
224
+ this.transcript = [];
225
+ this._wrapCache.clear();
226
+ this.requestRender();
227
+ }
228
+
229
+ /** @param {Array<object>} timeline snapshot().timeline entries */
230
+ setRowsFromTimeline(timeline) {
231
+ this.setRows(buildTaskRows(timeline));
232
+ }
233
+
234
+ /** @param {import("./rows.js").CockpitRow[]} rows */
235
+ setRows(rows) {
236
+ this.rows = rows;
237
+ this.selectedIndex = clampSelection(this.selectedIndex, rows.length);
238
+ this.requestRender();
239
+ }
240
+
241
+ /** @param {string} message */
242
+ setStatus(message) {
243
+ this.statusMessage = message ?? "";
244
+ this.requestRender();
245
+ }
246
+
247
+ /**
248
+ * Starts a real, live "in progress" indicator for a real async action —
249
+ * a ticking spinner frame plus real elapsed seconds, refreshed by
250
+ * app.js's own fast timer calling tickSpinner() repeatedly, instead of
251
+ * a static "Label…" string that just sits there unchanged until the
252
+ * action resolves. Overrides statusMessage while active — see
253
+ * actionStatusLine()'s own doc for the render-time precedence.
254
+ * @param {string} label
255
+ */
256
+ beginAction(label) {
257
+ this.actionLabel = label;
258
+ this.actionStartedAt = Date.now();
259
+ this.spinnerFrame = 0;
260
+ this.requestRender();
261
+ }
262
+
263
+ /** Ends the current live action indicator. Callers still set their own success/failure statusMessage via setStatus() afterward — this only stops the spinner. */
264
+ endAction() {
265
+ this.actionLabel = null;
266
+ this.actionStartedAt = null;
267
+ this.requestRender();
268
+ }
269
+
270
+ /** Advances the spinner one frame — a no-op when no action is in flight, so app.js's fast timer can tick unconditionally without checking state itself. */
271
+ tickSpinner() {
272
+ if (!this.actionLabel) return;
273
+ this.spinnerFrame = (this.spinnerFrame + 1) % CockpitView.SPINNER_FRAMES.length;
274
+ this.requestRender();
275
+ }
276
+
277
+ /**
278
+ * The real, live status line for an in-flight action — spinner frame +
279
+ * label + real elapsed seconds since it started — or null when nothing
280
+ * is running. Render call sites prefer this over the static
281
+ * statusMessage whenever it's non-null, since a live action in progress
282
+ * is always more current/relevant than a leftover static message.
283
+ * @returns {string|null}
284
+ */
285
+ actionStatusLine() {
286
+ if (!this.actionLabel) return null;
287
+ const elapsedSeconds = Math.max(0, Math.floor((Date.now() - this.actionStartedAt) / 1000));
288
+ return `${CockpitView.SPINNER_FRAMES[this.spinnerFrame]} ${this.actionLabel}… (${elapsedSeconds}s)`;
289
+ }
290
+
291
+ /**
292
+ * @param {string} taskId
293
+ * @param {string} markdown
294
+ */
295
+ showDetail(taskId, markdown) {
296
+ this.detailTaskId = taskId;
297
+ this.detailText = markdown ?? "(no plan markdown yet)";
298
+ this.mode = "detail";
299
+ this.statusMessage = "";
300
+ this.requestRender();
301
+ }
302
+
303
+ backToList() {
304
+ this.mode = "list";
305
+ this.detailTaskId = null;
306
+ this.detailText = "";
307
+ this.requestRender();
308
+ }
309
+
310
+ selectedRow() {
311
+ return this.rows[this.selectedIndex] ?? null;
312
+ }
313
+
314
+ moveSelection(delta) {
315
+ if (this.rows.length === 0) return;
316
+ this.selectedIndex = clampSelection(this.selectedIndex + delta, this.rows.length);
317
+ this.requestRender();
318
+ }
319
+
320
+ /** @param {string} data raw terminal input */
321
+ handleInput(data) {
322
+ if (matchesKey(data, "ctrl+c")) {
323
+ this.actions.onQuit();
324
+ return;
325
+ }
326
+
327
+ if (this.mode === "confirm-execute") {
328
+ this.handleConfirmInput(data);
329
+ return;
330
+ }
331
+
332
+ if (this.mode === "select-role") {
333
+ this.handleRoleSelectInput(data);
334
+ return;
335
+ }
336
+
337
+ if (this.mode === "detail") {
338
+ this.handleDetailInput(data);
339
+ return;
340
+ }
341
+
342
+ this.handleListInput(data);
343
+ }
344
+
345
+ handleConfirmInput(data) {
346
+ const row = this.selectedRow();
347
+ if (data === "y" || data === "Y") {
348
+ const decision = this.executeDecision;
349
+ // A real ProjectExecutionPreview (planExecution({role}) — PROJECT
350
+ // TEAM is the sole authority for execution, there is no other
351
+ // shape) always carries a `confirmationTarget` key, present only
352
+ // when something is genuinely confirmable: the assigned candidate
353
+ // on ROUTED, or a persisted, currently-eligible fallback on
354
+ // WAIT_FOR_PROJECT_TEAM. MANUAL_HANDOFF and a blocked role with no
355
+ // eligible alternative both carry `confirmationTarget: null` and
356
+ // must never launch here.
357
+ const canConfirm = !decision || Boolean(decision.confirmationTarget);
358
+ if (!canConfirm) return;
359
+ this.mode = "list";
360
+ this.executeDecision = null;
361
+ this.requestRender();
362
+ if (row) this.actions.onExecute(row.taskId, decision);
363
+ return;
364
+ }
365
+ if (data === "n" || data === "N" || matchesKey(data, Key.escape)) {
366
+ this.mode = "list";
367
+ this.executeDecision = null;
368
+ this.statusMessage = "Execution cancelled.";
369
+ this.requestRender();
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Up/down to move the role picker's selection, Enter to confirm it (and
375
+ * only then ask app.js for the real preview for that exact role),
376
+ * Esc/q to cancel back to the list without ever requesting a preview.
377
+ */
378
+ handleRoleSelectInput(data) {
379
+ if (matchesKey(data, Key.up)) {
380
+ this.roleSelectedIndex = Math.max(0, this.roleSelectedIndex - 1);
381
+ this.requestRender();
382
+ return;
383
+ }
384
+ if (matchesKey(data, Key.down)) {
385
+ this.roleSelectedIndex = Math.min(this.roleOptions.length - 1, this.roleSelectedIndex + 1);
386
+ this.requestRender();
387
+ return;
388
+ }
389
+ if (matchesKey(data, Key.enter)) {
390
+ const role = this.roleOptions[this.roleSelectedIndex];
391
+ const taskId = this.roleSelectTaskId;
392
+ this.mode = "list";
393
+ this.roleSelectTaskId = null;
394
+ this.requestRender();
395
+ if (taskId && role) this.actions.onRequestExecute(taskId, role);
396
+ return;
397
+ }
398
+ if (data === "n" || data === "N" || matchesKey(data, Key.escape) || data === "q") {
399
+ this.mode = "list";
400
+ this.roleSelectTaskId = null;
401
+ this.statusMessage = "Execution cancelled.";
402
+ this.requestRender();
403
+ }
404
+ }
405
+
406
+ handleDetailInput(data) {
407
+ if (matchesKey(data, Key.escape) || data === "q") {
408
+ this.backToList();
409
+ return;
410
+ }
411
+ }
412
+
413
+ handleListInput(data) {
414
+ const row = this.selectedRow();
415
+
416
+ if (matchesKey(data, Key.up)) { this.moveSelection(-1); return; }
417
+ if (matchesKey(data, Key.down)) { this.moveSelection(1); return; }
418
+ if (matchesKey(data, Key.enter)) {
419
+ if (row) this.actions.onShowPlan(row.taskId);
420
+ return;
421
+ }
422
+ if (data === "q") { this.actions.onQuit(); return; }
423
+ if (data === "r") { this.actions.onRefresh(); return; }
424
+
425
+ if (data === "a" && isActionAvailable("approve", row, this.workMode)) { this.actions.onApprove(row.taskId); return; }
426
+ if (data === "j" && isActionAvailable("reject", row, this.workMode)) { this.actions.onReject(row.taskId); return; }
427
+ if (data === "c" && isActionAvailable("cancel", row, this.workMode)) { this.actions.onCancel(row.taskId); return; }
428
+ if (data === "x" && isActionAvailable("execute", row, this.workMode)) {
429
+ const roles = this.projectTeamRoles();
430
+ if (roles.length > 0) {
431
+ this.showRoleSelect(row.taskId, roles);
432
+ } else {
433
+ // PROJECT TEAM is the sole authority for execution — with no real
434
+ // active team, there is no role to pick and nothing left to
435
+ // preview automatically. Never falls back to guessing from the
436
+ // task's text; the only way forward is a real /project analysis.
437
+ this.statusMessage = "No active project team — run /project to analyze and approve one before executing.";
438
+ this.requestRender();
439
+ }
440
+ return;
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Renders the real routing decision fetched for the pending
446
+ * confirm-execute prompt — provider, model (or "default", never
447
+ * invented), and the exact "why" the router computed. A non-ROUTED
448
+ * decision (WAIT_FOR_APPROVAL / NO_PROVIDER_AVAILABLE) shows its real
449
+ * reason and blocks confirmation instead of a generic prompt.
450
+ * @param {import("./rows.js").CockpitRow|null} row
451
+ * @returns {string[]}
452
+ */
453
+ confirmPromptLines(row) {
454
+ const decision = this.executeDecision;
455
+ if (!decision) return [theme.fg("warning", `Execute plan "${row?.taskId ?? ""}"? (y/n)`)];
456
+
457
+ if (decision.decision === "ROUTED") {
458
+ const model = decision.model ?? "default";
459
+ return [
460
+ theme.fg("warning", `Execute "${row?.taskId ?? ""}" with ${decision.provider} · ${model}? (y/n)`),
461
+ theme.fg("muted", `Why: ${decision.why}`)
462
+ ];
463
+ }
464
+
465
+ if (decision.decision === "MANUAL_HANDOFF") {
466
+ const modelLabel = decision.modelRef?.displayName ?? decision.model ?? "the assigned model";
467
+ return [
468
+ theme.fg("error", `${decision.role} is manual-only`),
469
+ theme.fg("muted", `Continue in ${decision.provider} with ${modelLabel} — Kairo can't launch this automatically.`),
470
+ theme.fg("muted", "(n/esc to go back)")
471
+ ];
472
+ }
473
+
474
+ if (decision.confirmationTarget) {
475
+ // WAIT_FOR_PROJECT_TEAM with a real, currently-eligible suggested
476
+ // alternative — offered for explicit confirmation, never a silent
477
+ // substitution for the blocked assignment.
478
+ const alt = decision.suggestedAlternative;
479
+ const altLabel = alt?.model?.displayName ?? alt?.model?.modelId ?? "unknown model";
480
+ return [
481
+ theme.fg("warning", `Assigned model unavailable for ${decision.role}`),
482
+ theme.fg("muted", `Suggested alternative: ${alt?.provider} · ${altLabel}`),
483
+ theme.fg("warning", "Confirm this alternative? (y/n)")
484
+ ];
485
+ }
486
+
487
+ return [
488
+ theme.fg("error", `Cannot auto-execute "${row?.taskId ?? ""}"`),
489
+ theme.fg("muted", decision.why ?? "no provider available"),
490
+ theme.fg("muted", "(n/esc to go back)")
491
+ ];
492
+ }
493
+
494
+ /**
495
+ * Renders the pending role picker — one line per real project-team role,
496
+ * the currently-selected one marked, plus a hint line. No routing
497
+ * decision has been fetched yet at this point; that only happens once
498
+ * Enter confirms a specific role.
499
+ * @returns {string[]}
500
+ */
501
+ roleSelectPromptLines() {
502
+ const lines = [theme.fg("warning", "Which role is this task for?")];
503
+ this.roleOptions.forEach((role, index) => {
504
+ const marker = index === this.roleSelectedIndex ? theme.fg("accent", "> ") : " ";
505
+ lines.push(`${marker}${role}`);
506
+ });
507
+ lines.push(theme.fg("muted", "Enter confirm · n/esc cancel"));
508
+ return lines;
509
+ }
510
+
511
+ /**
512
+ * @param {number} width
513
+ * @returns {string[]}
514
+ */
515
+ render(width) {
516
+ if (this.mode === "detail") return this.renderDetail(width);
517
+ return this.renderWorkspace(width);
518
+ }
519
+
520
+ /**
521
+ * The dashboard's card content — the compact USAGE bar and MODEL TEAMS —
522
+ * without the footer or conversation. Shared by renderWorkspace() (the
523
+ * monolithic fallback) and renderDashboard() (the fixed dashboard zone
524
+ * in the real-scroll layout), so both stay in sync automatically instead
525
+ * of drifting apart.
526
+ *
527
+ * USAGE is a plain one-line bar (two only when it genuinely doesn't fit
528
+ * — see compactUsageLines()), never a bordered card of its own: it's a
529
+ * glance-level status strip, not a widget with its own real content to
530
+ * frame. MODEL TEAMS is the only bordered card here and always gets the
531
+ * FULL real width — one unified widget (CAPABILITY/EFFICIENT columns,
532
+ * one row per role — see teamsColumnsLines()), never two separate AI
533
+ * TEAM/EFFICIENT TEAM cards, and never tiled beside USAGE (USAGE no
534
+ * longer has real height to tile against). MODEL TEAMS is always
535
+ * visible, independent of task selection — separate from STATUS
536
+ * (task-specific), which is only reachable while a row is selected, and
537
+ * once any task exists in history a row is *always* selected, so it
538
+ * can't be tucked behind "nothing else to show" the way STATUS's own
539
+ * content is.
540
+ */
541
+ renderDashboardLines(width) {
542
+ const lines = [...this.compactUsageLines(width)];
543
+ const { title, lines: panelLines } = this.projectTeamPanel(cardInnerWidth(width));
544
+ lines.push(...renderPanel(title, CARD_TONE.SUCCESS, width, panelLines));
545
+ return lines;
546
+ }
547
+
548
+ /**
549
+ * Before a real ProjectStrategy exists (see project-strategy.js), the
550
+ * panel stays honestly labeled GLOBAL MODEL GUIDE — the existing
551
+ * cross-project CAPABILITY/EFFICIENT recommendations are still real and
552
+ * useful, but they are NOT a project-specific team, and must never be
553
+ * presented as one. Once a real strategy exists (suggested/active/
554
+ * stale), the panel becomes PROJECT TEAM · <project> · <STATUS>, showing
555
+ * only the real roles THIS project's profile actually required
556
+ * (strategy.activeRoles/projectTeam — the real operational assignments,
557
+ * see buildProjectStrategy), never the full global 7-role list.
558
+ * @param {number} [width]
559
+ */
560
+ projectTeamPanel(width = 80) {
561
+ const strategy = this.snapshot?.projectStrategy;
562
+ const project = this.snapshot?.projectRoot?.split("/").filter(Boolean).pop() ?? "current project";
563
+ if (!strategy && this.pendingProjectAnalysis) {
564
+ // AWAITING_ANALYST: a real preflight already ran (LOCAL_PREFLIGHT),
565
+ // and the human still needs to pick + confirm a real model before
566
+ // ANALYZING can run — see app.js's /project analyst handler.
567
+ const lines = this.pendingProjectAnalysis.alternatives.map((alt) =>
568
+ ` ${alt.choice.padEnd(10)} ${this.aiTeamLabel(alt.model)}`);
569
+ lines.push(theme.fg("muted", "Use /project analyst quality|efficient --confirm to run the real analysis (consumes real quota)."));
570
+ return { title: `PROJECT ANALYSIS · ${project} — Select Project Analyst`, lines };
571
+ }
572
+ if (!strategy) {
573
+ return {
574
+ title: "GLOBAL MODEL GUIDE",
575
+ lines: [theme.fg("warning", `Project ${project} not analyzed — use /project analyze for a real, project-specific team.`), ...this.teamsColumnsLines(width)]
576
+ };
577
+ }
578
+ const lines = [];
579
+ if (strategy.bootstrapAnalyst) lines.push(`${"Project Analyst".padEnd(18)} ${this.teamRoleLabel(strategy.bootstrapAnalyst)}`);
580
+ if (strategy.orchestrator) lines.push(`${"Orchestrator".padEnd(18)} ${this.teamRoleLabel(strategy.orchestrator)}`);
581
+ // The real OPERATIONAL team (see buildProjectStrategy's own doc) —
582
+ // never qualityTeam, which is comparative reference only. The overlay
583
+ // (project-overlay.js) already shows projectTeam under this exact
584
+ // same "PROJECT TEAM" title; showing something else here under the
585
+ // same label was the real, reported mismatch. qualityTeam only
586
+ // remains as a fallback for a strategy persisted before projectTeam
587
+ // existed (see applyProjectTeamOverride's own legacy-entry comment).
588
+ for (const entry of strategy.projectTeam ?? strategy.qualityTeam ?? []) {
589
+ lines.push(`${entry.role.padEnd(18)} ${entry.model ? this.teamRoleLabel(entry.model) : theme.fg("warning", "no eligible option")}`);
590
+ }
591
+ if (strategy.status === "suggested") lines.push(theme.fg("muted", "Suggested from real project analysis. Use /project approve to activate."));
592
+ if (strategy.status === "stale") lines.push(theme.fg("warning", "Real evidence changed since approval — use /project refresh."));
593
+ return { title: `PROJECT TEAM · ${project} · ${strategy.status.toUpperCase()}`, lines };
594
+ }
595
+
596
+ /** Contextual key hints — shown in the dashboard's fixed zone, not the scrollable conversation. */
597
+ renderFooterLines() {
598
+ const row = this.selectedRow();
599
+ const footerLines = [theme.fg("muted", "Enter send · Shift+Tab mode · /help · /usage · q quit")];
600
+ if (row) {
601
+ // Only ever advertises a key the current WorkMode actually lets
602
+ // through (see rows.js's isActionAvailable) — ASK's list stays
603
+ // "Enter open" only, never a stale "a approve" that would silently
604
+ // do nothing if pressed.
605
+ const controls = ["Enter open"];
606
+ if (isActionAvailable("approve", row, this.workMode)) controls.push("a approve");
607
+ if (isActionAvailable("reject", row, this.workMode)) controls.push("j reject");
608
+ if (isActionAvailable("execute", row, this.workMode)) controls.push("x implement");
609
+ if (isActionAvailable("cancel", row, this.workMode)) controls.push("c cancel");
610
+ footerLines.push(theme.fg("muted", this.hasListFocus
611
+ ? `Plan controls: ${controls.join(" · ")}`
612
+ : `Tab for plan controls (${controls.slice(1).join("/") || "view only"}) — typing here just sends a message`));
613
+ }
614
+ return footerLines;
615
+ }
616
+
617
+ /**
618
+ * The fixed dashboard zone for the real-scroll layout (app.js wires this
619
+ * as its own VStack entry, shrink: 0, above a scrollable conversation):
620
+ * USAGE/AI TEAM/EFFICIENT TEAM cards plus the contextual footer hints.
621
+ * Unlike renderConversation(), this is framed/truncated like the rest of
622
+ * the cockpit's cards — it never needs to preserve unbounded content the
623
+ * way scrollable chat history does.
624
+ */
625
+ renderDashboard(width) {
626
+ const lines = [...this.renderDashboardLines(width), ...this.renderFooterLines()];
627
+ return lines.map((line) => truncateToWidth(line, width, "…"));
628
+ }
629
+
630
+ /**
631
+ * The scrollable conversation zone for the real-scroll layout: every
632
+ * retained transcript entry (never sliced to a viewport-sized recent
633
+ * window — pi-tui's ScrollView owns which lines are actually visible),
634
+ * wrapped (never truncated — a long response or /models breakdown must
635
+ * stay fully readable by scrolling, not lose text off the right edge),
636
+ * with the pending confirm-execute prompt appended at the very end so it
637
+ * surfaces immediately once ScrollView's follow:"end" behavior is
638
+ * active, exactly where a real chat's newest message would land.
639
+ */
640
+ renderConversation(width) {
641
+ if (this.mode !== "confirm-execute" && this.mode !== "select-role" && this.transcript.length === 0 && !this.statusMessage && !this.actionLabel) {
642
+ return [
643
+ theme.fg("muted", "Ask Kairo about this project, or describe work to plan."),
644
+ "",
645
+ theme.fg("muted", "Use /help to see commands.")
646
+ ];
647
+ }
648
+ const lines = [];
649
+ for (const entry of this.transcript) {
650
+ // Wrapping (wrapTextWithAnsi) is the expensive part of rendering a
651
+ // real conversation, and renderConversation() runs on EVERY render —
652
+ // including every keystroke while typing. Cached by entry id (stable,
653
+ // never re-derived from array index) + width, so a keystroke only
654
+ // ever re-wraps if the terminal itself was resized, never the whole
655
+ // history again. Entries are immutable once pushed, so nothing else
656
+ // can go stale here.
657
+ const cacheKey = `${entry.id}:${width}`;
658
+ let entryLines = this._wrapCache.get(cacheKey);
659
+ if (!entryLines) {
660
+ const isUser = entry.role === "You";
661
+ const label = isUser ? "> " : "Kairo ";
662
+ const prefixWidth = visibleWidth(label);
663
+ const coloredPrefix = isUser ? theme.fg("accent", label) : theme.fg("info", label);
664
+ const wrapped = wrapTextWithAnsi(entry.text, Math.max(1, width - prefixWidth));
665
+ entryLines = wrapped.map((wrappedLine, index) => (index === 0 ? `${coloredPrefix}${wrappedLine}` : `${" ".repeat(prefixWidth)}${wrappedLine}`));
666
+ this._wrapCache.set(cacheKey, entryLines);
667
+ }
668
+ lines.push(...entryLines);
669
+ }
670
+ // A live in-flight action always wins over a leftover static message —
671
+ // see actionStatusLine()'s own doc.
672
+ const liveStatus = this.actionStatusLine();
673
+ if (liveStatus) {
674
+ lines.push("");
675
+ lines.push(theme.fg("accent", liveStatus));
676
+ } else if (this.statusMessage) {
677
+ lines.push("");
678
+ lines.push(theme.fg("accent", this.statusMessage));
679
+ }
680
+ if (this.mode === "confirm-execute") {
681
+ lines.push("");
682
+ lines.push(...this.confirmPromptLines(this.selectedRow()));
683
+ }
684
+ if (this.mode === "select-role") {
685
+ lines.push("");
686
+ lines.push(...this.roleSelectPromptLines());
687
+ }
688
+ return lines;
689
+ }
690
+
691
+ /**
692
+ * Conversation-first workspace, framed like the rest of the cockpit
693
+ * (rounded cards, per-line tone) instead of plain padded text — a compact
694
+ * usage card up top, the transcript/workflow card owning the rest of the
695
+ * screen, and key hints as a plain footer beneath both. This is the
696
+ * monolithic single-render fallback for test doubles and pi-tui builds
697
+ * without viewport/layout support — the real cockpit uses
698
+ * renderDashboard()/renderConversation() instead, tiled by app.js with a
699
+ * real scrollable ScrollView around the conversation.
700
+ */
701
+ renderWorkspace(width) {
702
+ const lines = [...this.renderDashboardLines(width)];
703
+ const footerLines = this.renderFooterLines();
704
+
705
+ // The chat is plain, unframed text — it's the dominant, scrollable
706
+ // conversation surface, not another bordered widget. How much of it
707
+ // fits is real content, not padding: a short conversation just renders
708
+ // short instead of being stretched or capped by an arbitrary constant.
709
+ const viewportRows = this.getViewportRows?.();
710
+ const overhead = lines.length + 1 /* spacer before chat */ + footerLines.length;
711
+ const chatBudget = Number.isFinite(viewportRows) ? Math.max(0, viewportRows - overhead) : undefined;
712
+
713
+ lines.push("");
714
+ lines.push(...this.chatLines(chatBudget));
715
+ lines.push(...footerLines);
716
+ return lines.map((line) => truncateToWidth(line, width, "…"));
717
+ }
718
+
719
+ /**
720
+ * Model shown for a role in the compact widget: whichever one Kairo
721
+ * would actually use right now — the primary when it's available, else
722
+ * the fallback when that's available, else null (nothing eligible
723
+ * covers this role). Never the unavailable primary itself: showing an
724
+ * unusable model as the headline is exactly the confusion this method
725
+ * exists to avoid — the full primary/fallback/availability breakdown
726
+ * stays one level down, in aiTeamDetailLines().
727
+ */
728
+ static effectiveTeamModel({ primary, fallback }) {
729
+ if (primary.available) return primary;
730
+ if (fallback?.available) return fallback;
731
+ return null;
732
+ }
733
+
734
+ /**
735
+ * A model's displayed name — deliberately WITHOUT its provider. Used by
736
+ * the MODEL TEAMS widget and the plain /models view: a "Perfil → Modelo"
737
+ * glance, provider hidden (adapterId is kept internally for every real
738
+ * decision — concentration limits, corroboration, execution — this only
739
+ * affects what's shown). `/models --evidence` shows the provider
740
+ * explicitly instead (see aiTeamLabelWithProvider) — that's the audit
741
+ * trail where it belongs.
742
+ */
743
+ // modelName (real, cleaned via model-candidate-catalog.js's
744
+ // stripDisplayVariant — e.g. "GPT-5.6 Sol", never "GPT-5.6 Sol 1M
745
+ // Extra High") is preferred everywhere a compact widget shows a model.
746
+ // A caller not yet routed through the Recommendation Pool (no real
747
+ // modelName attached) falls back to the raw displayName, then modelId
748
+ // — never blank.
749
+ aiTeamLabel(model) {
750
+ return model.modelName ?? model.displayName ?? model.modelId;
751
+ }
752
+
753
+ /** Same as aiTeamLabel(), but with the real provider AND the raw, unmodified display text (variant/effort/context tokens intact) — deliberately NOT the cleaned modelName, since this is the technical `/models --evidence` breakdown, which keeps the real detail modelName strips out. */
754
+ aiTeamLabelWithProvider(model) {
755
+ const provider = model.adapterId.charAt(0).toUpperCase() + model.adapterId.slice(1);
756
+ const raw = model.displayName ?? model.modelId;
757
+ return `${provider} · ${raw}`;
758
+ }
759
+
760
+ /**
761
+ * Role -> model -> provider, for the PROJECT TEAM listing specifically:
762
+ * keeps aiTeamLabel()'s cleaned modelName (unlike aiTeamLabelWithProvider's
763
+ * raw/technical variant) but still names the real adapter each role would
764
+ * actually run against — two roles can land on visually similar model
765
+ * names from different providers, and knowing which subscription a role
766
+ * draws from is exactly what a real, provider-aware team review needs.
767
+ */
768
+ teamRoleLabel(model) {
769
+ const provider = model.adapterId.charAt(0).toUpperCase() + model.adapterId.slice(1);
770
+ return `${this.aiTeamLabel(model)} · ${provider}`;
771
+ }
772
+
773
+ /**
774
+ * The global "AI TEAM" widget: one line per role (Explorer / Architect /
775
+ * Builder / Debugger / Tester / Reviewer) naming only the
776
+ * model that would actually run right now. This is the general team,
777
+ * not a per-project portfolio: which of these roles a given repo
778
+ * activates is a separate, later decision. Kept deliberately terse —
779
+ * the real distribution policy behind each pick (capability margins,
780
+ * fallback, why it isn't always the raw top score) lives in
781
+ * aiTeamDetailLines(), reachable via /models, not cluttering the glance.
782
+ */
783
+ fitLines() {
784
+ const intel = this.snapshot?.modelIntelligence;
785
+ if (!intel || intel.status === "unknown") {
786
+ const reason = intel?.error ? ` (${intel.error})` : "";
787
+ return [theme.fg("muted", `No model benchmark data yet${reason}`)];
788
+ }
789
+ const freshness = intel.status === "live" ? "live" : `cached ${intel.age ?? "?"}`;
790
+ // QUALITY TEAM: the real, portfolio-coordinated pick — not a bare
791
+ // per-role leaderboard. The uncoordinated individual leader
792
+ // (globalGuide.capability) is evidence, surfaced in /models, never the
793
+ // dashboard headline — see teamsColumnsLines()'s own comment for why.
794
+ const team = intel.aiTeam ?? [];
795
+ if (!team.length) {
796
+ const reasons = Object.entries(intel.eligibility ?? {})
797
+ .filter(([, check]) => !check.ok)
798
+ .map(([adapterId, check]) => `${adapterId}: ${check.reason}`);
799
+ return [theme.fg("muted", `Evidence: ${freshness}`), theme.fg("warning", "No eligible model signals right now"), ...reasons.map((r) => theme.fg("muted", r))];
800
+ }
801
+ const lines = [theme.fg("muted", `Evidence: ${freshness}`)];
802
+ for (const entry of team) {
803
+ const effective = CockpitView.effectiveTeamModel(entry);
804
+ const modelText = effective ? this.aiTeamLabel(effective) : theme.fg("warning", "no eligible option right now");
805
+ lines.push(`${entry.role.padEnd(10)} ${modelText}`);
806
+ }
807
+ lines.push(theme.fg("muted", "Use /models for why, and /why for coverage and eligibility."));
808
+ return lines;
809
+ }
810
+
811
+ /**
812
+ * MODEL TEAMS' unified widget: one combined panel with a real, drawn "│"
813
+ * separator between the QUALITY TEAM and EFFICIENT TEAM columns, instead
814
+ * of two separate AI TEAM/EFFICIENT TEAM cards or a plain-space gap that
815
+ * could look like column drift. This is now the ONLY dashboard team
816
+ * widget — used at every width, side by side with USAGE on medium/wide
817
+ * terminals and stacked below it on narrow ones (see
818
+ * renderDashboardLines()).
819
+ *
820
+ * Both column widths are computed from the real width actually
821
+ * available (never a fixed constant) — split evenly between QUALITY and
822
+ * EFFICIENT after reserving room for the role column and both real "│"
823
+ * separators — and each cell is truncated INDEPENDENTLY, so a long
824
+ * QUALITY entry can never bleed into the EFFICIENT column even under a
825
+ * narrow terminal; truncation only ever happens when content actually
826
+ * doesn't fit, never as a fixed cap.
827
+ * @param {number} [width] - real content width available to this panel
828
+ * (already inside its frame — see cardInnerWidth()); defaults to a
829
+ * reasonable width for callers that don't have a real one yet (tests).
830
+ */
831
+ teamsColumnsLines(width = 80) {
832
+ const intel = this.snapshot?.modelIntelligence;
833
+ if (!intel || intel.status === "unknown") return this.fitLines();
834
+ // QUALITY TEAM / EFFICIENT TEAM: the real, portfolio-coordinated picks
835
+ // (aiTeam/efficientTeam — family concentration, provider distribution,
836
+ // Builder/Reviewer independence all apply). This widget is not a bare
837
+ // leaderboard: it shows a usable TEAM, correctly labeled as one. The
838
+ // real bug this session fixed wasn't showing coordination here — a
839
+ // real team needs it — it was the earlier "CAPABILITY"/"EFFICIENT"
840
+ // labels implying "the single best model, full stop" for what was
841
+ // always a coordinated pick. The uncoordinated individual leader
842
+ // (globalGuide.capability/efficient) is real evidence for a different
843
+ // question ("what's honestly best with nothing else in play?") and
844
+ // belongs in /models, never this dashboard headline.
845
+ const aiTeam = intel.aiTeam ?? [];
846
+ if (!aiTeam.length) return this.fitLines();
847
+ const freshness = intel.status === "live" ? "live" : `cached ${intel.age ?? "?"}`;
848
+ const efficientByRole = Object.fromEntries((intel.efficientTeam ?? []).map((entry) => [entry.role, entry]));
849
+
850
+ const roleWidth = 10;
851
+ const separator = " │ ";
852
+ const remaining = Math.max(2, width - roleWidth - separator.length * 2);
853
+ const capabilityWidth = Math.max(1, Math.ceil(remaining / 2));
854
+ const efficientWidth = Math.max(1, remaining - capabilityWidth);
855
+ const formatRow = (roleText, capabilityText, efficientText) => {
856
+ const roleCell = truncateToWidth(roleText, roleWidth, "").padEnd(roleWidth);
857
+ const capabilityClipped = truncateToWidth(capabilityText, capabilityWidth, "…");
858
+ const capabilityCell = capabilityClipped + " ".repeat(Math.max(0, capabilityWidth - visibleWidth(capabilityClipped)));
859
+ const efficientCell = truncateToWidth(efficientText, efficientWidth, "…");
860
+ return `${roleCell}${separator}${capabilityCell}${separator}${efficientCell}`;
861
+ };
862
+
863
+ const lines = [
864
+ theme.fg("muted", `Evidence: ${freshness}`),
865
+ theme.fg("muted", formatRow("", "QUALITY TEAM", "EFFICIENT TEAM"))
866
+ ];
867
+ for (const entry of aiTeam) {
868
+ const capabilityEffective = CockpitView.effectiveTeamModel(entry);
869
+ const capabilityText = capabilityEffective ? this.aiTeamLabel(capabilityEffective) : "no eligible option";
870
+ const efficientEntry = efficientByRole[entry.role];
871
+ const efficientEffective = efficientEntry ? CockpitView.effectiveTeamModel(efficientEntry) : null;
872
+ const efficientText = efficientEffective ? this.aiTeamLabel(efficientEffective) : "—";
873
+ lines.push(formatRow(entry.role, capabilityText, efficientText));
874
+ }
875
+ lines.push(theme.fg("muted", "Use /models for why."));
876
+ return lines;
877
+ }
878
+
879
+ /** Plain-language description of what each role optimizes for — mirrors
880
+ * buildAiTeamRoleDefinitions()'s real compute functions in
881
+ * model-intelligence.js, never a per-model claim, so it never needs
882
+ * updating when the underlying models change. */
883
+ static ROLE_CAPABILITY_BLURB = {
884
+ Explorer: "general reasoning capability",
885
+ Architect: "general reasoning capability",
886
+ Builder: "coding capability",
887
+ Debugger: "reasoning and terminal-debugging capability",
888
+ Tester: "coding and terminal-execution capability",
889
+ Reviewer: "independent reasoning and coding review"
890
+ };
891
+
892
+ /**
893
+ * The default, human-readable `/models` output: per role, the selected
894
+ * model, why (the real distribution-policy reason when there is one,
895
+ * else the role's plain-language capability requirement), the
896
+ * EFFICIENT TEAM alternative when it actually differs, and the real
897
+ * fallback used if the selection becomes unavailable. Deliberately no
898
+ * raw metrics, percentages, internal ids, or source names — that detail
899
+ * moves to /models --evidence (aiTeamDetailLines()) instead.
900
+ */
901
+ modelsExplainLines() {
902
+ const intel = this.snapshot?.modelIntelligence;
903
+ if (!intel || intel.status === "unknown") return this.fitLines();
904
+ const aiTeam = intel.aiTeam ?? [];
905
+ if (!aiTeam.length) return this.fitLines();
906
+ const efficientByRole = Object.fromEntries((intel.efficientTeam ?? []).map((entry) => [entry.role, entry]));
907
+ const leaderByRole = Object.fromEntries((intel.globalGuide?.capability ?? []).map((entry) => [entry.role, entry]));
908
+ const freshness = intel.status === "live" ? "live" : `cached ${intel.age ?? "?"}`;
909
+ const lines = [theme.fg("muted", `Evidence: ${freshness}`)];
910
+ aiTeam.forEach(({ role, primary, fallback, reason }, index) => {
911
+ // A blank string here would get silently dropped once routed through
912
+ // the persisted chat transcript (addTranscript trims and discards
913
+ // empty text) — a visible divider is the only separator that
914
+ // actually survives into the real, persisted chat history.
915
+ if (index > 0) lines.push(theme.fg("muted", "·"));
916
+ const availabilityNote = primary.available ? "" : " (currently unavailable)";
917
+ lines.push(`${role.padEnd(10)} ${this.aiTeamLabel(primary)}${availabilityNote}`);
918
+ const why = reason ?? `Selected for ${CockpitView.ROLE_CAPABILITY_BLURB[role] ?? "this role's capability requirement"}.`;
919
+ lines.push(theme.fg("muted", ` ${why}`));
920
+
921
+ // The uncoordinated individual leader (globalGuide) — evidence for
922
+ // "what's honestly best with nothing else in play?", never the
923
+ // dashboard headline. Only worth a line when it actually differs
924
+ // from the QUALITY TEAM pick — a diversity/concentration reason
925
+ // above already implies it does; a null reason means they agree.
926
+ const leaderEntry = leaderByRole[role];
927
+ if (leaderEntry?.primary && (leaderEntry.primary.adapterId !== primary.adapterId || leaderEntry.primary.modelId !== primary.modelId)) {
928
+ lines.push(theme.fg("muted", ` Individual leader: ${this.aiTeamLabel(leaderEntry.primary)} — the raw per-role best, uncoordinated with the rest of the team.`));
929
+ }
930
+
931
+ const efficientEntry = efficientByRole[role];
932
+ if (efficientEntry) {
933
+ const samePick = efficientEntry.primary.adapterId === primary.adapterId && efficientEntry.primary.modelId === primary.modelId;
934
+ if (samePick) {
935
+ lines.push(theme.fg("muted", " Efficient: same pick — no cheaper or faster real alternative within the capability floor."));
936
+ } else {
937
+ const efficientWhy = efficientEntry.reason ? ` — ${efficientEntry.reason}` : "";
938
+ lines.push(theme.fg("muted", ` Efficient: ${this.aiTeamLabel(efficientEntry.primary)}${efficientWhy}`));
939
+ }
940
+ }
941
+
942
+ if (fallback) {
943
+ lines.push(theme.fg("muted", ` Fallback: ${this.aiTeamLabel(fallback)} — used if this model becomes unavailable.`));
944
+ } else if (!primary.available) {
945
+ lines.push(theme.fg("warning", " Fallback: none eligible right now."));
946
+ }
947
+ });
948
+ lines.push(theme.fg("muted", "Use /models --evidence for the underlying metrics and sources."));
949
+ return lines;
950
+ }
951
+
952
+ /**
953
+ * Renders one team's full technical breakdown — primary (with its real
954
+ * provider shown, unlike the default views), availability, fallback,
955
+ * real per-capability benchmark coverage and confidence (decisionEvidence
956
+ * — see buildDecisionEvidence in model-intelligence.js; falls back to
957
+ * the older aggregate coverage/confidence fields for a snapshot saved
958
+ * before decisionEvidence existed, so it never breaks on old data),
959
+ * EFFICIENT's real retention/risk-floor and Pareto/tiebreak savings when
960
+ * present, the distribution-policy reason, and any real corroborating
961
+ * evidence the Model Intelligence Foundation registry has for that exact
962
+ * model. Never recalculates anything — every number here was already
963
+ * computed during real selection. Shared by AI TEAM and EFFICIENT TEAM
964
+ * inside aiTeamDetailLines(); never called on its own.
965
+ * @param {Array<object>} team
966
+ */
967
+ teamEvidenceLines(team) {
968
+ const lines = [];
969
+ const corroborationLine = (model) => (model.corroboration ?? [])
970
+ .map((entry) => `${entry.metric}=${entry.value} (${entry.source})`)
971
+ .join(" · ");
972
+ team.forEach(({ role, primary, fallback, reason, coverage, confidence, decisionEvidence }, index) => {
973
+ // A blank string here would get silently dropped once this line is
974
+ // routed through the persisted chat transcript (addTranscript trims
975
+ // and discards empty text) — a visible divider is the only separator
976
+ // that actually survives into the real, persisted chat history.
977
+ if (index > 0) lines.push(theme.fg("muted", "·"));
978
+ const primaryLabel = this.aiTeamLabelWithProvider(primary);
979
+ const primaryText = primary.available ? primaryLabel : `${primaryLabel} (not available)`;
980
+ lines.push(`${role.padEnd(10)} ${primaryText}`);
981
+
982
+ const capabilityCoverage = decisionEvidence?.coverage ?? {};
983
+ const capabilities = Object.keys(capabilityCoverage);
984
+ if (capabilities.length) {
985
+ // Real benchmark IDENTITIES, not sources (see
986
+ // capability-scoring.js's activeBenchmarkCountForCapability) —
987
+ // "0/3" for a required capability means every real score for it
988
+ // came from a composite index fallback, never a component
989
+ // benchmark, so it's called out explicitly rather than left to
990
+ // look like ordinary thin coverage.
991
+ const parts = capabilities.map((capability) => {
992
+ const c = capabilityCoverage[capability];
993
+ const fallbackNote = c.have === 0 && c.active > 0 ? " (composite fallback)" : "";
994
+ return `${capability} ${c.have}/${c.active}${fallbackNote}`;
995
+ });
996
+ const allComparable = capabilities.every((capability) => capabilityCoverage[capability].comparable);
997
+ const tone = allComparable ? "muted" : "warning";
998
+ lines.push(theme.fg(tone, ` ${parts.join(" · ")} · ${allComparable ? "comparable" : "provisional"} · confidence ${decisionEvidence.confidence ?? "unknown"}`));
999
+ } else if (coverage != null) {
1000
+ // A snapshot saved before decisionEvidence existed — the older,
1001
+ // single-fraction aggregate is still real data, just coarser.
1002
+ const coveragePercent = Math.round(coverage * 100);
1003
+ const coverageTone = coveragePercent < 100 ? "warning" : "muted";
1004
+ lines.push(theme.fg(coverageTone, ` coverage: ${coveragePercent}% of relevant capabilities scored · confidence: ${confidence ?? "unknown"}`));
1005
+ }
1006
+
1007
+ // EFFICIENT-only: real retention against the QUALITY leader and the
1008
+ // real risk-based floor it had to clear — never shown for QUALITY,
1009
+ // where these concepts don't apply (decisionEvidence.retention is
1010
+ // null there by construction).
1011
+ //
1012
+ // The raw ratio (chosen.gapValue / leader.gapValue) can genuinely
1013
+ // exceed 1 — the two gapValues come from different candidate-ranking
1014
+ // tiers (leader is eligibleRanked[0], the raw top-by-value; chosen
1015
+ // can come from the comparable-preferred pool once a provisional
1016
+ // raw leader is demoted — see preferComparableCandidates) — so a
1017
+ // value above 100% is real, not a bug, but "retention 117%" reads as
1018
+ // nonsensical: you can't retain more than the whole of something.
1019
+ // Reported instead as "exceeds QUALITY reference by N%", keeping the
1020
+ // word "retention" reserved for its own real 0-100% meaning; the raw
1021
+ // ratio itself is untouched in decisionEvidence.retention for audit.
1022
+ if (decisionEvidence?.retention != null && decisionEvidence.requiredFloor != null) {
1023
+ const floorPct = Math.round(decisionEvidence.requiredFloor * 100);
1024
+ const riskNote = `${decisionEvidence.riskLevel ?? "unknown"}-risk role`;
1025
+ if (decisionEvidence.retention > 1) {
1026
+ const excessPct = Math.round((decisionEvidence.retention - 1) * 100);
1027
+ lines.push(theme.fg("muted", ` exceeds QUALITY reference by ${excessPct}% · required ${floorPct}% · ${riskNote}`));
1028
+ } else {
1029
+ const retentionPct = Math.round(decisionEvidence.retention * 100);
1030
+ lines.push(theme.fg("muted", ` retention ${retentionPct}% · required ${floorPct}% · ${riskNote}`));
1031
+ }
1032
+ }
1033
+ // Real savings evidence — only ever shown when a real resource
1034
+ // dimension actually decided the pick (see describeEfficiencyDecision);
1035
+ // never invented when the metric that would justify it is missing.
1036
+ if (decisionEvidence?.savings) {
1037
+ const kind = decisionEvidence.decisionType === "pareto" ? "Pareto balance" : "Tiebreak";
1038
+ const { label, from, to } = decisionEvidence.savings;
1039
+ lines.push(theme.fg("muted", ` ${kind} · ${label} ${from} → ${to}`));
1040
+ }
1041
+
1042
+ const primaryEvidence = corroborationLine(primary);
1043
+ if (primaryEvidence) lines.push(theme.fg("muted", ` also: ${primaryEvidence}`));
1044
+ if (fallback) lines.push(theme.fg("muted", ` fallback ${this.aiTeamLabelWithProvider(fallback)}`));
1045
+ else if (!primary.available) lines.push(theme.fg("warning", " no eligible fallback right now"));
1046
+ if (reason) lines.push(theme.fg("muted", ` ${reason}`));
1047
+ });
1048
+ return lines;
1049
+ }
1050
+
1051
+ /**
1052
+ * `/models --evidence`: the full breakdown behind both AI TEAM and
1053
+ * EFFICIENT TEAM picks — real provider, primary, availability, fallback,
1054
+ * real coverage/confidence, the real distribution-policy reason
1055
+ * (near-tie, independence swap, temporarily-unavailable leader,
1056
+ * efficiency dimension), and any real corroborating evidence the Model
1057
+ * Intelligence Foundation registry has for that exact model (Hugging
1058
+ * Face, manufacturer snapshots, Kairo's own telemetry). Corroboration is
1059
+ * informational only: it never changed which model was picked, so it's
1060
+ * shown, never blended into the reason. Also lists every real catalog
1061
+ * model Kairo has access to but couldn't match to any real AA data —
1062
+ * UNSCORED, never given an invented score, never silently dropped. This
1063
+ * is the technical audit trail; modelsExplainLines() is the plain-
1064
+ * language default /models shows instead.
1065
+ */
1066
+ aiTeamDetailLines() {
1067
+ const intel = this.snapshot?.modelIntelligence;
1068
+ if (!intel || intel.status === "unknown") return this.fitLines();
1069
+ const team = intel.aiTeam ?? [];
1070
+ if (!team.length) return this.fitLines();
1071
+ const freshness = intel.status === "live" ? "live" : `cached ${intel.age ?? "?"}`;
1072
+ const lines = [theme.fg("muted", `Evidence: ${freshness}`)];
1073
+ lines.push(...this.teamEvidenceLines(team));
1074
+ const efficientTeam = intel.efficientTeam ?? [];
1075
+ if (efficientTeam.length) {
1076
+ lines.push(theme.fg("muted", "·"));
1077
+ lines.push(theme.fg("muted", "EFFICIENT TEAM"));
1078
+ lines.push(...this.teamEvidenceLines(efficientTeam));
1079
+ }
1080
+ const unscored = intel.unscoredModels ?? [];
1081
+ if (unscored.length) {
1082
+ lines.push(theme.fg("muted", "·"));
1083
+ lines.push(theme.fg("muted", "UNSCORED (real catalog model, no matching Artificial Analysis data — never given an invented score):"));
1084
+ for (const model of unscored) {
1085
+ lines.push(theme.fg("muted", ` ${this.aiTeamLabelWithProvider({ ...model, displayName: model.displayName ?? model.modelId })}`));
1086
+ }
1087
+ }
1088
+ return lines;
1089
+ }
1090
+
1091
+ /**
1092
+ * `/why` detail: every candidate provider's real eligibility outcome
1093
+ * (which were rejected and their exact reason, which survived) plus a
1094
+ * coverage/confidence line per provider — its real catalog source
1095
+ * (measured vs. documented) and how much of it Kairo could match to
1096
+ * real Artificial Analysis data. This is what stops "Fable is the best
1097
+ * model available now" from being read as "Fable is the only model
1098
+ * Kairo could ever evaluate": a provider can be fully eligible and
1099
+ * still have unmatched models simply because AA doesn't track them, or
1100
+ * because Kairo only has a documented catalog for it, not a live
1101
+ * per-account discovery (true for Claude today). The main FIT widget
1102
+ * stays a single line per role; this is the drill-down.
1103
+ */
1104
+ fitWhyLines() {
1105
+ const intel = this.snapshot?.modelIntelligence;
1106
+ const eligibility = Object.entries(intel?.eligibility ?? {});
1107
+ const coverage = intel?.coverage ?? [];
1108
+ if (!eligibility.length && !coverage.length) return [theme.fg("muted", "No eligibility data yet.")];
1109
+ const lines = eligibility.map(([adapterId, check]) => (check.ok
1110
+ ? theme.fg("success", `${adapterId}: eligible`)
1111
+ : theme.fg("muted", `${adapterId}: excluded — ${check.reason}`)));
1112
+ if (coverage.length) {
1113
+ lines.push("");
1114
+ lines.push(theme.fg("muted", "Catalog coverage (real data matched, not runtime eligibility):"));
1115
+ for (const entry of coverage) {
1116
+ lines.push(theme.fg("muted", `${entry.adapterId}: ${entry.catalogStatus} catalog, ${entry.matchedModels}/${entry.totalModels} models matched to Artificial Analysis`));
1117
+ }
1118
+ }
1119
+ return lines;
1120
+ }
1121
+
1122
+ /**
1123
+ * @param {number} [chatBudget] - how many chat lines actually fit on
1124
+ * screen; omitted (tests, narrow terminals) falls back to a fixed
1125
+ * recent-history window instead of showing everything unbounded.
1126
+ */
1127
+ chatLines(chatBudget) {
1128
+ if (this.mode !== "confirm-execute" && this.mode !== "select-role" && this.transcript.length === 0 && !this.statusMessage && !this.actionLabel) {
1129
+ return [
1130
+ theme.fg("muted", "Ask Kairo about this project, or describe work to plan."),
1131
+ "",
1132
+ theme.fg("muted", "Use /help to see commands.")
1133
+ ];
1134
+ }
1135
+ const lines = [];
1136
+ // The execute confirmation (y/n) is a real pending decision, not status
1137
+ // chrome — it belongs in the dominant chat surface, not a separate
1138
+ // widget that could be scrolled past or removed. Same for the role
1139
+ // picker that can precede it.
1140
+ if (this.mode === "confirm-execute") {
1141
+ lines.push(...this.confirmPromptLines(this.selectedRow()));
1142
+ lines.push("");
1143
+ }
1144
+ if (this.mode === "select-role") {
1145
+ lines.push(...this.roleSelectPromptLines());
1146
+ lines.push("");
1147
+ }
1148
+ // A live in-flight action always wins over a leftover static message —
1149
+ // see actionStatusLine()'s own doc.
1150
+ const liveStatus = this.actionStatusLine();
1151
+ if (liveStatus) {
1152
+ lines.push(theme.fg("accent", liveStatus));
1153
+ lines.push("");
1154
+ } else if (this.statusMessage) {
1155
+ lines.push(theme.fg("accent", this.statusMessage));
1156
+ lines.push("");
1157
+ }
1158
+ const historyLimit = Number.isFinite(chatBudget) ? Math.max(1, chatBudget - lines.length) : 8;
1159
+ for (const entry of this.transcript.slice(-historyLimit)) {
1160
+ const prefix = entry.role === "You" ? theme.fg("accent", "> ") : theme.fg("info", "Kairo ");
1161
+ lines.push(`${prefix}${entry.text}`);
1162
+ }
1163
+ return lines;
1164
+ }
1165
+
1166
+ /**
1167
+ * The compact USAGE bar: one plain line — `KAIRO · project │ Codex 5h
1168
+ * 58% / W 86% │ Claude S 34% / W 65% │ Go 100% / 100% / 96%` — never a
1169
+ * bordered card (see renderDashboardLines()). Only AUTOMATIC-routing
1170
+ * providers appear (Codex, Claude, OpenCode Go); Zen/Cursor are
1171
+ * manual/PAYG-risk, never part of the same automatic resource pool, and
1172
+ * stay in `/providers` instead. Two lines only when the real content
1173
+ * genuinely doesn't fit the given width — the header segment alone on
1174
+ * its own line, the three provider segments on the next — never padded
1175
+ * to any fixed height.
1176
+ * @param {number} [width]
1177
+ * @returns {string[]}
1178
+ */
1179
+ compactUsageLines(width = 80) {
1180
+ const project = this.snapshot?.projectRoot?.split("/").filter(Boolean).pop() ?? "current project";
1181
+ const usage = this.snapshot?.usage ?? {};
1182
+ const providers = this.snapshot?.providers ?? {};
1183
+ const status = (name) => providers[name]?.status ?? providers[name.toLowerCase()]?.status;
1184
+
1185
+ const codex = usage.codex;
1186
+ const codexText = codex?.primary
1187
+ ? `Codex 5h ${codex.primary.remainingPercent}%${quotaWarnSuffix(codex.primary.remainingPercent)}${codex.secondary ? ` / W ${codex.secondary.remainingPercent}%${quotaWarnSuffix(codex.secondary.remainingPercent)}` : ""}`
1188
+ : `Codex ${status("Codex") ?? "usage unknown"}`;
1189
+
1190
+ const claude = usage.claude;
1191
+ const claudeText = claude?.primary
1192
+ ? `Claude S ${claude.primary.remainingPercent}%${quotaWarnSuffix(claude.primary.remainingPercent)}${claude.secondary ? ` / W ${claude.secondary.remainingPercent}%${quotaWarnSuffix(claude.secondary.remainingPercent)}` : ""}`
1193
+ : `Claude ${status("Claude") ?? "usage unknown"}`;
1194
+
1195
+ const go = usage.opencode?.go;
1196
+ const goText = go?.windows?.length
1197
+ ? `Go ${go.windows.map((window) => {
1198
+ const limited = window.status === "rate-limited";
1199
+ return `${window.remainingPercent}%${limited ? " LIMITED" : quotaWarnSuffix(window.remainingPercent)}`;
1200
+ }).join(" / ")}`
1201
+ : `Go ${status("OpenCode") ?? "usage unknown"}`;
1202
+
1203
+ const header = `KAIRO · ${project}`;
1204
+ const segments = [codexText, claudeText, goText];
1205
+ const full = `${header} │ ${segments.join(" │ ")}`;
1206
+ if (visibleWidth(full) <= width) return [theme.fg("muted", full)];
1207
+ return [theme.fg("muted", header), theme.fg("muted", segments.join(" │ "))];
1208
+ }
1209
+
1210
+ providerLines() {
1211
+ const providers = this.snapshot?.providers ?? {};
1212
+ const entry = (name, fallback) => {
1213
+ const value = providers[name]?.status ?? providers[name.toLowerCase()]?.status;
1214
+ return value ?? fallback;
1215
+ };
1216
+ const usage = this.snapshot?.usage ?? {};
1217
+ const codex = usage.codex;
1218
+ const claude = usage.claude;
1219
+ const open = usage.opencode;
1220
+ const codexText = codex?.windows?.length
1221
+ ? codex.windows.map((window) => `${window.name} ${window.remainingPercent}% left`).join(" · ")
1222
+ : entry("Codex", "READY · usage unknown");
1223
+ const claudeText = claude?.windows?.length
1224
+ ? claude.windows.map((window) => `${window.label ?? window.name} ${window.remainingPercent}% left`).join(" · ")
1225
+ : entry("Claude", "READY · usage unknown");
1226
+ const goText = open?.go?.windows?.length
1227
+ ? open.go.windows.map((window) => `${shortWindowName(window.name)} ${window.remainingPercent}%${window.status === "rate-limited" ? " RATE LIMITED" : ""}`).join(" · ")
1228
+ : "usage unknown";
1229
+ const zen = open?.zen;
1230
+ const zenText = zen?.status === "local_recorded"
1231
+ ? `PAYG/manual · 7d local $${zen.totalCost.toFixed(2)} · ${compactNumber(zen.totalTokens)}`
1232
+ : "PAYG/manual · 7d local unknown";
1233
+ return [
1234
+ `Codex ${codexText}`,
1235
+ `Claude ${claudeText}`,
1236
+ `Go ${goText}`,
1237
+ `Zen ${zenText}`,
1238
+ `Cursor ${entry("Cursor", "MANUAL · usage unknown")}`
1239
+ ];
1240
+ }
1241
+
1242
+ integrationsLine() {
1243
+ const integrations = this.snapshot?.integrations ?? {};
1244
+ const state = (name, fallback) => integrations[name]?.status ?? integrations[name]?.state ?? fallback;
1245
+ return [
1246
+ `Engram ${state("engram", "available")}`,
1247
+ `MCP ${state("mcp", "available")}`,
1248
+ `Skills ${state("skills", "available")}`,
1249
+ `CodeGraph ${state("codegraph", "unknown")}`,
1250
+ `Graphify ${state("graphify", "unknown")}`,
1251
+ `Gentle ${state("gentle", "policy active")}`
1252
+ ].join(" ");
1253
+ }
1254
+
1255
+ /**
1256
+ * `/usage`: real automatic-routing resources only (Codex, Claude, Go).
1257
+ * Zen is explicitly PAYG/manual, never part of Kairo's automatic
1258
+ * resource pool — showing it here would misleadingly suggest it's on
1259
+ * the same footing as the automatic providers; it stays in
1260
+ * `/providers`, clearly labeled.
1261
+ */
1262
+ usageLines() {
1263
+ const usage = this.snapshot?.usage ?? {};
1264
+ const lines = [];
1265
+ const codex = usage.codex;
1266
+ lines.push(codex?.windows?.length
1267
+ ? `Codex ${codex.windows.map((window) => `${window.name} ${window.remainingPercent}% left${quotaWarnSuffix(window.remainingPercent)}${window.resetsAtIso ? ` reset ${window.resetsAtIso}` : ""}`).join(" · ")} · source: ${codex.source ?? "measured"}`
1268
+ : "Codex usage unknown · source: Codex app-server · no quota fabricated");
1269
+ const claude = usage.claude;
1270
+ lines.push(claude?.windows?.length
1271
+ ? `Claude ${claude.windows.map((window) => `${window.label ?? window.name} ${window.remainingPercent}% left${quotaWarnSuffix(window.remainingPercent)}`).join(" · ")} · source: ${claude.source ?? "measured"}`
1272
+ : "Claude usage unknown · no quota fabricated");
1273
+ const go = usage.opencode?.go;
1274
+ lines.push(go?.windows?.length
1275
+ ? `Go ${go.windows.map((window) => {
1276
+ const limited = window.status === "rate-limited";
1277
+ return `${shortWindowName(window.name)} ${window.remainingPercent}%${limited ? " RATE LIMITED" : quotaWarnSuffix(window.remainingPercent)}`;
1278
+ }).join(" · ")} · source: ${go.source ?? "measured"}`
1279
+ : "Go usage unknown · source unavailable");
1280
+ return lines;
1281
+ }
1282
+
1283
+ renderDetail(width) {
1284
+ const lines = [];
1285
+ lines.push(cardTop(`Plan: ${this.detailTaskId ?? ""}`, CARD_TONE.INFO, theme, width));
1286
+ for (const line of String(this.detailText).split("\n")) {
1287
+ lines.push(cardLine(line, CARD_TONE.INFO, theme, width));
1288
+ }
1289
+ lines.push(cardLine("", CARD_TONE.INFO, theme, width));
1290
+ lines.push(cardLine(theme.fg("muted", "esc/q back to list"), CARD_TONE.INFO, theme, width));
1291
+ lines.push(cardBottom(CARD_TONE.INFO, theme, width));
1292
+ return lines;
1293
+ }
1294
+ }
1295
+
1296
+ function shortWindowName(name) {
1297
+ return { rolling: "roll", weekly: "week", monthly: "month" }[name] ?? name;
1298
+ }