@rind-ai/cli 0.4.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/bin/rind.js +5 -5
  2. package/lib/assistant-renderer.js +179 -265
  3. package/lib/choice-menu-state.js +46 -46
  4. package/lib/cli-input-actions.js +548 -0
  5. package/lib/cli-output-controller.js +460 -0
  6. package/lib/cli-runtime-controller.js +350 -0
  7. package/lib/cli-state-store.js +32 -0
  8. package/lib/cli-state.js +41 -0
  9. package/lib/command-controller.js +159 -126
  10. package/lib/compact-context-state.js +22 -22
  11. package/lib/components/assistant-message.js +169 -0
  12. package/lib/components/composer-area.js +25 -0
  13. package/lib/components/dynamic-block.js +20 -0
  14. package/lib/components/monitor-stack.js +35 -0
  15. package/lib/components/text-block.js +47 -0
  16. package/lib/components/tool-block.js +122 -0
  17. package/lib/composer-terminal.js +224 -203
  18. package/lib/event-controller.js +243 -242
  19. package/lib/frontend-cli-implementation.js +656 -1111
  20. package/lib/input-controller.js +75 -94
  21. package/lib/input-errors.js +3 -3
  22. package/lib/interrupt-state.js +9 -9
  23. package/lib/line-editor.js +541 -541
  24. package/lib/local-slash-commands.js +217 -0
  25. package/lib/markdown-lines.js +103 -0
  26. package/lib/model-menu-state.js +50 -50
  27. package/lib/one-shot-progress.js +145 -0
  28. package/lib/one-shot.js +228 -0
  29. package/lib/question-menu-state.js +61 -0
  30. package/lib/rendering.js +1295 -1037
  31. package/lib/runtime-client.js +241 -193
  32. package/lib/runtime-env.js +21 -21
  33. package/lib/runtime-protocol.js +122 -15
  34. package/lib/slash-command-mode.js +16 -27
  35. package/lib/slash-menu-state.js +59 -59
  36. package/lib/{background-controller.js → task-monitor-controller.js} +411 -289
  37. package/lib/terminal-key.js +97 -97
  38. package/lib/text-width.js +335 -151
  39. package/lib/theme-menu-state.js +31 -0
  40. package/lib/theme.js +134 -0
  41. package/lib/tool-display.js +680 -0
  42. package/lib/tui/component.js +55 -0
  43. package/lib/tui/cursor.js +29 -0
  44. package/lib/tui/input-buffer.js +172 -0
  45. package/lib/tui/tui.js +591 -0
  46. package/lib/turn-controller.js +68 -78
  47. package/package.json +28 -28
  48. package/lib/assistant-stream-buffer.js +0 -25
  49. package/lib/terminal-ui.js +0 -581
