@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.
- package/CHANGELOG.md +79 -0
- package/package.json +2 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/scripts/ux-smoke-test.sh +3 -3
- package/src/cli.js +96 -11
- package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
- package/src/global/architect/architect-cli.js +76 -0
- package/src/global/architect/architect-codex.js +146 -0
- package/src/global/architect/architect-manager.js +125 -0
- package/src/global/architect/architect-store.js +377 -0
- package/src/global/architect/architect-types.js +47 -0
- package/src/global/cli-help.js +10 -1
- package/src/global/cockpit/app.js +493 -0
- package/src/global/cockpit/card.js +111 -0
- package/src/global/cockpit/cli.js +33 -0
- package/src/global/cockpit/gauge.js +31 -0
- package/src/global/cockpit/project-overlay.js +693 -0
- package/src/global/cockpit/rows.js +148 -0
- package/src/global/cockpit/theme.js +118 -0
- package/src/global/cockpit/view.js +1298 -0
- package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
- package/src/global/conversation/cli.js +53 -0
- package/src/global/conversation/codex-sandbox.js +230 -0
- package/src/global/conversation/cursor-sandbox.js +215 -0
- package/src/global/conversation/project-analysis.js +204 -0
- package/src/global/conversation/project-profile.js +178 -0
- package/src/global/conversation/project-router.js +149 -0
- package/src/global/conversation/project-strategy-store.js +64 -0
- package/src/global/conversation/project-strategy.js +514 -0
- package/src/global/conversation/sanitized-snapshot.js +169 -0
- package/src/global/conversation/secret-scanner.js +71 -0
- package/src/global/conversation/service.js +1090 -0
- package/src/global/conversation/session-store.js +75 -0
- package/src/global/conversation/transcript-store.js +79 -0
- package/src/global/conversation/ui.js +195 -0
- package/src/global/intelligence/capability-scoring.js +480 -0
- package/src/global/intelligence/execution-router.js +466 -0
- package/src/global/intelligence/kairo-telemetry-source.js +59 -0
- package/src/global/intelligence/kairobench-runner.js +85 -0
- package/src/global/intelligence/kairobench-source.js +34 -0
- package/src/global/intelligence/kairobench-tasks.js +47 -0
- package/src/global/intelligence/model-candidate-catalog.js +456 -0
- package/src/global/intelligence/model-capability-registry-sources.js +145 -0
- package/src/global/intelligence/model-capability-registry.js +125 -0
- package/src/global/intelligence/model-intelligence.js +1646 -0
- package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
- package/src/global/intelligence/quick-ask.js +149 -0
- package/src/global/intelligence/role-profiles.js +251 -0
- package/src/global/intelligence/skill-catalog.js +67 -0
- package/src/global/intelligence/subscription-pressure-source.js +41 -0
- package/src/global/mcp/kairo-mcp.js +51 -18
- package/src/global/mcp/work-snapshot-rule.js +4 -2
- package/src/global/mcp/workspace-binding.js +88 -0
- package/src/global/mcp/workspace-mcp-entry.js +74 -0
- package/src/global/mcp-install.js +8 -1
- package/src/global/observability/artificial-analysis-models.js +118 -0
- package/src/global/observability/claude-models.js +31 -0
- package/src/global/observability/claude-usage.js +112 -0
- package/src/global/observability/codex-models.js +96 -0
- package/src/global/observability/codex-usage.js +160 -0
- package/src/global/observability/cursor-auth.js +88 -0
- package/src/global/observability/cursor-models.js +101 -0
- package/src/global/observability/huggingface-leaderboard.js +97 -0
- package/src/global/observability/opencode-models.js +101 -0
- package/src/global/observability/opencode-usage.js +162 -0
- package/src/global/paths.js +49 -2
- package/src/global/profile.js +23 -1
- package/src/global/runtime/execution-adapters/claude.js +63 -30
- package/src/global/runtime/execution-adapters/codex.js +9 -2
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
- package/src/global/runtime/execution-adapters/opencode.js +83 -18
- package/src/global/runtime/execution-worktree-manager.js +924 -0
- package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
- package/src/global/runtime/execution-worktree-store.js +83 -0
- package/src/global/runtime/execution-worktree-types.js +45 -0
- package/src/global/runtime/run-events.js +38 -0
- package/src/global/runtime/run-manager.js +22 -6
- package/src/global/runtime/run-supervisor.js +41 -12
- package/src/global/runtime/usage-manager.js +96 -0
- package/src/global/runtime/usage-store.js +69 -0
- 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
|
+
}
|