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