@@ -0,0 +1,217 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { currentTheme, setTheme, themeNames, themeOptions } from "./theme.js";
5
+
6
+ export const LOCAL_SLASH_COMMANDS = Object.freeze([
7
+ { name: "compact", description: "Compact current session context", usage: "/compact" },
8
+ { name: "config", description: "Show config guidance", usage: "/config" },
9
+ { name: "doctor", description: "Run local setup diagnostics", usage: "/doctor" },
10
+ { name: "effort", description: "Show or change reasoning effort", usage: "/effort [low | medium | high | xhigh | max]" },
11
+ { name: "goal", description: "View or control the active goal", usage: "/goal [pause | resume | clear | objective]" },
12
+ { name: "help", description: "Show commands", usage: "/help [command]" },
13
+ { name: "init", description: "Draft RIND.md", usage: "/init [project|user]" },
14
+ { name: "login", description: "Show login setup guidance", usage: "/login" },
15
+ { name: "model", description: "Show or change the active model", usage: "/model | /model set <model>" },
16
+ { name: "sessions", description: "List recent sessions", usage: "/sessions [limit]" },
17
+ { name: "skill", description: "List skills", usage: "/skill [list]" },
18
+ { name: "status", description: "Show surface status", usage: "/status" },
19
+ { name: "team", description: "Manage the current Team", usage: "/team create [project-id] | /team init | /team list | /team blueprint [id] | /team add <description>" },
20
+ { name: "theme", description: "Switch the CLI color theme", usage: "/theme [latte | frappe | macchiato | mocha]" },
21
+ ]);
22
+
23
+ export async function loadLocalSettings(
24
+ rindHome = process.env.RIND_HOME || path.join(homedir(), ".rind"),
25
+ workspaceRoot = process.cwd(),
26
+ ) {
27
+ const userSettingsPath = path.join(rindHome, "settings.json");
28
+ const projectSettingsPath = path.resolve(workspaceRoot, ".rind", "settings.json");
29
+ const project = await readSettingsFile(projectSettingsPath);
30
+ if (project.exists && isCompleteProjectSettings(project.data)) {
31
+ return buildLocalSettings(projectSettingsPath, project.data, project.error, project.exists);
32
+ }
33
+ const settings = await readSettingsFile(userSettingsPath);
34
+ return buildLocalSettings(userSettingsPath, settings.data, settings.error, settings.exists);
35
+ }
36
+
37
+ async function readSettingsFile(settingsPath) {
38
+ let data = {};
39
+ let settingsExists = false;
40
+ let error = "";
41
+ try {
42
+ data = JSON.parse(await readFile(settingsPath, "utf8"));
43
+ settingsExists = true;
44
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
45
+ throw new Error("settings.json must contain a JSON object");
46
+ }
47
+ } catch (cause) {
48
+ if (!data || typeof data !== "object" || Array.isArray(data)) data = {};
49
+ if (cause?.code !== "ENOENT") {
50
+ error = cause instanceof Error ? cause.message : String(cause);
51
+ }
52
+ }
53
+ return { data, exists: settingsExists, error };
54
+ }
55
+
56
+ function buildLocalSettings(settingsPath, data, error, exists) {
57
+ return {
58
+ path: settingsPath,
59
+ exists,
60
+ error,
61
+ model: stringValue(data.model) || "gpt-4o-mini",
62
+ baseUrl: stringValue(data.baseUrl) || "https://api.openai.com/v1",
63
+ reasoningEffort: stringValue(data.reasoningEffort) || "",
64
+ hasApiKey: Boolean(stringValue(data.apiKey)),
65
+ };
66
+ }
67
+
68
+ function isCompleteProjectSettings(data) {
69
+ if (!data || typeof data !== "object" || Array.isArray(data)) return false;
70
+ const apiKey = stringValue(data.apiKey);
71
+ const baseUrl = stringValue(data.baseUrl);
72
+ const model = stringValue(data.model);
73
+ try {
74
+ const url = new URL(baseUrl);
75
+ return Boolean(apiKey && model && url.hostname && (url.protocol === "http:" || url.protocol === "https:"));
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ export async function executeLocalSlashCommand(input, context = {}) {
82
+ const match = String(input || "").trim().match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
83
+ if (!match) return null;
84
+ const name = match[1].toLowerCase();
85
+ const argument = String(match[2] || "").trim();
86
+ if (name === "config") return configResult(context.settings, argument);
87
+ if (name === "login") return argument ? usageResult("/login") : { text: "Login/config setup is not implemented yet.\nSet apiKey in ~/.rind/settings.json." };
88
+ if (name === "status") {
89
+ if (!argument && context.runtimeInitialized) return null;
90
+ return statusResult(context, argument);
91
+ }
92
+ if (name === "doctor") return doctorResult(context, argument);
93
+ if (name === "help") return helpResult(argument, context.commands || []);
94
+ if (name === "theme") return themeResult(argument, context);
95
+ if (name === "model" && !argument && !context.interactive) return { text: `Model: ${context.settings?.model || "unknown"}` };
96
+ return null;
97
+ }
98
+
99
+ function usageResult(usage) {
100
+ return { text: `Usage: ${usage}` };
101
+ }
102
+
103
+ function configResult(settings = {}, argument) {
104
+ if (argument) return usageResult("/config");
105
+ const apiKey = settings.hasApiKey ? "set" : "unset";
106
+ const reasoning = settings.reasoningEffort || "unset";
107
+ const entries = [
108
+ { label: "settings", value: settings.path || "~/.rind/settings.json", state: settings.exists ? "found" : "missing" },
109
+ { label: "apiKey", value: apiKey },
110
+ { label: "baseUrl", value: settings.baseUrl || "https://api.openai.com/v1" },
111
+ { label: "model", value: settings.model || "unknown" },
112
+ { label: "reasoningEffort", value: reasoning },
113
+ ];
114
+ return {
115
+ text: [
116
+ "Config:",
117
+ `- settings: ${entries[0].value} (${entries[0].state})`,
118
+ `- apiKey: ${apiKey}`,
119
+ `- baseUrl: ${entries[2].value}`,
120
+ `- model: ${entries[3].value}`,
121
+ `- reasoningEffort: ${reasoning}`,
122
+ ].join("\n"),
123
+ display: { type: "config", entries },
124
+ };
125
+ }
126
+
127
+ function statusResult(context, argument) {
128
+ if (argument) return usageResult("/status");
129
+ const session = String(context.sessionInfo?.session_id || "none");
130
+ const model = String(context.settings?.model || context.sessionInfo?.model || "unknown");
131
+ const runtime = context.runtimeInitialized ? "ready" : context.runtimeStarted ? "starting" : "not started";
132
+ return {
133
+ text: [
134
+ "Status:",
135
+ `Session: ${session}`,
136
+ `Model: ${model}`,
137
+ `Runtime: ${runtime}`,
138
+ "Messages: unknown",
139
+ ].join("\n"),
140
+ display: { type: "status", session, model, debug: false, messages: "unknown", runtime },
141
+ };
142
+ }
143
+
144
+ function doctorResult(context, argument) {
145
+ if (argument) return usageResult("/doctor");
146
+ const settings = context.settings || {};
147
+ const checks = [
148
+ check(!settings.error, "Settings", settings.error || (settings.exists ? "found" : "missing")),
149
+ check(settings.hasApiKey, "API key", settings.hasApiKey ? "set" : "unset"),
150
+ check(Boolean(settings.model), "Model", settings.model || "unset"),
151
+ check(Boolean(context.cwd), "Working directory", context.cwd || "unknown"),
152
+ ];
153
+ const failures = checks.filter((item) => item.status === "fail").length;
154
+ const warnings = checks.filter((item) => item.status === "warn").length;
155
+ return {
156
+ text: [
157
+ "Doctor:",
158
+ ...checks.map((item) => `- [${item.status}] ${item.name}: ${item.detail}`),
159
+ `Overall: ${failures} failure(s), ${warnings} warning(s).`,
160
+ ].join("\n"),
161
+ display: { type: "doctor", checks, failures, warnings, next_steps: [] },
162
+ };
163
+ }
164
+
165
+ function check(ok, name, detail) {
166
+ return { status: ok ? "ok" : "fail", name, detail };
167
+ }
168
+
169
+ function helpResult(argument, commands) {
170
+ const name = argument.replace(/^\//, "").toLowerCase();
171
+ const visible = commands.filter((command) => (!name || command.name === name || command.aliases?.includes(name)));
172
+ if (name && !visible.length) return { text: `Unknown command: /${name}\nRun /help to see available commands.` };
173
+ const selected = name ? visible[0] : null;
174
+ const text = selected
175
+ ? `/${selected.name}\n${selected.description}\nUsage: ${selected.usage || `/${selected.name}`}`
176
+ : ["Commands:", ...visible.map((command) => `/${command.name} - ${command.description}`)].join("\n");
177
+ return {
178
+ text,
179
+ display: { type: "help", ...(selected ? { command: selected } : { commands: visible }) },
180
+ };
181
+ }
182
+
183
+ function themeResult(argument, context = {}) {
184
+ const requested = argument.replace(/^\//, "").trim();
185
+ if (requested) {
186
+ const previous = currentTheme();
187
+ const applied = setTheme(requested);
188
+ if (!applied) {
189
+ return { text: `Unknown theme "${requested}". Available: ${themeNames().join(", ")}.` };
190
+ }
191
+ context.persistTheme?.(applied.name);
192
+ return {
193
+ text: `Theme: ${applied.name}`,
194
+ display: {
195
+ type: "theme",
196
+ changed: true,
197
+ previous: previous.name,
198
+ current: applied.name,
199
+ flavors: themeOptions(),
200
+ },
201
+ };
202
+ }
203
+ const current = currentTheme();
204
+ return {
205
+ text: `Theme: ${current.name}`,
206
+ display: {
207
+ type: "theme",
208
+ changed: false,
209
+ current: current.name,
210
+ flavors: themeOptions(),
211
+ },
212
+ };
213
+ }
214
+
215
+ function stringValue(value) {
216
+ return typeof value === "string" ? value.trim() : "";
217
+ }
@@ -0,0 +1,103 @@
1
+ const INLINE_TOKEN_RE = /(\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*)/g;
2
+ const PLAIN_TEXT_RE = /[`*#>|\[]/;
3
+
4
+ export function renderMarkdownishLine(line, color) {
5
+ const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/);
6
+ if (heading) {
7
+ return renderInline(heading[2], color, "heading");
8
+ }
9
+
10
+ const quote = line.match(/^(\s*)>\s?(.*)$/);
11
+ if (quote) {
12
+ return `${quote[1]}${dim("│ ", color)}${renderInline(quote[2], color)}`;
13
+ }
14
+
15
+ const list = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
16
+ if (list) {
17
+ const marker = /^\d+\.$/.test(list[2]) ? list[2] : "–";
18
+ return `${list[1]}${dim(`${marker} `, color)}${renderInline(list[3], color)}`;
19
+ }
20
+
21
+ return renderInline(line, color);
22
+ }
23
+
24
+ export function renderInline(text, color, baseStyle = "") {
25
+ const source = String(text || "");
26
+ let output = "";
27
+ let index = 0;
28
+ for (const match of source.matchAll(INLINE_TOKEN_RE)) {
29
+ output += styled(source.slice(index, match.index), color, baseStyle);
30
+ output += renderInlineToken(match[0], color, baseStyle);
31
+ index = match.index + match[0].length;
32
+ }
33
+ return output + styled(source.slice(index), color, baseStyle);
34
+ }
35
+
36
+ export function renderInlineToken(token, color, baseStyle) {
37
+ const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
38
+ if (link) {
39
+ return `${renderInline(link[1], color, baseStyle)} ${dim(`(${link[2]})`, color)}`;
40
+ }
41
+ if (token.startsWith("`") && token.endsWith("`")) {
42
+ return styled(token.slice(1, -1), color, "inlineCode");
43
+ }
44
+ if (token.startsWith("**") && token.endsWith("**")) {
45
+ return styled(token.slice(2, -2), color, baseStyle || "emphasis");
46
+ }
47
+ return token;
48
+ }
49
+
50
+ export function isPlainLine(line) {
51
+ if (!line) {
52
+ return true;
53
+ }
54
+ if (PLAIN_TEXT_RE.test(line)) {
55
+ return false;
56
+ }
57
+ const stripped = line.trimStart();
58
+ return !stripped.match(/^([-*+]|\d+\.)\s+/);
59
+ }
60
+
61
+ export function isTableLine(line, inCodeBlock) {
62
+ const stripped = line.trim();
63
+ return !inCodeBlock && stripped.includes("|") && stripped.split("|").length > 2;
64
+ }
65
+
66
+ export function parseTableRow(line) {
67
+ let stripped = line.trim();
68
+ if (stripped.startsWith("|")) {
69
+ stripped = stripped.slice(1);
70
+ }
71
+ if (stripped.endsWith("|")) {
72
+ stripped = stripped.slice(0, -1);
73
+ }
74
+ return stripped.split("|").map((cell) => cell.trim());
75
+ }
76
+
77
+ export function codeOpenLabel(label) {
78
+ return label ? `┌ code ${label}` : "┌ code";
79
+ }
80
+
81
+ import { paintRaw } from "./theme.js";
82
+
83
+ export function styled(text, color, style) {
84
+ if (!text || !color || !style) {
85
+ return text;
86
+ }
87
+ const roles = {
88
+ codeBlock: "fence",
89
+ emphasis: "warning",
90
+ heading: "accent",
91
+ inlineCode: "code",
92
+ tableHeader: "accent",
93
+ };
94
+ const role = roles[style] || "warning";
95
+ const painted = paintRaw[role](text);
96
+ return style === "heading" || style === "tableHeader" || style === "emphasis"
97
+ ? paintRaw.bold(painted)
98
+ : painted;
99
+ }
100
+
101
+ export function dim(text, color) {
102
+ return color ? `\x1b[2m${text}\x1b[0m` : text;
103
+ }
@@ -1,50 +1,50 @@
1
- export function createModelMenuState(models, currentModel = "") {
2
- const items = normalizeModels(models, currentModel);
3
- let selected = Math.max(0, items.findIndex((item) => item.current));
4
- return {
5
- items() {
6
- return items;
7
- },
8
- selectedIndex() {
9
- return selected;
10
- },
11
- selectedModel() {
12
- return items[selected] || null;
13
- },
14
- handleKey(key = {}) {
15
- if (!items.length) {
16
- return false;
17
- }
18
- if (key.name === "up") {
19
- selected = selected <= 0 ? items.length - 1 : selected - 1;
20
- return true;
21
- }
22
- if (key.name === "down") {
23
- selected = selected >= items.length - 1 ? 0 : selected + 1;
24
- return true;
25
- }
26
- return false;
27
- },
28
- };
29
- }
30
-
31
- function normalizeModels(models, currentModel) {
32
- const current = String(currentModel || "").trim();
33
- const seen = new Set();
34
- const items = [];
35
- let currentFound = false;
36
- for (const model of Array.isArray(models) ? models : []) {
37
- const name = String(model || "").trim();
38
- if (!name || seen.has(name)) {
39
- continue;
40
- }
41
- seen.add(name);
42
- const isCurrent = name === current;
43
- currentFound ||= isCurrent;
44
- items.push({ name, current: isCurrent });
45
- }
46
- if (current && !currentFound) {
47
- items.unshift({ name: current, current: true });
48
- }
49
- return items;
50
- }
1
+ export function createModelMenuState(models, currentModel = "") {
2
+ const items = normalizeModels(models, currentModel);
3
+ let selected = Math.max(0, items.findIndex((item) => item.current));
4
+ return {
5
+ items() {
6
+ return items;
7
+ },
8
+ selectedIndex() {
9
+ return selected;
10
+ },
11
+ selectedModel() {
12
+ return items[selected] || null;
13
+ },
14
+ handleKey(key = {}) {
15
+ if (!items.length) {
16
+ return false;
17
+ }
18
+ if (key.name === "up") {
19
+ selected = selected <= 0 ? items.length - 1 : selected - 1;
20
+ return true;
21
+ }
22
+ if (key.name === "down") {
23
+ selected = selected >= items.length - 1 ? 0 : selected + 1;
24
+ return true;
25
+ }
26
+ return false;
27
+ },
28
+ };
29
+ }
30
+
31
+ function normalizeModels(models, currentModel) {
32
+ const current = String(currentModel || "").trim();
33
+ const seen = new Set();
34
+ const items = [];
35
+ let currentFound = false;
36
+ for (const model of Array.isArray(models) ? models : []) {
37
+ const name = String(model || "").trim();
38
+ if (!name || seen.has(name)) {
39
+ continue;
40
+ }
41
+ seen.add(name);
42
+ const isCurrent = name === current;
43
+ currentFound ||= isCurrent;
44
+ items.push({ name, current: isCurrent });
45
+ }
46
+ if (current && !currentFound) {
47
+ items.unshift({ name: current, current: true });
48
+ }
49
+ return items;
50
+ }
@@ -0,0 +1,145 @@
1
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2
+ const SPINNER_INTERVAL_MS = 120;
3
+ const NAME_COLUMN_MAX = 26;
4
+
5
+ export function formatDuration(ms) {
6
+ const value = Math.max(0, Number(ms) || 0);
7
+ if (value < 1000) return `${Math.round(value)}ms`;
8
+ if (value < 60_000) return `${(value / 1000).toFixed(1)}s`;
9
+ return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
10
+ }
11
+
12
+ export function createOneShotProgress({ stderr, stream = null } = {}) {
13
+ const tty = stream ?? { isTTY: false };
14
+ const isTTY = Boolean(tty.isTTY);
15
+ const useColor = isTTY && process.env.NO_COLOR === undefined;
16
+ const c = useColor
17
+ ? { dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", reset: "\x1b[0m" }
18
+ : { dim: "", red: "", green: "", reset: "" };
19
+
20
+ const tools = new Map();
21
+ let toolCounter = 0;
22
+ let printedToolLines = 0;
23
+ let lastPendingToolId = null;
24
+ let spinnerTimer = null;
25
+ let spinnerFrame = 0;
26
+ let spinnerLabel = "";
27
+ let active = false;
28
+
29
+ function emit(text) {
30
+ stopSpinner();
31
+ stderr(text);
32
+ }
33
+
34
+ function startSpinner(label) {
35
+ spinnerLabel = label;
36
+ if (!isTTY || spinnerTimer) return;
37
+ spinnerFrame = 0;
38
+ renderSpinner();
39
+ spinnerTimer = setInterval(() => {
40
+ spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
41
+ renderSpinner();
42
+ }, SPINNER_INTERVAL_MS);
43
+ spinnerTimer.unref?.();
44
+ }
45
+
46
+ function renderSpinner() {
47
+ stderr(`\r${c.dim}${SPINNER_FRAMES[spinnerFrame]} ${spinnerLabel}${c.reset}\x1b[K`);
48
+ }
49
+
50
+ function stopSpinner() {
51
+ if (!spinnerTimer) return;
52
+ clearInterval(spinnerTimer);
53
+ spinnerTimer = null;
54
+ if (isTTY) stderr("\r\x1b[K");
55
+ }
56
+
57
+ function resumeSpinner() {
58
+ if (active && isTTY && spinnerLabel) startSpinner(spinnerLabel);
59
+ }
60
+
61
+ function toolLine(entry) {
62
+ const index = String(entry.index).padStart(2, " ");
63
+ const truncated = entry.name.length > NAME_COLUMN_MAX
64
+ ? `${entry.name.slice(0, NAME_COLUMN_MAX - 1)}…`
65
+ : entry.name;
66
+ if (entry.finishedAt === null) {
67
+ const pendingMark = isTTY ? ` ${c.dim}…${c.reset}` : "";
68
+ return ` ${c.dim}${index}${c.reset} ${truncated}${pendingMark}`;
69
+ }
70
+ const duration = c.dim + formatDuration(entry.durationMs) + c.reset;
71
+ const mark = entry.ok ? "" : ` ${c.red}✗${c.reset}`;
72
+ return ` ${c.dim}${index}${c.reset} ${truncated} ${duration}${mark}`;
73
+ }
74
+
75
+ return {
76
+ begin() {
77
+ active = true;
78
+ startSpinner("starting runtime");
79
+ },
80
+
81
+ hasTool(toolCallId) {
82
+ return tools.has(toolCallId);
83
+ },
84
+
85
+ get toolCount() {
86
+ return tools.size;
87
+ },
88
+
89
+ session({ sessionId, model, baseUrl }) {
90
+ const parts = [`session ${sessionId}`];
91
+ if (model) parts.push(`model ${model}`);
92
+ if (baseUrl) parts.push(`api ${baseUrl}`);
93
+ emit(`${c.dim}·${c.reset} ${parts.join(`${c.dim} · ${c.reset}`)}\n`);
94
+ startSpinner("working");
95
+ },
96
+
97
+ note(text) {
98
+ emit(`${c.dim}· ${text}${c.reset}\n`);
99
+ resumeSpinner();
100
+ },
101
+
102
+ toolStarted(toolCallId, toolName) {
103
+ const name = toolName || "tool";
104
+ toolCounter += 1;
105
+ const entry = { index: toolCounter, name, finishedAt: null, durationMs: 0, ok: true };
106
+ tools.set(toolCallId, entry);
107
+ emit(`${toolLine(entry)}\n`);
108
+ printedToolLines += 1;
109
+ lastPendingToolId = toolCallId;
110
+ resumeSpinner();
111
+ },
112
+
113
+ toolFinished(toolCallId, { ok, durationMs }) {
114
+ const entry = tools.get(toolCallId);
115
+ if (!entry || entry.finishedAt !== null) return;
116
+ entry.finishedAt = Date.now();
117
+ entry.durationMs = Number(durationMs) || 0;
118
+ entry.ok = Boolean(ok);
119
+ const canRewrite = isTTY && toolCallId === lastPendingToolId;
120
+ if (canRewrite) {
121
+ stopSpinner();
122
+ stderr(`\x1b[1A\r\x1b[K${toolLine(entry)}\n`);
123
+ lastPendingToolId = null;
124
+ resumeSpinner();
125
+ return;
126
+ }
127
+ if (!entry.ok) {
128
+ emit(` ${c.red}↳ ${entry.name} failed${c.reset} ${c.dim}${formatDuration(entry.durationMs)}${c.reset}\n`);
129
+ resumeSpinner();
130
+ }
131
+ if (toolCallId === lastPendingToolId) lastPendingToolId = null;
132
+ },
133
+
134
+ done(elapsedMs) {
135
+ active = false;
136
+ if (printedToolLines > 0) emit("\n");
137
+ emit(`${c.green}✓${c.reset} done in ${formatDuration(elapsedMs)}\n`);
138
+ },
139
+
140
+ fail(message) {
141
+ active = false;
142
+ emit(`${c.red}✗ ${message}${c.reset}\n`);
143
+ },
144
+ };
145
+ }