@datalabrotterdam/nova-ai-cli 1.1.0 → 1.2.0-alpha.2
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 +70 -0
- package/README.md +160 -101
- package/dist/chunk-NHGIUEIR.js +754 -0
- package/dist/chunk-SKAW2KXC.js +10 -0
- package/dist/headless-OFJFVJ3O.js +543 -0
- package/dist/index.js +155 -17
- package/dist/tui-NJWDD5X7.js +4810 -0
- package/dist/webui-L4GGMXJ3.js +9 -0
- package/package.json +32 -32
- package/dist/acp/agent.d.ts +0 -13
- package/dist/acp/agent.js +0 -242
- package/dist/acp/auth-server.d.ts +0 -10
- package/dist/acp/auth-server.js +0 -67
- package/dist/acp/credentials.d.ts +0 -11
- package/dist/acp/credentials.js +0 -30
- package/dist/acp/index.d.ts +0 -2
- package/dist/acp/index.js +0 -21
- package/dist/acp/mcp.d.ts +0 -11
- package/dist/acp/mcp.js +0 -90
- package/dist/acp/sessions.d.ts +0 -20
- package/dist/acp/sessions.js +0 -91
- package/dist/acp/tools/index.d.ts +0 -6
- package/dist/acp/tools/index.js +0 -5
- package/dist/acp/tools/marker.d.ts +0 -17
- package/dist/acp/tools/marker.js +0 -43
- package/dist/acp/tools/permission.d.ts +0 -2
- package/dist/acp/tools/permission.js +0 -20
- package/dist/acp/tools/read-file.d.ts +0 -2
- package/dist/acp/tools/read-file.js +0 -20
- package/dist/acp/tools/registry.d.ts +0 -4
- package/dist/acp/tools/registry.js +0 -10
- package/dist/acp/tools/reporting.d.ts +0 -3
- package/dist/acp/tools/reporting.js +0 -26
- package/dist/acp/tools/run-command.d.ts +0 -2
- package/dist/acp/tools/run-command.js +0 -29
- package/dist/acp/tools/system-prompt.d.ts +0 -2
- package/dist/acp/tools/system-prompt.js +0 -23
- package/dist/acp/tools/types.d.ts +0 -18
- package/dist/acp/tools/types.js +0 -1
- package/dist/acp/tools/write-file.d.ts +0 -2
- package/dist/acp/tools/write-file.js +0 -20
- package/dist/index.d.ts +0 -2
- package/dist/tui/index.d.ts +0 -7
- package/dist/tui/index.js +0 -11
- package/dist/webui/index.d.ts +0 -6
- package/dist/webui/index.js +0 -9
- package/src/acp/web/dist/assets/index-CSccI7lJ.css +0 -1
- package/src/acp/web/dist/assets/index-D2Z0sfLR.js +0 -1
- package/src/acp/web/dist/favicon.svg +0 -46
- package/src/acp/web/dist/index.html +0 -32
|
@@ -0,0 +1,4810 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cliVersion
|
|
3
|
+
} from "./chunk-SKAW2KXC.js";
|
|
4
|
+
import {
|
|
5
|
+
TuiAcpClient,
|
|
6
|
+
createStore,
|
|
7
|
+
readWorkspaceMcpConfiguration,
|
|
8
|
+
workspaceNeedsTrust
|
|
9
|
+
} from "./chunk-NHGIUEIR.js";
|
|
10
|
+
|
|
11
|
+
// src/tui/index.ts
|
|
12
|
+
import { createInterface } from "readline/promises";
|
|
13
|
+
import { readCredentials } from "@datalabrotterdam/nova-ai-agent/core/credentials.js";
|
|
14
|
+
import { listStoredSessions as listStoredSessions2 } from "@datalabrotterdam/nova-ai-agent/core/sessions.js";
|
|
15
|
+
import { setWorkspaceTrusted as setWorkspaceTrusted2 } from "@datalabrotterdam/nova-ai-agent/core/nova-home.js";
|
|
16
|
+
|
|
17
|
+
// src/tui/ink/app.tsx
|
|
18
|
+
import { Box as Box6, render, Static, Text as Text6, useBoxMetrics, useWindowSize } from "ink";
|
|
19
|
+
import { useEffect, useRef, useState as useState2, useSyncExternalStore as useSyncExternalStore2 } from "react";
|
|
20
|
+
import { savedPermissionMode } from "@datalabrotterdam/nova-ai-agent/core/policy/settings.js";
|
|
21
|
+
|
|
22
|
+
// src/tui/session/session-runner.ts
|
|
23
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
24
|
+
import {
|
|
25
|
+
NovaAIError
|
|
26
|
+
} from "@datalabrotterdam/nova-sdk";
|
|
27
|
+
import { createNovaClient } from "@datalabrotterdam/nova-ai-agent/core/nova-client.js";
|
|
28
|
+
import { NovaAgent } from "@datalabrotterdam/nova-ai-agent/acp/agent.js";
|
|
29
|
+
import { startInProcessAgent } from "@datalabrotterdam/nova-ai-agent/client/in-process.js";
|
|
30
|
+
import { replayHistory } from "@datalabrotterdam/nova-ai-agent/core/history.js";
|
|
31
|
+
import { saveDefaultModel } from "@datalabrotterdam/nova-ai-agent/core/credentials.js";
|
|
32
|
+
import {
|
|
33
|
+
savePermissionMode
|
|
34
|
+
} from "@datalabrotterdam/nova-ai-agent/core/policy/settings.js";
|
|
35
|
+
import { loadStoredSession } from "@datalabrotterdam/nova-ai-agent/core/sessions.js";
|
|
36
|
+
import { discoverSkills } from "@datalabrotterdam/nova-ai-agent/core/skills.js";
|
|
37
|
+
import {
|
|
38
|
+
resolveModelContextWindow
|
|
39
|
+
} from "@datalabrotterdam/nova-ai-agent/core/context-compaction.js";
|
|
40
|
+
import {
|
|
41
|
+
chatModels,
|
|
42
|
+
resolveDefaultModel,
|
|
43
|
+
resolveModelSupportsImageInput
|
|
44
|
+
} from "@datalabrotterdam/nova-ai-agent/core/model-capabilities.js";
|
|
45
|
+
|
|
46
|
+
// src/tui/files/file-mentions.ts
|
|
47
|
+
import { statSync } from "fs";
|
|
48
|
+
import { isAbsolute, relative, resolve, sep } from "path";
|
|
49
|
+
var MENTION_RE = /@(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
|
|
50
|
+
var TRAILING_PUNCTUATION = /[.,;:!?\)\]\}]+$/;
|
|
51
|
+
function findWorkspaceFileMentions(text, cwd) {
|
|
52
|
+
const workspaceRoot = resolve(cwd);
|
|
53
|
+
const mentions = /* @__PURE__ */ new Map();
|
|
54
|
+
for (const match of text.matchAll(MENTION_RE)) {
|
|
55
|
+
const raw = match[1] ?? match[2] ?? match[3] ?? "";
|
|
56
|
+
const candidates = [raw, raw.replace(TRAILING_PUNCTUATION, "")].filter((value, index, all) => value && all.indexOf(value) === index);
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
const normalized = candidate.replace(/[\\/]/g, sep);
|
|
59
|
+
const absolutePath = resolve(workspaceRoot, normalized);
|
|
60
|
+
const relativePath = relative(workspaceRoot, absolutePath);
|
|
61
|
+
if (relativePath.startsWith(`..${sep}`) || relativePath === ".." || isAbsolute(relativePath)) continue;
|
|
62
|
+
try {
|
|
63
|
+
if (!statSync(absolutePath).isFile()) continue;
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
mentions.set(absolutePath.toLowerCase(), {
|
|
68
|
+
absolutePath,
|
|
69
|
+
relativePath: relativePath.split(sep).join("/")
|
|
70
|
+
});
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...mentions.values()];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/tui/files/prompt-pastes.ts
|
|
78
|
+
var LARGE_PASTE_LINE_THRESHOLD = 10;
|
|
79
|
+
var LARGE_PASTE_CHARACTER_THRESHOLD = 1e3;
|
|
80
|
+
function normalizeClipboardPaste(value) {
|
|
81
|
+
const decoded = value.replace(/\u001b\[(\d+);5u/g, (match, code) => {
|
|
82
|
+
const point = Number(code);
|
|
83
|
+
if (point >= 97 && point <= 122) return String.fromCharCode(point - 96);
|
|
84
|
+
if (point >= 65 && point <= 90) return String.fromCharCode(point - 64);
|
|
85
|
+
return match;
|
|
86
|
+
});
|
|
87
|
+
return decoded.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ").split("").filter((character) => character === "\n" || character.charCodeAt(0) >= 32).join("");
|
|
88
|
+
}
|
|
89
|
+
function isLargeClipboardPaste(text) {
|
|
90
|
+
return text.split("\n").length > LARGE_PASTE_LINE_THRESHOLD || text.length > LARGE_PASTE_CHARACTER_THRESHOLD;
|
|
91
|
+
}
|
|
92
|
+
var DraftPasteAttachments = class {
|
|
93
|
+
nextPasteNumber = 1;
|
|
94
|
+
pastes = /* @__PURE__ */ new Map();
|
|
95
|
+
add(text) {
|
|
96
|
+
const lineCount = text.split("\n").length;
|
|
97
|
+
const characterCount = text.length;
|
|
98
|
+
const count = lineCount > LARGE_PASTE_LINE_THRESHOLD ? `${lineCount} lines` : `${characterCount} characters`;
|
|
99
|
+
const marker = `[Pasted from clipboard #${this.nextPasteNumber++}: ${count}]`;
|
|
100
|
+
const attachment = { marker, text, lineCount, characterCount };
|
|
101
|
+
this.pastes.set(marker, attachment);
|
|
102
|
+
return attachment;
|
|
103
|
+
}
|
|
104
|
+
referencedBy(text) {
|
|
105
|
+
return [...this.pastes.values()].filter(
|
|
106
|
+
(paste) => text.includes(paste.marker)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
clear() {
|
|
110
|
+
this.pastes.clear();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
function expandPromptPastes(text, pastes) {
|
|
114
|
+
if (pastes.length === 0) return text;
|
|
115
|
+
const byMarker = new Map(pastes.map((paste) => [paste.marker, paste.text]));
|
|
116
|
+
const markers = [...byMarker.keys()].sort((left, right) => right.length - left.length).map((marker) => marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
117
|
+
return text.replace(
|
|
118
|
+
new RegExp(markers.join("|"), "g"),
|
|
119
|
+
(marker) => byMarker.get(marker) ?? marker
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/tui/session/session-runner.ts
|
|
124
|
+
var nextId = 0;
|
|
125
|
+
function uid() {
|
|
126
|
+
nextId += 1;
|
|
127
|
+
return `history-${nextId}`;
|
|
128
|
+
}
|
|
129
|
+
var RESTORED_TOOL_METADATA = {
|
|
130
|
+
read_file: { kind: "read", mutating: false },
|
|
131
|
+
list_directory: { kind: "search", mutating: false },
|
|
132
|
+
search_text: { kind: "search", mutating: false },
|
|
133
|
+
inspect_environment: { kind: "read", mutating: false },
|
|
134
|
+
load_skill: { kind: "read", mutating: false },
|
|
135
|
+
memory_read: { kind: "read", mutating: false },
|
|
136
|
+
load_memory: { kind: "read", mutating: false },
|
|
137
|
+
list_background_jobs: { kind: "read", mutating: false },
|
|
138
|
+
read_background_output: { kind: "read", mutating: false },
|
|
139
|
+
wait_for_background_jobs: { kind: "think", mutating: false },
|
|
140
|
+
ask_user: { kind: "think", mutating: false },
|
|
141
|
+
update_plan: { kind: "think", mutating: false },
|
|
142
|
+
enter_plan_mode: { kind: "switch_mode", mutating: false },
|
|
143
|
+
write_file: { kind: "edit", mutating: true },
|
|
144
|
+
edit_file: { kind: "edit", mutating: true },
|
|
145
|
+
memory_write: { kind: "edit", mutating: true },
|
|
146
|
+
save_memory: { kind: "edit", mutating: true },
|
|
147
|
+
run_command: { kind: "execute", mutating: true },
|
|
148
|
+
run_package_script: { kind: "execute", mutating: true },
|
|
149
|
+
start_background_command: { kind: "execute", mutating: true },
|
|
150
|
+
start_background_agent: { kind: "think", mutating: true },
|
|
151
|
+
kill_background_job: { kind: "execute", mutating: true },
|
|
152
|
+
release_background_job: { kind: "execute", mutating: true }
|
|
153
|
+
};
|
|
154
|
+
function restoreSessionMessages(messages) {
|
|
155
|
+
return replayHistory(messages).flatMap((item) => {
|
|
156
|
+
if (item.kind === "user") return [{ id: uid(), role: "user", text: item.text }];
|
|
157
|
+
if (item.kind === "agent") {
|
|
158
|
+
return [{ id: uid(), role: "assistant", text: item.text, streaming: false }];
|
|
159
|
+
}
|
|
160
|
+
if (item.name === "update_plan") return [];
|
|
161
|
+
const metadata = RESTORED_TOOL_METADATA[item.name] ?? {
|
|
162
|
+
// Persisted ACP/MCP calls do not currently retain annotations. Keep
|
|
163
|
+
// unknown calls compact without falsely labelling them as changes.
|
|
164
|
+
kind: "execute",
|
|
165
|
+
mutating: false
|
|
166
|
+
};
|
|
167
|
+
return [
|
|
168
|
+
{
|
|
169
|
+
id: uid(),
|
|
170
|
+
role: "tool",
|
|
171
|
+
call: {
|
|
172
|
+
toolCallId: uid(),
|
|
173
|
+
name: item.name,
|
|
174
|
+
args: item.args,
|
|
175
|
+
kind: metadata.kind,
|
|
176
|
+
mutating: metadata.mutating,
|
|
177
|
+
status: item.status,
|
|
178
|
+
output: item.output,
|
|
179
|
+
diff: null
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
];
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
var SessionRunner = class {
|
|
186
|
+
constructor(store, credentials, cwd) {
|
|
187
|
+
this.store = store;
|
|
188
|
+
this.credentials = credentials;
|
|
189
|
+
this.loadMcpConfiguration(cwd);
|
|
190
|
+
this.skills = discoverSkills(cwd).map(
|
|
191
|
+
({ name, description, source, path }) => ({
|
|
192
|
+
name,
|
|
193
|
+
description,
|
|
194
|
+
source,
|
|
195
|
+
path
|
|
196
|
+
})
|
|
197
|
+
);
|
|
198
|
+
this.cwd = cwd;
|
|
199
|
+
this.acpClient = new TuiAcpClient(
|
|
200
|
+
store,
|
|
201
|
+
cwd,
|
|
202
|
+
(event, job) => this.handleBackgroundLifecycle(event, job),
|
|
203
|
+
(mode) => this.applyInteractionMode(mode, `Agent switched to ${mode} mode.`)
|
|
204
|
+
);
|
|
205
|
+
this.connection = startInProcessAgent(() => this.acpClient.asAcpClient(), this.agent);
|
|
206
|
+
this.nova = this.connection.client;
|
|
207
|
+
this.initialized = this.nova.initialize({
|
|
208
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
209
|
+
clientCapabilities: this.acpClient.capabilities,
|
|
210
|
+
clientInfo: { name: "nova-ai-cli-tui", version: "1" }
|
|
211
|
+
});
|
|
212
|
+
this.novaClient = createNovaClient(credentials.apiKey);
|
|
213
|
+
const model = credentials.defaultModel ?? process.env.NOVA_MODEL;
|
|
214
|
+
if (!model)
|
|
215
|
+
throw new Error(
|
|
216
|
+
"No Nova model configured. Re-run authentication or set NOVA_MODEL."
|
|
217
|
+
);
|
|
218
|
+
this.model = model;
|
|
219
|
+
this.sessionId = crypto.randomUUID();
|
|
220
|
+
this.sessionReady = Promise.resolve();
|
|
221
|
+
}
|
|
222
|
+
store;
|
|
223
|
+
credentials;
|
|
224
|
+
sessionId;
|
|
225
|
+
cwd;
|
|
226
|
+
title = null;
|
|
227
|
+
model;
|
|
228
|
+
interactionMode = "agent";
|
|
229
|
+
/** The agent, served in-process over a real ACP connection (tests may stub it). */
|
|
230
|
+
agent = new NovaAgent();
|
|
231
|
+
acpClient;
|
|
232
|
+
connection;
|
|
233
|
+
nova;
|
|
234
|
+
initialized;
|
|
235
|
+
novaClient;
|
|
236
|
+
mcpServers = [];
|
|
237
|
+
mcpConfigurationFailures = [];
|
|
238
|
+
mcpStatus = {
|
|
239
|
+
configured: [],
|
|
240
|
+
connected: [],
|
|
241
|
+
failures: []
|
|
242
|
+
};
|
|
243
|
+
skills;
|
|
244
|
+
sessionReady;
|
|
245
|
+
agentSessionLoaded = false;
|
|
246
|
+
promptActive = false;
|
|
247
|
+
completedBackgroundAgentJobs = /* @__PURE__ */ new Set();
|
|
248
|
+
handoffActive = false;
|
|
249
|
+
queuedAttachments = /* @__PURE__ */ new Map();
|
|
250
|
+
editingQueuedMessageId = null;
|
|
251
|
+
imageSupportByModel = /* @__PURE__ */ new Map();
|
|
252
|
+
contextWindow = null;
|
|
253
|
+
contextWindowModel = null;
|
|
254
|
+
contextWindowRequest = 0;
|
|
255
|
+
contextUsageRequest = 0;
|
|
256
|
+
modelCheck = null;
|
|
257
|
+
resumeFrom(storedSessionId) {
|
|
258
|
+
const stored = loadStoredSession(storedSessionId);
|
|
259
|
+
if (!stored) return false;
|
|
260
|
+
const previousSessionId = this.agentSessionLoaded ? this.sessionId : null;
|
|
261
|
+
if (previousSessionId) void this.nova.cancel({ sessionId: previousSessionId });
|
|
262
|
+
this.queuedAttachments.clear();
|
|
263
|
+
this.editingQueuedMessageId = null;
|
|
264
|
+
this.sessionId = stored.sessionId;
|
|
265
|
+
this.cwd = stored.cwd;
|
|
266
|
+
this.title = stored.title;
|
|
267
|
+
this.loadMcpConfiguration(stored.cwd);
|
|
268
|
+
this.skills = discoverSkills(stored.cwd).map(
|
|
269
|
+
({ name, description, source, path }) => ({
|
|
270
|
+
name,
|
|
271
|
+
description,
|
|
272
|
+
source,
|
|
273
|
+
path
|
|
274
|
+
})
|
|
275
|
+
);
|
|
276
|
+
const messages = restoreSessionMessages(stored.messages);
|
|
277
|
+
this.store.setState({
|
|
278
|
+
messages,
|
|
279
|
+
plan: [],
|
|
280
|
+
sessionId: this.sessionId,
|
|
281
|
+
cwd: this.cwd,
|
|
282
|
+
queuedCount: 0,
|
|
283
|
+
contextUsage: null
|
|
284
|
+
});
|
|
285
|
+
this.agentSessionLoaded = false;
|
|
286
|
+
this.sessionReady = this.initialized.then(
|
|
287
|
+
() => previousSessionId ? this.nova.closeSession({ sessionId: previousSessionId }) : {}
|
|
288
|
+
).then(async () => {
|
|
289
|
+
this.acpClient.muteSessionUpdates = true;
|
|
290
|
+
try {
|
|
291
|
+
return await this.nova.loadSession({
|
|
292
|
+
sessionId: stored.sessionId,
|
|
293
|
+
cwd: stored.cwd,
|
|
294
|
+
mcpServers: this.mcpServers
|
|
295
|
+
});
|
|
296
|
+
} finally {
|
|
297
|
+
this.acpClient.muteSessionUpdates = false;
|
|
298
|
+
}
|
|
299
|
+
}).then((response) => {
|
|
300
|
+
this.applyMcpStatus(response._meta);
|
|
301
|
+
this.applySkillStatus(response._meta);
|
|
302
|
+
this.agentSessionLoaded = true;
|
|
303
|
+
this.applyQueue([]);
|
|
304
|
+
void this.nova.setSessionMode({ sessionId: this.sessionId, modeId: this.interactionMode }).catch(() => {
|
|
305
|
+
});
|
|
306
|
+
this.applyPermissionMode(this.sessionId);
|
|
307
|
+
void this.updateContextUsage();
|
|
308
|
+
void this.refreshContextUsage().catch(() => {
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
void this.sessionReady.catch((err) => {
|
|
312
|
+
this.acpClient.appendError(
|
|
313
|
+
err instanceof Error ? err.message : "Failed to load session."
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
cancel() {
|
|
319
|
+
void this.nova.cancel({ sessionId: this.sessionId }).catch(() => {
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
queuedMessages() {
|
|
323
|
+
return this.promptQueueEntries().map((prompt) => prompt.text);
|
|
324
|
+
}
|
|
325
|
+
queuedMessageEntries() {
|
|
326
|
+
return this.promptQueueEntries().map(({ id, text, kind }) => ({
|
|
327
|
+
id,
|
|
328
|
+
text,
|
|
329
|
+
kind
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The editor needs an answer right away: it comes from the local queue
|
|
334
|
+
* mirror, and the agent's response reconciles it (an entry that already
|
|
335
|
+
* started running cannot be edited any more).
|
|
336
|
+
*/
|
|
337
|
+
beginQueuedMessageEdit(id) {
|
|
338
|
+
if (!this.agentSessionLoaded) return false;
|
|
339
|
+
if (!this.promptQueueEntries().some((entry) => entry.id === id)) return false;
|
|
340
|
+
this.editingQueuedMessageId = id;
|
|
341
|
+
this.syncQueueTranscript();
|
|
342
|
+
this.store.setState({ statusLine: "Editing queued message." });
|
|
343
|
+
void this.nova.queueEditBegin({ sessionId: this.sessionId, id }).then(({ updated, entries }) => {
|
|
344
|
+
this.applyQueue(entries);
|
|
345
|
+
if (!updated && this.editingQueuedMessageId === id) {
|
|
346
|
+
this.editingQueuedMessageId = null;
|
|
347
|
+
this.syncQueueTranscript("That message was already sent.");
|
|
348
|
+
}
|
|
349
|
+
}).catch(() => {
|
|
350
|
+
});
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
updateQueuedMessage(id, text) {
|
|
354
|
+
if (!this.agentSessionLoaded) return false;
|
|
355
|
+
if (!this.promptQueueEntries().some((entry) => entry.id === id)) return false;
|
|
356
|
+
this.applyQueue(
|
|
357
|
+
this.promptQueueEntries().map((entry) => entry.id === id ? { ...entry, text } : entry)
|
|
358
|
+
);
|
|
359
|
+
void this.nova.queueUpdate({ sessionId: this.sessionId, id, text }).then(({ entries }) => this.applyQueue(entries)).catch(() => {
|
|
360
|
+
});
|
|
361
|
+
this.syncQueueTranscript();
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
finishQueuedMessageEdit(id, text, images = [], pastes = []) {
|
|
365
|
+
const entry = this.promptQueueEntries().find((prompt) => prompt.id === id);
|
|
366
|
+
if (!entry) {
|
|
367
|
+
if (this.editingQueuedMessageId === id)
|
|
368
|
+
this.editingQueuedMessageId = null;
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
const value = text.trim();
|
|
372
|
+
if (!value) {
|
|
373
|
+
this.applyQueue(this.promptQueueEntries().filter((queued) => queued.id !== id));
|
|
374
|
+
void this.nova.queueRemove({ sessionId: this.sessionId, id }).then(({ entries }) => this.applyQueue(entries)).catch(() => {
|
|
375
|
+
});
|
|
376
|
+
this.queuedAttachments.delete(id);
|
|
377
|
+
if (this.editingQueuedMessageId === id)
|
|
378
|
+
this.editingQueuedMessageId = null;
|
|
379
|
+
this.syncQueueTranscript("Queued message removed.");
|
|
380
|
+
if (!this.store.getState().busy && !this.promptActive)
|
|
381
|
+
void this.runNextQueuedPrompt();
|
|
382
|
+
} else {
|
|
383
|
+
const attachments = this.queuedAttachments.get(id) ?? {
|
|
384
|
+
images: [],
|
|
385
|
+
pastes: []
|
|
386
|
+
};
|
|
387
|
+
this.queuedAttachments.set(id, {
|
|
388
|
+
images: mergeReferencedImages(attachments.images, images, value),
|
|
389
|
+
pastes: mergeReferencedPastes(attachments.pastes, pastes, value)
|
|
390
|
+
});
|
|
391
|
+
void this.nova.queueUpdate({ sessionId: this.sessionId, id, text: value }).then(({ entries }) => this.applyQueue(entries)).catch(() => {
|
|
392
|
+
}).then(() => this.finalizeQueuedMessageEdit(id, value));
|
|
393
|
+
}
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
clearQueuedMessages() {
|
|
397
|
+
if (!this.agentSessionLoaded) return 0;
|
|
398
|
+
const count = this.promptQueueEntries().length;
|
|
399
|
+
this.applyQueue([]);
|
|
400
|
+
void this.nova.queueClear({ sessionId: this.sessionId }).then(({ entries }) => this.applyQueue(entries)).catch(() => {
|
|
401
|
+
});
|
|
402
|
+
this.queuedAttachments.clear();
|
|
403
|
+
this.editingQueuedMessageId = null;
|
|
404
|
+
this.syncQueueTranscript(
|
|
405
|
+
count ? `Cleared ${count} queued message${count === 1 ? "" : "s"}.` : "Queue already empty."
|
|
406
|
+
);
|
|
407
|
+
return count;
|
|
408
|
+
}
|
|
409
|
+
steer(text) {
|
|
410
|
+
const value = text.trim();
|
|
411
|
+
if (!value) return false;
|
|
412
|
+
if (!this.store.getState().busy && !this.promptActive) {
|
|
413
|
+
void this.submit(value);
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
void this.enqueueQueuedPrompt(value, [], [], "steer", true);
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
configuredMcpServers() {
|
|
420
|
+
return [...this.mcpServers];
|
|
421
|
+
}
|
|
422
|
+
mcpSessionStatus() {
|
|
423
|
+
return {
|
|
424
|
+
configured: this.mcpStatus.configured.map((server) => ({ ...server })),
|
|
425
|
+
connected: [...this.mcpStatus.connected],
|
|
426
|
+
failures: this.mcpStatus.failures.map((failure) => ({ ...failure }))
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
skillSessionStatus() {
|
|
430
|
+
return this.skills.map((skill) => ({ ...skill }));
|
|
431
|
+
}
|
|
432
|
+
contextUsageSnapshot() {
|
|
433
|
+
return this.store.getState().contextUsage;
|
|
434
|
+
}
|
|
435
|
+
async refreshContextUsage() {
|
|
436
|
+
await this.ensureSession();
|
|
437
|
+
const sessionId = this.sessionId;
|
|
438
|
+
const model = this.model;
|
|
439
|
+
if (this.contextWindowModel !== model) {
|
|
440
|
+
const request = ++this.contextWindowRequest;
|
|
441
|
+
const contextWindow = await resolveModelContextWindow(
|
|
442
|
+
this.novaClient,
|
|
443
|
+
model
|
|
444
|
+
);
|
|
445
|
+
if (request === this.contextWindowRequest && model === this.model) {
|
|
446
|
+
this.contextWindow = contextWindow;
|
|
447
|
+
this.contextWindowModel = model;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (sessionId !== this.sessionId || !this.agentSessionLoaded) {
|
|
451
|
+
return this.refreshContextUsage();
|
|
452
|
+
}
|
|
453
|
+
const usage = await this.updateContextUsage();
|
|
454
|
+
if (!usage)
|
|
455
|
+
throw new Error(
|
|
456
|
+
"Context usage is unavailable before the session is ready."
|
|
457
|
+
);
|
|
458
|
+
return usage;
|
|
459
|
+
}
|
|
460
|
+
async close() {
|
|
461
|
+
await this.acpClient.releaseAllTerminals();
|
|
462
|
+
await this.nova.closeSession({ sessionId: this.sessionId }).catch(() => {
|
|
463
|
+
});
|
|
464
|
+
await this.connection.close();
|
|
465
|
+
}
|
|
466
|
+
setModel(model) {
|
|
467
|
+
this.model = model;
|
|
468
|
+
this.contextWindow = null;
|
|
469
|
+
this.contextWindowModel = null;
|
|
470
|
+
this.contextWindowRequest++;
|
|
471
|
+
saveDefaultModel(model);
|
|
472
|
+
if (this.agentSessionLoaded) void this.updateContextUsage();
|
|
473
|
+
void this.refreshContextUsage().catch(() => {
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Hands the mode to the agent's permission policy (which decides) and
|
|
478
|
+
* remembers it in the private per-project settings; bypass is never stored.
|
|
479
|
+
*/
|
|
480
|
+
setPermissionMode(mode) {
|
|
481
|
+
this.store.setState({ permissionMode: mode });
|
|
482
|
+
try {
|
|
483
|
+
savePermissionMode(this.cwd, toAgentPermissionMode(mode));
|
|
484
|
+
} catch {
|
|
485
|
+
}
|
|
486
|
+
if (this.agentSessionLoaded) this.applyPermissionMode(this.sessionId);
|
|
487
|
+
}
|
|
488
|
+
applyPermissionMode(sessionId) {
|
|
489
|
+
void this.nova.setSessionConfigOption({
|
|
490
|
+
sessionId,
|
|
491
|
+
configId: "permission_mode",
|
|
492
|
+
value: toAgentPermissionMode(this.store.getState().permissionMode)
|
|
493
|
+
}).catch(() => {
|
|
494
|
+
this.acpClient.appendError("Could not change the permission mode.");
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
setInteractionMode(mode) {
|
|
498
|
+
this.applyInteractionMode(mode, `Mode: ${mode}`);
|
|
499
|
+
if (this.agentSessionLoaded) {
|
|
500
|
+
void this.nova.setSessionMode({ sessionId: this.sessionId, modeId: mode }).catch(() => {
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Starts a brand-new session: aborts any in-flight prompt, drops the
|
|
506
|
+
* queued messages and server-side conversation history, and gets a fresh
|
|
507
|
+
* sessionId — unlike a transcript-only clear, the model has no memory of
|
|
508
|
+
* what came before.
|
|
509
|
+
*/
|
|
510
|
+
async startNewSession() {
|
|
511
|
+
const previousSessionId = this.agentSessionLoaded ? this.sessionId : null;
|
|
512
|
+
this.cancel();
|
|
513
|
+
this.queuedAttachments.clear();
|
|
514
|
+
this.editingQueuedMessageId = null;
|
|
515
|
+
this.title = null;
|
|
516
|
+
this.promptActive = false;
|
|
517
|
+
this.agentSessionLoaded = false;
|
|
518
|
+
this.store.setState({ contextUsage: null, plan: [] });
|
|
519
|
+
if (previousSessionId)
|
|
520
|
+
await this.nova.closeSession({ sessionId: previousSessionId });
|
|
521
|
+
this.sessionReady = this.createSession();
|
|
522
|
+
await this.sessionReady;
|
|
523
|
+
this.store.setState({
|
|
524
|
+
messages: [],
|
|
525
|
+
statusLine: null,
|
|
526
|
+
queuedCount: 0,
|
|
527
|
+
busy: false
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
/** Chat models only: embedding, speech and transcription models can't run a session. */
|
|
531
|
+
async listModels() {
|
|
532
|
+
const { data } = await this.novaClient.models.list({ limit: 100 });
|
|
533
|
+
return chatModels(data).map((model) => ({
|
|
534
|
+
id: model.id,
|
|
535
|
+
name: model.name ?? null
|
|
536
|
+
}));
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Replaces a saved model that cannot chat (older logins saved the first
|
|
540
|
+
* model of the list, e.g. an embedding model) and saves the replacement.
|
|
541
|
+
* Runs once; a failed lookup keeps the model and the prompt reports errors.
|
|
542
|
+
*/
|
|
543
|
+
ensureUsableModel() {
|
|
544
|
+
this.modelCheck ??= (async () => {
|
|
545
|
+
try {
|
|
546
|
+
const { data } = await this.novaClient.models.list({ limit: 100 });
|
|
547
|
+
const model = resolveDefaultModel(data, this.model);
|
|
548
|
+
if (!model || model === this.model) return;
|
|
549
|
+
const previous = this.model;
|
|
550
|
+
this.setModel(model);
|
|
551
|
+
this.store.setState({
|
|
552
|
+
statusLine: `${previous} is not a chat model; switched to ${model} (Ctrl+P to pick another).`
|
|
553
|
+
});
|
|
554
|
+
} catch {
|
|
555
|
+
}
|
|
556
|
+
})();
|
|
557
|
+
return this.modelCheck;
|
|
558
|
+
}
|
|
559
|
+
async supportsImageInput(model = this.model) {
|
|
560
|
+
const cached = this.imageSupportByModel.get(model);
|
|
561
|
+
if (cached !== void 0) return cached;
|
|
562
|
+
const supported = await resolveModelSupportsImageInput(
|
|
563
|
+
this.novaClient,
|
|
564
|
+
model
|
|
565
|
+
);
|
|
566
|
+
this.imageSupportByModel.set(model, supported);
|
|
567
|
+
return supported;
|
|
568
|
+
}
|
|
569
|
+
async compactContext() {
|
|
570
|
+
if (this.store.getState().busy || this.promptActive) {
|
|
571
|
+
throw new Error(
|
|
572
|
+
"Wait for the active request to finish before compacting context."
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
await this.ensureSession();
|
|
576
|
+
this.store.setState({ busy: true, statusLine: "Compacting context..." });
|
|
577
|
+
try {
|
|
578
|
+
const result = await this.nova.sessionCompact({
|
|
579
|
+
sessionId: this.sessionId,
|
|
580
|
+
model: this.model
|
|
581
|
+
});
|
|
582
|
+
this.store.setState({
|
|
583
|
+
statusLine: result.compacted ? `Context compacted: ${result.removedMessages} older messages summarized.` : "Context is already compact."
|
|
584
|
+
});
|
|
585
|
+
void this.updateContextUsage();
|
|
586
|
+
return result;
|
|
587
|
+
} finally {
|
|
588
|
+
this.store.setState({ busy: false });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
async rewind(turns = 1) {
|
|
592
|
+
if (this.promptActive || this.store.getState().busy) {
|
|
593
|
+
throw new Error("Cancel the active request before rewinding.");
|
|
594
|
+
}
|
|
595
|
+
await this.ensureSession();
|
|
596
|
+
const result = await this.nova.sessionRewind({
|
|
597
|
+
sessionId: this.sessionId,
|
|
598
|
+
turns
|
|
599
|
+
});
|
|
600
|
+
const stored = loadStoredSession(this.sessionId);
|
|
601
|
+
if (!stored) throw new Error("Rewound session could not be reloaded.");
|
|
602
|
+
this.queuedAttachments.clear();
|
|
603
|
+
this.editingQueuedMessageId = null;
|
|
604
|
+
this.title = stored.title;
|
|
605
|
+
this.acpClient.resetStreaming();
|
|
606
|
+
this.store.setState({
|
|
607
|
+
messages: restoreSessionMessages(stored.messages),
|
|
608
|
+
plan: [],
|
|
609
|
+
queuedCount: 0,
|
|
610
|
+
statusLine: `Rewound ${result.removedCheckpoints.length} turn${result.removedCheckpoints.length === 1 ? "" : "s"}.`
|
|
611
|
+
});
|
|
612
|
+
void this.updateContextUsage();
|
|
613
|
+
return result;
|
|
614
|
+
}
|
|
615
|
+
async startBackgroundShell(command) {
|
|
616
|
+
await this.ensureSession();
|
|
617
|
+
const { job } = await this.nova.backgroundStartTerminal({
|
|
618
|
+
sessionId: this.sessionId,
|
|
619
|
+
command
|
|
620
|
+
});
|
|
621
|
+
return job;
|
|
622
|
+
}
|
|
623
|
+
async startBackgroundAgent(prompt) {
|
|
624
|
+
await this.ensureSession();
|
|
625
|
+
const { job } = await this.nova.backgroundStartPrompt({
|
|
626
|
+
sessionId: this.sessionId,
|
|
627
|
+
prompt: [{ type: "text", text: prompt }]
|
|
628
|
+
});
|
|
629
|
+
return job;
|
|
630
|
+
}
|
|
631
|
+
async listBackgroundJobs(kind) {
|
|
632
|
+
await this.ensureSession();
|
|
633
|
+
const { jobs } = await this.nova.backgroundList({
|
|
634
|
+
sessionId: this.sessionId
|
|
635
|
+
});
|
|
636
|
+
return kind ? jobs.filter((job) => job.kind === kind) : jobs;
|
|
637
|
+
}
|
|
638
|
+
async backgroundOutput(jobId) {
|
|
639
|
+
await this.ensureSession();
|
|
640
|
+
return this.nova.backgroundOutput({ jobId });
|
|
641
|
+
}
|
|
642
|
+
async killBackgroundJob(jobId) {
|
|
643
|
+
await this.ensureSession();
|
|
644
|
+
const { job } = await this.nova.backgroundKill({ jobId });
|
|
645
|
+
return job;
|
|
646
|
+
}
|
|
647
|
+
async releaseBackgroundJob(jobId) {
|
|
648
|
+
await this.ensureSession();
|
|
649
|
+
const { job } = await this.nova.backgroundRelease({ jobId });
|
|
650
|
+
return job;
|
|
651
|
+
}
|
|
652
|
+
async handleBackgroundLifecycle(event, job) {
|
|
653
|
+
if (job.kind !== "prompt" || event !== "completed") return;
|
|
654
|
+
this.completedBackgroundAgentJobs.add(job.jobId);
|
|
655
|
+
await this.maybeHandoffBackgroundAgentOutputs();
|
|
656
|
+
}
|
|
657
|
+
async submit(text, images = [], pastes = []) {
|
|
658
|
+
const value = text.trim();
|
|
659
|
+
if (!value) return;
|
|
660
|
+
if (this.store.getState().busy || this.promptActive) {
|
|
661
|
+
await this.enqueueQueuedPrompt(value, images, pastes, "followup");
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
await this.executePrompt(value, images, pastes);
|
|
665
|
+
}
|
|
666
|
+
async executePrompt(value, images = [], pastes = [], preparedPrompt) {
|
|
667
|
+
this.acpClient.appendUserMessage(value);
|
|
668
|
+
this.acpClient.resetStreaming();
|
|
669
|
+
this.promptActive = true;
|
|
670
|
+
this.store.setState({ busy: true, statusLine: null });
|
|
671
|
+
let pendingToolFailure = "Tool stopped before completion.";
|
|
672
|
+
try {
|
|
673
|
+
await this.ensureSession();
|
|
674
|
+
const prompt = preparedPrompt ?? await this.preparePrompt(value, images, pastes);
|
|
675
|
+
const response = await this.nova.prompt({
|
|
676
|
+
sessionId: this.sessionId,
|
|
677
|
+
prompt,
|
|
678
|
+
_meta: {
|
|
679
|
+
"nova-ai-cli/model": this.model
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
if (response.stopReason === "cancelled") {
|
|
683
|
+
pendingToolFailure = "Canceled.";
|
|
684
|
+
this.store.setState({ statusLine: "Request canceled." });
|
|
685
|
+
} else if (response.stopReason === "max_turn_requests") {
|
|
686
|
+
this.store.setState({
|
|
687
|
+
statusLine: "Tool-use safety limit reached; the final response uses the results gathered so far."
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
} catch (err) {
|
|
691
|
+
const message = err instanceof NovaAIError ? `Nova AI request failed (status ${err.status}, requestId ${err.requestId}): ${err.message}` : err instanceof Error ? err.message : "Unknown error.";
|
|
692
|
+
pendingToolFailure = `Request failed: ${message}`;
|
|
693
|
+
this.acpClient.appendError(message);
|
|
694
|
+
this.store.setState({ statusLine: "Request failed." });
|
|
695
|
+
} finally {
|
|
696
|
+
this.promptActive = false;
|
|
697
|
+
this.acpClient.failPendingTools(pendingToolFailure);
|
|
698
|
+
this.acpClient.resetStreaming();
|
|
699
|
+
void this.updateContextUsage();
|
|
700
|
+
this.store.setState({ busy: false });
|
|
701
|
+
}
|
|
702
|
+
await this.runNextQueuedPrompt();
|
|
703
|
+
}
|
|
704
|
+
async enqueueQueuedPrompt(text, images, pastes, kind, front = false) {
|
|
705
|
+
const provisionalId = `pending-${crypto.randomUUID()}`;
|
|
706
|
+
const provisional = {
|
|
707
|
+
id: provisionalId,
|
|
708
|
+
version: 0,
|
|
709
|
+
text,
|
|
710
|
+
kind,
|
|
711
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
712
|
+
editing: false
|
|
713
|
+
};
|
|
714
|
+
if (this.agentSessionLoaded) {
|
|
715
|
+
const current = this.promptQueueEntries();
|
|
716
|
+
this.applyQueue(front ? [provisional, ...current] : [...current, provisional]);
|
|
717
|
+
this.syncQueueTranscript();
|
|
718
|
+
}
|
|
719
|
+
if (!this.agentSessionLoaded) await this.ensureSession();
|
|
720
|
+
const provisionalPrompt = this.promptBlocks(
|
|
721
|
+
expandPromptPastes(text, pastes),
|
|
722
|
+
images
|
|
723
|
+
);
|
|
724
|
+
const { entry, entries } = await this.nova.queueEnqueue({
|
|
725
|
+
sessionId: this.sessionId,
|
|
726
|
+
text,
|
|
727
|
+
prompt: provisionalPrompt,
|
|
728
|
+
kind,
|
|
729
|
+
front
|
|
730
|
+
});
|
|
731
|
+
this.applyQueue(entries);
|
|
732
|
+
this.queuedAttachments.set(entry.id, {
|
|
733
|
+
images: [...images],
|
|
734
|
+
pastes: [...pastes]
|
|
735
|
+
});
|
|
736
|
+
this.syncQueueTranscript(
|
|
737
|
+
kind === "steer" ? `Steering at the next tool boundary \xB7 queued:${entries.length}` : `Queued message ${entries.length}: ${summarizeQueueMessage(text)}`
|
|
738
|
+
);
|
|
739
|
+
if (kind === "followup") {
|
|
740
|
+
const locked = await this.nova.queueEditBegin({
|
|
741
|
+
sessionId: this.sessionId,
|
|
742
|
+
id: entry.id
|
|
743
|
+
});
|
|
744
|
+
this.applyQueue(locked.entries);
|
|
745
|
+
const expectedVersion = locked.entries.find(
|
|
746
|
+
(candidate) => candidate.id === entry.id
|
|
747
|
+
)?.version;
|
|
748
|
+
const prompt = await this.preparePrompt(text, images, pastes);
|
|
749
|
+
const updated = await this.nova.queueUpdate({
|
|
750
|
+
sessionId: this.sessionId,
|
|
751
|
+
id: entry.id,
|
|
752
|
+
prompt,
|
|
753
|
+
editing: false,
|
|
754
|
+
expectedVersion
|
|
755
|
+
});
|
|
756
|
+
this.applyQueue(updated.entries);
|
|
757
|
+
}
|
|
758
|
+
if (!this.store.getState().busy && !this.promptActive) {
|
|
759
|
+
await this.runNextQueuedPrompt();
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
syncQueueTranscript(statusLine) {
|
|
763
|
+
const entries = this.promptQueueEntries();
|
|
764
|
+
this.store.setState((state) => {
|
|
765
|
+
const transcript = state.messages.filter(
|
|
766
|
+
(message) => !(message.role === "user" && message.queued)
|
|
767
|
+
);
|
|
768
|
+
const queued = entries.map((prompt) => ({
|
|
769
|
+
id: prompt.id,
|
|
770
|
+
role: "user",
|
|
771
|
+
text: prompt.text,
|
|
772
|
+
queued: prompt.kind
|
|
773
|
+
}));
|
|
774
|
+
return {
|
|
775
|
+
messages: [...transcript, ...queued],
|
|
776
|
+
queuedCount: entries.length,
|
|
777
|
+
...statusLine !== void 0 ? { statusLine } : {}
|
|
778
|
+
};
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
async runNextQueuedPrompt() {
|
|
782
|
+
if (this.store.getState().busy || this.promptActive) return;
|
|
783
|
+
if (!this.agentSessionLoaded) return;
|
|
784
|
+
const { entry: next } = await this.nova.queueTakeNext({
|
|
785
|
+
sessionId: this.sessionId
|
|
786
|
+
});
|
|
787
|
+
if (!next) return;
|
|
788
|
+
this.applyQueue(this.promptQueueEntries().filter((entry) => entry.id !== next.id));
|
|
789
|
+
this.queuedAttachments.delete(next.id);
|
|
790
|
+
if (this.editingQueuedMessageId === next.id)
|
|
791
|
+
this.editingQueuedMessageId = null;
|
|
792
|
+
this.syncQueueTranscript();
|
|
793
|
+
await this.executePrompt(next.text, [], [], next.prompt);
|
|
794
|
+
}
|
|
795
|
+
async finalizeQueuedMessageEdit(id, text) {
|
|
796
|
+
const attachments = this.queuedAttachments.get(id) ?? {
|
|
797
|
+
images: [],
|
|
798
|
+
pastes: []
|
|
799
|
+
};
|
|
800
|
+
const prompt = await this.preparePrompt(
|
|
801
|
+
text,
|
|
802
|
+
attachments.images,
|
|
803
|
+
attachments.pastes
|
|
804
|
+
);
|
|
805
|
+
const { updated, entries } = await this.nova.queueUpdate({
|
|
806
|
+
sessionId: this.sessionId,
|
|
807
|
+
id,
|
|
808
|
+
text,
|
|
809
|
+
prompt,
|
|
810
|
+
editing: false
|
|
811
|
+
});
|
|
812
|
+
this.applyQueue(entries);
|
|
813
|
+
if (!updated) return;
|
|
814
|
+
if (this.editingQueuedMessageId === id) this.editingQueuedMessageId = null;
|
|
815
|
+
this.syncQueueTranscript("Queued message updated.");
|
|
816
|
+
if (!this.store.getState().busy && !this.promptActive) {
|
|
817
|
+
await this.runNextQueuedPrompt();
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/** The local mirror of the agent's queue (responses and _nova/queue/changed). */
|
|
821
|
+
promptQueueEntries() {
|
|
822
|
+
if (!this.agentSessionLoaded) return [];
|
|
823
|
+
return this.acpClient.queueSnapshot;
|
|
824
|
+
}
|
|
825
|
+
applyQueue(entries) {
|
|
826
|
+
this.acpClient.queueSnapshot = entries;
|
|
827
|
+
}
|
|
828
|
+
async preparePrompt(text, images, pastes) {
|
|
829
|
+
const modelText = await this.injectFileMentions(
|
|
830
|
+
expandPromptPastes(text, pastes)
|
|
831
|
+
);
|
|
832
|
+
return this.promptBlocks(modelText, images);
|
|
833
|
+
}
|
|
834
|
+
promptBlocks(text, images) {
|
|
835
|
+
return [
|
|
836
|
+
{ type: "text", text },
|
|
837
|
+
...images.map((image) => ({
|
|
838
|
+
type: "image",
|
|
839
|
+
data: image.data,
|
|
840
|
+
mimeType: image.mimeType
|
|
841
|
+
}))
|
|
842
|
+
];
|
|
843
|
+
}
|
|
844
|
+
async createSession() {
|
|
845
|
+
this.loadMcpConfiguration(this.cwd);
|
|
846
|
+
await this.initialized;
|
|
847
|
+
const { sessionId, _meta } = await this.nova.newSession({
|
|
848
|
+
cwd: this.cwd,
|
|
849
|
+
mcpServers: this.mcpServers
|
|
850
|
+
});
|
|
851
|
+
this.sessionId = sessionId;
|
|
852
|
+
this.applyMcpStatus(_meta);
|
|
853
|
+
this.applySkillStatus(_meta);
|
|
854
|
+
this.agentSessionLoaded = true;
|
|
855
|
+
this.applyQueue([]);
|
|
856
|
+
void this.nova.setSessionMode({ sessionId, modeId: this.interactionMode }).catch(() => {
|
|
857
|
+
});
|
|
858
|
+
this.applyPermissionMode(sessionId);
|
|
859
|
+
void this.updateContextUsage();
|
|
860
|
+
void this.refreshContextUsage().catch(() => {
|
|
861
|
+
});
|
|
862
|
+
if (this.mcpStatus.failures.length > 0) {
|
|
863
|
+
this.store.setState({
|
|
864
|
+
statusLine: `MCP: ${this.mcpStatus.failures.length} server${this.mcpStatus.failures.length === 1 ? "" : "s"} failed to connect.`
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
this.store.setState({ sessionId });
|
|
868
|
+
}
|
|
869
|
+
applyMcpStatus(meta) {
|
|
870
|
+
const value = meta?.["nova-ai-cli/mcp"];
|
|
871
|
+
if (!value || typeof value !== "object") return;
|
|
872
|
+
const record = value;
|
|
873
|
+
const configured = Array.isArray(record.configured) ? record.configured.flatMap((entry) => {
|
|
874
|
+
if (!entry || typeof entry !== "object") return [];
|
|
875
|
+
const item = entry;
|
|
876
|
+
return typeof item.name === "string" && typeof item.transport === "string" ? [{ name: item.name, transport: item.transport }] : [];
|
|
877
|
+
}) : [];
|
|
878
|
+
const connected = Array.isArray(record.connected) ? record.connected.filter(
|
|
879
|
+
(entry) => typeof entry === "string"
|
|
880
|
+
) : [];
|
|
881
|
+
const failures = Array.isArray(record.failures) ? record.failures.flatMap((entry) => {
|
|
882
|
+
if (!entry || typeof entry !== "object") return [];
|
|
883
|
+
const item = entry;
|
|
884
|
+
return typeof item.serverName === "string" && typeof item.message === "string" ? [{ serverName: item.serverName, message: item.message }] : [];
|
|
885
|
+
}) : [];
|
|
886
|
+
this.mcpStatus = {
|
|
887
|
+
configured,
|
|
888
|
+
connected,
|
|
889
|
+
failures: [...this.mcpConfigurationFailures, ...failures]
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
loadMcpConfiguration(cwd) {
|
|
893
|
+
const configuration = readWorkspaceMcpConfiguration(cwd);
|
|
894
|
+
this.mcpServers = configuration.servers;
|
|
895
|
+
this.mcpConfigurationFailures = configuration.failures;
|
|
896
|
+
this.mcpStatus = {
|
|
897
|
+
configured: this.mcpServers.map((server) => ({
|
|
898
|
+
name: server.name,
|
|
899
|
+
transport: "type" in server ? server.type : "stdio"
|
|
900
|
+
})),
|
|
901
|
+
connected: [],
|
|
902
|
+
failures: [...this.mcpConfigurationFailures]
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
applySkillStatus(meta) {
|
|
906
|
+
const value = meta?.["nova-ai-cli/skills"];
|
|
907
|
+
if (!Array.isArray(value)) return;
|
|
908
|
+
this.skills = value.flatMap((entry) => {
|
|
909
|
+
if (!entry || typeof entry !== "object") return [];
|
|
910
|
+
const skill = entry;
|
|
911
|
+
return typeof skill.name === "string" && typeof skill.description === "string" && (skill.source === "user" || skill.source === "workspace") && typeof skill.path === "string" ? [
|
|
912
|
+
{
|
|
913
|
+
name: skill.name,
|
|
914
|
+
description: skill.description,
|
|
915
|
+
source: skill.source,
|
|
916
|
+
path: skill.path
|
|
917
|
+
}
|
|
918
|
+
] : [];
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
async ensureSession() {
|
|
922
|
+
await this.ensureUsableModel();
|
|
923
|
+
await this.sessionReady;
|
|
924
|
+
if (!this.agentSessionLoaded) {
|
|
925
|
+
this.sessionReady = this.createSession();
|
|
926
|
+
await this.sessionReady;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
applyInteractionMode(mode, statusLine) {
|
|
930
|
+
this.interactionMode = mode;
|
|
931
|
+
this.store.setState({ interactionMode: mode, statusLine });
|
|
932
|
+
if (this.agentSessionLoaded) void this.updateContextUsage();
|
|
933
|
+
}
|
|
934
|
+
/** Asks the agent for the context estimate; only the newest answer is shown. */
|
|
935
|
+
async updateContextUsage() {
|
|
936
|
+
if (!this.agentSessionLoaded) return null;
|
|
937
|
+
const request = ++this.contextUsageRequest;
|
|
938
|
+
const sessionId = this.sessionId;
|
|
939
|
+
try {
|
|
940
|
+
const usage = await this.nova.sessionContextUsage({
|
|
941
|
+
sessionId,
|
|
942
|
+
contextWindow: this.contextWindow,
|
|
943
|
+
mode: this.interactionMode
|
|
944
|
+
});
|
|
945
|
+
if (request === this.contextUsageRequest && sessionId === this.sessionId) {
|
|
946
|
+
this.store.setState({ contextUsage: usage });
|
|
947
|
+
}
|
|
948
|
+
return usage;
|
|
949
|
+
} catch {
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
async maybeHandoffBackgroundAgentOutputs() {
|
|
954
|
+
if (this.handoffActive || this.promptActive || this.completedBackgroundAgentJobs.size === 0)
|
|
955
|
+
return;
|
|
956
|
+
const jobs = (await this.nova.backgroundList({ sessionId: this.sessionId })).jobs.filter((job) => job.kind === "prompt");
|
|
957
|
+
if (jobs.some((job) => job.status === "running")) return;
|
|
958
|
+
this.handoffActive = true;
|
|
959
|
+
const jobIds = [...this.completedBackgroundAgentJobs];
|
|
960
|
+
this.completedBackgroundAgentJobs.clear();
|
|
961
|
+
try {
|
|
962
|
+
await this.submit(
|
|
963
|
+
`Background agent jobs have finished (${jobIds.join(", ")}). Their handoff notes precede this message; use them to continue the main task, and call read_background_output with a jobId if you need a full transcript.`
|
|
964
|
+
);
|
|
965
|
+
} finally {
|
|
966
|
+
this.handoffActive = false;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
async injectFileMentions(text) {
|
|
970
|
+
const mentioned = findWorkspaceFileMentions(text, this.cwd);
|
|
971
|
+
if (mentioned.length === 0) return text;
|
|
972
|
+
const blocks = await Promise.all(
|
|
973
|
+
mentioned.map(async ({ absolutePath, relativePath }) => {
|
|
974
|
+
try {
|
|
975
|
+
const { content } = await this.acpClient.readTextFile({
|
|
976
|
+
sessionId: this.sessionId,
|
|
977
|
+
path: absolutePath
|
|
978
|
+
});
|
|
979
|
+
return `
|
|
980
|
+
--- file: ${relativePath} ---
|
|
981
|
+
${content}
|
|
982
|
+
---`;
|
|
983
|
+
} catch (err) {
|
|
984
|
+
return `
|
|
985
|
+
[could not read @${relativePath}: ${err instanceof Error ? err.message : "unknown error"}]`;
|
|
986
|
+
}
|
|
987
|
+
})
|
|
988
|
+
);
|
|
989
|
+
return `${text}${blocks.join("")}`;
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
function summarizeQueueMessage(text) {
|
|
993
|
+
const oneLine = text.replace(/\s+/g, " ");
|
|
994
|
+
return oneLine.length > 60 ? `${oneLine.slice(0, 59)}\u2026` : oneLine;
|
|
995
|
+
}
|
|
996
|
+
function mergeReferencedImages(existing, added, text) {
|
|
997
|
+
const byMarker = new Map(
|
|
998
|
+
[...existing, ...added].map((image) => [image.marker, image])
|
|
999
|
+
);
|
|
1000
|
+
return [...byMarker.values()].filter((image) => text.includes(image.marker));
|
|
1001
|
+
}
|
|
1002
|
+
function mergeReferencedPastes(existing, added, text) {
|
|
1003
|
+
const byMarker = new Map(
|
|
1004
|
+
[...existing, ...added].map((paste) => [paste.marker, paste])
|
|
1005
|
+
);
|
|
1006
|
+
return [...byMarker.values()].filter((paste) => text.includes(paste.marker));
|
|
1007
|
+
}
|
|
1008
|
+
function toAgentPermissionMode(mode) {
|
|
1009
|
+
return mode === "bypassAll" ? "bypassPermissions" : mode === "acceptEdits" ? "acceptEdits" : "default";
|
|
1010
|
+
}
|
|
1011
|
+
function fromAgentPermissionMode(mode) {
|
|
1012
|
+
return mode === "bypassPermissions" ? "bypassAll" : mode === "acceptEdits" ? "acceptEdits" : "ask";
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// src/tui/update-check.ts
|
|
1016
|
+
var PACKAGE_NAME = "@datalabrotterdam/nova-ai-cli";
|
|
1017
|
+
var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME.replace("/", "%2F")}/latest`;
|
|
1018
|
+
var DEFAULT_TIMEOUT_MS = 3e3;
|
|
1019
|
+
var UPDATE_COMMAND = "npm install -g @datalabrotterdam/nova-ai-cli@latest";
|
|
1020
|
+
async function checkForUpdate(options = {}) {
|
|
1021
|
+
try {
|
|
1022
|
+
const currentVersion = options.currentVersion ?? readInstalledVersion();
|
|
1023
|
+
if (!currentVersion) return null;
|
|
1024
|
+
const response = await (options.fetchImpl ?? fetch)(REGISTRY_URL, {
|
|
1025
|
+
headers: { accept: "application/json" },
|
|
1026
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
1027
|
+
});
|
|
1028
|
+
if (!response.ok) return null;
|
|
1029
|
+
const payload = await response.json();
|
|
1030
|
+
if (!isVersionPayload(payload)) return null;
|
|
1031
|
+
if (!isNewerVersion(payload.version, currentVersion)) return null;
|
|
1032
|
+
return {
|
|
1033
|
+
currentVersion,
|
|
1034
|
+
latestVersion: payload.version,
|
|
1035
|
+
command: UPDATE_COMMAND
|
|
1036
|
+
};
|
|
1037
|
+
} catch {
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
function readInstalledVersion() {
|
|
1042
|
+
return cliVersion();
|
|
1043
|
+
}
|
|
1044
|
+
function isNewerVersion(candidate, current) {
|
|
1045
|
+
const next = parseVersion(candidate);
|
|
1046
|
+
const installed = parseVersion(current);
|
|
1047
|
+
if (!next || !installed) return false;
|
|
1048
|
+
for (let index = 0; index < 3; index++) {
|
|
1049
|
+
const difference = next.core[index] - installed.core[index];
|
|
1050
|
+
if (difference !== 0) return difference > 0;
|
|
1051
|
+
}
|
|
1052
|
+
return comparePrerelease(next.prerelease, installed.prerelease) > 0;
|
|
1053
|
+
}
|
|
1054
|
+
function isVersionPayload(value) {
|
|
1055
|
+
return typeof value === "object" && value !== null && "version" in value && typeof value.version === "string";
|
|
1056
|
+
}
|
|
1057
|
+
function parseVersion(value) {
|
|
1058
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
|
|
1059
|
+
value
|
|
1060
|
+
);
|
|
1061
|
+
if (!match) return null;
|
|
1062
|
+
return {
|
|
1063
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
1064
|
+
prerelease: match[4]?.split(".") ?? []
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
function comparePrerelease(left, right) {
|
|
1068
|
+
if (left.length === 0 || right.length === 0) {
|
|
1069
|
+
if (left.length === right.length) return 0;
|
|
1070
|
+
return left.length === 0 ? 1 : -1;
|
|
1071
|
+
}
|
|
1072
|
+
const length = Math.max(left.length, right.length);
|
|
1073
|
+
for (let index = 0; index < length; index++) {
|
|
1074
|
+
const leftPart = left[index];
|
|
1075
|
+
const rightPart = right[index];
|
|
1076
|
+
if (leftPart === void 0 || rightPart === void 0) {
|
|
1077
|
+
return leftPart === rightPart ? 0 : leftPart === void 0 ? -1 : 1;
|
|
1078
|
+
}
|
|
1079
|
+
if (leftPart === rightPart) continue;
|
|
1080
|
+
const leftNumber = /^\d+$/.test(leftPart) ? Number(leftPart) : null;
|
|
1081
|
+
const rightNumber = /^\d+$/.test(rightPart) ? Number(rightPart) : null;
|
|
1082
|
+
if (leftNumber !== null && rightNumber !== null)
|
|
1083
|
+
return leftNumber - rightNumber;
|
|
1084
|
+
if (leftNumber !== null || rightNumber !== null)
|
|
1085
|
+
return leftNumber !== null ? -1 : 1;
|
|
1086
|
+
return leftPart < rightPart ? -1 : 1;
|
|
1087
|
+
}
|
|
1088
|
+
return 0;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// src/tui/ink/terminal-keyboard.ts
|
|
1092
|
+
var KITTY_QUERY = "\x1B[?u";
|
|
1093
|
+
var DEVICE_ATTRIBUTES_QUERY = "\x1B[c";
|
|
1094
|
+
var KITTY_REPLY = /\u001b\[\?\d+u/g;
|
|
1095
|
+
var DEVICE_ATTRIBUTES_REPLY = /\u001b\[\?[\d;]*c/g;
|
|
1096
|
+
function detectKittyKeyboard(stdin, stdout, timeoutMs = 1e3) {
|
|
1097
|
+
if (!stdin.isTTY || !stdout.isTTY || !stdin.setRawMode) return Promise.resolve(false);
|
|
1098
|
+
const wasRaw = stdin.isRaw ?? false;
|
|
1099
|
+
stdin.setRawMode(true);
|
|
1100
|
+
return new Promise((resolve2) => {
|
|
1101
|
+
let received = "";
|
|
1102
|
+
let settled = false;
|
|
1103
|
+
const finish = () => {
|
|
1104
|
+
if (settled) return;
|
|
1105
|
+
settled = true;
|
|
1106
|
+
clearTimeout(timer);
|
|
1107
|
+
stdin.off("data", onData);
|
|
1108
|
+
stdin.pause();
|
|
1109
|
+
const supported = new RegExp(KITTY_REPLY.source).test(received);
|
|
1110
|
+
const typed = received.replace(KITTY_REPLY, "").replace(DEVICE_ATTRIBUTES_REPLY, "");
|
|
1111
|
+
if (typed) stdin.unshift(Buffer.from(typed, "utf8"));
|
|
1112
|
+
stdin.setRawMode?.(wasRaw);
|
|
1113
|
+
resolve2(supported);
|
|
1114
|
+
};
|
|
1115
|
+
const onData = (chunk) => {
|
|
1116
|
+
received += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
1117
|
+
if (new RegExp(DEVICE_ATTRIBUTES_REPLY.source).test(received)) finish();
|
|
1118
|
+
};
|
|
1119
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
1120
|
+
stdin.on("data", onData);
|
|
1121
|
+
stdout.write(`${KITTY_QUERY}${DEVICE_ATTRIBUTES_QUERY}`);
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
function kittyKeyboardOverride(env = process.env) {
|
|
1125
|
+
if (env.NOVA_KITTY_KEYBOARD === "1") return true;
|
|
1126
|
+
if (env.NOVA_KITTY_KEYBOARD === "0") return false;
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
function isLateTerminalReply(input) {
|
|
1130
|
+
return /^\u001b?\[\?[\d;]*[uc]$/.test(input);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// src/tui/ink/components/prompt-input.tsx
|
|
1134
|
+
import { Box, Text, useInput, usePaste } from "ink";
|
|
1135
|
+
import { useSyncExternalStore } from "react";
|
|
1136
|
+
|
|
1137
|
+
// src/tui/ink/theme.ts
|
|
1138
|
+
import chalk from "chalk";
|
|
1139
|
+
var palette = {
|
|
1140
|
+
primary: "#7dd3fc",
|
|
1141
|
+
accent: "#c4b5fd",
|
|
1142
|
+
success: "#86efac",
|
|
1143
|
+
warning: "#fbbf24",
|
|
1144
|
+
danger: "#f87171",
|
|
1145
|
+
muted: "#94a3b8",
|
|
1146
|
+
faint: "#64748b",
|
|
1147
|
+
code: "#fcd34d",
|
|
1148
|
+
diffAddBg: "#1b3a24",
|
|
1149
|
+
diffRemoveBg: "#3d1f1f"
|
|
1150
|
+
};
|
|
1151
|
+
var colors = {
|
|
1152
|
+
primary: chalk.hex(palette.primary),
|
|
1153
|
+
accent: chalk.hex(palette.accent),
|
|
1154
|
+
success: chalk.hex(palette.success),
|
|
1155
|
+
warning: chalk.hex(palette.warning),
|
|
1156
|
+
danger: chalk.hex(palette.danger),
|
|
1157
|
+
muted: chalk.hex(palette.muted),
|
|
1158
|
+
faint: chalk.hex(palette.faint),
|
|
1159
|
+
code: chalk.hex(palette.code)
|
|
1160
|
+
};
|
|
1161
|
+
var ASSISTANT_MARKER = String.fromCodePoint(10022);
|
|
1162
|
+
var WORKING_FRAMES = [ASSISTANT_MARKER, "\u2727", "\xB7", "\u2727"];
|
|
1163
|
+
|
|
1164
|
+
// src/tui/ink/components/prompt-input.tsx
|
|
1165
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
1166
|
+
var segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
1167
|
+
var MAX_SUGGESTIONS = 8;
|
|
1168
|
+
function PromptInput({
|
|
1169
|
+
editor,
|
|
1170
|
+
busy,
|
|
1171
|
+
maxRows,
|
|
1172
|
+
onKey
|
|
1173
|
+
}) {
|
|
1174
|
+
useSyncExternalStore(editor.subscribe, editor.getVersion);
|
|
1175
|
+
useInput((input, key) => {
|
|
1176
|
+
if (onKey(input, key)) return;
|
|
1177
|
+
editor.handleKey(input, key);
|
|
1178
|
+
});
|
|
1179
|
+
usePaste((text) => editor.paste(text));
|
|
1180
|
+
const lines = editor.buffer.lineList;
|
|
1181
|
+
const { row, col } = editor.buffer.cursor;
|
|
1182
|
+
const visibleRows = Math.max(1, maxRows);
|
|
1183
|
+
const start = Math.min(
|
|
1184
|
+
Math.max(0, row - visibleRows + 1),
|
|
1185
|
+
Math.max(0, lines.length - visibleRows)
|
|
1186
|
+
);
|
|
1187
|
+
const shown = lines.slice(start, start + visibleRows);
|
|
1188
|
+
const autocomplete = editor.autocomplete;
|
|
1189
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
1190
|
+
/* @__PURE__ */ jsxs(
|
|
1191
|
+
Box,
|
|
1192
|
+
{
|
|
1193
|
+
flexDirection: "column",
|
|
1194
|
+
borderStyle: "round",
|
|
1195
|
+
borderColor: busy ? palette.warning : palette.primary,
|
|
1196
|
+
paddingRight: 1,
|
|
1197
|
+
children: [
|
|
1198
|
+
start > 0 ? /* @__PURE__ */ jsx(Text, { color: palette.faint, children: ` \u2191 ${start} more line${start === 1 ? "" : "s"}` }) : null,
|
|
1199
|
+
shown.map((line, index) => {
|
|
1200
|
+
const lineIndex = start + index;
|
|
1201
|
+
return /* @__PURE__ */ jsxs(Box, { children: [
|
|
1202
|
+
/* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: palette.primary, children: lineIndex === 0 ? "> " : " " }) }),
|
|
1203
|
+
/* @__PURE__ */ jsx(Box, { flexGrow: 1, flexShrink: 1, children: /* @__PURE__ */ jsx(Text, { children: lineIndex === row ? withCursor(line, col) : line || " " }) })
|
|
1204
|
+
] }, lineIndex);
|
|
1205
|
+
}),
|
|
1206
|
+
start + shown.length < lines.length ? /* @__PURE__ */ jsx(Text, { color: palette.faint, children: ` \u2193 ${lines.length - start - shown.length} more line${lines.length - start - shown.length === 1 ? "" : "s"}` }) : null
|
|
1207
|
+
]
|
|
1208
|
+
}
|
|
1209
|
+
),
|
|
1210
|
+
autocomplete ? /* @__PURE__ */ jsx(Suggestions, { view: autocomplete }) : null
|
|
1211
|
+
] });
|
|
1212
|
+
}
|
|
1213
|
+
function Suggestions({ view }) {
|
|
1214
|
+
const count = view.items.length;
|
|
1215
|
+
const rows = Math.min(MAX_SUGGESTIONS, count);
|
|
1216
|
+
const start = Math.max(0, Math.min(view.selected - Math.floor(rows / 2), count - rows));
|
|
1217
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [
|
|
1218
|
+
view.items.slice(start, start + rows).map((item, index) => {
|
|
1219
|
+
const selected = start + index === view.selected;
|
|
1220
|
+
return /* @__PURE__ */ jsxs(Box, { children: [
|
|
1221
|
+
/* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: selected ? palette.primary : void 0, bold: selected, children: `${selected ? "\u203A " : " "}${item.label}` }) }),
|
|
1222
|
+
item.description ? /* @__PURE__ */ jsx(Box, { flexGrow: 1, flexShrink: 1, marginLeft: 2, children: /* @__PURE__ */ jsx(Text, { color: palette.muted, wrap: "truncate-end", children: item.description }) }) : null
|
|
1223
|
+
] }, `${item.value}:${start + index}`);
|
|
1224
|
+
}),
|
|
1225
|
+
count > rows ? /* @__PURE__ */ jsx(Text, { color: palette.faint, children: ` ${view.selected + 1}/${count} \xB7 Tab accept \xB7 Esc close` }) : null
|
|
1226
|
+
] });
|
|
1227
|
+
}
|
|
1228
|
+
function withCursor(line, col) {
|
|
1229
|
+
const before = line.slice(0, col);
|
|
1230
|
+
const rest = line.slice(col);
|
|
1231
|
+
const first = segmenter.segment(rest)[Symbol.iterator]().next();
|
|
1232
|
+
const current = first.done ? " " : first.value.segment;
|
|
1233
|
+
const after = first.done ? "" : rest.slice(current.length);
|
|
1234
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1235
|
+
before,
|
|
1236
|
+
/* @__PURE__ */ jsx(Text, { inverse: true, children: current }),
|
|
1237
|
+
after
|
|
1238
|
+
] });
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// src/tui/ink/components/status-line.tsx
|
|
1242
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
1243
|
+
|
|
1244
|
+
// src/tui/ink/format.ts
|
|
1245
|
+
import { diffLines } from "diff";
|
|
1246
|
+
|
|
1247
|
+
// src/tui/ink/highlight.ts
|
|
1248
|
+
import { highlight, supportsLanguage } from "cli-highlight";
|
|
1249
|
+
var LANGUAGE_ALIASES = {
|
|
1250
|
+
mts: "typescript",
|
|
1251
|
+
cts: "typescript",
|
|
1252
|
+
mjs: "javascript",
|
|
1253
|
+
cjs: "javascript",
|
|
1254
|
+
sh: "bash",
|
|
1255
|
+
zsh: "bash",
|
|
1256
|
+
yml: "yaml"
|
|
1257
|
+
};
|
|
1258
|
+
function languageFromPath(path) {
|
|
1259
|
+
const ext = path?.split(/[\\/]/).pop()?.split(".").pop()?.toLowerCase();
|
|
1260
|
+
return ext ? supportedLanguage(ext) : void 0;
|
|
1261
|
+
}
|
|
1262
|
+
function supportedLanguage(name) {
|
|
1263
|
+
if (!name) return void 0;
|
|
1264
|
+
const candidate = LANGUAGE_ALIASES[name.toLowerCase()] ?? name.toLowerCase();
|
|
1265
|
+
return supportsLanguage(candidate) ? candidate : void 0;
|
|
1266
|
+
}
|
|
1267
|
+
function highlightLines(text, language) {
|
|
1268
|
+
if (!text) return [];
|
|
1269
|
+
const trimmed = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
1270
|
+
if (!language) return trimmed.split("\n");
|
|
1271
|
+
try {
|
|
1272
|
+
return highlight(trimmed, { language, ignoreIllegals: true }).split("\n");
|
|
1273
|
+
} catch {
|
|
1274
|
+
return trimmed.split("\n");
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// src/tui/ink/format.ts
|
|
1279
|
+
function formatElapsedTime(elapsedMs) {
|
|
1280
|
+
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1e3));
|
|
1281
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1282
|
+
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
|
1283
|
+
const seconds = totalSeconds % 60;
|
|
1284
|
+
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
|
1285
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
1286
|
+
return `${seconds}s`;
|
|
1287
|
+
}
|
|
1288
|
+
function formatTokenCount(value) {
|
|
1289
|
+
if (value >= 1e6) {
|
|
1290
|
+
const millions = value / 1e6;
|
|
1291
|
+
return `${millions >= 10 ? Math.round(millions) : millions.toFixed(1).replace(/\.0$/, "")}m`;
|
|
1292
|
+
}
|
|
1293
|
+
if (value >= 1e3) {
|
|
1294
|
+
const thousands = value / 1e3;
|
|
1295
|
+
return `${thousands >= 10 ? Math.round(thousands) : thousands.toFixed(1).replace(/\.0$/, "")}k`;
|
|
1296
|
+
}
|
|
1297
|
+
return String(value);
|
|
1298
|
+
}
|
|
1299
|
+
function formatImageSize(bytes) {
|
|
1300
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1301
|
+
return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KiB`;
|
|
1302
|
+
}
|
|
1303
|
+
function toolCallSummary(call) {
|
|
1304
|
+
const candidates = [
|
|
1305
|
+
call.args.path,
|
|
1306
|
+
call.args.command,
|
|
1307
|
+
call.args.script,
|
|
1308
|
+
call.args.query,
|
|
1309
|
+
call.args.url
|
|
1310
|
+
];
|
|
1311
|
+
return candidates.find((value) => typeof value === "string") ?? "";
|
|
1312
|
+
}
|
|
1313
|
+
function isCompactTool(call) {
|
|
1314
|
+
return !call.mutating || call.kind === "read" || call.kind === "search";
|
|
1315
|
+
}
|
|
1316
|
+
function isActivityTool(call) {
|
|
1317
|
+
return isCompactTool(call) || call.name === "run_command" || call.name === "run_package_script";
|
|
1318
|
+
}
|
|
1319
|
+
function activityStatus(calls) {
|
|
1320
|
+
return calls.some((call) => call.status === "failed") ? "failed" : calls.some((call) => call.status === "pending") ? "pending" : "completed";
|
|
1321
|
+
}
|
|
1322
|
+
function activitySummary(calls, pending) {
|
|
1323
|
+
const counts = { files: 0, directories: 0, searches: 0, shells: 0, other: 0 };
|
|
1324
|
+
for (const call of calls) {
|
|
1325
|
+
if (call.name === "read_file") counts.files++;
|
|
1326
|
+
else if (call.name === "list_directory") counts.directories++;
|
|
1327
|
+
else if (call.name === "search_text") counts.searches++;
|
|
1328
|
+
else if (call.name === "run_command" || call.name === "run_package_script")
|
|
1329
|
+
counts.shells++;
|
|
1330
|
+
else counts.other++;
|
|
1331
|
+
}
|
|
1332
|
+
const phrases = [
|
|
1333
|
+
activityPhrase(counts.files, pending ? "reading" : "read", "file"),
|
|
1334
|
+
activityPhrase(
|
|
1335
|
+
counts.directories,
|
|
1336
|
+
pending ? "listing" : "listed",
|
|
1337
|
+
"directory"
|
|
1338
|
+
),
|
|
1339
|
+
activityPhrase(counts.searches, pending ? "searching" : "searched", "path"),
|
|
1340
|
+
activityPhrase(counts.shells, pending ? "running" : "ran", "shell command"),
|
|
1341
|
+
activityPhrase(counts.other, pending ? "using" : "used", "tool")
|
|
1342
|
+
].filter((phrase) => !!phrase);
|
|
1343
|
+
if (phrases.length === 0) return pending ? "Working" : "Finished";
|
|
1344
|
+
return phrases.map(
|
|
1345
|
+
(phrase, index) => index === 0 ? phrase[0].toUpperCase() + phrase.slice(1) : phrase
|
|
1346
|
+
).join(", ");
|
|
1347
|
+
}
|
|
1348
|
+
function activityPhrase(count, verb, noun) {
|
|
1349
|
+
if (count === 0) return null;
|
|
1350
|
+
const plural = noun === "directory" ? "directories" : `${noun}s`;
|
|
1351
|
+
return `${verb} ${count} ${count === 1 ? noun : plural}`;
|
|
1352
|
+
}
|
|
1353
|
+
function activityDetails(calls, limit = 3) {
|
|
1354
|
+
return calls.flatMap(callActivityDetails).map(sanitizeTerminalText).filter(Boolean).slice(-limit);
|
|
1355
|
+
}
|
|
1356
|
+
function callActivityDetails(call) {
|
|
1357
|
+
if (call.status === "failed") {
|
|
1358
|
+
const output = outputLines(call.output).slice(-3);
|
|
1359
|
+
if (output.length === 0) return ["Error: Tool failed."];
|
|
1360
|
+
return output.map((line, index) => index === 0 ? `Error: ${line}` : line);
|
|
1361
|
+
}
|
|
1362
|
+
if (call.name === "run_command" || call.name === "run_package_script") {
|
|
1363
|
+
const output = outputLines(call.output);
|
|
1364
|
+
if (output.length > 0) return output;
|
|
1365
|
+
const command = typeof call.args.command === "string" ? call.args.command : typeof call.args.script === "string" ? `npm run ${call.args.script}` : call.name;
|
|
1366
|
+
return [`$ ${command}`];
|
|
1367
|
+
}
|
|
1368
|
+
const path = typeof call.args.path === "string" ? call.args.path : null;
|
|
1369
|
+
return [path ?? call.name];
|
|
1370
|
+
}
|
|
1371
|
+
function outputLines(output) {
|
|
1372
|
+
return (output ?? "").split(/\r?\n|\r/).map((line) => sanitizeTerminalText(line.trimEnd())).filter(Boolean);
|
|
1373
|
+
}
|
|
1374
|
+
function sanitizeTerminalText(value) {
|
|
1375
|
+
return value.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "").replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "");
|
|
1376
|
+
}
|
|
1377
|
+
function changedLines(oldText, newText, path) {
|
|
1378
|
+
const language = languageFromPath(path);
|
|
1379
|
+
const oldHighlighted = highlightLines(oldText, language);
|
|
1380
|
+
const newHighlighted = highlightLines(newText, language);
|
|
1381
|
+
let oldIndex = 0;
|
|
1382
|
+
let newIndex = 0;
|
|
1383
|
+
const result = [];
|
|
1384
|
+
for (const change of diffLines(oldText, newText)) {
|
|
1385
|
+
const value = change.value.endsWith("\n") ? change.value.slice(0, -1) : change.value;
|
|
1386
|
+
const plainLines = value.split("\n");
|
|
1387
|
+
if (change.added) {
|
|
1388
|
+
plainLines.forEach((plain, index) => {
|
|
1389
|
+
const text = newHighlighted[newIndex + index] ?? plain;
|
|
1390
|
+
result.push({ kind: "added", text: text.replace(/\r$/, "") });
|
|
1391
|
+
});
|
|
1392
|
+
newIndex += plainLines.length;
|
|
1393
|
+
} else if (change.removed) {
|
|
1394
|
+
plainLines.forEach((plain, index) => {
|
|
1395
|
+
const text = oldHighlighted[oldIndex + index] ?? plain;
|
|
1396
|
+
result.push({ kind: "removed", text: text.replace(/\r$/, "") });
|
|
1397
|
+
});
|
|
1398
|
+
oldIndex += plainLines.length;
|
|
1399
|
+
} else {
|
|
1400
|
+
oldIndex += plainLines.length;
|
|
1401
|
+
newIndex += plainLines.length;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
return result;
|
|
1405
|
+
}
|
|
1406
|
+
function formatToolCallDetails(call) {
|
|
1407
|
+
const rows = [
|
|
1408
|
+
`${call.name} [${call.status}]`,
|
|
1409
|
+
`kind: ${call.kind}`,
|
|
1410
|
+
`mutating: ${call.mutating ? "yes" : "no"}`,
|
|
1411
|
+
"",
|
|
1412
|
+
"Arguments:",
|
|
1413
|
+
JSON.stringify(call.args, null, 2)
|
|
1414
|
+
];
|
|
1415
|
+
if (call.diff) {
|
|
1416
|
+
rows.push("", `Diff: ${call.diff.path}`);
|
|
1417
|
+
for (const line of changedLines(
|
|
1418
|
+
call.diff.oldText ?? "",
|
|
1419
|
+
call.diff.newText,
|
|
1420
|
+
call.diff.path
|
|
1421
|
+
)) {
|
|
1422
|
+
rows.push(`${line.kind === "added" ? "+" : "-"} ${line.text}`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if (call.output) rows.push("", "Output:", call.output);
|
|
1426
|
+
return rows.join("\n");
|
|
1427
|
+
}
|
|
1428
|
+
function formatContextUsage(usage) {
|
|
1429
|
+
const rows = [
|
|
1430
|
+
["System", usage.categories.system],
|
|
1431
|
+
["Conversation", usage.categories.conversation],
|
|
1432
|
+
["Agents", usage.categories.agents],
|
|
1433
|
+
["Thinking", usage.categories.thinking],
|
|
1434
|
+
["Tools", usage.categories.tools],
|
|
1435
|
+
["Skills", usage.categories.skills],
|
|
1436
|
+
["Memory", usage.categories.memory]
|
|
1437
|
+
];
|
|
1438
|
+
const lines = rows.map(([label, tokens]) => {
|
|
1439
|
+
const share = usage.totalTokens ? `${(tokens / usage.totalTokens * 100).toFixed(1)}%` : "0.0%";
|
|
1440
|
+
return `${label.padEnd(14)} ${tokens.toLocaleString("en-US").padStart(10)} ${share.padStart(6)}`;
|
|
1441
|
+
});
|
|
1442
|
+
const total = usage.contextWindow ? `${usage.totalTokens.toLocaleString("en-US")} / ${usage.contextWindow.toLocaleString("en-US")} (${usage.percentUsed?.toFixed(1) ?? "0.0"}%)` : usage.totalTokens.toLocaleString("en-US");
|
|
1443
|
+
const remaining = usage.remainingTokens === null ? null : `Remaining ${usage.remainingTokens.toLocaleString("en-US").padStart(10)}`;
|
|
1444
|
+
return [
|
|
1445
|
+
"Estimated tokens in the next model request",
|
|
1446
|
+
"",
|
|
1447
|
+
...lines,
|
|
1448
|
+
"",
|
|
1449
|
+
`Total ${total}`,
|
|
1450
|
+
...remaining ? [remaining] : [],
|
|
1451
|
+
"",
|
|
1452
|
+
`Footer: ctx:${formatTokenCount(usage.totalTokens)}${usage.contextWindow ? `/${formatTokenCount(usage.contextWindow)}` : ""}`,
|
|
1453
|
+
"Counts are estimates because Nova models can use different tokenizers."
|
|
1454
|
+
].join("\n");
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// src/tui/ink/components/status-line.tsx
|
|
1458
|
+
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
1459
|
+
function StatusLine({
|
|
1460
|
+
ui,
|
|
1461
|
+
model,
|
|
1462
|
+
mcp
|
|
1463
|
+
}) {
|
|
1464
|
+
const separator = /* @__PURE__ */ jsx2(Text2, { color: palette.faint, children: " | " });
|
|
1465
|
+
const usage = ui.contextUsage ? `ctx:${formatTokenCount(ui.contextUsage.totalTokens)}${ui.contextUsage.contextWindow ? `/${formatTokenCount(ui.contextUsage.contextWindow)}` : ""}` : null;
|
|
1466
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
|
|
1467
|
+
ui.updateAvailable ? /* @__PURE__ */ jsx2(Text2, { color: palette.warning, children: `Update ${ui.updateAvailable.latestVersion} available (current ${ui.updateAvailable.currentVersion}). Run: ${ui.updateAvailable.command}` }) : null,
|
|
1468
|
+
ui.statusLine ? /* @__PURE__ */ jsx2(Text2, { color: palette.muted, children: ui.statusLine }) : null,
|
|
1469
|
+
/* @__PURE__ */ jsxs2(Text2, { wrap: "truncate-end", children: [
|
|
1470
|
+
/* @__PURE__ */ jsx2(Text2, { color: palette.muted, children: model }),
|
|
1471
|
+
separator,
|
|
1472
|
+
ui.interactionMode,
|
|
1473
|
+
separator,
|
|
1474
|
+
ui.permissionMode,
|
|
1475
|
+
separator,
|
|
1476
|
+
ui.busy ? /* @__PURE__ */ jsx2(Text2, { color: palette.warning, children: "working" }) : /* @__PURE__ */ jsx2(Text2, { color: palette.success, children: "ready" }),
|
|
1477
|
+
usage ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
1478
|
+
separator,
|
|
1479
|
+
usage
|
|
1480
|
+
] }) : null,
|
|
1481
|
+
ui.queuedCount ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
1482
|
+
separator,
|
|
1483
|
+
`queued:${ui.queuedCount}`
|
|
1484
|
+
] }) : null,
|
|
1485
|
+
mcp.configured ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
1486
|
+
separator,
|
|
1487
|
+
`mcp:${mcp.connected}/${mcp.configured}`,
|
|
1488
|
+
mcp.failed ? /* @__PURE__ */ jsx2(Text2, { color: palette.danger, children: ` !${mcp.failed}` }) : null
|
|
1489
|
+
] }) : null,
|
|
1490
|
+
mcp.skills ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
1491
|
+
separator,
|
|
1492
|
+
`skills:${mcp.skills}`
|
|
1493
|
+
] }) : null
|
|
1494
|
+
] })
|
|
1495
|
+
] });
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// src/tui/ink/components/transcript.tsx
|
|
1499
|
+
import { Box as Box4, Text as Text4 } from "ink";
|
|
1500
|
+
|
|
1501
|
+
// src/tui/ink/components/markdown.tsx
|
|
1502
|
+
import chalk2 from "chalk";
|
|
1503
|
+
import { Box as Box3, Text as Text3 } from "ink";
|
|
1504
|
+
import { marked } from "marked";
|
|
1505
|
+
import stringWidth from "string-width";
|
|
1506
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
1507
|
+
function Markdown({ text }) {
|
|
1508
|
+
const tokens = marked.lexer(text, { gfm: true });
|
|
1509
|
+
return /* @__PURE__ */ jsx3(Blocks, { tokens });
|
|
1510
|
+
}
|
|
1511
|
+
function Blocks({ tokens, tight = false }) {
|
|
1512
|
+
const blocks = tokens.filter((token) => token.type !== "space");
|
|
1513
|
+
return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: blocks.map((token, index) => /* @__PURE__ */ jsx3(Box3, { marginTop: index > 0 && !tight ? 1 : 0, children: /* @__PURE__ */ jsx3(Block, { token }) }, index)) });
|
|
1514
|
+
}
|
|
1515
|
+
function Block({ token }) {
|
|
1516
|
+
switch (token.type) {
|
|
1517
|
+
case "heading": {
|
|
1518
|
+
const heading = token;
|
|
1519
|
+
return /* @__PURE__ */ jsx3(Text3, { bold: true, color: palette.primary, children: inline(heading.tokens) });
|
|
1520
|
+
}
|
|
1521
|
+
case "paragraph":
|
|
1522
|
+
return /* @__PURE__ */ jsx3(Text3, { children: inline(token.tokens) });
|
|
1523
|
+
case "text": {
|
|
1524
|
+
const text = token;
|
|
1525
|
+
return /* @__PURE__ */ jsx3(Text3, { children: text.tokens ? inline(text.tokens) : text.text });
|
|
1526
|
+
}
|
|
1527
|
+
case "code":
|
|
1528
|
+
return /* @__PURE__ */ jsx3(CodeBlock, { code: token });
|
|
1529
|
+
case "list":
|
|
1530
|
+
return /* @__PURE__ */ jsx3(List, { list: token });
|
|
1531
|
+
case "blockquote":
|
|
1532
|
+
return /* @__PURE__ */ jsx3(
|
|
1533
|
+
Box3,
|
|
1534
|
+
{
|
|
1535
|
+
borderStyle: "bold",
|
|
1536
|
+
borderColor: palette.accent,
|
|
1537
|
+
borderTop: false,
|
|
1538
|
+
borderRight: false,
|
|
1539
|
+
borderBottom: false,
|
|
1540
|
+
paddingLeft: 1,
|
|
1541
|
+
children: /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: /* @__PURE__ */ jsx3(Blocks, { tokens: token.tokens }) })
|
|
1542
|
+
}
|
|
1543
|
+
);
|
|
1544
|
+
case "hr":
|
|
1545
|
+
return /* @__PURE__ */ jsx3(
|
|
1546
|
+
Box3,
|
|
1547
|
+
{
|
|
1548
|
+
flexGrow: 1,
|
|
1549
|
+
borderStyle: "single",
|
|
1550
|
+
borderColor: palette.faint,
|
|
1551
|
+
borderTop: true,
|
|
1552
|
+
borderRight: false,
|
|
1553
|
+
borderBottom: false,
|
|
1554
|
+
borderLeft: false
|
|
1555
|
+
}
|
|
1556
|
+
);
|
|
1557
|
+
case "table":
|
|
1558
|
+
return /* @__PURE__ */ jsx3(Table, { table: token });
|
|
1559
|
+
case "html":
|
|
1560
|
+
return /* @__PURE__ */ jsx3(Text3, { children: token.text.trimEnd() });
|
|
1561
|
+
default:
|
|
1562
|
+
return "raw" in token ? /* @__PURE__ */ jsx3(Text3, { children: String(token.raw).trimEnd() }) : null;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
function CodeBlock({ code }) {
|
|
1566
|
+
const language = supportedLanguage(code.lang);
|
|
1567
|
+
const lines = highlightLines(code.text, language);
|
|
1568
|
+
return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", paddingLeft: 2, children: lines.map((line, index) => /* @__PURE__ */ jsx3(Text3, { children: language ? line : chalk2.white(line) }, index)) });
|
|
1569
|
+
}
|
|
1570
|
+
function List({ list }) {
|
|
1571
|
+
const start = typeof list.start === "number" ? list.start : 1;
|
|
1572
|
+
return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: list.items.map((item, index) => {
|
|
1573
|
+
const bullet = list.ordered ? `${start + index}.` : "\u2022";
|
|
1574
|
+
const checkbox = item.task ? item.checked ? "[x] " : "[ ] " : "";
|
|
1575
|
+
const content = item.tokens.filter((token) => token.type !== "checkbox");
|
|
1576
|
+
return /* @__PURE__ */ jsxs3(Box3, { marginTop: index > 0 && list.loose ? 1 : 0, children: [
|
|
1577
|
+
/* @__PURE__ */ jsx3(Box3, { flexShrink: 0, children: /* @__PURE__ */ jsx3(Text3, { color: palette.accent, children: `${bullet} ` }) }),
|
|
1578
|
+
/* @__PURE__ */ jsx3(Box3, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: checkbox ? /* @__PURE__ */ jsxs3(Box3, { children: [
|
|
1579
|
+
/* @__PURE__ */ jsx3(Box3, { flexShrink: 0, children: /* @__PURE__ */ jsx3(Text3, { color: item.checked ? palette.success : palette.faint, children: checkbox }) }),
|
|
1580
|
+
/* @__PURE__ */ jsx3(Box3, { flexGrow: 1, flexShrink: 1, children: /* @__PURE__ */ jsx3(Blocks, { tokens: content, tight: !list.loose }) })
|
|
1581
|
+
] }) : /* @__PURE__ */ jsx3(Blocks, { tokens: content, tight: !list.loose }) })
|
|
1582
|
+
] }, index);
|
|
1583
|
+
}) });
|
|
1584
|
+
}
|
|
1585
|
+
function Table({ table }) {
|
|
1586
|
+
const header = table.header.map((cell) => chalk2.bold(inline(cell.tokens)));
|
|
1587
|
+
const rows = table.rows.map((row) => row.map((cell) => inline(cell.tokens)));
|
|
1588
|
+
const widths = header.map(
|
|
1589
|
+
(cell, column) => Math.max(
|
|
1590
|
+
stringWidth(cell),
|
|
1591
|
+
...rows.map((row) => stringWidth(row[column] ?? ""))
|
|
1592
|
+
)
|
|
1593
|
+
);
|
|
1594
|
+
const pad = (value, column) => {
|
|
1595
|
+
const space = Math.max(0, widths[column] - stringWidth(value));
|
|
1596
|
+
const align = table.align[column];
|
|
1597
|
+
if (align === "right") return " ".repeat(space) + value;
|
|
1598
|
+
if (align === "center") {
|
|
1599
|
+
const left = Math.floor(space / 2);
|
|
1600
|
+
return " ".repeat(left) + value + " ".repeat(space - left);
|
|
1601
|
+
}
|
|
1602
|
+
return value + " ".repeat(space);
|
|
1603
|
+
};
|
|
1604
|
+
const line = (cells) => cells.map((cell, column) => pad(cell, column)).join(colors.faint(" \u2502 "));
|
|
1605
|
+
const separator = colors.faint(
|
|
1606
|
+
widths.map((width) => "\u2500".repeat(width)).join("\u2500\u253C\u2500")
|
|
1607
|
+
);
|
|
1608
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
|
|
1609
|
+
/* @__PURE__ */ jsx3(Text3, { children: line(header) }),
|
|
1610
|
+
/* @__PURE__ */ jsx3(Text3, { children: separator }),
|
|
1611
|
+
rows.map((row, index) => /* @__PURE__ */ jsx3(Text3, { children: line(row) }, index))
|
|
1612
|
+
] });
|
|
1613
|
+
}
|
|
1614
|
+
function inline(tokens) {
|
|
1615
|
+
if (!tokens) return "";
|
|
1616
|
+
return tokens.map((token) => {
|
|
1617
|
+
switch (token.type) {
|
|
1618
|
+
case "strong":
|
|
1619
|
+
return chalk2.bold(inline(token.tokens));
|
|
1620
|
+
case "em":
|
|
1621
|
+
return chalk2.italic(inline(token.tokens));
|
|
1622
|
+
case "del":
|
|
1623
|
+
return chalk2.strikethrough(inline(token.tokens));
|
|
1624
|
+
case "codespan":
|
|
1625
|
+
return colors.code(token.text);
|
|
1626
|
+
case "br":
|
|
1627
|
+
return "\n";
|
|
1628
|
+
case "link": {
|
|
1629
|
+
const link = token;
|
|
1630
|
+
const label = inline(link.tokens);
|
|
1631
|
+
const plain = link.text.replace(/^mailto:/, "");
|
|
1632
|
+
return plain === link.href || label === link.href ? colors.primary(chalk2.underline(link.href)) : `${colors.primary(chalk2.underline(label))} ${colors.faint(`(${link.href})`)}`;
|
|
1633
|
+
}
|
|
1634
|
+
case "image": {
|
|
1635
|
+
const image = token;
|
|
1636
|
+
return colors.faint(`[image: ${image.text || image.href}]`);
|
|
1637
|
+
}
|
|
1638
|
+
case "text": {
|
|
1639
|
+
const text = token;
|
|
1640
|
+
return text.tokens ? inline(text.tokens) : text.text;
|
|
1641
|
+
}
|
|
1642
|
+
case "escape":
|
|
1643
|
+
return token.text;
|
|
1644
|
+
case "html":
|
|
1645
|
+
return token.text;
|
|
1646
|
+
default:
|
|
1647
|
+
return "text" in token ? String(token.text) : String(token.raw ?? "");
|
|
1648
|
+
}
|
|
1649
|
+
}).join("");
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// src/tui/ink/components/transcript.tsx
|
|
1653
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
1654
|
+
var COLLAPSED_DIFF_LINES = 40;
|
|
1655
|
+
function TranscriptBlockView({ block, frame = 0 }) {
|
|
1656
|
+
switch (block.kind) {
|
|
1657
|
+
case "header":
|
|
1658
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingLeft: 2, children: [
|
|
1659
|
+
/* @__PURE__ */ jsx4(Text4, { color: palette.primary, children: `${ASSISTANT_MARKER} Nova AI` }),
|
|
1660
|
+
/* @__PURE__ */ jsx4(Text4, { color: palette.muted, children: `Workspace: ${block.cwd}` }),
|
|
1661
|
+
/* @__PURE__ */ jsx4(Text4, { color: palette.faint, children: "Ask a question, use @file, or type /help." })
|
|
1662
|
+
] });
|
|
1663
|
+
case "user":
|
|
1664
|
+
return /* @__PURE__ */ jsx4(Prefixed, { prefix: "> ", color: palette.primary, children: /* @__PURE__ */ jsx4(Text4, { color: palette.primary, children: block.text }) });
|
|
1665
|
+
case "queued": {
|
|
1666
|
+
const label = block.queued === "steer" ? "steer" : "queued";
|
|
1667
|
+
return /* @__PURE__ */ jsx4(Prefixed, { prefix: `${label}> `, color: palette.warning, children: /* @__PURE__ */ jsx4(Text4, { color: palette.muted, children: block.text || "(empty; press Enter to remove from queue)" }) });
|
|
1668
|
+
}
|
|
1669
|
+
case "assistant": {
|
|
1670
|
+
const text = block.first ? block.text.replace(/^\s+/, "") : block.text;
|
|
1671
|
+
return /* @__PURE__ */ jsx4(Prefixed, { prefix: block.first ? `${ASSISTANT_MARKER} ` : " ", color: palette.accent, children: /* @__PURE__ */ jsx4(Markdown, { text: text.trimEnd() }) });
|
|
1672
|
+
}
|
|
1673
|
+
case "error":
|
|
1674
|
+
return /* @__PURE__ */ jsx4(Prefixed, { prefix: "! ", color: palette.danger, children: /* @__PURE__ */ jsx4(Text4, { color: palette.danger, children: block.text }) });
|
|
1675
|
+
case "tool":
|
|
1676
|
+
return /* @__PURE__ */ jsx4(ToolCallBlock, { call: block.call, expanded: block.expanded, frame });
|
|
1677
|
+
case "activity":
|
|
1678
|
+
return /* @__PURE__ */ jsx4(ActivityBlock, { calls: block.calls, frame });
|
|
1679
|
+
case "background": {
|
|
1680
|
+
const { job } = block;
|
|
1681
|
+
const color = job.status === "failed" || job.status === "killed" ? palette.danger : job.status === "completed" ? palette.success : palette.warning;
|
|
1682
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingLeft: 1, children: [
|
|
1683
|
+
/* @__PURE__ */ jsxs4(Text4, { children: [
|
|
1684
|
+
/* @__PURE__ */ jsx4(Text4, { color: palette.accent, children: "background" }),
|
|
1685
|
+
` ${job.kind}: ${job.title} [`,
|
|
1686
|
+
/* @__PURE__ */ jsx4(Text4, { color, children: job.status }),
|
|
1687
|
+
"]"
|
|
1688
|
+
] }),
|
|
1689
|
+
block.expanded && job.preview ? /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { color: palette.muted, children: job.preview }) }) : null
|
|
1690
|
+
] });
|
|
1691
|
+
}
|
|
1692
|
+
case "plan":
|
|
1693
|
+
return /* @__PURE__ */ jsx4(PlanView, { entries: block.entries, frame });
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
function Prefixed({
|
|
1697
|
+
prefix,
|
|
1698
|
+
color,
|
|
1699
|
+
children
|
|
1700
|
+
}) {
|
|
1701
|
+
return /* @__PURE__ */ jsxs4(Box4, { paddingLeft: 1, children: [
|
|
1702
|
+
/* @__PURE__ */ jsx4(Box4, { flexShrink: 0, children: /* @__PURE__ */ jsx4(Text4, { color, children: prefix }) }),
|
|
1703
|
+
/* @__PURE__ */ jsx4(Box4, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children })
|
|
1704
|
+
] });
|
|
1705
|
+
}
|
|
1706
|
+
function StatusIcon({
|
|
1707
|
+
status,
|
|
1708
|
+
frame = 0
|
|
1709
|
+
}) {
|
|
1710
|
+
if (status === "pending")
|
|
1711
|
+
return /* @__PURE__ */ jsx4(Text4, { color: palette.warning, children: WORKING_FRAMES[frame % WORKING_FRAMES.length] });
|
|
1712
|
+
if (status === "failed") return /* @__PURE__ */ jsx4(Text4, { color: palette.danger, children: "!" });
|
|
1713
|
+
return /* @__PURE__ */ jsx4(Text4, { color: palette.success, children: "\u2713" });
|
|
1714
|
+
}
|
|
1715
|
+
function ToolCallBlock({
|
|
1716
|
+
call,
|
|
1717
|
+
expanded,
|
|
1718
|
+
frame = 0
|
|
1719
|
+
}) {
|
|
1720
|
+
const summary = toolCallSummary(call);
|
|
1721
|
+
const failure = outputLines(call.output).slice(-3);
|
|
1722
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingLeft: 1, children: [
|
|
1723
|
+
/* @__PURE__ */ jsxs4(Text4, { children: [
|
|
1724
|
+
/* @__PURE__ */ jsx4(StatusIcon, { status: call.status, frame }),
|
|
1725
|
+
" ",
|
|
1726
|
+
/* @__PURE__ */ jsx4(Text4, { color: palette.accent, children: call.name }),
|
|
1727
|
+
call.mutating ? /* @__PURE__ */ jsx4(Text4, { color: palette.warning, children: " (changes)" }) : null,
|
|
1728
|
+
summary ? `: ${summary}` : ""
|
|
1729
|
+
] }),
|
|
1730
|
+
call.status === "failed" ? /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { color: palette.danger, children: failure.length ? failure.join("\n") : "Tool failed." }) }) : call.diff ? /* @__PURE__ */ jsx4(
|
|
1731
|
+
DiffView,
|
|
1732
|
+
{
|
|
1733
|
+
oldText: call.diff.oldText ?? "",
|
|
1734
|
+
newText: call.diff.newText,
|
|
1735
|
+
path: call.diff.path,
|
|
1736
|
+
limit: expanded ? void 0 : COLLAPSED_DIFF_LINES
|
|
1737
|
+
}
|
|
1738
|
+
) : expanded && !isCompactTool(call) ? /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { color: palette.muted, children: formatToolCallDetails(call) }) }) : null
|
|
1739
|
+
] });
|
|
1740
|
+
}
|
|
1741
|
+
function ActivityBlock({
|
|
1742
|
+
calls,
|
|
1743
|
+
frame = 0
|
|
1744
|
+
}) {
|
|
1745
|
+
const status = activityStatus(calls);
|
|
1746
|
+
const pending = status === "pending";
|
|
1747
|
+
const details = activityDetails(calls);
|
|
1748
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingLeft: 1, children: [
|
|
1749
|
+
/* @__PURE__ */ jsxs4(Text4, { children: [
|
|
1750
|
+
/* @__PURE__ */ jsx4(StatusIcon, { status, frame }),
|
|
1751
|
+
" ",
|
|
1752
|
+
/* @__PURE__ */ jsx4(Text4, { color: palette.accent, children: activitySummary(calls, pending) }),
|
|
1753
|
+
pending ? /* @__PURE__ */ jsx4(Text4, { color: palette.faint, children: "\u2026" }) : null
|
|
1754
|
+
] }),
|
|
1755
|
+
details.map((detail, index) => /* @__PURE__ */ jsxs4(Box4, { children: [
|
|
1756
|
+
/* @__PURE__ */ jsx4(Box4, { flexShrink: 0, children: /* @__PURE__ */ jsx4(Text4, { color: palette.faint, children: index === 0 ? " \u23BF " : " " }) }),
|
|
1757
|
+
/* @__PURE__ */ jsx4(Box4, { flexGrow: 1, flexShrink: 1, children: /* @__PURE__ */ jsx4(Text4, { color: palette.muted, wrap: "truncate-end", children: detail }) })
|
|
1758
|
+
] }, index))
|
|
1759
|
+
] });
|
|
1760
|
+
}
|
|
1761
|
+
function DiffView({
|
|
1762
|
+
oldText,
|
|
1763
|
+
newText,
|
|
1764
|
+
path,
|
|
1765
|
+
limit
|
|
1766
|
+
}) {
|
|
1767
|
+
const changes = changedLines(oldText, newText, path);
|
|
1768
|
+
if (changes.length === 0)
|
|
1769
|
+
return /* @__PURE__ */ jsx4(Box4, { paddingLeft: 3, children: /* @__PURE__ */ jsx4(Text4, { color: palette.faint, children: "no textual changes" }) });
|
|
1770
|
+
const shown = limit === void 0 ? changes : changes.slice(0, limit);
|
|
1771
|
+
const hidden = changes.length - shown.length;
|
|
1772
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
|
|
1773
|
+
shown.map((line, index) => /* @__PURE__ */ jsxs4(Box4, { children: [
|
|
1774
|
+
/* @__PURE__ */ jsx4(Box4, { flexShrink: 0, children: /* @__PURE__ */ jsxs4(Text4, { children: [
|
|
1775
|
+
" ",
|
|
1776
|
+
/* @__PURE__ */ jsx4(Text4, { color: line.kind === "added" ? palette.success : palette.danger, children: line.kind === "added" ? "+" : "-" }),
|
|
1777
|
+
" "
|
|
1778
|
+
] }) }),
|
|
1779
|
+
/* @__PURE__ */ jsx4(
|
|
1780
|
+
Box4,
|
|
1781
|
+
{
|
|
1782
|
+
flexGrow: 1,
|
|
1783
|
+
flexShrink: 1,
|
|
1784
|
+
backgroundColor: line.kind === "added" ? palette.diffAddBg : palette.diffRemoveBg,
|
|
1785
|
+
children: /* @__PURE__ */ jsx4(Text4, { children: line.text || " " })
|
|
1786
|
+
}
|
|
1787
|
+
)
|
|
1788
|
+
] }, index)),
|
|
1789
|
+
hidden > 0 ? /* @__PURE__ */ jsx4(Box4, { paddingLeft: 5, children: /* @__PURE__ */ jsx4(Text4, { color: palette.faint, children: `\u2026 ${hidden} more changed line${hidden === 1 ? "" : "s"} (ctrl+o to expand, ctrl+t to inspect)` }) }) : null
|
|
1790
|
+
] });
|
|
1791
|
+
}
|
|
1792
|
+
function PlanView({
|
|
1793
|
+
entries,
|
|
1794
|
+
frame = 0
|
|
1795
|
+
}) {
|
|
1796
|
+
const completed = entries.filter((entry) => entry.status === "completed").length;
|
|
1797
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingLeft: 1, children: [
|
|
1798
|
+
/* @__PURE__ */ jsxs4(Text4, { children: [
|
|
1799
|
+
/* @__PURE__ */ jsx4(Text4, { color: palette.accent, children: "Tasks" }),
|
|
1800
|
+
/* @__PURE__ */ jsx4(Text4, { color: palette.muted, children: ` (${completed}/${entries.length})` })
|
|
1801
|
+
] }),
|
|
1802
|
+
entries.map((entry, index) => /* @__PURE__ */ jsxs4(Box4, { children: [
|
|
1803
|
+
/* @__PURE__ */ jsx4(Box4, { flexShrink: 0, children: entry.status === "completed" ? /* @__PURE__ */ jsx4(Text4, { color: palette.success, children: "[\u2713] " }) : entry.status === "in_progress" ? /* @__PURE__ */ jsx4(Text4, { color: palette.warning, children: `[${WORKING_FRAMES[frame % WORKING_FRAMES.length]}] ` }) : /* @__PURE__ */ jsx4(Text4, { color: palette.faint, children: "[ ] " }) }),
|
|
1804
|
+
/* @__PURE__ */ jsx4(Box4, { flexGrow: 1, flexShrink: 1, children: /* @__PURE__ */ jsx4(Text4, { color: entry.status === "in_progress" ? palette.primary : palette.muted, children: entry.content }) })
|
|
1805
|
+
] }, index))
|
|
1806
|
+
] });
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// src/tui/ink/controller.ts
|
|
1810
|
+
import { setWorkspaceTrusted } from "@datalabrotterdam/nova-ai-agent/core/nova-home.js";
|
|
1811
|
+
import { INTERACTION_MODES } from "@datalabrotterdam/nova-ai-agent/core/interaction-modes.js";
|
|
1812
|
+
|
|
1813
|
+
// src/tui/commands/builtins.ts
|
|
1814
|
+
import { isInteractionMode } from "@datalabrotterdam/nova-ai-agent/core/interaction-modes.js";
|
|
1815
|
+
var permissionModes = ["ask", "acceptEdits", "bypassAll"];
|
|
1816
|
+
var builtinCommands = [
|
|
1817
|
+
{
|
|
1818
|
+
name: "help",
|
|
1819
|
+
description: "Show available commands",
|
|
1820
|
+
run(ctx) {
|
|
1821
|
+
const lines = builtinCommands.map((c) => `/${c.name} \u2014 ${c.description}`);
|
|
1822
|
+
ctx.print(lines.join("\n"));
|
|
1823
|
+
}
|
|
1824
|
+
},
|
|
1825
|
+
{
|
|
1826
|
+
name: "clear",
|
|
1827
|
+
description: "Start a new session (clears transcript and conversation history)",
|
|
1828
|
+
run(ctx) {
|
|
1829
|
+
return ctx.newSession();
|
|
1830
|
+
}
|
|
1831
|
+
},
|
|
1832
|
+
{
|
|
1833
|
+
name: "compact",
|
|
1834
|
+
description: "Summarize older messages to reduce the model context",
|
|
1835
|
+
async run(ctx) {
|
|
1836
|
+
const result = await ctx.compactContext();
|
|
1837
|
+
ctx.print(
|
|
1838
|
+
result.compacted ? `Compacted context: summarized ${result.removedMessages} older messages and kept ${result.keptMessages} recent messages.` : "Context is already compact; there are not enough older messages to summarize."
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
},
|
|
1842
|
+
{
|
|
1843
|
+
name: "rewind",
|
|
1844
|
+
description: "Rewind completed conversation turns (/rewind [count])",
|
|
1845
|
+
async run(ctx, args) {
|
|
1846
|
+
const value = args.trim();
|
|
1847
|
+
const turns = value ? Number(value) : 1;
|
|
1848
|
+
if (!Number.isSafeInteger(turns) || turns < 1) {
|
|
1849
|
+
ctx.print("Usage: /rewind [positive-turn-count]");
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
const result = await ctx.rewind(turns);
|
|
1853
|
+
const labels = result.removedCheckpoints.map((checkpoint) => checkpoint.userText).join(" | ");
|
|
1854
|
+
ctx.print(
|
|
1855
|
+
`Rewound ${result.removedCheckpoints.length} turn${result.removedCheckpoints.length === 1 ? "" : "s"}${labels ? `: ${labels}` : "."}`
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
},
|
|
1859
|
+
{
|
|
1860
|
+
name: "resume",
|
|
1861
|
+
description: "Resume the latest session, or /resume <session-id>",
|
|
1862
|
+
run(ctx, args) {
|
|
1863
|
+
const sessionId = args.trim() || void 0;
|
|
1864
|
+
if (!ctx.resumeSession(sessionId)) {
|
|
1865
|
+
ctx.print(
|
|
1866
|
+
sessionId ? `Session not found: ${sessionId}` : "No saved sessions for this workspace."
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
},
|
|
1871
|
+
{
|
|
1872
|
+
name: "session",
|
|
1873
|
+
description: "Browse and switch sessions",
|
|
1874
|
+
run(ctx) {
|
|
1875
|
+
ctx.openSessionSwitcher();
|
|
1876
|
+
}
|
|
1877
|
+
},
|
|
1878
|
+
{
|
|
1879
|
+
name: "sessions",
|
|
1880
|
+
description: "Alias for /session",
|
|
1881
|
+
run(ctx) {
|
|
1882
|
+
ctx.openSessionSwitcher();
|
|
1883
|
+
}
|
|
1884
|
+
},
|
|
1885
|
+
{
|
|
1886
|
+
name: "exit",
|
|
1887
|
+
description: "Exit nova-ai-cli",
|
|
1888
|
+
run(ctx) {
|
|
1889
|
+
ctx.exit();
|
|
1890
|
+
}
|
|
1891
|
+
},
|
|
1892
|
+
{
|
|
1893
|
+
name: "mode",
|
|
1894
|
+
description: "Switch interaction mode (/mode agent|ask|plan)",
|
|
1895
|
+
run(ctx, args) {
|
|
1896
|
+
const mode = args.trim();
|
|
1897
|
+
if (!mode) {
|
|
1898
|
+
ctx.print(`Current mode: ${ctx.getInteractionMode()}`);
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
if (!isInteractionMode(mode)) {
|
|
1902
|
+
ctx.print("Usage: /mode agent|ask|plan");
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
ctx.setInteractionMode(mode);
|
|
1906
|
+
}
|
|
1907
|
+
},
|
|
1908
|
+
{
|
|
1909
|
+
name: "permission",
|
|
1910
|
+
description: "Switch permission mode (/permission ask|acceptEdits|bypassAll)",
|
|
1911
|
+
run(ctx, args) {
|
|
1912
|
+
const mode = args.trim();
|
|
1913
|
+
if (!mode) {
|
|
1914
|
+
ctx.openPermissionPicker();
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
if (!permissionModes.includes(mode)) {
|
|
1918
|
+
ctx.print("Usage: /permission ask|acceptEdits|bypassAll");
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
ctx.setPermissionMode(mode);
|
|
1922
|
+
}
|
|
1923
|
+
},
|
|
1924
|
+
{
|
|
1925
|
+
name: "trust",
|
|
1926
|
+
description: "Trust this workspace: start the MCP servers it declares and apply its .nova-ai allow rules",
|
|
1927
|
+
run(ctx) {
|
|
1928
|
+
ctx.print(ctx.trustWorkspace());
|
|
1929
|
+
}
|
|
1930
|
+
},
|
|
1931
|
+
{
|
|
1932
|
+
name: "model",
|
|
1933
|
+
description: "Switch the active model (/model <id>, or no args to pick interactively)",
|
|
1934
|
+
run(ctx, args) {
|
|
1935
|
+
const id = args.trim();
|
|
1936
|
+
if (id) {
|
|
1937
|
+
ctx.setModel(id);
|
|
1938
|
+
ctx.print(`Switched model to ${id}.`);
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
ctx.openModelPicker();
|
|
1942
|
+
}
|
|
1943
|
+
},
|
|
1944
|
+
{
|
|
1945
|
+
name: "tools",
|
|
1946
|
+
description: "Inspect tool calls from this session",
|
|
1947
|
+
run(ctx) {
|
|
1948
|
+
ctx.openToolInspector();
|
|
1949
|
+
}
|
|
1950
|
+
},
|
|
1951
|
+
{
|
|
1952
|
+
name: "mcp",
|
|
1953
|
+
description: "Inspect configured MCP servers",
|
|
1954
|
+
run(ctx) {
|
|
1955
|
+
ctx.openMcpInspector();
|
|
1956
|
+
}
|
|
1957
|
+
},
|
|
1958
|
+
{
|
|
1959
|
+
name: "skills",
|
|
1960
|
+
description: "Inspect discovered user and workspace skills",
|
|
1961
|
+
run(ctx) {
|
|
1962
|
+
ctx.openSkillInspector();
|
|
1963
|
+
}
|
|
1964
|
+
},
|
|
1965
|
+
{
|
|
1966
|
+
name: "usage",
|
|
1967
|
+
description: "Inspect estimated context usage by category",
|
|
1968
|
+
run(ctx) {
|
|
1969
|
+
return ctx.openUsageInspector();
|
|
1970
|
+
}
|
|
1971
|
+
},
|
|
1972
|
+
{
|
|
1973
|
+
name: "queue",
|
|
1974
|
+
description: "Inspect or clear the message queue (/queue [clear])",
|
|
1975
|
+
run(ctx, args) {
|
|
1976
|
+
const value = args.trim();
|
|
1977
|
+
if (value === "clear") {
|
|
1978
|
+
ctx.clearQueuedMessages();
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
if (value) {
|
|
1982
|
+
ctx.print(
|
|
1983
|
+
"Usage: /queue [clear]. Submit text directly to queue it while the model is working."
|
|
1984
|
+
);
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
const queued = ctx.queuedMessages();
|
|
1988
|
+
ctx.print(
|
|
1989
|
+
queued.length ? queued.map((message, index) => `${index + 1}. ${message}`).join("\n") : "Message queue is empty. Submit text while the model is working to queue it."
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
},
|
|
1993
|
+
{
|
|
1994
|
+
name: "steer",
|
|
1995
|
+
description: "Add guidance to the active turn at its next tool boundary (/steer <message>)",
|
|
1996
|
+
run(ctx, args) {
|
|
1997
|
+
const value = args.trim();
|
|
1998
|
+
if (!value) {
|
|
1999
|
+
ctx.print("Usage: /steer <message>");
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
ctx.steerMessage(value);
|
|
2003
|
+
}
|
|
2004
|
+
},
|
|
2005
|
+
{
|
|
2006
|
+
name: "shell",
|
|
2007
|
+
description: "Manage background shell jobs (/shell run|ps|output|kill|release)",
|
|
2008
|
+
run(ctx, args) {
|
|
2009
|
+
return runBackgroundCommand(ctx, "terminal", args, {
|
|
2010
|
+
usage: "Usage: /shell run <command> | /shell ps | /shell output <jobId> | /shell kill <jobId|all> | /shell release <jobId|all>",
|
|
2011
|
+
start: (command) => ctx.startBackgroundShell(command)
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
},
|
|
2015
|
+
{
|
|
2016
|
+
name: "agent",
|
|
2017
|
+
description: "Manage background agent jobs (/agent run|ps|output|kill|release)",
|
|
2018
|
+
run(ctx, args) {
|
|
2019
|
+
return runBackgroundCommand(ctx, "prompt", args, {
|
|
2020
|
+
usage: "Usage: /agent run <prompt> | /agent ps | /agent output <jobId> | /agent kill <jobId|all> | /agent release <jobId|all>",
|
|
2021
|
+
start: (prompt) => ctx.startBackgroundAgent(prompt)
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
];
|
|
2026
|
+
async function runBackgroundCommand(ctx, kind, args, options) {
|
|
2027
|
+
const trimmed = args.trim();
|
|
2028
|
+
const [subcommand, ...rest] = splitCommand(trimmed);
|
|
2029
|
+
const value = rest.join(" ").trim();
|
|
2030
|
+
try {
|
|
2031
|
+
switch (subcommand) {
|
|
2032
|
+
case "run": {
|
|
2033
|
+
if (!value) {
|
|
2034
|
+
ctx.print(options.usage);
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
const job = await options.start(value);
|
|
2038
|
+
ctx.print(
|
|
2039
|
+
`Started background ${kind === "terminal" ? "shell" : "agent"} job.
|
|
2040
|
+
${formatJob(job)}`
|
|
2041
|
+
);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
case "":
|
|
2045
|
+
case "ps":
|
|
2046
|
+
case "list": {
|
|
2047
|
+
const jobs = await ctx.listBackgroundJobs(kind);
|
|
2048
|
+
ctx.print(
|
|
2049
|
+
jobs.length ? jobs.map(formatJob).join("\n\n") : `No background ${kind === "terminal" ? "shell" : "agent"} jobs.`
|
|
2050
|
+
);
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
case "output":
|
|
2054
|
+
case "out": {
|
|
2055
|
+
if (!value) {
|
|
2056
|
+
ctx.print(options.usage);
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
const result = await ctx.backgroundOutput(value);
|
|
2060
|
+
ctx.print(
|
|
2061
|
+
`${formatJob(result.job)}
|
|
2062
|
+
|
|
2063
|
+
${result.output}${result.truncated ? "\n[output truncated]" : ""}`
|
|
2064
|
+
);
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
case "kill":
|
|
2068
|
+
case "stop": {
|
|
2069
|
+
await mutateJobs(ctx, kind, value, "kill");
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
case "release": {
|
|
2073
|
+
await mutateJobs(ctx, kind, value, "release");
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
default:
|
|
2077
|
+
ctx.print(options.usage);
|
|
2078
|
+
}
|
|
2079
|
+
} catch (err) {
|
|
2080
|
+
ctx.print(
|
|
2081
|
+
err instanceof Error ? err.message : "Background command failed."
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
async function mutateJobs(ctx, kind, target, action) {
|
|
2086
|
+
if (!target) {
|
|
2087
|
+
ctx.print(
|
|
2088
|
+
`${action === "kill" ? "Kill" : "Release"} requires a job id or 'all'.`
|
|
2089
|
+
);
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
const jobs = target === "all" ? await ctx.listBackgroundJobs(kind) : (await ctx.listBackgroundJobs(kind)).filter(
|
|
2093
|
+
(job) => job.jobId === target
|
|
2094
|
+
);
|
|
2095
|
+
if (jobs.length === 0) {
|
|
2096
|
+
ctx.print(
|
|
2097
|
+
target === "all" ? `No background ${kind === "terminal" ? "shell" : "agent"} jobs.` : `Background job not found: ${target}`
|
|
2098
|
+
);
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
const changed = [];
|
|
2102
|
+
for (const job of jobs) {
|
|
2103
|
+
changed.push(
|
|
2104
|
+
action === "kill" ? await ctx.killBackgroundJob(job.jobId) : await ctx.releaseBackgroundJob(job.jobId)
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
ctx.print(changed.map(formatJob).join("\n\n"));
|
|
2108
|
+
}
|
|
2109
|
+
function splitCommand(input) {
|
|
2110
|
+
if (!input) return [""];
|
|
2111
|
+
const firstSpace = input.search(/\s/);
|
|
2112
|
+
if (firstSpace === -1) return [input];
|
|
2113
|
+
return [input.slice(0, firstSpace), input.slice(firstSpace + 1)];
|
|
2114
|
+
}
|
|
2115
|
+
function formatJob(job) {
|
|
2116
|
+
return [
|
|
2117
|
+
`jobId: ${job.jobId}`,
|
|
2118
|
+
`kind: ${job.kind}`,
|
|
2119
|
+
`title: ${job.title}`,
|
|
2120
|
+
`status: ${job.status}`,
|
|
2121
|
+
job.terminalId ? `terminalId: ${job.terminalId}` : null,
|
|
2122
|
+
job.outputPath ? `outputPath: ${job.outputPath}` : null,
|
|
2123
|
+
job.exitCode !== void 0 ? `exitCode: ${job.exitCode}` : null,
|
|
2124
|
+
job.signal ? `signal: ${job.signal}` : null,
|
|
2125
|
+
job.error ? `error: ${job.error}` : null
|
|
2126
|
+
].filter(Boolean).join("\n");
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// src/tui/commands/index.ts
|
|
2130
|
+
var COMMANDS = [...builtinCommands];
|
|
2131
|
+
function allCommands() {
|
|
2132
|
+
return COMMANDS;
|
|
2133
|
+
}
|
|
2134
|
+
function findCommand(name) {
|
|
2135
|
+
return COMMANDS.find((c) => c.name === name);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// src/tui/files/clipboard-image.ts
|
|
2139
|
+
import { execFile } from "child_process";
|
|
2140
|
+
var MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
2141
|
+
var MAX_COMMAND_OUTPUT = 15 * 1024 * 1024;
|
|
2142
|
+
var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
2143
|
+
async function readClipboardImage(options = {}) {
|
|
2144
|
+
const platform = options.platform ?? process.platform;
|
|
2145
|
+
const execute = options.execute ?? executeFile;
|
|
2146
|
+
if (platform === "win32") {
|
|
2147
|
+
let base64;
|
|
2148
|
+
try {
|
|
2149
|
+
base64 = await execute("powershell.exe", [
|
|
2150
|
+
"-NoProfile",
|
|
2151
|
+
"-NonInteractive",
|
|
2152
|
+
"-STA",
|
|
2153
|
+
"-Command",
|
|
2154
|
+
WINDOWS_CLIPBOARD_SCRIPT
|
|
2155
|
+
]);
|
|
2156
|
+
} catch {
|
|
2157
|
+
throw new Error("No image was found on the clipboard.");
|
|
2158
|
+
}
|
|
2159
|
+
return fromBase64(base64.toString("utf8"));
|
|
2160
|
+
}
|
|
2161
|
+
if (platform === "darwin") {
|
|
2162
|
+
let base64;
|
|
2163
|
+
try {
|
|
2164
|
+
base64 = await execute("osascript", [
|
|
2165
|
+
"-l",
|
|
2166
|
+
"JavaScript",
|
|
2167
|
+
"-e",
|
|
2168
|
+
MACOS_CLIPBOARD_SCRIPT
|
|
2169
|
+
]);
|
|
2170
|
+
} catch {
|
|
2171
|
+
throw new Error("No image was found on the clipboard.");
|
|
2172
|
+
}
|
|
2173
|
+
return fromBase64(base64.toString("utf8"));
|
|
2174
|
+
}
|
|
2175
|
+
if (platform === "linux") {
|
|
2176
|
+
const attempts = [
|
|
2177
|
+
["wl-paste", ["--type", "image/png"]],
|
|
2178
|
+
["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
|
|
2179
|
+
];
|
|
2180
|
+
for (const [command, args] of attempts) {
|
|
2181
|
+
try {
|
|
2182
|
+
return fromPng(await execute(command, args));
|
|
2183
|
+
} catch {
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
throw new Error(
|
|
2187
|
+
"No PNG image was found on the clipboard. Install wl-clipboard (Wayland) or xclip (X11)."
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
throw new Error(`Clipboard image paste is not supported on ${platform}.`);
|
|
2191
|
+
}
|
|
2192
|
+
function fromBase64(value) {
|
|
2193
|
+
const normalized = value.replace(/\s+/g, "");
|
|
2194
|
+
if (!normalized || !/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) {
|
|
2195
|
+
throw new Error("The clipboard does not contain an image.");
|
|
2196
|
+
}
|
|
2197
|
+
return fromPng(Buffer.from(normalized, "base64"));
|
|
2198
|
+
}
|
|
2199
|
+
function fromPng(buffer) {
|
|
2200
|
+
if (buffer.length === 0 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
2201
|
+
throw new Error("The clipboard does not contain a PNG image.");
|
|
2202
|
+
}
|
|
2203
|
+
if (buffer.length > MAX_IMAGE_BYTES) {
|
|
2204
|
+
throw new Error(
|
|
2205
|
+
`Clipboard image is too large (${formatBytes(buffer.length)}); maximum is ${formatBytes(MAX_IMAGE_BYTES)}.`
|
|
2206
|
+
);
|
|
2207
|
+
}
|
|
2208
|
+
return {
|
|
2209
|
+
data: buffer.toString("base64"),
|
|
2210
|
+
mimeType: "image/png",
|
|
2211
|
+
byteLength: buffer.length
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
function executeFile(command, args) {
|
|
2215
|
+
return new Promise((resolve2, reject) => {
|
|
2216
|
+
execFile(
|
|
2217
|
+
command,
|
|
2218
|
+
args,
|
|
2219
|
+
{
|
|
2220
|
+
encoding: "buffer",
|
|
2221
|
+
maxBuffer: MAX_COMMAND_OUTPUT,
|
|
2222
|
+
timeout: 8e3,
|
|
2223
|
+
windowsHide: true
|
|
2224
|
+
},
|
|
2225
|
+
(error, stdout) => {
|
|
2226
|
+
if (error) reject(error);
|
|
2227
|
+
else resolve2(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout));
|
|
2228
|
+
}
|
|
2229
|
+
);
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
function formatBytes(bytes) {
|
|
2233
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
|
2234
|
+
}
|
|
2235
|
+
var WINDOWS_CLIPBOARD_SCRIPT = [
|
|
2236
|
+
"$image = Get-Clipboard -Format Image -ErrorAction Stop",
|
|
2237
|
+
"if ($null -eq $image) { throw 'Clipboard does not contain an image.' }",
|
|
2238
|
+
"$stream = [System.IO.MemoryStream]::new()",
|
|
2239
|
+
"try {",
|
|
2240
|
+
" $image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
2241
|
+
" [Console]::Out.Write([Convert]::ToBase64String($stream.ToArray()))",
|
|
2242
|
+
"} finally {",
|
|
2243
|
+
" $stream.Dispose()",
|
|
2244
|
+
" $image.Dispose()",
|
|
2245
|
+
"}"
|
|
2246
|
+
].join("\n");
|
|
2247
|
+
var MACOS_CLIPBOARD_SCRIPT = `
|
|
2248
|
+
ObjC.import('AppKit');
|
|
2249
|
+
const pasteboard = $.NSPasteboard.generalPasteboard;
|
|
2250
|
+
let data = pasteboard.dataForType('public.png');
|
|
2251
|
+
if (!data) data = pasteboard.dataForType('public.tiff');
|
|
2252
|
+
if (!data) throw new Error('Clipboard does not contain an image.');
|
|
2253
|
+
const image = $.NSImage.alloc.initWithData(data);
|
|
2254
|
+
const bitmap = $.NSBitmapImageRep.imageRepWithData(image.TIFFRepresentation);
|
|
2255
|
+
const png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $.NSDictionary.dictionary);
|
|
2256
|
+
console.log(ObjC.unwrap(png.base64EncodedStringWithOptions(0)));
|
|
2257
|
+
`;
|
|
2258
|
+
|
|
2259
|
+
// src/tui/files/find-fd.ts
|
|
2260
|
+
import { spawnSync } from "child_process";
|
|
2261
|
+
var FD_CANDIDATES = ["fd", "fdfind"];
|
|
2262
|
+
var cachedPath;
|
|
2263
|
+
function resolveFdPath(run = (command, args) => spawnSync(command, args, { stdio: "ignore" })) {
|
|
2264
|
+
if (cachedPath !== void 0) return cachedPath;
|
|
2265
|
+
for (const candidate of FD_CANDIDATES) {
|
|
2266
|
+
const result = run(candidate, ["--version"]);
|
|
2267
|
+
if (!result.error && result.status === 0) {
|
|
2268
|
+
cachedPath = candidate;
|
|
2269
|
+
return cachedPath;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
cachedPath = null;
|
|
2273
|
+
return cachedPath;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// src/tui/files/list-workspace-files.ts
|
|
2277
|
+
import { readdirSync } from "fs";
|
|
2278
|
+
import { join, relative as relative2, sep as sep2 } from "path";
|
|
2279
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".nova-ai", "out", "coverage", ".next", ".turbo"]);
|
|
2280
|
+
function listWorkspaceFiles(cwd, max = 2e3) {
|
|
2281
|
+
const results = [];
|
|
2282
|
+
const stack = [cwd];
|
|
2283
|
+
while (stack.length > 0 && results.length < max) {
|
|
2284
|
+
const dir = stack.pop();
|
|
2285
|
+
let entries;
|
|
2286
|
+
try {
|
|
2287
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
2288
|
+
} catch {
|
|
2289
|
+
continue;
|
|
2290
|
+
}
|
|
2291
|
+
for (const entry of entries) {
|
|
2292
|
+
if (results.length >= max) break;
|
|
2293
|
+
if (entry.isSymbolicLink()) continue;
|
|
2294
|
+
const full = join(dir, entry.name);
|
|
2295
|
+
if (entry.isDirectory()) {
|
|
2296
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
2297
|
+
stack.push(full);
|
|
2298
|
+
} else if (entry.isFile()) {
|
|
2299
|
+
results.push(relative2(cwd, full).split(sep2).join("/"));
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
return results;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// src/tui/files/prompt-images.ts
|
|
2307
|
+
var DraftImageAttachments = class {
|
|
2308
|
+
nextImageNumber = 1;
|
|
2309
|
+
images = /* @__PURE__ */ new Map();
|
|
2310
|
+
add(image) {
|
|
2311
|
+
const marker = `[#Image${this.nextImageNumber++}]`;
|
|
2312
|
+
const attachment = { ...image, marker };
|
|
2313
|
+
this.images.set(marker, attachment);
|
|
2314
|
+
return attachment;
|
|
2315
|
+
}
|
|
2316
|
+
referencedBy(text) {
|
|
2317
|
+
return [...this.images.values()].filter(
|
|
2318
|
+
(image) => text.includes(image.marker)
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
clear() {
|
|
2322
|
+
this.images.clear();
|
|
2323
|
+
}
|
|
2324
|
+
};
|
|
2325
|
+
|
|
2326
|
+
// src/tui/files/workspace-autocomplete.ts
|
|
2327
|
+
import { spawn } from "child_process";
|
|
2328
|
+
import { readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
2329
|
+
import { homedir } from "os";
|
|
2330
|
+
import { basename, dirname, join as join2 } from "path";
|
|
2331
|
+
var PATH_DELIMITERS = /* @__PURE__ */ new Set([" ", " ", '"', "'", "="]);
|
|
2332
|
+
var MAX_SUGGESTIONS2 = 20;
|
|
2333
|
+
var WorkspaceAutocompleteProvider = class {
|
|
2334
|
+
constructor(commands, cwd, files, fdPath = null) {
|
|
2335
|
+
this.commands = commands;
|
|
2336
|
+
this.cwd = cwd;
|
|
2337
|
+
this.files = files;
|
|
2338
|
+
this.fdPath = fdPath;
|
|
2339
|
+
}
|
|
2340
|
+
commands;
|
|
2341
|
+
cwd;
|
|
2342
|
+
files;
|
|
2343
|
+
fdPath;
|
|
2344
|
+
triggerCharacters = ["@", "/"];
|
|
2345
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
2346
|
+
const before = (lines[cursorLine] ?? "").slice(0, cursorCol);
|
|
2347
|
+
const mention = extractMentionPrefix(before);
|
|
2348
|
+
if (mention) return this.mentionSuggestions(mention, options.signal);
|
|
2349
|
+
if (cursorLine === 0 && before.startsWith("/") && !/\s/.test(before)) {
|
|
2350
|
+
const items2 = filterCommands(this.commands, before.slice(1));
|
|
2351
|
+
return items2.length ? { items: items2, prefix: before } : null;
|
|
2352
|
+
}
|
|
2353
|
+
if (!options.force) return null;
|
|
2354
|
+
const pathPrefix = extractPathPrefix(before);
|
|
2355
|
+
if (pathPrefix === null) return null;
|
|
2356
|
+
const items = this.pathSuggestions(pathPrefix);
|
|
2357
|
+
return items.length ? { items, prefix: pathPrefix } : null;
|
|
2358
|
+
}
|
|
2359
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
2360
|
+
const before = (lines[cursorLine] ?? "").slice(0, cursorCol).trim();
|
|
2361
|
+
return !(before.startsWith("/") && !before.includes(" "));
|
|
2362
|
+
}
|
|
2363
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
2364
|
+
const line = lines[cursorLine] ?? "";
|
|
2365
|
+
const beforePrefix = line.slice(0, cursorCol - prefix.length);
|
|
2366
|
+
let after = line.slice(cursorCol);
|
|
2367
|
+
if (prefix.includes('"') && item.value.endsWith('"') && after.startsWith('"'))
|
|
2368
|
+
after = after.slice(1);
|
|
2369
|
+
const isCommand = cursorLine === 0 && prefix.startsWith("/") && beforePrefix.trim() === "";
|
|
2370
|
+
const isDirectory = item.value.replace(/"$/, "").endsWith("/");
|
|
2371
|
+
let inserted;
|
|
2372
|
+
let cursorOffset;
|
|
2373
|
+
if (isCommand) {
|
|
2374
|
+
inserted = `/${item.value} `;
|
|
2375
|
+
cursorOffset = inserted.length;
|
|
2376
|
+
} else {
|
|
2377
|
+
const suffix = isDirectory || !prefix.startsWith("@") ? "" : " ";
|
|
2378
|
+
inserted = `${item.value}${suffix}`;
|
|
2379
|
+
cursorOffset = isDirectory && item.value.endsWith('"') ? item.value.length - 1 : inserted.length;
|
|
2380
|
+
}
|
|
2381
|
+
const next = [...lines];
|
|
2382
|
+
next[cursorLine] = `${beforePrefix}${inserted}${after}`;
|
|
2383
|
+
return {
|
|
2384
|
+
lines: next,
|
|
2385
|
+
cursorLine,
|
|
2386
|
+
cursorCol: beforePrefix.length + cursorOffset
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
async mentionSuggestions(prefix, signal) {
|
|
2390
|
+
if (signal.aborted) return null;
|
|
2391
|
+
const quoted = prefix.startsWith('@"');
|
|
2392
|
+
const query = prefix.slice(quoted ? 2 : 1).replace(/"$/, "").replace(/\\/g, "/");
|
|
2393
|
+
if (this.fdPath) {
|
|
2394
|
+
const entries = await walkWithFd(this.cwd, this.fdPath, query, signal);
|
|
2395
|
+
if (signal.aborted) return null;
|
|
2396
|
+
const items2 = entries.map((entry) => ({
|
|
2397
|
+
...entry,
|
|
2398
|
+
score: query ? scorePath(entry.path, query.toLowerCase()) : 1
|
|
2399
|
+
})).filter((entry) => entry.score > 0).sort((left, right) => right.score - left.score).slice(0, MAX_SUGGESTIONS2).map((entry) => mentionItem(entry.path, entry.isDirectory, quoted));
|
|
2400
|
+
if (items2.length) return { items: items2, prefix };
|
|
2401
|
+
}
|
|
2402
|
+
const lowered = query.toLowerCase();
|
|
2403
|
+
const items = this.files.map((path) => ({ path, score: scorePath(path, lowered) })).filter((entry) => entry.score > 0).sort(
|
|
2404
|
+
(left, right) => right.score - left.score || left.path.localeCompare(right.path)
|
|
2405
|
+
).slice(0, MAX_SUGGESTIONS2).map(({ path }) => mentionItem(path, false, quoted));
|
|
2406
|
+
return items.length ? { items, prefix } : null;
|
|
2407
|
+
}
|
|
2408
|
+
pathSuggestions(prefix) {
|
|
2409
|
+
const quoted = prefix.startsWith('"');
|
|
2410
|
+
const raw = quoted ? prefix.slice(1) : prefix;
|
|
2411
|
+
const expanded = raw === "~" || raw.startsWith("~/") ? join2(homedir(), raw.slice(1)) : raw;
|
|
2412
|
+
const absolute = expanded.startsWith("/") || /^[A-Za-z]:[\\/]/.test(expanded);
|
|
2413
|
+
const separator = Math.max(raw.lastIndexOf("/"), raw.lastIndexOf("\\"));
|
|
2414
|
+
const listsDirectory = raw === "" || raw === "~" || separator === raw.length - 1;
|
|
2415
|
+
const directoryPart = listsDirectory ? expanded : dirname(expanded);
|
|
2416
|
+
const namePrefix = listsDirectory ? "" : basename(expanded);
|
|
2417
|
+
const displayDirectory = raw === "~" ? "~/" : separator >= 0 ? raw.slice(0, separator + 1) : "";
|
|
2418
|
+
const searchDirectory = absolute ? directoryPart : join2(this.cwd, directoryPart);
|
|
2419
|
+
let entries;
|
|
2420
|
+
try {
|
|
2421
|
+
entries = readdirSync2(searchDirectory, { withFileTypes: true });
|
|
2422
|
+
} catch {
|
|
2423
|
+
return [];
|
|
2424
|
+
}
|
|
2425
|
+
return entries.filter(
|
|
2426
|
+
(entry) => entry.name.toLowerCase().startsWith(namePrefix.toLowerCase())
|
|
2427
|
+
).map((entry) => {
|
|
2428
|
+
let isDirectory = entry.isDirectory();
|
|
2429
|
+
if (!isDirectory && entry.isSymbolicLink()) {
|
|
2430
|
+
try {
|
|
2431
|
+
isDirectory = statSync2(
|
|
2432
|
+
join2(searchDirectory, entry.name)
|
|
2433
|
+
).isDirectory();
|
|
2434
|
+
} catch {
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
const path = `${displayDirectory}${entry.name}${isDirectory ? "/" : ""}`;
|
|
2438
|
+
return {
|
|
2439
|
+
value: quoted || path.includes(" ") ? `"${path}"` : path,
|
|
2440
|
+
label: `${entry.name}${isDirectory ? "/" : ""}`,
|
|
2441
|
+
isDirectory
|
|
2442
|
+
};
|
|
2443
|
+
}).sort(
|
|
2444
|
+
(left, right) => Number(right.isDirectory) - Number(left.isDirectory) || left.label.localeCompare(right.label)
|
|
2445
|
+
).slice(0, 50).map(({ value, label }) => ({ value, label }));
|
|
2446
|
+
}
|
|
2447
|
+
};
|
|
2448
|
+
function mentionItem(path, isDirectory, quoted) {
|
|
2449
|
+
const plain = path.replace(/\/$/, "");
|
|
2450
|
+
const completion = isDirectory ? `${plain}/` : plain;
|
|
2451
|
+
return {
|
|
2452
|
+
value: quoted || completion.includes(" ") ? `@"${completion}"` : `@${completion}`,
|
|
2453
|
+
label: `${basename(plain)}${isDirectory ? "/" : ""}`,
|
|
2454
|
+
description: plain
|
|
2455
|
+
};
|
|
2456
|
+
}
|
|
2457
|
+
function filterCommands(commands, query) {
|
|
2458
|
+
const lowered = query.toLowerCase();
|
|
2459
|
+
const rank = (name) => {
|
|
2460
|
+
const value = name.toLowerCase();
|
|
2461
|
+
if (!lowered) return 1;
|
|
2462
|
+
if (value.startsWith(lowered)) return 3;
|
|
2463
|
+
if (value.includes(lowered)) return 2;
|
|
2464
|
+
let cursor = 0;
|
|
2465
|
+
for (const char of lowered) {
|
|
2466
|
+
cursor = value.indexOf(char, cursor);
|
|
2467
|
+
if (cursor === -1) return 0;
|
|
2468
|
+
cursor++;
|
|
2469
|
+
}
|
|
2470
|
+
return 1;
|
|
2471
|
+
};
|
|
2472
|
+
return commands.map((command) => ({ command, score: rank(command.name) })).filter((entry) => entry.score > 0).sort((left, right) => right.score - left.score).map(({ command }) => ({
|
|
2473
|
+
value: command.name,
|
|
2474
|
+
label: command.name,
|
|
2475
|
+
...command.description ? { description: command.description } : {}
|
|
2476
|
+
}));
|
|
2477
|
+
}
|
|
2478
|
+
function extractMentionPrefix(text) {
|
|
2479
|
+
return /(?:^|\s)(@(?:"[^"]*|[^\s]*))$/.exec(text)?.[1] ?? null;
|
|
2480
|
+
}
|
|
2481
|
+
function extractPathPrefix(text) {
|
|
2482
|
+
let quoteStart = -1;
|
|
2483
|
+
for (let index = 0; index < text.length; index++) {
|
|
2484
|
+
if (text[index] === '"') quoteStart = quoteStart === -1 ? index : -1;
|
|
2485
|
+
}
|
|
2486
|
+
if (quoteStart >= 0) return text.slice(quoteStart);
|
|
2487
|
+
for (let index = text.length - 1; index >= 0; index--) {
|
|
2488
|
+
if (PATH_DELIMITERS.has(text[index])) return text.slice(index + 1);
|
|
2489
|
+
}
|
|
2490
|
+
return text;
|
|
2491
|
+
}
|
|
2492
|
+
function scorePath(path, query) {
|
|
2493
|
+
if (!query) return 1;
|
|
2494
|
+
const normalized = path.toLowerCase().replace(/\/$/, "");
|
|
2495
|
+
const name = basename(normalized);
|
|
2496
|
+
if (name === query) return 100;
|
|
2497
|
+
if (name.startsWith(query)) return 80;
|
|
2498
|
+
if (name.includes(query)) return 60;
|
|
2499
|
+
if (normalized.includes(query)) return 40;
|
|
2500
|
+
let cursor = 0;
|
|
2501
|
+
for (const char of query) {
|
|
2502
|
+
cursor = normalized.indexOf(char, cursor);
|
|
2503
|
+
if (cursor === -1) return 0;
|
|
2504
|
+
cursor++;
|
|
2505
|
+
}
|
|
2506
|
+
return 20;
|
|
2507
|
+
}
|
|
2508
|
+
function fdQuery(query) {
|
|
2509
|
+
if (!query.includes("/")) return query;
|
|
2510
|
+
const trailing = query.endsWith("/");
|
|
2511
|
+
const segments = query.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean).map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
2512
|
+
if (segments.length === 0) return query;
|
|
2513
|
+
return `${segments.join("[\\\\/]")}${trailing ? "[\\\\/]" : ""}`;
|
|
2514
|
+
}
|
|
2515
|
+
function walkWithFd(cwd, fdPath, query, signal) {
|
|
2516
|
+
const args = [
|
|
2517
|
+
"--base-directory",
|
|
2518
|
+
cwd,
|
|
2519
|
+
"--max-results",
|
|
2520
|
+
"100",
|
|
2521
|
+
"--type",
|
|
2522
|
+
"f",
|
|
2523
|
+
"--type",
|
|
2524
|
+
"d",
|
|
2525
|
+
"--follow",
|
|
2526
|
+
"--hidden",
|
|
2527
|
+
"--exclude",
|
|
2528
|
+
".git"
|
|
2529
|
+
];
|
|
2530
|
+
if (query.includes("/")) args.push("--full-path");
|
|
2531
|
+
if (query) args.push(fdQuery(query));
|
|
2532
|
+
return new Promise((resolve2) => {
|
|
2533
|
+
if (signal.aborted) {
|
|
2534
|
+
resolve2([]);
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
2538
|
+
let stdout = "";
|
|
2539
|
+
let settled = false;
|
|
2540
|
+
const finish = (entries) => {
|
|
2541
|
+
if (settled) return;
|
|
2542
|
+
settled = true;
|
|
2543
|
+
signal.removeEventListener("abort", onAbort);
|
|
2544
|
+
resolve2(entries);
|
|
2545
|
+
};
|
|
2546
|
+
const onAbort = () => {
|
|
2547
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
2548
|
+
};
|
|
2549
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2550
|
+
child.stdout.setEncoding("utf8");
|
|
2551
|
+
child.stdout.on("data", (chunk) => {
|
|
2552
|
+
stdout += chunk;
|
|
2553
|
+
});
|
|
2554
|
+
child.on("error", () => finish([]));
|
|
2555
|
+
child.on("close", (code) => {
|
|
2556
|
+
if (signal.aborted || code !== 0) {
|
|
2557
|
+
finish([]);
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
finish(
|
|
2561
|
+
stdout.split("\n").filter(Boolean).map((line) => line.replace(/\\/g, "/")).filter((line) => !/(^|\/)\.git(\/|$)/.test(line)).map((line) => ({ path: line, isDirectory: line.endsWith("/") }))
|
|
2562
|
+
);
|
|
2563
|
+
});
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
// src/tui/session/session-picker.ts
|
|
2568
|
+
import { listStoredSessions } from "@datalabrotterdam/nova-ai-agent/core/sessions.js";
|
|
2569
|
+
function listSessionsForCwd(cwd) {
|
|
2570
|
+
return listStoredSessions(cwd);
|
|
2571
|
+
}
|
|
2572
|
+
function listSessionPickerItems(cwd, now = Date.now()) {
|
|
2573
|
+
return listSessionsForCwd(cwd).map(
|
|
2574
|
+
(session) => toSessionPickerItem(session, now)
|
|
2575
|
+
);
|
|
2576
|
+
}
|
|
2577
|
+
function toSessionPickerItem(session, now = Date.now()) {
|
|
2578
|
+
const age = formatTimeAgo(session.updatedAt, now);
|
|
2579
|
+
const title = oneLineTitle(session.title);
|
|
2580
|
+
return {
|
|
2581
|
+
value: session.sessionId,
|
|
2582
|
+
label: age,
|
|
2583
|
+
description: title,
|
|
2584
|
+
columns: {
|
|
2585
|
+
leading: age,
|
|
2586
|
+
main: title,
|
|
2587
|
+
trailing: formatDateTime(session.updatedAt)
|
|
2588
|
+
}
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
function formatTimeAgo(updatedAt, now = Date.now()) {
|
|
2592
|
+
const timestamp = Date.parse(updatedAt);
|
|
2593
|
+
if (!Number.isFinite(timestamp)) return "unknown";
|
|
2594
|
+
const seconds = Math.max(0, Math.floor((now - timestamp) / 1e3));
|
|
2595
|
+
if (seconds < 5) return "just now";
|
|
2596
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
2597
|
+
const minutes = Math.floor(seconds / 60);
|
|
2598
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
2599
|
+
const hours = Math.floor(minutes / 60);
|
|
2600
|
+
if (hours < 24) return `${hours}h ago`;
|
|
2601
|
+
const days = Math.floor(hours / 24);
|
|
2602
|
+
if (days < 14) return `${days}d ago`;
|
|
2603
|
+
const weeks = Math.floor(days / 7);
|
|
2604
|
+
if (days < 60) return `${weeks}w ago`;
|
|
2605
|
+
const months = Math.floor(days / 30);
|
|
2606
|
+
if (days < 730) return `${months}mo ago`;
|
|
2607
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
2608
|
+
}
|
|
2609
|
+
function oneLineTitle(title) {
|
|
2610
|
+
return title?.replace(/\s+/g, " ").trim() || "(untitled)";
|
|
2611
|
+
}
|
|
2612
|
+
function formatDateTime(updatedAt) {
|
|
2613
|
+
const timestamp = Date.parse(updatedAt);
|
|
2614
|
+
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : "unknown";
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// src/tui/ink/dialogs/state.ts
|
|
2618
|
+
import { OTHER_OPTION_ID } from "@datalabrotterdam/nova-ai-agent/core/user-questions.js";
|
|
2619
|
+
|
|
2620
|
+
// src/tui/ink/editor/buffer.ts
|
|
2621
|
+
var segmenter2 = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
2622
|
+
function boundaries(text) {
|
|
2623
|
+
const result = [0];
|
|
2624
|
+
for (const { index, segment } of segmenter2.segment(text)) {
|
|
2625
|
+
result.push(index + segment.length);
|
|
2626
|
+
}
|
|
2627
|
+
return result;
|
|
2628
|
+
}
|
|
2629
|
+
function previousBoundary(text, offset) {
|
|
2630
|
+
let previous = 0;
|
|
2631
|
+
for (const boundary of boundaries(text)) {
|
|
2632
|
+
if (boundary >= offset) return previous;
|
|
2633
|
+
previous = boundary;
|
|
2634
|
+
}
|
|
2635
|
+
return previous;
|
|
2636
|
+
}
|
|
2637
|
+
function nextBoundary(text, offset) {
|
|
2638
|
+
for (const boundary of boundaries(text)) {
|
|
2639
|
+
if (boundary > offset) return boundary;
|
|
2640
|
+
}
|
|
2641
|
+
return text.length;
|
|
2642
|
+
}
|
|
2643
|
+
var WORD = /[\p{L}\p{N}_]/u;
|
|
2644
|
+
var TextBuffer = class {
|
|
2645
|
+
lines = [""];
|
|
2646
|
+
row = 0;
|
|
2647
|
+
col = 0;
|
|
2648
|
+
/** Column the cursor tries to keep while moving up and down. */
|
|
2649
|
+
preferredCol = null;
|
|
2650
|
+
get text() {
|
|
2651
|
+
return this.lines.join("\n");
|
|
2652
|
+
}
|
|
2653
|
+
get cursor() {
|
|
2654
|
+
return { row: this.row, col: this.col };
|
|
2655
|
+
}
|
|
2656
|
+
get lineList() {
|
|
2657
|
+
return this.lines;
|
|
2658
|
+
}
|
|
2659
|
+
get isEmpty() {
|
|
2660
|
+
return this.lines.length === 1 && this.lines[0] === "";
|
|
2661
|
+
}
|
|
2662
|
+
setText(text) {
|
|
2663
|
+
this.lines = text.split("\n");
|
|
2664
|
+
this.row = this.lines.length - 1;
|
|
2665
|
+
this.col = this.lines[this.row].length;
|
|
2666
|
+
this.preferredCol = null;
|
|
2667
|
+
}
|
|
2668
|
+
/** Replaces the lines and cursor at once (used by autocomplete). */
|
|
2669
|
+
setState(lines, row, col) {
|
|
2670
|
+
this.lines = lines.length ? [...lines] : [""];
|
|
2671
|
+
this.row = Math.max(0, Math.min(row, this.lines.length - 1));
|
|
2672
|
+
this.col = Math.max(0, Math.min(col, this.lines[this.row].length));
|
|
2673
|
+
this.preferredCol = null;
|
|
2674
|
+
}
|
|
2675
|
+
insert(text) {
|
|
2676
|
+
if (!text) return;
|
|
2677
|
+
const line = this.lines[this.row];
|
|
2678
|
+
const before = line.slice(0, this.col);
|
|
2679
|
+
const after = line.slice(this.col);
|
|
2680
|
+
const parts = text.split("\n");
|
|
2681
|
+
if (parts.length === 1) {
|
|
2682
|
+
this.lines[this.row] = before + text + after;
|
|
2683
|
+
this.col += text.length;
|
|
2684
|
+
} else {
|
|
2685
|
+
const last = parts.at(-1);
|
|
2686
|
+
const inserted = [before + parts[0], ...parts.slice(1, -1), last + after];
|
|
2687
|
+
this.lines.splice(this.row, 1, ...inserted);
|
|
2688
|
+
this.row += parts.length - 1;
|
|
2689
|
+
this.col = last.length;
|
|
2690
|
+
}
|
|
2691
|
+
this.preferredCol = null;
|
|
2692
|
+
}
|
|
2693
|
+
newline() {
|
|
2694
|
+
this.insert("\n");
|
|
2695
|
+
}
|
|
2696
|
+
backspace() {
|
|
2697
|
+
if (this.col > 0) {
|
|
2698
|
+
const line = this.lines[this.row];
|
|
2699
|
+
const start = previousBoundary(line, this.col);
|
|
2700
|
+
this.lines[this.row] = line.slice(0, start) + line.slice(this.col);
|
|
2701
|
+
this.col = start;
|
|
2702
|
+
} else if (this.row > 0) {
|
|
2703
|
+
const previous = this.lines[this.row - 1];
|
|
2704
|
+
this.lines.splice(this.row - 1, 2, previous + this.lines[this.row]);
|
|
2705
|
+
this.row--;
|
|
2706
|
+
this.col = previous.length;
|
|
2707
|
+
} else {
|
|
2708
|
+
return false;
|
|
2709
|
+
}
|
|
2710
|
+
this.preferredCol = null;
|
|
2711
|
+
return true;
|
|
2712
|
+
}
|
|
2713
|
+
deleteForward() {
|
|
2714
|
+
const line = this.lines[this.row];
|
|
2715
|
+
if (this.col < line.length) {
|
|
2716
|
+
const end = nextBoundary(line, this.col);
|
|
2717
|
+
this.lines[this.row] = line.slice(0, this.col) + line.slice(end);
|
|
2718
|
+
} else if (this.row < this.lines.length - 1) {
|
|
2719
|
+
this.lines.splice(this.row, 2, line + this.lines[this.row + 1]);
|
|
2720
|
+
} else {
|
|
2721
|
+
return false;
|
|
2722
|
+
}
|
|
2723
|
+
this.preferredCol = null;
|
|
2724
|
+
return true;
|
|
2725
|
+
}
|
|
2726
|
+
deleteWordBackward() {
|
|
2727
|
+
if (this.col === 0) return this.backspace();
|
|
2728
|
+
const line = this.lines[this.row];
|
|
2729
|
+
const start = this.wordStartBefore(line, this.col);
|
|
2730
|
+
this.lines[this.row] = line.slice(0, start) + line.slice(this.col);
|
|
2731
|
+
this.col = start;
|
|
2732
|
+
this.preferredCol = null;
|
|
2733
|
+
return true;
|
|
2734
|
+
}
|
|
2735
|
+
deleteToLineStart() {
|
|
2736
|
+
if (this.col === 0) return this.backspace();
|
|
2737
|
+
const line = this.lines[this.row];
|
|
2738
|
+
this.lines[this.row] = line.slice(this.col);
|
|
2739
|
+
this.col = 0;
|
|
2740
|
+
this.preferredCol = null;
|
|
2741
|
+
return true;
|
|
2742
|
+
}
|
|
2743
|
+
deleteToLineEnd() {
|
|
2744
|
+
const line = this.lines[this.row];
|
|
2745
|
+
if (this.col === line.length) return this.deleteForward();
|
|
2746
|
+
this.lines[this.row] = line.slice(0, this.col);
|
|
2747
|
+
this.preferredCol = null;
|
|
2748
|
+
return true;
|
|
2749
|
+
}
|
|
2750
|
+
left() {
|
|
2751
|
+
if (this.col > 0) {
|
|
2752
|
+
this.col = previousBoundary(this.lines[this.row], this.col);
|
|
2753
|
+
} else if (this.row > 0) {
|
|
2754
|
+
this.row--;
|
|
2755
|
+
this.col = this.lines[this.row].length;
|
|
2756
|
+
}
|
|
2757
|
+
this.preferredCol = null;
|
|
2758
|
+
}
|
|
2759
|
+
right() {
|
|
2760
|
+
const line = this.lines[this.row];
|
|
2761
|
+
if (this.col < line.length) {
|
|
2762
|
+
this.col = nextBoundary(line, this.col);
|
|
2763
|
+
} else if (this.row < this.lines.length - 1) {
|
|
2764
|
+
this.row++;
|
|
2765
|
+
this.col = 0;
|
|
2766
|
+
}
|
|
2767
|
+
this.preferredCol = null;
|
|
2768
|
+
}
|
|
2769
|
+
wordLeft() {
|
|
2770
|
+
if (this.col === 0) {
|
|
2771
|
+
this.left();
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
this.col = this.wordStartBefore(this.lines[this.row], this.col);
|
|
2775
|
+
this.preferredCol = null;
|
|
2776
|
+
}
|
|
2777
|
+
wordRight() {
|
|
2778
|
+
const line = this.lines[this.row];
|
|
2779
|
+
if (this.col === line.length) {
|
|
2780
|
+
this.right();
|
|
2781
|
+
return;
|
|
2782
|
+
}
|
|
2783
|
+
let offset = this.col;
|
|
2784
|
+
while (offset < line.length && !WORD.test(line[offset])) offset++;
|
|
2785
|
+
while (offset < line.length && WORD.test(line[offset])) offset++;
|
|
2786
|
+
this.col = offset;
|
|
2787
|
+
this.preferredCol = null;
|
|
2788
|
+
}
|
|
2789
|
+
/** Moves up one line; false when already on the first line. */
|
|
2790
|
+
up() {
|
|
2791
|
+
if (this.row === 0) return false;
|
|
2792
|
+
this.moveVertically(-1);
|
|
2793
|
+
return true;
|
|
2794
|
+
}
|
|
2795
|
+
/** Moves down one line; false when already on the last line. */
|
|
2796
|
+
down() {
|
|
2797
|
+
if (this.row === this.lines.length - 1) return false;
|
|
2798
|
+
this.moveVertically(1);
|
|
2799
|
+
return true;
|
|
2800
|
+
}
|
|
2801
|
+
lineStart() {
|
|
2802
|
+
this.col = 0;
|
|
2803
|
+
this.preferredCol = null;
|
|
2804
|
+
}
|
|
2805
|
+
lineEnd() {
|
|
2806
|
+
this.col = this.lines[this.row].length;
|
|
2807
|
+
this.preferredCol = null;
|
|
2808
|
+
}
|
|
2809
|
+
moveVertically(delta) {
|
|
2810
|
+
this.preferredCol ??= this.col;
|
|
2811
|
+
this.row += delta;
|
|
2812
|
+
const line = this.lines[this.row];
|
|
2813
|
+
this.col = snapToBoundary(line, Math.min(this.preferredCol, line.length));
|
|
2814
|
+
}
|
|
2815
|
+
wordStartBefore(line, offset) {
|
|
2816
|
+
let start = offset;
|
|
2817
|
+
while (start > 0 && !WORD.test(line[start - 1])) start--;
|
|
2818
|
+
while (start > 0 && WORD.test(line[start - 1])) start--;
|
|
2819
|
+
return start;
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
function snapToBoundary(text, offset) {
|
|
2823
|
+
let result = 0;
|
|
2824
|
+
for (const boundary of boundaries(text)) {
|
|
2825
|
+
if (boundary > offset) break;
|
|
2826
|
+
result = boundary;
|
|
2827
|
+
}
|
|
2828
|
+
return result;
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
// src/tui/ink/dialogs/state.ts
|
|
2832
|
+
var isCancel = (input, key) => !!key.escape || !!key.ctrl && input === "c";
|
|
2833
|
+
function windowStart(selected, count, rows) {
|
|
2834
|
+
const maximum = Math.max(0, count - rows);
|
|
2835
|
+
return Math.max(0, Math.min(selected - Math.floor(rows / 2), maximum));
|
|
2836
|
+
}
|
|
2837
|
+
var SelectionState = class {
|
|
2838
|
+
constructor(items, onSelect, onCancel) {
|
|
2839
|
+
this.items = items;
|
|
2840
|
+
this.onSelect = onSelect;
|
|
2841
|
+
this.onCancel = onCancel;
|
|
2842
|
+
}
|
|
2843
|
+
items;
|
|
2844
|
+
onSelect;
|
|
2845
|
+
onCancel;
|
|
2846
|
+
selected = 0;
|
|
2847
|
+
handleKey(input, key, pageRows) {
|
|
2848
|
+
const count = this.items.length;
|
|
2849
|
+
if (isCancel(input, key)) {
|
|
2850
|
+
this.onCancel();
|
|
2851
|
+
return "closed";
|
|
2852
|
+
}
|
|
2853
|
+
if (key.return) {
|
|
2854
|
+
const item = this.items[this.selected];
|
|
2855
|
+
if (!item) return "ignored";
|
|
2856
|
+
this.onSelect(item);
|
|
2857
|
+
return "closed";
|
|
2858
|
+
}
|
|
2859
|
+
if (count === 0) return "ignored";
|
|
2860
|
+
let next = this.selected;
|
|
2861
|
+
if (key.upArrow) next = this.selected === 0 ? count - 1 : this.selected - 1;
|
|
2862
|
+
else if (key.downArrow) next = this.selected === count - 1 ? 0 : this.selected + 1;
|
|
2863
|
+
else if (key.pageUp) next = Math.max(0, this.selected - pageRows);
|
|
2864
|
+
else if (key.pageDown) next = Math.min(count - 1, this.selected + pageRows);
|
|
2865
|
+
else if (key.home) next = 0;
|
|
2866
|
+
else if (key.end) next = count - 1;
|
|
2867
|
+
else return "ignored";
|
|
2868
|
+
if (next === this.selected) return "ignored";
|
|
2869
|
+
this.selected = next;
|
|
2870
|
+
return "changed";
|
|
2871
|
+
}
|
|
2872
|
+
};
|
|
2873
|
+
var ScrollState = class {
|
|
2874
|
+
constructor(onClose) {
|
|
2875
|
+
this.onClose = onClose;
|
|
2876
|
+
}
|
|
2877
|
+
onClose;
|
|
2878
|
+
offset = 0;
|
|
2879
|
+
handleKey(input, key, pageRows) {
|
|
2880
|
+
if (isCancel(input, key) || key.return && !key.meta) {
|
|
2881
|
+
this.onClose();
|
|
2882
|
+
return "closed";
|
|
2883
|
+
}
|
|
2884
|
+
const page = Math.max(1, pageRows);
|
|
2885
|
+
const before = this.offset;
|
|
2886
|
+
if (key.upArrow) this.offset = Math.max(0, this.offset - 1);
|
|
2887
|
+
else if (key.downArrow) this.offset += 1;
|
|
2888
|
+
else if (key.pageUp) this.offset = Math.max(0, this.offset - page);
|
|
2889
|
+
else if (key.pageDown || input === " ") this.offset += page;
|
|
2890
|
+
else if (key.home) this.offset = 0;
|
|
2891
|
+
else if (key.end) this.offset = Number.MAX_SAFE_INTEGER;
|
|
2892
|
+
else return "ignored";
|
|
2893
|
+
return this.offset === before ? "ignored" : "changed";
|
|
2894
|
+
}
|
|
2895
|
+
/** Clamps the offset to the content; call before rendering. */
|
|
2896
|
+
clamp(totalRows, visibleRows) {
|
|
2897
|
+
this.offset = Math.max(0, Math.min(this.offset, totalRows - visibleRows));
|
|
2898
|
+
return this.offset;
|
|
2899
|
+
}
|
|
2900
|
+
};
|
|
2901
|
+
var ToolInspectorState = class {
|
|
2902
|
+
constructor(getCalls, onClose) {
|
|
2903
|
+
this.getCalls = getCalls;
|
|
2904
|
+
this.onClose = onClose;
|
|
2905
|
+
}
|
|
2906
|
+
getCalls;
|
|
2907
|
+
onClose;
|
|
2908
|
+
selected = 0;
|
|
2909
|
+
expanded = false;
|
|
2910
|
+
detailOffset = 0;
|
|
2911
|
+
calls() {
|
|
2912
|
+
const calls = this.getCalls();
|
|
2913
|
+
this.selected = Math.max(0, Math.min(this.selected, calls.length - 1));
|
|
2914
|
+
return calls;
|
|
2915
|
+
}
|
|
2916
|
+
handleKey(input, key, pageRows) {
|
|
2917
|
+
if (isCancel(input, key)) {
|
|
2918
|
+
this.onClose();
|
|
2919
|
+
return "closed";
|
|
2920
|
+
}
|
|
2921
|
+
const calls = this.calls();
|
|
2922
|
+
if (calls.length === 0) return "ignored";
|
|
2923
|
+
const before = `${this.selected}:${this.expanded}:${this.detailOffset}`;
|
|
2924
|
+
if (key.upArrow) this.select(this.selected === 0 ? calls.length - 1 : this.selected - 1);
|
|
2925
|
+
else if (key.downArrow)
|
|
2926
|
+
this.select(this.selected === calls.length - 1 ? 0 : this.selected + 1);
|
|
2927
|
+
else if (key.leftArrow) this.setExpanded(false);
|
|
2928
|
+
else if (key.rightArrow) this.setExpanded(true);
|
|
2929
|
+
else if (key.return || input === " ") this.setExpanded(!this.expanded);
|
|
2930
|
+
else if (key.pageUp) {
|
|
2931
|
+
if (this.expanded) this.detailOffset = Math.max(0, this.detailOffset - pageRows);
|
|
2932
|
+
else this.select(Math.max(0, this.selected - pageRows));
|
|
2933
|
+
} else if (key.pageDown) {
|
|
2934
|
+
if (this.expanded) this.detailOffset += pageRows;
|
|
2935
|
+
else this.select(Math.min(calls.length - 1, this.selected + pageRows));
|
|
2936
|
+
} else if (key.home) {
|
|
2937
|
+
if (this.expanded) this.detailOffset = 0;
|
|
2938
|
+
else this.select(0);
|
|
2939
|
+
} else if (key.end) {
|
|
2940
|
+
if (this.expanded) this.detailOffset = Number.MAX_SAFE_INTEGER;
|
|
2941
|
+
else this.select(calls.length - 1);
|
|
2942
|
+
} else return "ignored";
|
|
2943
|
+
return before === `${this.selected}:${this.expanded}:${this.detailOffset}` ? "ignored" : "changed";
|
|
2944
|
+
}
|
|
2945
|
+
select(index) {
|
|
2946
|
+
if (index === this.selected) return;
|
|
2947
|
+
this.selected = index;
|
|
2948
|
+
this.detailOffset = 0;
|
|
2949
|
+
}
|
|
2950
|
+
setExpanded(expanded) {
|
|
2951
|
+
if (expanded === this.expanded) return;
|
|
2952
|
+
this.expanded = expanded;
|
|
2953
|
+
this.detailOffset = 0;
|
|
2954
|
+
}
|
|
2955
|
+
};
|
|
2956
|
+
var QuestionState = class {
|
|
2957
|
+
constructor(pending, onClose) {
|
|
2958
|
+
this.pending = pending;
|
|
2959
|
+
this.onClose = onClose;
|
|
2960
|
+
}
|
|
2961
|
+
pending;
|
|
2962
|
+
onClose;
|
|
2963
|
+
questionIndex = 0;
|
|
2964
|
+
selected = 0;
|
|
2965
|
+
customMode = false;
|
|
2966
|
+
validationMessage = "";
|
|
2967
|
+
customInput = new TextBuffer();
|
|
2968
|
+
selections = /* @__PURE__ */ new Map();
|
|
2969
|
+
customAnswers = /* @__PURE__ */ new Map();
|
|
2970
|
+
get question() {
|
|
2971
|
+
return this.pending.request.questions[this.questionIndex];
|
|
2972
|
+
}
|
|
2973
|
+
isSelected(questionId, optionId) {
|
|
2974
|
+
return this.selection(questionId).has(optionId);
|
|
2975
|
+
}
|
|
2976
|
+
customAnswer(questionId) {
|
|
2977
|
+
return this.customAnswers.get(questionId);
|
|
2978
|
+
}
|
|
2979
|
+
handleKey(input, key) {
|
|
2980
|
+
if (this.customMode) return this.handleCustomKey(input, key);
|
|
2981
|
+
const question = this.question;
|
|
2982
|
+
if (!question) return "ignored";
|
|
2983
|
+
const count = question.options.length + 1;
|
|
2984
|
+
if (isCancel(input, key)) {
|
|
2985
|
+
this.pending.resolve({ action: "cancel", answers: [] });
|
|
2986
|
+
this.onClose();
|
|
2987
|
+
return "closed";
|
|
2988
|
+
}
|
|
2989
|
+
if (key.upArrow) return this.moveTo((this.selected - 1 + count) % count);
|
|
2990
|
+
if (key.downArrow) return this.moveTo((this.selected + 1) % count);
|
|
2991
|
+
if (key.home) return this.moveTo(0);
|
|
2992
|
+
if (key.end) return this.moveTo(count - 1);
|
|
2993
|
+
if (input === " " && question.type === "multiple") return this.toggleCurrent();
|
|
2994
|
+
if (key.return) return this.submitCurrent();
|
|
2995
|
+
return "ignored";
|
|
2996
|
+
}
|
|
2997
|
+
handleCustomKey(input, key) {
|
|
2998
|
+
if (key.escape) {
|
|
2999
|
+
this.customMode = false;
|
|
3000
|
+
this.validationMessage = "";
|
|
3001
|
+
return "changed";
|
|
3002
|
+
}
|
|
3003
|
+
if (key.ctrl && input === "c") {
|
|
3004
|
+
this.pending.resolve({ action: "cancel", answers: [] });
|
|
3005
|
+
this.onClose();
|
|
3006
|
+
return "closed";
|
|
3007
|
+
}
|
|
3008
|
+
if (key.return) return this.submitCustom(this.customInput.text);
|
|
3009
|
+
if (key.backspace) this.customInput.backspace();
|
|
3010
|
+
else if (key.delete) this.customInput.deleteForward();
|
|
3011
|
+
else if (key.leftArrow) this.customInput.left();
|
|
3012
|
+
else if (key.rightArrow) this.customInput.right();
|
|
3013
|
+
else if (key.home || key.ctrl && input === "a") this.customInput.lineStart();
|
|
3014
|
+
else if (key.end || key.ctrl && input === "e") this.customInput.lineEnd();
|
|
3015
|
+
else if (key.ctrl && input === "u") this.customInput.deleteToLineStart();
|
|
3016
|
+
else if (key.ctrl || key.meta || key.tab || key.upArrow || key.downArrow) return "ignored";
|
|
3017
|
+
else {
|
|
3018
|
+
const text = input.replace(/[\u0000-\u001f\u007f]/g, "");
|
|
3019
|
+
if (!text) return "ignored";
|
|
3020
|
+
this.customInput.insert(text);
|
|
3021
|
+
}
|
|
3022
|
+
return "changed";
|
|
3023
|
+
}
|
|
3024
|
+
/** Typed text from a paste while writing an own answer. */
|
|
3025
|
+
paste(text) {
|
|
3026
|
+
if (!this.customMode) return "ignored";
|
|
3027
|
+
this.customInput.insert(text.replace(/\s*\n\s*/g, " ").replace(/[\u0000-\u001f\u007f]/g, ""));
|
|
3028
|
+
return "changed";
|
|
3029
|
+
}
|
|
3030
|
+
moveTo(index) {
|
|
3031
|
+
if (index === this.selected) return "ignored";
|
|
3032
|
+
this.selected = index;
|
|
3033
|
+
this.validationMessage = "";
|
|
3034
|
+
return "changed";
|
|
3035
|
+
}
|
|
3036
|
+
toggleCurrent() {
|
|
3037
|
+
const question = this.question;
|
|
3038
|
+
if (this.selected === question.options.length) return this.enterCustomMode();
|
|
3039
|
+
const option = question.options[this.selected];
|
|
3040
|
+
if (!option) return "ignored";
|
|
3041
|
+
const selection = this.selection(question.id);
|
|
3042
|
+
if (selection.has(option.id)) selection.delete(option.id);
|
|
3043
|
+
else selection.add(option.id);
|
|
3044
|
+
this.validationMessage = "";
|
|
3045
|
+
return "changed";
|
|
3046
|
+
}
|
|
3047
|
+
submitCurrent() {
|
|
3048
|
+
const question = this.question;
|
|
3049
|
+
if (this.selected === question.options.length) return this.enterCustomMode();
|
|
3050
|
+
if (question.type === "single") {
|
|
3051
|
+
const option = question.options[this.selected];
|
|
3052
|
+
if (!option) return "ignored";
|
|
3053
|
+
this.selections.set(question.id, /* @__PURE__ */ new Set([option.id]));
|
|
3054
|
+
return this.advance();
|
|
3055
|
+
}
|
|
3056
|
+
if (this.selection(question.id).size === 0) {
|
|
3057
|
+
this.validationMessage = "Select at least one option before continuing.";
|
|
3058
|
+
return "changed";
|
|
3059
|
+
}
|
|
3060
|
+
return this.advance();
|
|
3061
|
+
}
|
|
3062
|
+
enterCustomMode() {
|
|
3063
|
+
const question = this.question;
|
|
3064
|
+
this.customMode = true;
|
|
3065
|
+
this.validationMessage = "";
|
|
3066
|
+
this.customInput.setText(this.customAnswers.get(question.id) ?? "");
|
|
3067
|
+
return "changed";
|
|
3068
|
+
}
|
|
3069
|
+
submitCustom(value) {
|
|
3070
|
+
const question = this.question;
|
|
3071
|
+
const answer = value.trim();
|
|
3072
|
+
if (!answer) {
|
|
3073
|
+
this.validationMessage = "Enter your own answer before continuing.";
|
|
3074
|
+
return "changed";
|
|
3075
|
+
}
|
|
3076
|
+
if (question.type === "single") {
|
|
3077
|
+
this.selections.set(question.id, /* @__PURE__ */ new Set([OTHER_OPTION_ID]));
|
|
3078
|
+
} else {
|
|
3079
|
+
this.selection(question.id).add(OTHER_OPTION_ID);
|
|
3080
|
+
}
|
|
3081
|
+
this.customAnswers.set(question.id, answer);
|
|
3082
|
+
this.customMode = false;
|
|
3083
|
+
return this.advance();
|
|
3084
|
+
}
|
|
3085
|
+
advance() {
|
|
3086
|
+
this.validationMessage = "";
|
|
3087
|
+
if (this.questionIndex < this.pending.request.questions.length - 1) {
|
|
3088
|
+
this.questionIndex++;
|
|
3089
|
+
this.selected = 0;
|
|
3090
|
+
return "changed";
|
|
3091
|
+
}
|
|
3092
|
+
this.pending.resolve({
|
|
3093
|
+
action: "accept",
|
|
3094
|
+
answers: this.pending.request.questions.map((question) => ({
|
|
3095
|
+
questionId: question.id,
|
|
3096
|
+
selectedOptionIds: [...this.selection(question.id)],
|
|
3097
|
+
customAnswer: this.customAnswers.get(question.id)
|
|
3098
|
+
}))
|
|
3099
|
+
});
|
|
3100
|
+
this.onClose();
|
|
3101
|
+
return "closed";
|
|
3102
|
+
}
|
|
3103
|
+
selection(questionId) {
|
|
3104
|
+
let selected = this.selections.get(questionId);
|
|
3105
|
+
if (!selected) {
|
|
3106
|
+
selected = /* @__PURE__ */ new Set();
|
|
3107
|
+
this.selections.set(questionId, selected);
|
|
3108
|
+
}
|
|
3109
|
+
return selected;
|
|
3110
|
+
}
|
|
3111
|
+
};
|
|
3112
|
+
function permissionKey(request, input, key) {
|
|
3113
|
+
if (input === "a" || key.return) request.resolve(true, "once");
|
|
3114
|
+
else if (input === "s") request.resolve(true, "session");
|
|
3115
|
+
else if (input === "w") request.resolve(true, "always");
|
|
3116
|
+
else if (input === "d" || isCancel(input, key)) request.resolve(false, "once");
|
|
3117
|
+
else return "ignored";
|
|
3118
|
+
return "closed";
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
// src/tui/ink/editor/prompt-editor.ts
|
|
3122
|
+
var MAX_HISTORY = 200;
|
|
3123
|
+
var PromptEditor = class {
|
|
3124
|
+
buffer = new TextBuffer();
|
|
3125
|
+
history = [];
|
|
3126
|
+
/** -1: the draft; otherwise an index into history, newest last. */
|
|
3127
|
+
historyIndex = -1;
|
|
3128
|
+
historyDraft = "";
|
|
3129
|
+
queuedMessages = null;
|
|
3130
|
+
editingQueueId = null;
|
|
3131
|
+
queueDraft = "";
|
|
3132
|
+
draftPastes = new DraftPasteAttachments();
|
|
3133
|
+
autocompleteProvider = null;
|
|
3134
|
+
autocompleteState = null;
|
|
3135
|
+
autocompleteAbort = null;
|
|
3136
|
+
autocompleteRequest = 0;
|
|
3137
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3138
|
+
version = 0;
|
|
3139
|
+
lastText = "";
|
|
3140
|
+
onSubmit;
|
|
3141
|
+
onChange;
|
|
3142
|
+
onQueuedMessageEditStart;
|
|
3143
|
+
onQueuedMessageEditFinish;
|
|
3144
|
+
subscribe = (listener) => {
|
|
3145
|
+
this.listeners.add(listener);
|
|
3146
|
+
return () => this.listeners.delete(listener);
|
|
3147
|
+
};
|
|
3148
|
+
/** Changes whenever anything visible changed (for useSyncExternalStore). */
|
|
3149
|
+
getVersion = () => this.version;
|
|
3150
|
+
getText() {
|
|
3151
|
+
return this.buffer.text;
|
|
3152
|
+
}
|
|
3153
|
+
setText(text) {
|
|
3154
|
+
this.buffer.setText(text);
|
|
3155
|
+
this.closeAutocomplete();
|
|
3156
|
+
this.changed();
|
|
3157
|
+
}
|
|
3158
|
+
insertTextAtCursor(text) {
|
|
3159
|
+
this.buffer.insert(text);
|
|
3160
|
+
this.changed();
|
|
3161
|
+
}
|
|
3162
|
+
get autocomplete() {
|
|
3163
|
+
return this.autocompleteState;
|
|
3164
|
+
}
|
|
3165
|
+
setAutocompleteProvider(provider) {
|
|
3166
|
+
this.autocompleteProvider = provider;
|
|
3167
|
+
}
|
|
3168
|
+
setQueuedMessageProvider(provider) {
|
|
3169
|
+
this.queuedMessages = provider;
|
|
3170
|
+
}
|
|
3171
|
+
editingQueuedMessage() {
|
|
3172
|
+
return this.editingQueueId;
|
|
3173
|
+
}
|
|
3174
|
+
referencedPastes(text) {
|
|
3175
|
+
return this.draftPastes.referencedBy(text);
|
|
3176
|
+
}
|
|
3177
|
+
clearPastes() {
|
|
3178
|
+
this.draftPastes.clear();
|
|
3179
|
+
}
|
|
3180
|
+
addToHistory(text) {
|
|
3181
|
+
const value = text.trim();
|
|
3182
|
+
if (!value || this.history.at(-1) === value) return;
|
|
3183
|
+
this.history.push(value);
|
|
3184
|
+
if (this.history.length > MAX_HISTORY) this.history.shift();
|
|
3185
|
+
}
|
|
3186
|
+
/** Text from the terminal's bracketed paste. */
|
|
3187
|
+
paste(text) {
|
|
3188
|
+
const normalized = normalizeClipboardPaste(text);
|
|
3189
|
+
if (!normalized) return;
|
|
3190
|
+
if (isLargeClipboardPaste(normalized)) {
|
|
3191
|
+
this.buffer.insert(this.draftPastes.add(normalized).marker);
|
|
3192
|
+
} else {
|
|
3193
|
+
this.buffer.insert(normalized);
|
|
3194
|
+
}
|
|
3195
|
+
this.changed();
|
|
3196
|
+
this.refreshAutocomplete();
|
|
3197
|
+
}
|
|
3198
|
+
/** Returns false when the key is not the editor's (global shortcuts). */
|
|
3199
|
+
handleKey(input, key) {
|
|
3200
|
+
if (isLateTerminalReply(input)) return true;
|
|
3201
|
+
if (input.length > 1 && input.includes("\r") && !key.ctrl && !key.meta) {
|
|
3202
|
+
const parts = input.split(/\r\n?/);
|
|
3203
|
+
parts.forEach((part, index) => {
|
|
3204
|
+
if (part) this.handleKey(part, {});
|
|
3205
|
+
if (index < parts.length - 1) this.handleKey("\r", { return: true });
|
|
3206
|
+
});
|
|
3207
|
+
return true;
|
|
3208
|
+
}
|
|
3209
|
+
if (this.autocompleteState && this.handleAutocompleteKey(key))
|
|
3210
|
+
return true;
|
|
3211
|
+
if (key.return && (key.meta || key.shift)) return this.edit(() => this.buffer.newline());
|
|
3212
|
+
if (input === "\n" && !key.return) return this.edit(() => this.buffer.newline());
|
|
3213
|
+
if (key.return) {
|
|
3214
|
+
this.submitOrContinue();
|
|
3215
|
+
return true;
|
|
3216
|
+
}
|
|
3217
|
+
if (key.tab && !key.shift) {
|
|
3218
|
+
this.refreshAutocomplete(true);
|
|
3219
|
+
return true;
|
|
3220
|
+
}
|
|
3221
|
+
if (key.upArrow) {
|
|
3222
|
+
if ((this.editingQueueId !== null || this.buffer.isEmpty) && this.navigateQueuedMessages(-1))
|
|
3223
|
+
return true;
|
|
3224
|
+
if (!this.buffer.up()) this.historyOlder();
|
|
3225
|
+
this.closeAutocomplete();
|
|
3226
|
+
this.changed();
|
|
3227
|
+
return true;
|
|
3228
|
+
}
|
|
3229
|
+
if (key.downArrow) {
|
|
3230
|
+
if (this.editingQueueId !== null && this.navigateQueuedMessages(1))
|
|
3231
|
+
return true;
|
|
3232
|
+
if (!this.buffer.down()) this.historyNewer();
|
|
3233
|
+
this.closeAutocomplete();
|
|
3234
|
+
this.changed();
|
|
3235
|
+
return true;
|
|
3236
|
+
}
|
|
3237
|
+
if (key.backspace) {
|
|
3238
|
+
return this.edit(
|
|
3239
|
+
() => key.meta || key.ctrl ? this.buffer.deleteWordBackward() : this.buffer.backspace()
|
|
3240
|
+
);
|
|
3241
|
+
}
|
|
3242
|
+
if (key.delete) return this.edit(() => this.buffer.deleteForward());
|
|
3243
|
+
if (key.leftArrow) {
|
|
3244
|
+
return this.move(
|
|
3245
|
+
() => key.meta || key.ctrl ? this.buffer.wordLeft() : this.buffer.left()
|
|
3246
|
+
);
|
|
3247
|
+
}
|
|
3248
|
+
if (key.rightArrow) {
|
|
3249
|
+
return this.move(
|
|
3250
|
+
() => key.meta || key.ctrl ? this.buffer.wordRight() : this.buffer.right()
|
|
3251
|
+
);
|
|
3252
|
+
}
|
|
3253
|
+
if (key.home) return this.move(() => this.buffer.lineStart());
|
|
3254
|
+
if (key.end) return this.move(() => this.buffer.lineEnd());
|
|
3255
|
+
if (key.ctrl) {
|
|
3256
|
+
switch (input) {
|
|
3257
|
+
case "a":
|
|
3258
|
+
return this.move(() => this.buffer.lineStart());
|
|
3259
|
+
case "e":
|
|
3260
|
+
return this.move(() => this.buffer.lineEnd());
|
|
3261
|
+
case "u":
|
|
3262
|
+
return this.edit(() => this.buffer.deleteToLineStart());
|
|
3263
|
+
case "w":
|
|
3264
|
+
return this.edit(() => this.buffer.deleteWordBackward());
|
|
3265
|
+
case "j":
|
|
3266
|
+
return this.edit(() => this.buffer.newline());
|
|
3267
|
+
case "d":
|
|
3268
|
+
if (this.buffer.isEmpty) return false;
|
|
3269
|
+
return this.edit(() => this.buffer.deleteForward());
|
|
3270
|
+
default:
|
|
3271
|
+
return false;
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
if (key.meta) {
|
|
3275
|
+
if (input === "b") return this.move(() => this.buffer.wordLeft());
|
|
3276
|
+
if (input === "f") return this.move(() => this.buffer.wordRight());
|
|
3277
|
+
return false;
|
|
3278
|
+
}
|
|
3279
|
+
if (key.escape || key.tab || key.pageUp || key.pageDown) return false;
|
|
3280
|
+
const text = stripControlCharacters(input);
|
|
3281
|
+
if (!text) return false;
|
|
3282
|
+
return this.edit(() => this.buffer.insert(text));
|
|
3283
|
+
}
|
|
3284
|
+
handleAutocompleteKey(key) {
|
|
3285
|
+
const state = this.autocompleteState;
|
|
3286
|
+
if (key.upArrow || key.downArrow) {
|
|
3287
|
+
const count = state.items.length;
|
|
3288
|
+
const delta = key.upArrow ? -1 : 1;
|
|
3289
|
+
this.autocompleteState = {
|
|
3290
|
+
...state,
|
|
3291
|
+
selected: (state.selected + delta + count) % count
|
|
3292
|
+
};
|
|
3293
|
+
this.changed();
|
|
3294
|
+
return true;
|
|
3295
|
+
}
|
|
3296
|
+
if (key.escape) {
|
|
3297
|
+
this.closeAutocomplete();
|
|
3298
|
+
this.changed();
|
|
3299
|
+
return true;
|
|
3300
|
+
}
|
|
3301
|
+
if (key.tab && !key.shift) {
|
|
3302
|
+
this.acceptAutocomplete();
|
|
3303
|
+
return true;
|
|
3304
|
+
}
|
|
3305
|
+
if (key.return && !key.meta && !key.shift) {
|
|
3306
|
+
const item = state.items[state.selected];
|
|
3307
|
+
const { row, col } = this.buffer.cursor;
|
|
3308
|
+
const before = (this.buffer.lineList[row] ?? "").slice(0, col);
|
|
3309
|
+
if (item && before === `/${item.value}`) {
|
|
3310
|
+
this.closeAutocomplete();
|
|
3311
|
+
return false;
|
|
3312
|
+
}
|
|
3313
|
+
this.acceptAutocomplete();
|
|
3314
|
+
return true;
|
|
3315
|
+
}
|
|
3316
|
+
return false;
|
|
3317
|
+
}
|
|
3318
|
+
acceptAutocomplete() {
|
|
3319
|
+
const state = this.autocompleteState;
|
|
3320
|
+
const provider = this.autocompleteProvider;
|
|
3321
|
+
const item = state?.items[state.selected];
|
|
3322
|
+
this.closeAutocomplete();
|
|
3323
|
+
if (!state || !provider || !item) return;
|
|
3324
|
+
const { row, col } = this.buffer.cursor;
|
|
3325
|
+
const result = provider.applyCompletion(
|
|
3326
|
+
[...this.buffer.lineList],
|
|
3327
|
+
row,
|
|
3328
|
+
col,
|
|
3329
|
+
item,
|
|
3330
|
+
state.prefix
|
|
3331
|
+
);
|
|
3332
|
+
this.buffer.setState(result.lines, result.cursorLine, result.cursorCol);
|
|
3333
|
+
this.changed();
|
|
3334
|
+
if (item.value.replace(/"$/, "").endsWith("/")) this.refreshAutocomplete();
|
|
3335
|
+
}
|
|
3336
|
+
closeAutocomplete() {
|
|
3337
|
+
this.autocompleteRequest++;
|
|
3338
|
+
this.autocompleteAbort?.abort();
|
|
3339
|
+
this.autocompleteAbort = null;
|
|
3340
|
+
this.autocompleteState = null;
|
|
3341
|
+
}
|
|
3342
|
+
/** Looks up suggestions for the text before the cursor; Tab forces paths. */
|
|
3343
|
+
refreshAutocomplete(force = false) {
|
|
3344
|
+
const provider = this.autocompleteProvider;
|
|
3345
|
+
const { row, col } = this.buffer.cursor;
|
|
3346
|
+
const lines = [...this.buffer.lineList];
|
|
3347
|
+
const before = (lines[row] ?? "").slice(0, col);
|
|
3348
|
+
const relevant = force || extractMentionPrefix(before) !== null || row === 0 && before.startsWith("/") && !/\s/.test(before);
|
|
3349
|
+
if (!provider || !relevant) {
|
|
3350
|
+
if (this.autocompleteState) {
|
|
3351
|
+
this.closeAutocomplete();
|
|
3352
|
+
this.changed();
|
|
3353
|
+
}
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
3356
|
+
if (force && !provider.shouldTriggerFileCompletion(lines, row, col)) return;
|
|
3357
|
+
this.autocompleteAbort?.abort();
|
|
3358
|
+
const abort = new AbortController();
|
|
3359
|
+
this.autocompleteAbort = abort;
|
|
3360
|
+
const request = ++this.autocompleteRequest;
|
|
3361
|
+
void provider.getSuggestions(lines, row, col, { signal: abort.signal, force }).then((suggestions) => {
|
|
3362
|
+
if (request !== this.autocompleteRequest) return;
|
|
3363
|
+
this.autocompleteAbort = null;
|
|
3364
|
+
if (!suggestions || suggestions.items.length === 0) {
|
|
3365
|
+
this.autocompleteState = null;
|
|
3366
|
+
} else if (force && suggestions.items.length === 1) {
|
|
3367
|
+
this.autocompleteState = { ...suggestions, selected: 0 };
|
|
3368
|
+
this.acceptAutocomplete();
|
|
3369
|
+
return;
|
|
3370
|
+
} else {
|
|
3371
|
+
this.autocompleteState = { ...suggestions, selected: 0 };
|
|
3372
|
+
}
|
|
3373
|
+
this.changed();
|
|
3374
|
+
}).catch(() => {
|
|
3375
|
+
if (request === this.autocompleteRequest) this.autocompleteState = null;
|
|
3376
|
+
});
|
|
3377
|
+
}
|
|
3378
|
+
submitOrContinue() {
|
|
3379
|
+
const { row, col } = this.buffer.cursor;
|
|
3380
|
+
const line = this.buffer.lineList[row] ?? "";
|
|
3381
|
+
if (col > 0 && line[col - 1] === "\\") {
|
|
3382
|
+
this.buffer.backspace();
|
|
3383
|
+
this.buffer.newline();
|
|
3384
|
+
this.changed();
|
|
3385
|
+
return;
|
|
3386
|
+
}
|
|
3387
|
+
if (this.editingQueueId) {
|
|
3388
|
+
this.finishQueueEdit();
|
|
3389
|
+
return;
|
|
3390
|
+
}
|
|
3391
|
+
const text = this.buffer.text;
|
|
3392
|
+
this.buffer.setText("");
|
|
3393
|
+
this.historyIndex = -1;
|
|
3394
|
+
this.historyDraft = "";
|
|
3395
|
+
this.closeAutocomplete();
|
|
3396
|
+
this.changed();
|
|
3397
|
+
this.onSubmit?.(text);
|
|
3398
|
+
}
|
|
3399
|
+
historyOlder() {
|
|
3400
|
+
if (this.history.length === 0) return;
|
|
3401
|
+
if (this.historyIndex === -1) {
|
|
3402
|
+
this.historyDraft = this.buffer.text;
|
|
3403
|
+
this.historyIndex = this.history.length - 1;
|
|
3404
|
+
} else if (this.historyIndex > 0) {
|
|
3405
|
+
this.historyIndex--;
|
|
3406
|
+
} else {
|
|
3407
|
+
return;
|
|
3408
|
+
}
|
|
3409
|
+
this.buffer.setText(this.history[this.historyIndex]);
|
|
3410
|
+
}
|
|
3411
|
+
historyNewer() {
|
|
3412
|
+
if (this.historyIndex === -1) return;
|
|
3413
|
+
if (this.historyIndex < this.history.length - 1) {
|
|
3414
|
+
this.historyIndex++;
|
|
3415
|
+
this.buffer.setText(this.history[this.historyIndex]);
|
|
3416
|
+
} else {
|
|
3417
|
+
this.historyIndex = -1;
|
|
3418
|
+
this.buffer.setText(this.historyDraft);
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
navigateQueuedMessages(direction) {
|
|
3422
|
+
const items = this.queuedMessages?.() ?? [];
|
|
3423
|
+
if (items.length === 0) {
|
|
3424
|
+
if (this.editingQueueId) this.finishQueueEdit();
|
|
3425
|
+
return false;
|
|
3426
|
+
}
|
|
3427
|
+
if (!this.editingQueueId) {
|
|
3428
|
+
if (direction > 0) return false;
|
|
3429
|
+
this.queueDraft = this.buffer.text;
|
|
3430
|
+
this.selectQueuedMessage(items.at(-1));
|
|
3431
|
+
return true;
|
|
3432
|
+
}
|
|
3433
|
+
const currentIndex = items.findIndex(
|
|
3434
|
+
(item) => item.id === this.editingQueueId
|
|
3435
|
+
);
|
|
3436
|
+
if (currentIndex < 0) {
|
|
3437
|
+
this.editingQueueId = null;
|
|
3438
|
+
this.setText(this.queueDraft);
|
|
3439
|
+
return true;
|
|
3440
|
+
}
|
|
3441
|
+
const nextIndex = currentIndex + direction;
|
|
3442
|
+
if (nextIndex < 0) return true;
|
|
3443
|
+
if (nextIndex >= items.length) {
|
|
3444
|
+
this.finishQueueEdit();
|
|
3445
|
+
return true;
|
|
3446
|
+
}
|
|
3447
|
+
const previousId = this.editingQueueId;
|
|
3448
|
+
const previousText = this.buffer.text;
|
|
3449
|
+
this.editingQueueId = null;
|
|
3450
|
+
this.onQueuedMessageEditFinish?.(previousId, previousText);
|
|
3451
|
+
this.selectQueuedMessage(items[nextIndex]);
|
|
3452
|
+
return true;
|
|
3453
|
+
}
|
|
3454
|
+
selectQueuedMessage(item) {
|
|
3455
|
+
this.editingQueueId = item.id;
|
|
3456
|
+
this.onQueuedMessageEditStart?.(item.id);
|
|
3457
|
+
this.setText(item.text);
|
|
3458
|
+
}
|
|
3459
|
+
finishQueueEdit() {
|
|
3460
|
+
const id = this.editingQueueId;
|
|
3461
|
+
if (!id) return;
|
|
3462
|
+
const text = this.buffer.text;
|
|
3463
|
+
const draft = this.queueDraft;
|
|
3464
|
+
this.editingQueueId = null;
|
|
3465
|
+
this.queueDraft = "";
|
|
3466
|
+
this.onQueuedMessageEditFinish?.(id, text);
|
|
3467
|
+
this.setText(draft);
|
|
3468
|
+
}
|
|
3469
|
+
edit(apply) {
|
|
3470
|
+
apply();
|
|
3471
|
+
this.historyIndex = -1;
|
|
3472
|
+
this.changed();
|
|
3473
|
+
this.refreshAutocomplete();
|
|
3474
|
+
return true;
|
|
3475
|
+
}
|
|
3476
|
+
move(apply) {
|
|
3477
|
+
apply();
|
|
3478
|
+
this.closeAutocomplete();
|
|
3479
|
+
this.changed();
|
|
3480
|
+
return true;
|
|
3481
|
+
}
|
|
3482
|
+
changed() {
|
|
3483
|
+
this.version++;
|
|
3484
|
+
const text = this.buffer.text;
|
|
3485
|
+
if (text !== this.lastText) {
|
|
3486
|
+
this.lastText = text;
|
|
3487
|
+
this.onChange?.(text);
|
|
3488
|
+
}
|
|
3489
|
+
for (const listener of this.listeners) listener();
|
|
3490
|
+
}
|
|
3491
|
+
};
|
|
3492
|
+
function stripControlCharacters(input) {
|
|
3493
|
+
return input.replace(/\r\n?/g, "\n").replace(/\t/g, " ").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "");
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
// src/tui/ink/prompt-submission.ts
|
|
3497
|
+
function bindPromptSubmission({
|
|
3498
|
+
editor,
|
|
3499
|
+
draftImages,
|
|
3500
|
+
runner,
|
|
3501
|
+
store,
|
|
3502
|
+
submit,
|
|
3503
|
+
appendError
|
|
3504
|
+
}) {
|
|
3505
|
+
let submissionPending = false;
|
|
3506
|
+
editor.onSubmit = (text) => {
|
|
3507
|
+
if (submissionPending) return;
|
|
3508
|
+
submissionPending = true;
|
|
3509
|
+
let ownsSubmissionLock = true;
|
|
3510
|
+
const releaseSubmissionLock = () => {
|
|
3511
|
+
if (!ownsSubmissionLock) return;
|
|
3512
|
+
ownsSubmissionLock = false;
|
|
3513
|
+
submissionPending = false;
|
|
3514
|
+
};
|
|
3515
|
+
void (async () => {
|
|
3516
|
+
const images = text.trimStart().startsWith("/") ? [] : draftImages.referencedBy(text);
|
|
3517
|
+
const pastes = editor.referencedPastes(text);
|
|
3518
|
+
if (images.length > 0 && !await runner.supportsImageInput()) {
|
|
3519
|
+
editor.setText(text);
|
|
3520
|
+
store.setState({
|
|
3521
|
+
statusLine: `Model ${runner.model} does not support image input; the draft was kept.`
|
|
3522
|
+
});
|
|
3523
|
+
return;
|
|
3524
|
+
}
|
|
3525
|
+
editor.setText("");
|
|
3526
|
+
draftImages.clear();
|
|
3527
|
+
editor.clearPastes();
|
|
3528
|
+
const completion = submit(text, images, pastes);
|
|
3529
|
+
releaseSubmissionLock();
|
|
3530
|
+
await completion;
|
|
3531
|
+
})().catch((error) => {
|
|
3532
|
+
appendError(
|
|
3533
|
+
error instanceof Error ? error.message : "Failed to submit input."
|
|
3534
|
+
);
|
|
3535
|
+
store.setState({ busy: false, statusLine: "Input failed." });
|
|
3536
|
+
}).finally(releaseSubmissionLock);
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
// src/tui/ink/transcript/committer.ts
|
|
3541
|
+
var FINAL_JOB_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "killed", "released"]);
|
|
3542
|
+
var TranscriptCommitter = class {
|
|
3543
|
+
epoch = 0;
|
|
3544
|
+
committed = [];
|
|
3545
|
+
committedView = [];
|
|
3546
|
+
/** Ids of the messages whose blocks are all committed, in order. */
|
|
3547
|
+
committedIds = [];
|
|
3548
|
+
/** How much of a streaming assistant message is already committed. */
|
|
3549
|
+
partial = null;
|
|
3550
|
+
backgroundStatus = /* @__PURE__ */ new Map();
|
|
3551
|
+
sessionId = null;
|
|
3552
|
+
expanded = false;
|
|
3553
|
+
planSignature = null;
|
|
3554
|
+
snapshot = null;
|
|
3555
|
+
lastState = null;
|
|
3556
|
+
redrawRequested = false;
|
|
3557
|
+
/** The next sync starts a new epoch, e.g. after the terminal width changed. */
|
|
3558
|
+
requestRedraw() {
|
|
3559
|
+
this.redrawRequested = true;
|
|
3560
|
+
this.lastState = null;
|
|
3561
|
+
}
|
|
3562
|
+
sync(state, expanded) {
|
|
3563
|
+
if (this.snapshot && state === this.lastState && expanded === this.expanded)
|
|
3564
|
+
return this.snapshot;
|
|
3565
|
+
this.lastState = state;
|
|
3566
|
+
if (this.redrawRequested || state.sessionId !== this.sessionId || expanded !== this.expanded || !this.prefixIntact(state.messages)) {
|
|
3567
|
+
this.startEpoch(state, expanded);
|
|
3568
|
+
}
|
|
3569
|
+
const messages = state.messages;
|
|
3570
|
+
this.commitBackgroundChanges(messages);
|
|
3571
|
+
const live = [];
|
|
3572
|
+
let committing = true;
|
|
3573
|
+
const emit = (block, final, consumed) => {
|
|
3574
|
+
if (committing && final) {
|
|
3575
|
+
this.committed.push(block);
|
|
3576
|
+
this.committedIds.push(...consumed);
|
|
3577
|
+
} else {
|
|
3578
|
+
committing = false;
|
|
3579
|
+
live.push(block);
|
|
3580
|
+
}
|
|
3581
|
+
};
|
|
3582
|
+
let index = this.committedIds.length;
|
|
3583
|
+
while (index < messages.length) {
|
|
3584
|
+
const message = messages[index];
|
|
3585
|
+
if (message.role === "tool" && !expanded && isActivityTool(message.call)) {
|
|
3586
|
+
const calls = [];
|
|
3587
|
+
const ids = [];
|
|
3588
|
+
let end = index;
|
|
3589
|
+
while (end < messages.length) {
|
|
3590
|
+
const next = messages[end];
|
|
3591
|
+
if (next.role !== "tool" || !isActivityTool(next.call)) break;
|
|
3592
|
+
calls.push(next.call);
|
|
3593
|
+
ids.push(next.id);
|
|
3594
|
+
end++;
|
|
3595
|
+
}
|
|
3596
|
+
const following = messages[end];
|
|
3597
|
+
const closed = calls.every((call) => call.status !== "pending") && (following !== void 0 && !isQueued(following) || !state.busy);
|
|
3598
|
+
emit({ key: `activity:${message.id}`, kind: "activity", calls }, closed, ids);
|
|
3599
|
+
index = end;
|
|
3600
|
+
continue;
|
|
3601
|
+
}
|
|
3602
|
+
switch (message.role) {
|
|
3603
|
+
case "user":
|
|
3604
|
+
if (message.queued) {
|
|
3605
|
+
emit(
|
|
3606
|
+
{ key: message.id, kind: "queued", text: message.text, queued: message.queued },
|
|
3607
|
+
false,
|
|
3608
|
+
[]
|
|
3609
|
+
);
|
|
3610
|
+
} else {
|
|
3611
|
+
emit({ key: message.id, kind: "user", text: message.text }, true, [message.id]);
|
|
3612
|
+
}
|
|
3613
|
+
break;
|
|
3614
|
+
case "error":
|
|
3615
|
+
emit({ key: message.id, kind: "error", text: message.text }, true, [message.id]);
|
|
3616
|
+
break;
|
|
3617
|
+
case "tool":
|
|
3618
|
+
emit(
|
|
3619
|
+
{ key: message.id, kind: "tool", call: message.call, expanded },
|
|
3620
|
+
message.call.status !== "pending",
|
|
3621
|
+
[message.id]
|
|
3622
|
+
);
|
|
3623
|
+
break;
|
|
3624
|
+
case "background":
|
|
3625
|
+
if (committing) this.backgroundStatus.set(message.job.jobId, message.job.status);
|
|
3626
|
+
emit(
|
|
3627
|
+
{ key: message.id, kind: "background", job: message.job, expanded },
|
|
3628
|
+
true,
|
|
3629
|
+
[message.id]
|
|
3630
|
+
);
|
|
3631
|
+
break;
|
|
3632
|
+
case "assistant":
|
|
3633
|
+
this.emitAssistant(message, committing, emit);
|
|
3634
|
+
if (message.streaming) committing = false;
|
|
3635
|
+
break;
|
|
3636
|
+
}
|
|
3637
|
+
index++;
|
|
3638
|
+
}
|
|
3639
|
+
this.emitPlan(state, committing, live);
|
|
3640
|
+
if (this.committedView.length !== this.committed.length)
|
|
3641
|
+
this.committedView = this.committed.slice();
|
|
3642
|
+
this.snapshot = { epoch: this.epoch, committed: this.committedView, live };
|
|
3643
|
+
return this.snapshot;
|
|
3644
|
+
}
|
|
3645
|
+
emitAssistant(message, committing, emit) {
|
|
3646
|
+
let offset = this.partial?.id === message.id ? this.partial.offset : 0;
|
|
3647
|
+
const block = (text, streaming) => ({
|
|
3648
|
+
key: `${message.id}:${offset}`,
|
|
3649
|
+
kind: "assistant",
|
|
3650
|
+
text,
|
|
3651
|
+
first: offset === 0,
|
|
3652
|
+
streaming
|
|
3653
|
+
});
|
|
3654
|
+
if (!message.streaming) {
|
|
3655
|
+
const rest2 = message.text.slice(offset);
|
|
3656
|
+
if (committing && this.partial?.id === message.id) this.partial = null;
|
|
3657
|
+
if (rest2.trim()) emit(block(rest2, false), true, [message.id]);
|
|
3658
|
+
else if (committing) this.committedIds.push(message.id);
|
|
3659
|
+
return;
|
|
3660
|
+
}
|
|
3661
|
+
if (committing) {
|
|
3662
|
+
const split = safeSplitOffset(message.text, offset);
|
|
3663
|
+
if (split > offset && message.text.slice(offset, split).trim()) {
|
|
3664
|
+
emit(block(message.text.slice(offset, split), false), true, []);
|
|
3665
|
+
offset = split;
|
|
3666
|
+
this.partial = { id: message.id, offset };
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
const rest = message.text.slice(offset);
|
|
3670
|
+
emit(block(rest, true), false, []);
|
|
3671
|
+
}
|
|
3672
|
+
emitPlan(state, committing, live) {
|
|
3673
|
+
if (state.plan.length === 0) return;
|
|
3674
|
+
const signature = JSON.stringify(state.plan);
|
|
3675
|
+
if (signature === this.planSignature) return;
|
|
3676
|
+
const block = {
|
|
3677
|
+
key: `plan:${this.committed.length}:${signature.length}`,
|
|
3678
|
+
kind: "plan",
|
|
3679
|
+
entries: state.plan
|
|
3680
|
+
};
|
|
3681
|
+
if (committing && !state.busy) {
|
|
3682
|
+
this.committed.push(block);
|
|
3683
|
+
this.planSignature = signature;
|
|
3684
|
+
} else {
|
|
3685
|
+
live.push(block);
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
/** A background job that finishes after it was printed gets a new line. */
|
|
3689
|
+
commitBackgroundChanges(messages) {
|
|
3690
|
+
for (let index = 0; index < this.committedIds.length; index++) {
|
|
3691
|
+
const message = messages[index];
|
|
3692
|
+
if (message?.role !== "background") continue;
|
|
3693
|
+
const { job } = message;
|
|
3694
|
+
const previous = this.backgroundStatus.get(job.jobId);
|
|
3695
|
+
if (previous === job.status) continue;
|
|
3696
|
+
this.backgroundStatus.set(job.jobId, job.status);
|
|
3697
|
+
if (!FINAL_JOB_STATUSES.has(job.status)) continue;
|
|
3698
|
+
this.committed.push({
|
|
3699
|
+
key: `${message.id}:${job.status}`,
|
|
3700
|
+
kind: "background",
|
|
3701
|
+
job,
|
|
3702
|
+
expanded: this.expanded
|
|
3703
|
+
});
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
prefixIntact(messages) {
|
|
3707
|
+
if (messages.length < this.committedIds.length) return false;
|
|
3708
|
+
for (let index = 0; index < this.committedIds.length; index++) {
|
|
3709
|
+
if (messages[index].id !== this.committedIds[index]) return false;
|
|
3710
|
+
}
|
|
3711
|
+
if (this.partial) {
|
|
3712
|
+
const message = messages[this.committedIds.length];
|
|
3713
|
+
if (message?.id !== this.partial.id || message.role !== "assistant" || message.text.length < this.partial.offset)
|
|
3714
|
+
return false;
|
|
3715
|
+
}
|
|
3716
|
+
return true;
|
|
3717
|
+
}
|
|
3718
|
+
startEpoch(state, expanded) {
|
|
3719
|
+
this.redrawRequested = false;
|
|
3720
|
+
this.epoch++;
|
|
3721
|
+
this.sessionId = state.sessionId;
|
|
3722
|
+
this.expanded = expanded;
|
|
3723
|
+
this.committed = [{ key: `header:${this.epoch}`, kind: "header", cwd: state.cwd }];
|
|
3724
|
+
this.committedView = [];
|
|
3725
|
+
this.committedIds = [];
|
|
3726
|
+
this.partial = null;
|
|
3727
|
+
this.backgroundStatus.clear();
|
|
3728
|
+
this.planSignature = null;
|
|
3729
|
+
}
|
|
3730
|
+
};
|
|
3731
|
+
function isQueued(message) {
|
|
3732
|
+
return message.role === "user" && message.queued !== void 0;
|
|
3733
|
+
}
|
|
3734
|
+
function safeSplitOffset(text, from) {
|
|
3735
|
+
let inFence = false;
|
|
3736
|
+
let fenceMarker = "";
|
|
3737
|
+
let lineStart = 0;
|
|
3738
|
+
let split = from;
|
|
3739
|
+
let previousBlank = false;
|
|
3740
|
+
while (lineStart <= text.length) {
|
|
3741
|
+
const newline = text.indexOf("\n", lineStart);
|
|
3742
|
+
if (newline === -1) break;
|
|
3743
|
+
const line = text.slice(lineStart, newline);
|
|
3744
|
+
const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
|
|
3745
|
+
if (fence) {
|
|
3746
|
+
if (!inFence) {
|
|
3747
|
+
inFence = true;
|
|
3748
|
+
fenceMarker = fence[1][0].repeat(fence[1].length);
|
|
3749
|
+
} else if (line.trim().startsWith(fenceMarker)) {
|
|
3750
|
+
inFence = false;
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
const blank = line.trim() === "";
|
|
3754
|
+
const next = newline + 1;
|
|
3755
|
+
if (!inFence && blank && !previousBlank && next > from && lineStart > from) {
|
|
3756
|
+
split = next;
|
|
3757
|
+
}
|
|
3758
|
+
previousBlank = blank;
|
|
3759
|
+
lineStart = next;
|
|
3760
|
+
}
|
|
3761
|
+
return split;
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
// src/tui/ink/controller.ts
|
|
3765
|
+
var CLEAR_SCREEN_AND_SCROLLBACK = "\x1B[2J\x1B[3J\x1B[H";
|
|
3766
|
+
var EXIT_CONFIRM_MS = 2e3;
|
|
3767
|
+
var TuiController = class {
|
|
3768
|
+
constructor(store, runner, io) {
|
|
3769
|
+
this.store = store;
|
|
3770
|
+
this.runner = runner;
|
|
3771
|
+
this.io = io;
|
|
3772
|
+
this.view = this.buildView();
|
|
3773
|
+
this.lastEpoch = this.view.transcript.epoch;
|
|
3774
|
+
this.configureEditor();
|
|
3775
|
+
this.unsubscribeStore = store.subscribe(() => this.refresh());
|
|
3776
|
+
}
|
|
3777
|
+
store;
|
|
3778
|
+
runner;
|
|
3779
|
+
io;
|
|
3780
|
+
editor = new PromptEditor();
|
|
3781
|
+
draftImages = new DraftImageAttachments();
|
|
3782
|
+
committer = new TranscriptCommitter();
|
|
3783
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3784
|
+
overlay = null;
|
|
3785
|
+
question = null;
|
|
3786
|
+
expandedTools = false;
|
|
3787
|
+
exiting = false;
|
|
3788
|
+
view;
|
|
3789
|
+
lastEpoch = 0;
|
|
3790
|
+
exitArmed = false;
|
|
3791
|
+
exitTimer;
|
|
3792
|
+
animationTimer;
|
|
3793
|
+
windowTitle;
|
|
3794
|
+
windowTitleBusy = false;
|
|
3795
|
+
windowTitleFrame = 0;
|
|
3796
|
+
imagePasteActive = false;
|
|
3797
|
+
stopped = false;
|
|
3798
|
+
unsubscribeStore;
|
|
3799
|
+
resolveExit = () => {
|
|
3800
|
+
};
|
|
3801
|
+
/** Settles once the user asked to quit; `run` then tears the view down. */
|
|
3802
|
+
exitRequested = new Promise((resolve2) => {
|
|
3803
|
+
this.resolveExit = resolve2;
|
|
3804
|
+
});
|
|
3805
|
+
subscribe = (listener) => {
|
|
3806
|
+
this.listeners.add(listener);
|
|
3807
|
+
return () => this.listeners.delete(listener);
|
|
3808
|
+
};
|
|
3809
|
+
getView = () => this.view;
|
|
3810
|
+
start() {
|
|
3811
|
+
void this.runner.ensureUsableModel().then(() => this.refresh());
|
|
3812
|
+
this.syncWindowTitle();
|
|
3813
|
+
this.animationTimer = setInterval(() => this.syncWindowTitle(true), 120);
|
|
3814
|
+
this.animationTimer.unref?.();
|
|
3815
|
+
}
|
|
3816
|
+
/** Stops timers and the session; the caller unmounts the view afterwards. */
|
|
3817
|
+
async stop() {
|
|
3818
|
+
if (this.stopped) return;
|
|
3819
|
+
this.stopped = true;
|
|
3820
|
+
if (this.exitTimer) clearTimeout(this.exitTimer);
|
|
3821
|
+
if (this.animationTimer) clearInterval(this.animationTimer);
|
|
3822
|
+
this.setWindowTitle(`${ASSISTANT_MARKER} Nova AI`);
|
|
3823
|
+
this.unsubscribeStore();
|
|
3824
|
+
await this.runner.close().catch(() => {
|
|
3825
|
+
});
|
|
3826
|
+
}
|
|
3827
|
+
requestExit() {
|
|
3828
|
+
if (this.exiting) return;
|
|
3829
|
+
this.exiting = true;
|
|
3830
|
+
this.refresh();
|
|
3831
|
+
this.resolveExit();
|
|
3832
|
+
}
|
|
3833
|
+
// ---------------------------------------------------------------- keys
|
|
3834
|
+
/** Shortcuts that work while the prompt has focus; false lets the editor have the key. */
|
|
3835
|
+
handlePromptKey(input, key) {
|
|
3836
|
+
if (key.ctrl && input === "c") {
|
|
3837
|
+
this.pressCtrlC();
|
|
3838
|
+
return true;
|
|
3839
|
+
}
|
|
3840
|
+
if (key.escape) {
|
|
3841
|
+
if (this.editor.autocomplete) return false;
|
|
3842
|
+
if (this.store.getState().busy) {
|
|
3843
|
+
this.runner.cancel();
|
|
3844
|
+
return true;
|
|
3845
|
+
}
|
|
3846
|
+
return false;
|
|
3847
|
+
}
|
|
3848
|
+
if (key.tab && key.shift) {
|
|
3849
|
+
this.cycleInteractionMode();
|
|
3850
|
+
return true;
|
|
3851
|
+
}
|
|
3852
|
+
if (key.meta && input === "v") {
|
|
3853
|
+
void this.pasteClipboardImage();
|
|
3854
|
+
return true;
|
|
3855
|
+
}
|
|
3856
|
+
if (!key.ctrl) return false;
|
|
3857
|
+
switch (input) {
|
|
3858
|
+
case "r":
|
|
3859
|
+
this.openSessions();
|
|
3860
|
+
return true;
|
|
3861
|
+
case "p":
|
|
3862
|
+
void this.openModels();
|
|
3863
|
+
return true;
|
|
3864
|
+
case "k":
|
|
3865
|
+
this.openPermissionModes();
|
|
3866
|
+
return true;
|
|
3867
|
+
case "b":
|
|
3868
|
+
void this.openBackgroundTasks();
|
|
3869
|
+
return true;
|
|
3870
|
+
case "o":
|
|
3871
|
+
this.toggleToolDetails();
|
|
3872
|
+
return true;
|
|
3873
|
+
case "t":
|
|
3874
|
+
this.openToolInspector();
|
|
3875
|
+
return true;
|
|
3876
|
+
default:
|
|
3877
|
+
return false;
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
pressCtrlC() {
|
|
3881
|
+
if (this.exitArmed) {
|
|
3882
|
+
this.requestExit();
|
|
3883
|
+
return;
|
|
3884
|
+
}
|
|
3885
|
+
if (this.store.getState().busy) this.runner.cancel();
|
|
3886
|
+
this.exitArmed = true;
|
|
3887
|
+
this.store.setState({ statusLine: "Press Ctrl+C again within 2s to exit." });
|
|
3888
|
+
if (this.exitTimer) clearTimeout(this.exitTimer);
|
|
3889
|
+
this.exitTimer = setTimeout(() => {
|
|
3890
|
+
this.exitArmed = false;
|
|
3891
|
+
if (this.store.getState().statusLine?.startsWith("Press Ctrl+C"))
|
|
3892
|
+
this.store.setState({ statusLine: null });
|
|
3893
|
+
}, EXIT_CONFIRM_MS);
|
|
3894
|
+
this.exitTimer.unref?.();
|
|
3895
|
+
}
|
|
3896
|
+
closeOverlay = () => {
|
|
3897
|
+
this.overlay = null;
|
|
3898
|
+
this.store.setState({ mode: "chat" });
|
|
3899
|
+
this.refresh();
|
|
3900
|
+
};
|
|
3901
|
+
// ------------------------------------------------------------ overlays
|
|
3902
|
+
showOverlay(overlay, mode = "chat") {
|
|
3903
|
+
this.overlay = overlay;
|
|
3904
|
+
this.store.setState({ mode });
|
|
3905
|
+
this.refresh();
|
|
3906
|
+
}
|
|
3907
|
+
showText(title, content) {
|
|
3908
|
+
this.showOverlay({
|
|
3909
|
+
kind: "text",
|
|
3910
|
+
title,
|
|
3911
|
+
content: content || "(no output)",
|
|
3912
|
+
state: new ScrollState(this.closeOverlay)
|
|
3913
|
+
});
|
|
3914
|
+
}
|
|
3915
|
+
showSelection(title, items, mode, onSelect) {
|
|
3916
|
+
if (items.length === 0) {
|
|
3917
|
+
this.showText(title, "Nothing to show.");
|
|
3918
|
+
return;
|
|
3919
|
+
}
|
|
3920
|
+
const state = new SelectionState(
|
|
3921
|
+
items,
|
|
3922
|
+
(item) => {
|
|
3923
|
+
this.closeOverlay();
|
|
3924
|
+
onSelect(item.value);
|
|
3925
|
+
},
|
|
3926
|
+
this.closeOverlay
|
|
3927
|
+
);
|
|
3928
|
+
this.showOverlay({ kind: "selection", title, state }, mode);
|
|
3929
|
+
}
|
|
3930
|
+
openSessions = () => {
|
|
3931
|
+
this.showSelection(
|
|
3932
|
+
"Sessions \xB7 age | prompt | datetime",
|
|
3933
|
+
listSessionPickerItems(this.runner.cwd),
|
|
3934
|
+
"session-switcher",
|
|
3935
|
+
(sessionId) => {
|
|
3936
|
+
if (!this.runner.resumeFrom(sessionId))
|
|
3937
|
+
this.appendError(`Session not found: ${sessionId}`);
|
|
3938
|
+
}
|
|
3939
|
+
);
|
|
3940
|
+
};
|
|
3941
|
+
openModels = async () => {
|
|
3942
|
+
this.showText("Models", "Loading models...");
|
|
3943
|
+
try {
|
|
3944
|
+
const models = await this.runner.listModels();
|
|
3945
|
+
this.showSelection(
|
|
3946
|
+
"Models",
|
|
3947
|
+
models.map((model) => ({
|
|
3948
|
+
value: model.id,
|
|
3949
|
+
label: model.id,
|
|
3950
|
+
description: model.name ?? void 0
|
|
3951
|
+
})),
|
|
3952
|
+
"model-picker",
|
|
3953
|
+
(model) => this.setModel(model)
|
|
3954
|
+
);
|
|
3955
|
+
} catch (error) {
|
|
3956
|
+
this.showText("Models", errorMessage(error, "Failed to list models."));
|
|
3957
|
+
}
|
|
3958
|
+
};
|
|
3959
|
+
openPermissionModes = () => {
|
|
3960
|
+
const modes = [
|
|
3961
|
+
{ value: "ask", label: "ask", description: "Prompt before tools make changes" },
|
|
3962
|
+
{ value: "acceptEdits", label: "acceptEdits", description: "Allow file edits automatically" },
|
|
3963
|
+
{ value: "bypassAll", label: "bypassAll", description: "Allow all tools automatically" }
|
|
3964
|
+
];
|
|
3965
|
+
this.showSelection(
|
|
3966
|
+
"Permission mode",
|
|
3967
|
+
modes,
|
|
3968
|
+
"permission-picker",
|
|
3969
|
+
(value) => this.setPermissionMode(value)
|
|
3970
|
+
);
|
|
3971
|
+
};
|
|
3972
|
+
openBackgroundTasks = async () => {
|
|
3973
|
+
try {
|
|
3974
|
+
const jobs = await this.runner.listBackgroundJobs();
|
|
3975
|
+
this.showSelection(
|
|
3976
|
+
"Background shells and agents",
|
|
3977
|
+
jobs.map((job) => ({
|
|
3978
|
+
value: job.jobId,
|
|
3979
|
+
label: `${job.kind}: ${job.title}`,
|
|
3980
|
+
description: job.status
|
|
3981
|
+
})),
|
|
3982
|
+
"background-tasks",
|
|
3983
|
+
(jobId) => {
|
|
3984
|
+
void this.runner.backgroundOutput(jobId).then(
|
|
3985
|
+
(result) => this.showText(
|
|
3986
|
+
`${result.job.kind}: ${result.job.title}`,
|
|
3987
|
+
`jobId: ${jobId}
|
|
3988
|
+
status: ${result.job.status}
|
|
3989
|
+
outputPath: ${result.outputPath ?? result.job.outputPath ?? ""}
|
|
3990
|
+
|
|
3991
|
+
${result.output}${result.truncated ? "\n\n[output truncated]" : ""}`
|
|
3992
|
+
)
|
|
3993
|
+
).catch(
|
|
3994
|
+
(error) => this.showText("Background job", errorMessage(error, "Failed to read output."))
|
|
3995
|
+
);
|
|
3996
|
+
}
|
|
3997
|
+
);
|
|
3998
|
+
} catch (error) {
|
|
3999
|
+
this.showText("Background jobs", errorMessage(error, "Failed to list background jobs."));
|
|
4000
|
+
}
|
|
4001
|
+
};
|
|
4002
|
+
openToolInspector = () => {
|
|
4003
|
+
if (this.toolCalls().length === 0) {
|
|
4004
|
+
this.showText("Tool calls", "No tool calls yet.");
|
|
4005
|
+
return;
|
|
4006
|
+
}
|
|
4007
|
+
this.showOverlay({
|
|
4008
|
+
kind: "tools",
|
|
4009
|
+
state: new ToolInspectorState(() => this.toolCalls(), this.closeOverlay)
|
|
4010
|
+
});
|
|
4011
|
+
};
|
|
4012
|
+
openMcpInspector = () => {
|
|
4013
|
+
const servers = this.runner.configuredMcpServers();
|
|
4014
|
+
const connection = this.runner.mcpSessionStatus();
|
|
4015
|
+
const configuredNames = new Set(servers.map((server) => server.name));
|
|
4016
|
+
const configurationFailures = connection.failures.filter((failure) => !configuredNames.has(failure.serverName)).map((failure) => `${failure.serverName}
|
|
4017
|
+
status: failed - ${failure.message}`);
|
|
4018
|
+
const rows = [
|
|
4019
|
+
...servers.map((server) => formatMcpServer(server, connection)),
|
|
4020
|
+
...configurationFailures
|
|
4021
|
+
];
|
|
4022
|
+
this.showText(
|
|
4023
|
+
"MCP servers",
|
|
4024
|
+
rows.length ? rows.join("\n\n") : "No MCP servers configured for this workspace.\n\nAdd a project .mcp.json or mcpServers to .nova-ai/settings.json; ACP connects them when the session starts."
|
|
4025
|
+
);
|
|
4026
|
+
};
|
|
4027
|
+
openSkillInspector = () => {
|
|
4028
|
+
const skills = this.runner.skillSessionStatus();
|
|
4029
|
+
this.showText(
|
|
4030
|
+
`Skills (${skills.length})`,
|
|
4031
|
+
skills.length ? skills.map((skill) => `${skill.name} [${skill.source}]
|
|
4032
|
+
${skill.description}
|
|
4033
|
+
${skill.path}`).join("\n\n") : "No skills discovered. Add SKILL.md under .agents/skills, .claude/skills, or .codex/skills in the workspace or user profile."
|
|
4034
|
+
);
|
|
4035
|
+
};
|
|
4036
|
+
openUsageInspector = async () => {
|
|
4037
|
+
this.showText("Context usage", "Calculating context usage...");
|
|
4038
|
+
try {
|
|
4039
|
+
const usage = await this.runner.refreshContextUsage();
|
|
4040
|
+
this.showText("Context usage", formatContextUsage(usage));
|
|
4041
|
+
} catch (error) {
|
|
4042
|
+
this.showText("Context usage", errorMessage(error, "Failed to calculate context usage."));
|
|
4043
|
+
}
|
|
4044
|
+
};
|
|
4045
|
+
/**
|
|
4046
|
+
* Prints the whole transcript again. After the width changed, the terminal
|
|
4047
|
+
* re-wrapped what is on screen on its own and the live area can't be
|
|
4048
|
+
* erased exactly (Ink leaves stale prompt borders), and old scrollback keeps
|
|
4049
|
+
* the old wrapping.
|
|
4050
|
+
*/
|
|
4051
|
+
redrawAll() {
|
|
4052
|
+
if (this.stopped || this.exiting) return;
|
|
4053
|
+
this.committer.requestRedraw();
|
|
4054
|
+
this.refresh();
|
|
4055
|
+
}
|
|
4056
|
+
toggleToolDetails() {
|
|
4057
|
+
this.expandedTools = !this.expandedTools;
|
|
4058
|
+
this.store.setState({
|
|
4059
|
+
statusLine: this.expandedTools ? "Verbose tool details expanded; read/search stay compact." : "Verbose tool details collapsed."
|
|
4060
|
+
});
|
|
4061
|
+
}
|
|
4062
|
+
toolCalls() {
|
|
4063
|
+
return this.store.getState().messages.flatMap((message) => message.role === "tool" ? [message.call] : []);
|
|
4064
|
+
}
|
|
4065
|
+
// ------------------------------------------------------- session state
|
|
4066
|
+
setModel(model) {
|
|
4067
|
+
this.runner.setModel(model);
|
|
4068
|
+
this.store.setState({ statusLine: `Model: ${model}` });
|
|
4069
|
+
}
|
|
4070
|
+
setPermissionMode(mode) {
|
|
4071
|
+
this.runner.setPermissionMode(mode);
|
|
4072
|
+
this.store.setState({ statusLine: `Permission mode: ${mode}` });
|
|
4073
|
+
}
|
|
4074
|
+
setInteractionMode(mode) {
|
|
4075
|
+
this.runner.setInteractionMode(mode);
|
|
4076
|
+
}
|
|
4077
|
+
cycleInteractionMode() {
|
|
4078
|
+
const current = INTERACTION_MODES.indexOf(this.runner.interactionMode);
|
|
4079
|
+
this.setInteractionMode(INTERACTION_MODES[(current + 1) % INTERACTION_MODES.length]);
|
|
4080
|
+
}
|
|
4081
|
+
appendAssistant(text) {
|
|
4082
|
+
this.store.setState((state) => ({
|
|
4083
|
+
messages: [
|
|
4084
|
+
...state.messages,
|
|
4085
|
+
{ id: `command-${Date.now()}-${state.messages.length}`, role: "assistant", text, streaming: false }
|
|
4086
|
+
]
|
|
4087
|
+
}));
|
|
4088
|
+
}
|
|
4089
|
+
appendError = (text) => {
|
|
4090
|
+
this.store.setState((state) => ({
|
|
4091
|
+
messages: [
|
|
4092
|
+
...state.messages,
|
|
4093
|
+
{ id: `error-${Date.now()}-${state.messages.length}`, role: "error", text }
|
|
4094
|
+
]
|
|
4095
|
+
}));
|
|
4096
|
+
};
|
|
4097
|
+
commandContext = {
|
|
4098
|
+
print: (text) => this.appendAssistant(text),
|
|
4099
|
+
clear: () => void this.runner.startNewSession(),
|
|
4100
|
+
newSession: () => this.runner.startNewSession(),
|
|
4101
|
+
exit: () => this.requestExit(),
|
|
4102
|
+
resumeSession: (sessionId) => {
|
|
4103
|
+
const id = sessionId ?? listSessionsForCwd(this.runner.cwd)[0]?.sessionId;
|
|
4104
|
+
return id ? this.runner.resumeFrom(id) : false;
|
|
4105
|
+
},
|
|
4106
|
+
openSessionSwitcher: this.openSessions,
|
|
4107
|
+
openModelPicker: () => void this.openModels(),
|
|
4108
|
+
getModel: () => this.runner.model,
|
|
4109
|
+
setModel: (model) => this.setModel(model),
|
|
4110
|
+
getInteractionMode: () => this.runner.interactionMode,
|
|
4111
|
+
setInteractionMode: (mode) => this.setInteractionMode(mode),
|
|
4112
|
+
getPermissionMode: () => this.store.getState().permissionMode,
|
|
4113
|
+
setPermissionMode: (mode) => this.setPermissionMode(mode),
|
|
4114
|
+
openPermissionPicker: this.openPermissionModes,
|
|
4115
|
+
trustWorkspace: () => {
|
|
4116
|
+
setWorkspaceTrusted(this.runner.cwd, true);
|
|
4117
|
+
return "Workspace trusted: its allow rules apply now; MCP servers it declares start with the next session (/clear).";
|
|
4118
|
+
},
|
|
4119
|
+
openToolInspector: this.openToolInspector,
|
|
4120
|
+
openMcpInspector: this.openMcpInspector,
|
|
4121
|
+
openSkillInspector: this.openSkillInspector,
|
|
4122
|
+
openUsageInspector: this.openUsageInspector,
|
|
4123
|
+
steerMessage: (message) => this.runner.steer(message),
|
|
4124
|
+
queuedMessages: () => this.runner.queuedMessages(),
|
|
4125
|
+
clearQueuedMessages: () => this.runner.clearQueuedMessages(),
|
|
4126
|
+
compactContext: () => this.runner.compactContext(),
|
|
4127
|
+
rewind: (turns) => this.runner.rewind(turns),
|
|
4128
|
+
listModels: () => this.runner.listModels(),
|
|
4129
|
+
startBackgroundShell: (command) => this.runner.startBackgroundShell(command),
|
|
4130
|
+
startBackgroundAgent: (prompt) => this.runner.startBackgroundAgent(prompt),
|
|
4131
|
+
listBackgroundJobs: (kind) => this.runner.listBackgroundJobs(kind),
|
|
4132
|
+
backgroundOutput: (jobId) => this.runner.backgroundOutput(jobId),
|
|
4133
|
+
killBackgroundJob: (jobId) => this.runner.killBackgroundJob(jobId),
|
|
4134
|
+
releaseBackgroundJob: (jobId) => this.runner.releaseBackgroundJob(jobId)
|
|
4135
|
+
};
|
|
4136
|
+
// ------------------------------------------------------------ prompt
|
|
4137
|
+
submit = async (text, images = [], pastes = []) => {
|
|
4138
|
+
const value = text.trim();
|
|
4139
|
+
if (!value) return;
|
|
4140
|
+
this.editor.addToHistory(value);
|
|
4141
|
+
this.store.setState((state) => ({
|
|
4142
|
+
inputHistory: [...state.inputHistory, value],
|
|
4143
|
+
statusLine: null
|
|
4144
|
+
}));
|
|
4145
|
+
if (!value.startsWith("/")) {
|
|
4146
|
+
await this.runner.submit(value, images, pastes);
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
const commandValue = expandPromptPastes(value, pastes);
|
|
4150
|
+
const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(commandValue);
|
|
4151
|
+
const name = match?.[1] ?? "";
|
|
4152
|
+
const command = findCommand(name);
|
|
4153
|
+
if (!command) {
|
|
4154
|
+
this.appendError(`Unknown command: /${name}`);
|
|
4155
|
+
return;
|
|
4156
|
+
}
|
|
4157
|
+
try {
|
|
4158
|
+
await command.run(this.commandContext, match?.[2] ?? "");
|
|
4159
|
+
} catch (error) {
|
|
4160
|
+
this.appendError(`Command /${name} failed: ${errorMessage(error, "Unknown error.")}`);
|
|
4161
|
+
this.store.setState({ statusLine: `Command /${name} failed.` });
|
|
4162
|
+
}
|
|
4163
|
+
};
|
|
4164
|
+
async pasteClipboardImage() {
|
|
4165
|
+
if (this.imagePasteActive) return;
|
|
4166
|
+
this.imagePasteActive = true;
|
|
4167
|
+
const model = this.runner.model;
|
|
4168
|
+
this.store.setState({ statusLine: "Reading clipboard image..." });
|
|
4169
|
+
try {
|
|
4170
|
+
if (!await this.runner.supportsImageInput(model)) {
|
|
4171
|
+
this.store.setState({ statusLine: `Model ${model} does not support image input.` });
|
|
4172
|
+
return;
|
|
4173
|
+
}
|
|
4174
|
+
if (this.runner.model !== model && !await this.runner.supportsImageInput()) {
|
|
4175
|
+
this.store.setState({
|
|
4176
|
+
statusLine: `Model ${this.runner.model} does not support image input.`
|
|
4177
|
+
});
|
|
4178
|
+
return;
|
|
4179
|
+
}
|
|
4180
|
+
const attachment = this.draftImages.add(await readClipboardImage());
|
|
4181
|
+
this.editor.insertTextAtCursor(attachment.marker);
|
|
4182
|
+
this.store.setState({
|
|
4183
|
+
statusLine: `Attached ${attachment.marker} (${formatImageSize(attachment.byteLength)}).`
|
|
4184
|
+
});
|
|
4185
|
+
} catch (error) {
|
|
4186
|
+
this.store.setState({
|
|
4187
|
+
statusLine: `Image paste failed: ${errorMessage(error, "Unknown error.")}`
|
|
4188
|
+
});
|
|
4189
|
+
} finally {
|
|
4190
|
+
this.imagePasteActive = false;
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
4193
|
+
configureEditor() {
|
|
4194
|
+
const { editor, runner, draftImages } = this;
|
|
4195
|
+
editor.setQueuedMessageProvider(() => runner.queuedMessageEntries());
|
|
4196
|
+
editor.onQueuedMessageEditStart = (id) => {
|
|
4197
|
+
runner.beginQueuedMessageEdit(id);
|
|
4198
|
+
};
|
|
4199
|
+
editor.onQueuedMessageEditFinish = (id, text) => {
|
|
4200
|
+
runner.finishQueuedMessageEdit(
|
|
4201
|
+
id,
|
|
4202
|
+
text,
|
|
4203
|
+
draftImages.referencedBy(text),
|
|
4204
|
+
editor.referencedPastes(text)
|
|
4205
|
+
);
|
|
4206
|
+
draftImages.clear();
|
|
4207
|
+
};
|
|
4208
|
+
editor.onChange = (text) => {
|
|
4209
|
+
const id = editor.editingQueuedMessage();
|
|
4210
|
+
if (id) runner.updateQueuedMessage(id, text);
|
|
4211
|
+
};
|
|
4212
|
+
const fdPath = resolveFdPath();
|
|
4213
|
+
editor.setAutocompleteProvider(
|
|
4214
|
+
new WorkspaceAutocompleteProvider(
|
|
4215
|
+
allCommands().map((command) => ({
|
|
4216
|
+
name: command.name,
|
|
4217
|
+
description: command.description
|
|
4218
|
+
})),
|
|
4219
|
+
runner.cwd,
|
|
4220
|
+
// fd (when available) walks the tree itself and respects .gitignore;
|
|
4221
|
+
// the pre-scanned list is only its fallback.
|
|
4222
|
+
fdPath ? [] : listWorkspaceFiles(runner.cwd, 1e4),
|
|
4223
|
+
fdPath
|
|
4224
|
+
)
|
|
4225
|
+
);
|
|
4226
|
+
bindPromptSubmission({
|
|
4227
|
+
editor,
|
|
4228
|
+
draftImages,
|
|
4229
|
+
runner,
|
|
4230
|
+
store: this.store,
|
|
4231
|
+
submit: this.submit,
|
|
4232
|
+
appendError: this.appendError
|
|
4233
|
+
});
|
|
4234
|
+
}
|
|
4235
|
+
// -------------------------------------------------------------- view
|
|
4236
|
+
refresh() {
|
|
4237
|
+
this.syncWindowTitle();
|
|
4238
|
+
this.view = this.buildView();
|
|
4239
|
+
if (this.view.transcript.epoch !== this.lastEpoch) {
|
|
4240
|
+
this.lastEpoch = this.view.transcript.epoch;
|
|
4241
|
+
this.io.write(CLEAR_SCREEN_AND_SCROLLBACK);
|
|
4242
|
+
}
|
|
4243
|
+
for (const listener of this.listeners) listener();
|
|
4244
|
+
}
|
|
4245
|
+
buildView() {
|
|
4246
|
+
const ui = this.store.getState();
|
|
4247
|
+
return {
|
|
4248
|
+
ui,
|
|
4249
|
+
transcript: this.committer.sync(ui, this.expandedTools),
|
|
4250
|
+
overlay: this.overlay,
|
|
4251
|
+
question: this.questionState(ui.pendingQuestion),
|
|
4252
|
+
model: this.runner.model,
|
|
4253
|
+
mcp: mcpCounts(this.runner),
|
|
4254
|
+
expandedTools: this.expandedTools,
|
|
4255
|
+
exiting: this.exiting
|
|
4256
|
+
};
|
|
4257
|
+
}
|
|
4258
|
+
questionState(pending) {
|
|
4259
|
+
if (!pending) {
|
|
4260
|
+
this.question = null;
|
|
4261
|
+
return null;
|
|
4262
|
+
}
|
|
4263
|
+
if (this.question?.pending.id !== pending.id) {
|
|
4264
|
+
this.question = new QuestionState(pending, () => {
|
|
4265
|
+
});
|
|
4266
|
+
}
|
|
4267
|
+
return this.question;
|
|
4268
|
+
}
|
|
4269
|
+
syncWindowTitle(advance = false) {
|
|
4270
|
+
const busy = this.store.getState().busy;
|
|
4271
|
+
if (busy !== this.windowTitleBusy) {
|
|
4272
|
+
this.windowTitleBusy = busy;
|
|
4273
|
+
this.windowTitleFrame = 0;
|
|
4274
|
+
} else if (busy && advance) {
|
|
4275
|
+
this.windowTitleFrame = (this.windowTitleFrame + 1) % WORKING_FRAMES.length;
|
|
4276
|
+
}
|
|
4277
|
+
const marker = busy ? WORKING_FRAMES[this.windowTitleFrame] : ASSISTANT_MARKER;
|
|
4278
|
+
this.setWindowTitle(`${marker} ${busy ? "Nova-AI" : "Nova AI"}`);
|
|
4279
|
+
}
|
|
4280
|
+
setWindowTitle(title) {
|
|
4281
|
+
if (title === this.windowTitle) return;
|
|
4282
|
+
this.windowTitle = title;
|
|
4283
|
+
this.io.write(`\x1B]0;${title}\x07`);
|
|
4284
|
+
}
|
|
4285
|
+
};
|
|
4286
|
+
function formatMcpServer(server, status) {
|
|
4287
|
+
const type = "type" in server ? server.type : "stdio";
|
|
4288
|
+
const lines = [`${server.name} [${type}]`];
|
|
4289
|
+
if ("url" in server) lines.push(`url: ${server.url}`);
|
|
4290
|
+
if ("command" in server) {
|
|
4291
|
+
lines.push(`command: ${server.command}`);
|
|
4292
|
+
if (server.args.length) lines.push(`args: ${server.args.join(" ")}`);
|
|
4293
|
+
if (server.env.length)
|
|
4294
|
+
lines.push(`env: ${server.env.map((entry) => entry.name).join(", ")} (values hidden)`);
|
|
4295
|
+
}
|
|
4296
|
+
const failure = status.failures.find((item) => item.serverName === server.name);
|
|
4297
|
+
lines.push(
|
|
4298
|
+
failure ? `status: failed - ${failure.message}` : status.connected.includes(server.name) ? "status: connected" : "status: configured; connection starts with the ACP session"
|
|
4299
|
+
);
|
|
4300
|
+
return lines.join("\n");
|
|
4301
|
+
}
|
|
4302
|
+
function mcpCounts(runner) {
|
|
4303
|
+
const status = runner.mcpSessionStatus();
|
|
4304
|
+
return {
|
|
4305
|
+
configured: status.configured.length,
|
|
4306
|
+
connected: status.connected.length,
|
|
4307
|
+
failed: status.failures.length,
|
|
4308
|
+
skills: runner.skillSessionStatus().length
|
|
4309
|
+
};
|
|
4310
|
+
}
|
|
4311
|
+
function errorMessage(error, fallback) {
|
|
4312
|
+
return error instanceof Error ? error.message : fallback;
|
|
4313
|
+
}
|
|
4314
|
+
|
|
4315
|
+
// src/tui/ink/dialogs/views.tsx
|
|
4316
|
+
import { Box as Box5, Text as Text5, useInput as useInput2, usePaste as usePaste2 } from "ink";
|
|
4317
|
+
import stringWidth2 from "string-width";
|
|
4318
|
+
import { useMemo, useState } from "react";
|
|
4319
|
+
import { OTHER_OPTION_ID as OTHER_OPTION_ID2 } from "@datalabrotterdam/nova-ai-agent/core/user-questions.js";
|
|
4320
|
+
import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
4321
|
+
function useDialogInput(handle, isActive) {
|
|
4322
|
+
const [, setTick] = useState(0);
|
|
4323
|
+
useInput2(
|
|
4324
|
+
(input, key) => {
|
|
4325
|
+
if (handle(input, key) === "changed") setTick((tick) => tick + 1);
|
|
4326
|
+
},
|
|
4327
|
+
{ isActive }
|
|
4328
|
+
);
|
|
4329
|
+
}
|
|
4330
|
+
function Frame({ title, children, footer }) {
|
|
4331
|
+
return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", borderStyle: "round", borderColor: palette.primary, paddingX: 1, children: [
|
|
4332
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.primary, wrap: "truncate-end", children: title }),
|
|
4333
|
+
children,
|
|
4334
|
+
footer ? /* @__PURE__ */ jsx5(Text5, { color: palette.faint, wrap: "truncate-end", children: footer }) : null
|
|
4335
|
+
] });
|
|
4336
|
+
}
|
|
4337
|
+
function SelectionDialog({
|
|
4338
|
+
title,
|
|
4339
|
+
state,
|
|
4340
|
+
height,
|
|
4341
|
+
isActive = true
|
|
4342
|
+
}) {
|
|
4343
|
+
const rows = Math.max(1, Math.min(state.items.length, height - 5));
|
|
4344
|
+
useDialogInput((input, key) => state.handleKey(input, key, rows), isActive);
|
|
4345
|
+
const start = windowStart(state.selected, state.items.length, rows);
|
|
4346
|
+
const visible = state.items.slice(start, start + rows);
|
|
4347
|
+
const leadingWidth = Math.min(
|
|
4348
|
+
14,
|
|
4349
|
+
Math.max(0, ...state.items.map((item) => item.columns ? stringWidth2(item.columns.leading) : 0))
|
|
4350
|
+
);
|
|
4351
|
+
return /* @__PURE__ */ jsx5(
|
|
4352
|
+
Frame,
|
|
4353
|
+
{
|
|
4354
|
+
title,
|
|
4355
|
+
footer: `${state.selected + 1}/${state.items.length} \xB7 \u2191\u2193 PgUp/PgDn navigate \xB7 Enter select \xB7 Esc close`,
|
|
4356
|
+
children: visible.map((item, index) => {
|
|
4357
|
+
const selected = start + index === state.selected;
|
|
4358
|
+
const marker = /* @__PURE__ */ jsx5(Text5, { color: palette.primary, children: selected ? "> " : " " });
|
|
4359
|
+
if (item.columns) {
|
|
4360
|
+
return /* @__PURE__ */ jsxs5(Box5, { children: [
|
|
4361
|
+
marker,
|
|
4362
|
+
/* @__PURE__ */ jsx5(Box5, { width: leadingWidth, flexShrink: 0, children: /* @__PURE__ */ jsx5(Text5, { bold: selected, wrap: "truncate-end", children: item.columns.leading }) }),
|
|
4363
|
+
/* @__PURE__ */ jsx5(Box5, { flexGrow: 1, flexShrink: 1, marginLeft: 2, children: /* @__PURE__ */ jsx5(Text5, { bold: selected, wrap: "truncate-end", children: item.columns.main.replace(/\s+/g, " ").trim() }) }),
|
|
4364
|
+
/* @__PURE__ */ jsx5(Box5, { flexShrink: 0, marginLeft: 2, children: /* @__PURE__ */ jsx5(Text5, { color: palette.muted, children: item.columns.trailing }) })
|
|
4365
|
+
] }, item.value);
|
|
4366
|
+
}
|
|
4367
|
+
return /* @__PURE__ */ jsxs5(Box5, { children: [
|
|
4368
|
+
marker,
|
|
4369
|
+
/* @__PURE__ */ jsx5(Box5, { flexShrink: 0, minWidth: Math.min(32, stringWidth2(item.label)), children: /* @__PURE__ */ jsx5(Text5, { bold: selected, wrap: "truncate-end", children: item.label || item.value }) }),
|
|
4370
|
+
item.description ? /* @__PURE__ */ jsx5(Box5, { flexGrow: 1, flexShrink: 1, marginLeft: 2, children: /* @__PURE__ */ jsx5(Text5, { color: palette.muted, wrap: "truncate-end", children: item.description.replace(/[\r\n]+/g, " ").trim() }) }) : null
|
|
4371
|
+
] }, item.value);
|
|
4372
|
+
})
|
|
4373
|
+
}
|
|
4374
|
+
);
|
|
4375
|
+
}
|
|
4376
|
+
function ScrollPanel({
|
|
4377
|
+
title,
|
|
4378
|
+
content,
|
|
4379
|
+
height,
|
|
4380
|
+
width,
|
|
4381
|
+
state,
|
|
4382
|
+
isActive = true
|
|
4383
|
+
}) {
|
|
4384
|
+
const bodyRows = Math.max(1, height - 4);
|
|
4385
|
+
useDialogInput((input, key) => state.handleKey(input, key, bodyRows), isActive);
|
|
4386
|
+
const wrapWidth = Math.max(1, width - 4);
|
|
4387
|
+
const lines = useMemo(
|
|
4388
|
+
() => wrapPlain(sanitizeTerminalText(content.replace(/\r\n?/g, "\n").replace(/\t/g, " ")) || "(no output)", wrapWidth),
|
|
4389
|
+
[content, wrapWidth]
|
|
4390
|
+
);
|
|
4391
|
+
const offset = state.clamp(lines.length, bodyRows);
|
|
4392
|
+
const visible = lines.slice(offset, offset + bodyRows);
|
|
4393
|
+
return /* @__PURE__ */ jsx5(
|
|
4394
|
+
Frame,
|
|
4395
|
+
{
|
|
4396
|
+
title,
|
|
4397
|
+
footer: `${offset + 1}-${offset + visible.length} of ${lines.length} \xB7 \u2191\u2193 PgUp/PgDn Home/End \xB7 Esc close`,
|
|
4398
|
+
children: /* @__PURE__ */ jsx5(Text5, { children: visible.join("\n") })
|
|
4399
|
+
}
|
|
4400
|
+
);
|
|
4401
|
+
}
|
|
4402
|
+
function ToolInspector({
|
|
4403
|
+
state,
|
|
4404
|
+
height,
|
|
4405
|
+
width,
|
|
4406
|
+
isActive = true
|
|
4407
|
+
}) {
|
|
4408
|
+
const calls = state.calls();
|
|
4409
|
+
const listRows = state.expanded ? Math.min(calls.length, Math.max(1, Math.min(6, height - 8))) : Math.min(calls.length, Math.max(1, height - 4));
|
|
4410
|
+
const detailRows = Math.max(1, height - listRows - 5);
|
|
4411
|
+
useDialogInput(
|
|
4412
|
+
(input, key) => state.handleKey(input, key, state.expanded ? detailRows : listRows),
|
|
4413
|
+
isActive
|
|
4414
|
+
);
|
|
4415
|
+
if (calls.length === 0) {
|
|
4416
|
+
return /* @__PURE__ */ jsx5(Frame, { title: "Tool calls", footer: "Esc close", children: /* @__PURE__ */ jsx5(Text5, { color: palette.faint, children: "No tool calls yet." }) });
|
|
4417
|
+
}
|
|
4418
|
+
const start = windowStart(state.selected, calls.length, listRows);
|
|
4419
|
+
const selected = calls[state.selected];
|
|
4420
|
+
let details = [];
|
|
4421
|
+
let detailStart = 0;
|
|
4422
|
+
if (state.expanded) {
|
|
4423
|
+
const all = wrapPlain(formatToolCallDetails(selected), Math.max(1, width - 6));
|
|
4424
|
+
detailStart = Math.max(0, Math.min(state.detailOffset, all.length - detailRows));
|
|
4425
|
+
state.detailOffset = detailStart;
|
|
4426
|
+
details = all.slice(detailStart, detailStart + detailRows);
|
|
4427
|
+
details.push(
|
|
4428
|
+
`(${detailStart + 1}-${detailStart + details.length} of ${all.length})`
|
|
4429
|
+
);
|
|
4430
|
+
}
|
|
4431
|
+
return /* @__PURE__ */ jsxs5(
|
|
4432
|
+
Frame,
|
|
4433
|
+
{
|
|
4434
|
+
title: `Tool calls (${calls.length})`,
|
|
4435
|
+
footer: `${state.selected + 1}/${calls.length} \xB7 \u2191\u2193 select \xB7 Enter/Space/\u2190\u2192 details \xB7 Esc close`,
|
|
4436
|
+
children: [
|
|
4437
|
+
calls.slice(start, start + listRows).map((call, index) => {
|
|
4438
|
+
const isSelected = start + index === state.selected;
|
|
4439
|
+
const summary = toolCallSummary(call);
|
|
4440
|
+
return /* @__PURE__ */ jsxs5(Box5, { children: [
|
|
4441
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.primary, children: isSelected ? "> " : " " }),
|
|
4442
|
+
/* @__PURE__ */ jsx5(Box5, { flexGrow: 1, flexShrink: 1, children: /* @__PURE__ */ jsxs5(Text5, { bold: isSelected, wrap: "truncate-end", children: [
|
|
4443
|
+
isSelected && state.expanded ? "v " : "> ",
|
|
4444
|
+
/* @__PURE__ */ jsx5(StatusIcon, { status: call.status }),
|
|
4445
|
+
" ",
|
|
4446
|
+
call.name,
|
|
4447
|
+
summary ? `: ${summary}` : ""
|
|
4448
|
+
] }) }),
|
|
4449
|
+
width >= 48 ? /* @__PURE__ */ jsx5(Box5, { flexShrink: 0, marginLeft: 2, children: /* @__PURE__ */ jsx5(Text5, { color: palette.muted, children: `${call.status} | ${call.kind}${call.mutating ? " | changes" : ""}` }) }) : null
|
|
4450
|
+
] }, call.toolCallId);
|
|
4451
|
+
}),
|
|
4452
|
+
state.expanded ? /* @__PURE__ */ jsxs5(Box5, { paddingLeft: 2, flexDirection: "column", children: [
|
|
4453
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.muted, children: details.slice(0, -1).join("\n") }),
|
|
4454
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.faint, children: details.at(-1) })
|
|
4455
|
+
] }) : null
|
|
4456
|
+
]
|
|
4457
|
+
}
|
|
4458
|
+
);
|
|
4459
|
+
}
|
|
4460
|
+
function QuestionDialog({
|
|
4461
|
+
state,
|
|
4462
|
+
isActive = true
|
|
4463
|
+
}) {
|
|
4464
|
+
const [, setTick] = useState(0);
|
|
4465
|
+
useInput2(
|
|
4466
|
+
(input, key) => {
|
|
4467
|
+
if (state.handleKey(input, key) === "changed") setTick((tick) => tick + 1);
|
|
4468
|
+
},
|
|
4469
|
+
{ isActive }
|
|
4470
|
+
);
|
|
4471
|
+
usePaste2(
|
|
4472
|
+
(text) => {
|
|
4473
|
+
if (state.paste(text) === "changed") setTick((tick) => tick + 1);
|
|
4474
|
+
},
|
|
4475
|
+
{ isActive }
|
|
4476
|
+
);
|
|
4477
|
+
const question = state.question;
|
|
4478
|
+
if (!question) return null;
|
|
4479
|
+
const total = state.pending.request.questions.length;
|
|
4480
|
+
const heading = /* @__PURE__ */ jsxs5(Fragment3, { children: [
|
|
4481
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.faint, children: `Question ${state.questionIndex + 1}/${total} \xB7 ${state.customMode ? "own answer" : question.type === "multiple" ? "select one or more" : "select one"}` }),
|
|
4482
|
+
/* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", children: [
|
|
4483
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.primary, children: question.question }),
|
|
4484
|
+
question.description ? /* @__PURE__ */ jsx5(Text5, { color: palette.muted, children: question.description }) : null
|
|
4485
|
+
] })
|
|
4486
|
+
] });
|
|
4487
|
+
if (state.customMode) {
|
|
4488
|
+
const { col } = state.customInput.cursor;
|
|
4489
|
+
const text = state.customInput.text;
|
|
4490
|
+
return /* @__PURE__ */ jsxs5(Frame, { title: state.pending.request.message, footer: "Enter submit \xB7 Esc back to choices", children: [
|
|
4491
|
+
heading,
|
|
4492
|
+
/* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", children: [
|
|
4493
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.primary, children: "Your answer" }),
|
|
4494
|
+
/* @__PURE__ */ jsxs5(Text5, { children: [
|
|
4495
|
+
"> ",
|
|
4496
|
+
text.slice(0, col),
|
|
4497
|
+
/* @__PURE__ */ jsx5(Text5, { inverse: true, children: text.slice(col, col + 1) || " " }),
|
|
4498
|
+
text.slice(col + 1)
|
|
4499
|
+
] }),
|
|
4500
|
+
state.validationMessage ? /* @__PURE__ */ jsx5(Text5, { color: palette.danger, children: state.validationMessage }) : null
|
|
4501
|
+
] })
|
|
4502
|
+
] });
|
|
4503
|
+
}
|
|
4504
|
+
const otherIndex = question.options.length;
|
|
4505
|
+
const otherAnswer = state.customAnswer(question.id);
|
|
4506
|
+
return /* @__PURE__ */ jsxs5(
|
|
4507
|
+
Frame,
|
|
4508
|
+
{
|
|
4509
|
+
title: state.pending.request.message,
|
|
4510
|
+
footer: question.type === "multiple" ? "\u2191\u2193 navigate \xB7 Space toggle \xB7 Enter continue \xB7 Esc cancel" : "\u2191\u2193 navigate \xB7 Enter select \xB7 Esc cancel",
|
|
4511
|
+
children: [
|
|
4512
|
+
heading,
|
|
4513
|
+
/* @__PURE__ */ jsxs5(Box5, { marginTop: 1, flexDirection: "column", children: [
|
|
4514
|
+
question.options.map((option, index) => {
|
|
4515
|
+
const cursor = index === state.selected;
|
|
4516
|
+
const mark = question.type === "multiple" ? state.isSelected(question.id, option.id) ? "[x]" : "[ ]" : cursor ? "(o)" : "( )";
|
|
4517
|
+
return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
|
|
4518
|
+
/* @__PURE__ */ jsxs5(Text5, { wrap: "truncate-end", children: [
|
|
4519
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.primary, children: cursor ? ">" : " " }),
|
|
4520
|
+
" ",
|
|
4521
|
+
/* @__PURE__ */ jsx5(Text5, { color: mark === "[x]" ? palette.success : cursor ? palette.primary : void 0, children: mark }),
|
|
4522
|
+
" ",
|
|
4523
|
+
option.label,
|
|
4524
|
+
option.recommended ? /* @__PURE__ */ jsx5(Text5, { color: palette.success, children: " (recommended)" }) : null
|
|
4525
|
+
] }),
|
|
4526
|
+
option.description ? /* @__PURE__ */ jsx5(Box5, { paddingLeft: 6, children: /* @__PURE__ */ jsx5(Text5, { color: palette.muted, wrap: "truncate-end", children: option.description.replace(/[\r\n]+/g, " ") }) }) : null
|
|
4527
|
+
] }, option.id);
|
|
4528
|
+
}),
|
|
4529
|
+
/* @__PURE__ */ jsxs5(Text5, { wrap: "truncate-end", children: [
|
|
4530
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.primary, children: state.selected === otherIndex ? ">" : " " }),
|
|
4531
|
+
" ",
|
|
4532
|
+
state.isSelected(question.id, OTHER_OPTION_ID2) ? /* @__PURE__ */ jsx5(Text5, { color: palette.success, children: "[x]" }) : "[ ]",
|
|
4533
|
+
" ",
|
|
4534
|
+
otherAnswer ? `Other: ${otherAnswer}` : "Other... (write your own answer)"
|
|
4535
|
+
] }),
|
|
4536
|
+
state.validationMessage ? /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { color: palette.danger, children: state.validationMessage }) }) : null
|
|
4537
|
+
] })
|
|
4538
|
+
]
|
|
4539
|
+
}
|
|
4540
|
+
);
|
|
4541
|
+
}
|
|
4542
|
+
function PermissionDialog({
|
|
4543
|
+
request,
|
|
4544
|
+
onClose,
|
|
4545
|
+
isActive = true
|
|
4546
|
+
}) {
|
|
4547
|
+
useInput2(
|
|
4548
|
+
(input, key) => {
|
|
4549
|
+
if (permissionKey(request, input, key) === "closed") onClose();
|
|
4550
|
+
},
|
|
4551
|
+
{ isActive }
|
|
4552
|
+
);
|
|
4553
|
+
const args = JSON.stringify(request.args, null, 2).split("\n");
|
|
4554
|
+
const shown = args.slice(0, 12);
|
|
4555
|
+
return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", borderStyle: "round", borderColor: palette.warning, paddingX: 1, children: [
|
|
4556
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.warning, bold: true, children: request.title ?? `Permission requested: ${request.toolName}` }),
|
|
4557
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.muted, children: `${request.toolName} \xB7 ${request.kind} tool` }),
|
|
4558
|
+
/* @__PURE__ */ jsxs5(Box5, { marginTop: 1, paddingLeft: 1, flexDirection: "column", children: [
|
|
4559
|
+
/* @__PURE__ */ jsx5(Text5, { children: shown.join("\n") }),
|
|
4560
|
+
args.length > shown.length ? /* @__PURE__ */ jsx5(Text5, { color: palette.faint, children: `\u2026 ${args.length - shown.length} more lines` }) : null
|
|
4561
|
+
] }),
|
|
4562
|
+
/* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text5, { children: [
|
|
4563
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.primary, children: "[a]" }),
|
|
4564
|
+
" once ",
|
|
4565
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.primary, children: "[s]" }),
|
|
4566
|
+
" this session",
|
|
4567
|
+
" ",
|
|
4568
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.primary, children: "[w]" }),
|
|
4569
|
+
" always ",
|
|
4570
|
+
/* @__PURE__ */ jsx5(Text5, { color: palette.danger, children: "[d]" }),
|
|
4571
|
+
" deny"
|
|
4572
|
+
] }) })
|
|
4573
|
+
] });
|
|
4574
|
+
}
|
|
4575
|
+
function wrapPlain(text, width) {
|
|
4576
|
+
const rows = [];
|
|
4577
|
+
for (const line of text.split(/\r?\n/)) {
|
|
4578
|
+
if (line === "") {
|
|
4579
|
+
rows.push("");
|
|
4580
|
+
continue;
|
|
4581
|
+
}
|
|
4582
|
+
let current = "";
|
|
4583
|
+
let currentWidth = 0;
|
|
4584
|
+
for (const character of line) {
|
|
4585
|
+
const characterWidth = stringWidth2(character);
|
|
4586
|
+
if (currentWidth + characterWidth > width && current) {
|
|
4587
|
+
rows.push(current);
|
|
4588
|
+
current = "";
|
|
4589
|
+
currentWidth = 0;
|
|
4590
|
+
}
|
|
4591
|
+
current += character;
|
|
4592
|
+
currentWidth += characterWidth;
|
|
4593
|
+
}
|
|
4594
|
+
rows.push(current);
|
|
4595
|
+
}
|
|
4596
|
+
return rows;
|
|
4597
|
+
}
|
|
4598
|
+
|
|
4599
|
+
// src/tui/ink/app.tsx
|
|
4600
|
+
import { Fragment as Fragment4, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
4601
|
+
var FRAME_MS = 120;
|
|
4602
|
+
var RESIZE_SETTLE_MS = 100;
|
|
4603
|
+
async function runInkTui(credentials, cwd, args, options = {}) {
|
|
4604
|
+
const store = createStore({
|
|
4605
|
+
messages: [],
|
|
4606
|
+
plan: [],
|
|
4607
|
+
pendingPermission: null,
|
|
4608
|
+
pendingQuestion: null,
|
|
4609
|
+
inputHistory: [],
|
|
4610
|
+
busy: false,
|
|
4611
|
+
mode: "chat",
|
|
4612
|
+
// Never from the repository's .nova-ai/settings.json: a cloned repo must
|
|
4613
|
+
// not be able to start Nova with approvals switched off.
|
|
4614
|
+
permissionMode: fromAgentPermissionMode(savedPermissionMode(cwd)),
|
|
4615
|
+
interactionMode: "agent",
|
|
4616
|
+
sessionId: "",
|
|
4617
|
+
cwd,
|
|
4618
|
+
statusLine: null,
|
|
4619
|
+
queuedCount: 0,
|
|
4620
|
+
contextUsage: null,
|
|
4621
|
+
updateAvailable: null
|
|
4622
|
+
});
|
|
4623
|
+
const runner = options.createRunner?.(store) ?? new SessionRunner(store, credentials, cwd);
|
|
4624
|
+
const resumeIndex = args.indexOf("--resume");
|
|
4625
|
+
const resumeId = resumeIndex >= 0 ? args[resumeIndex + 1] : void 0;
|
|
4626
|
+
if (resumeId) runner.resumeFrom(resumeId);
|
|
4627
|
+
else store.setState({ sessionId: runner.sessionId });
|
|
4628
|
+
const stdout = options.stdout ?? process.stdout;
|
|
4629
|
+
const stdin = options.stdin ?? process.stdin;
|
|
4630
|
+
const kittyKeyboard = options.kittyKeyboard ?? kittyKeyboardOverride() ?? await detectKittyKeyboard(stdin, stdout);
|
|
4631
|
+
const controller = new TuiController(store, runner, {
|
|
4632
|
+
write: (data) => {
|
|
4633
|
+
stdout.write(data);
|
|
4634
|
+
}
|
|
4635
|
+
});
|
|
4636
|
+
const instance = render(/* @__PURE__ */ jsx6(App, { controller }), {
|
|
4637
|
+
stdout,
|
|
4638
|
+
stdin,
|
|
4639
|
+
exitOnCtrlC: false,
|
|
4640
|
+
patchConsole: true,
|
|
4641
|
+
// Ink otherwise turns non-interactive whenever CI is set in the
|
|
4642
|
+
// environment; the TUI only runs on a terminal anyway.
|
|
4643
|
+
interactive: Boolean(stdout.isTTY),
|
|
4644
|
+
kittyKeyboard: { mode: kittyKeyboard ? "enabled" : "disabled" }
|
|
4645
|
+
});
|
|
4646
|
+
controller.start();
|
|
4647
|
+
const onSignal = () => controller.requestExit();
|
|
4648
|
+
process.once("SIGTERM", onSignal);
|
|
4649
|
+
let printedColumns = stdout.columns;
|
|
4650
|
+
let resizeTimer;
|
|
4651
|
+
const onResize = () => {
|
|
4652
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
4653
|
+
resizeTimer = setTimeout(() => {
|
|
4654
|
+
if (stdout.columns === printedColumns) return;
|
|
4655
|
+
printedColumns = stdout.columns;
|
|
4656
|
+
controller.redrawAll();
|
|
4657
|
+
}, RESIZE_SETTLE_MS);
|
|
4658
|
+
};
|
|
4659
|
+
stdout.on("resize", onResize);
|
|
4660
|
+
void (options.checkForUpdate ?? checkForUpdate)().then((updateAvailable) => {
|
|
4661
|
+
if (updateAvailable) store.setState({ updateAvailable });
|
|
4662
|
+
});
|
|
4663
|
+
try {
|
|
4664
|
+
await controller.exitRequested;
|
|
4665
|
+
await instance.waitUntilRenderFlush();
|
|
4666
|
+
await controller.stop();
|
|
4667
|
+
} finally {
|
|
4668
|
+
process.off("SIGTERM", onSignal);
|
|
4669
|
+
stdout.off("resize", onResize);
|
|
4670
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
4671
|
+
instance.unmount();
|
|
4672
|
+
await instance.waitUntilExit().catch(() => {
|
|
4673
|
+
});
|
|
4674
|
+
}
|
|
4675
|
+
stdout.write(`Resume this session with: nova-ai --resume ${runner.sessionId}
|
|
4676
|
+
`);
|
|
4677
|
+
}
|
|
4678
|
+
function App({ controller }) {
|
|
4679
|
+
const view = useSyncExternalStore2(controller.subscribe, controller.getView);
|
|
4680
|
+
const { rows, columns } = useWindowSize();
|
|
4681
|
+
const bottomRef = useRef(null);
|
|
4682
|
+
const bottom = useBoxMetrics(bottomRef);
|
|
4683
|
+
const working = useWorking(view);
|
|
4684
|
+
const { transcript } = view;
|
|
4685
|
+
const scrollback = (
|
|
4686
|
+
// <Static> is laid out as an absolute box; without a width its text
|
|
4687
|
+
// wraps against its content and runs past the terminal edge.
|
|
4688
|
+
/* @__PURE__ */ jsx6(Static, { items: [...transcript.committed], style: { width: columns }, children: (block) => /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(TranscriptBlockView, { block }) }, block.key) }, transcript.epoch)
|
|
4689
|
+
);
|
|
4690
|
+
if (view.exiting) return scrollback;
|
|
4691
|
+
const liveRows = Math.max(3, rows - (bottom.hasMeasured ? bottom.height : 6) - 2);
|
|
4692
|
+
const dialogRows = Math.max(6, rows - 4);
|
|
4693
|
+
return /* @__PURE__ */ jsxs6(Fragment4, { children: [
|
|
4694
|
+
scrollback,
|
|
4695
|
+
/* @__PURE__ */ jsx6(Box6, { flexDirection: "column", maxHeight: liveRows, overflowY: "hidden", justifyContent: "flex-end", children: /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", flexShrink: 0, children: [
|
|
4696
|
+
transcript.live.map((block) => /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(TranscriptBlockView, { block, frame: working.frame }) }, block.key)),
|
|
4697
|
+
working.line ? /* @__PURE__ */ jsx6(Box6, { marginTop: 1, paddingLeft: 1, children: /* @__PURE__ */ jsxs6(Text6, { children: [
|
|
4698
|
+
/* @__PURE__ */ jsx6(Text6, { color: palette.accent, children: WORKING_FRAMES[working.frame] }),
|
|
4699
|
+
/* @__PURE__ */ jsx6(Text6, { color: palette.muted, children: ` ${working.line}` })
|
|
4700
|
+
] }) }) : null
|
|
4701
|
+
] }) }),
|
|
4702
|
+
/* @__PURE__ */ jsxs6(Box6, { ref: bottomRef, flexDirection: "column", marginTop: 1, children: [
|
|
4703
|
+
/* @__PURE__ */ jsx6(Surface, { controller, view, rows: dialogRows, columns }),
|
|
4704
|
+
/* @__PURE__ */ jsx6(StatusLine, { ui: view.ui, model: view.model, mcp: view.mcp })
|
|
4705
|
+
] })
|
|
4706
|
+
] });
|
|
4707
|
+
}
|
|
4708
|
+
function Surface({
|
|
4709
|
+
controller,
|
|
4710
|
+
view,
|
|
4711
|
+
rows,
|
|
4712
|
+
columns
|
|
4713
|
+
}) {
|
|
4714
|
+
if (view.question) return /* @__PURE__ */ jsx6(QuestionDialog, { state: view.question }, view.question.pending.id);
|
|
4715
|
+
const permission = view.ui.pendingPermission;
|
|
4716
|
+
if (permission)
|
|
4717
|
+
return /* @__PURE__ */ jsx6(PermissionDialog, { request: permission, onClose: () => {
|
|
4718
|
+
} }, permission.toolCallId);
|
|
4719
|
+
const overlay = view.overlay;
|
|
4720
|
+
if (overlay?.kind === "selection")
|
|
4721
|
+
return /* @__PURE__ */ jsx6(SelectionDialog, { title: overlay.title, state: overlay.state, height: rows });
|
|
4722
|
+
if (overlay?.kind === "text")
|
|
4723
|
+
return /* @__PURE__ */ jsx6(
|
|
4724
|
+
ScrollPanel,
|
|
4725
|
+
{
|
|
4726
|
+
title: overlay.title,
|
|
4727
|
+
content: overlay.content,
|
|
4728
|
+
state: overlay.state,
|
|
4729
|
+
height: rows,
|
|
4730
|
+
width: columns
|
|
4731
|
+
}
|
|
4732
|
+
);
|
|
4733
|
+
if (overlay?.kind === "tools") return /* @__PURE__ */ jsx6(ToolInspector, { state: overlay.state, height: rows, width: columns });
|
|
4734
|
+
return /* @__PURE__ */ jsx6(
|
|
4735
|
+
PromptInput,
|
|
4736
|
+
{
|
|
4737
|
+
editor: controller.editor,
|
|
4738
|
+
busy: view.ui.busy,
|
|
4739
|
+
maxRows: Math.max(3, Math.floor(rows / 3)),
|
|
4740
|
+
onKey: (input, key) => controller.handlePromptKey(input, key)
|
|
4741
|
+
}
|
|
4742
|
+
);
|
|
4743
|
+
}
|
|
4744
|
+
function useWorking(view) {
|
|
4745
|
+
const busy = view.ui.busy;
|
|
4746
|
+
const pendingTool = view.ui.messages.some(
|
|
4747
|
+
(message) => message.role === "tool" && message.call.status === "pending"
|
|
4748
|
+
);
|
|
4749
|
+
const animating = busy || pendingTool;
|
|
4750
|
+
const [frame, setFrame] = useState2(0);
|
|
4751
|
+
const startedAt = useRef(null);
|
|
4752
|
+
if (busy && startedAt.current === null) startedAt.current = Date.now();
|
|
4753
|
+
if (!busy) startedAt.current = null;
|
|
4754
|
+
useEffect(() => {
|
|
4755
|
+
if (!animating) {
|
|
4756
|
+
setFrame(0);
|
|
4757
|
+
return;
|
|
4758
|
+
}
|
|
4759
|
+
const timer = setInterval(() => setFrame((value) => (value + 1) % WORKING_FRAMES.length), FRAME_MS);
|
|
4760
|
+
return () => clearInterval(timer);
|
|
4761
|
+
}, [animating]);
|
|
4762
|
+
if (!busy || pendingTool || view.ui.pendingPermission || view.question) return { frame, line: null };
|
|
4763
|
+
const elapsed = formatElapsedTime(Date.now() - (startedAt.current ?? Date.now()));
|
|
4764
|
+
return { frame, line: `Working (${elapsed} \u2022 esc to interrupt)` };
|
|
4765
|
+
}
|
|
4766
|
+
|
|
4767
|
+
// src/tui/index.ts
|
|
4768
|
+
async function runChat(options) {
|
|
4769
|
+
const stored = readCredentials();
|
|
4770
|
+
if (!stored) {
|
|
4771
|
+
console.error("nova-ai isn't connected to a Nova AI account yet. Run `nova-ai login` first.");
|
|
4772
|
+
return 1;
|
|
4773
|
+
}
|
|
4774
|
+
const credentials = options.model ? { ...stored, defaultModel: options.model } : stored;
|
|
4775
|
+
const cwd = process.cwd();
|
|
4776
|
+
let resume = options.resume;
|
|
4777
|
+
if (options.continueLast) {
|
|
4778
|
+
resume = listStoredSessions2(cwd)[0]?.sessionId ?? null;
|
|
4779
|
+
if (!resume) {
|
|
4780
|
+
console.error(`No earlier session in ${cwd} to continue.`);
|
|
4781
|
+
return 1;
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
if (workspaceNeedsTrust(cwd)) await askToTrust(cwd);
|
|
4785
|
+
const args = resume ? ["--resume", resume] : [];
|
|
4786
|
+
await runInkTui(credentials, cwd, args);
|
|
4787
|
+
return 0;
|
|
4788
|
+
}
|
|
4789
|
+
async function askToTrust(cwd) {
|
|
4790
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
4791
|
+
try {
|
|
4792
|
+
console.log(`
|
|
4793
|
+
${cwd}`);
|
|
4794
|
+
console.log("This workspace declares MCP servers and/or permission allow rules in .mcp.json or .nova-ai/.");
|
|
4795
|
+
console.log("Trusting it starts those servers and applies those rules. Only trust code you know.");
|
|
4796
|
+
const answer = (await prompt.question("Trust this workspace? [y/N] ")).trim().toLowerCase();
|
|
4797
|
+
if (answer === "y" || answer === "yes") {
|
|
4798
|
+
setWorkspaceTrusted2(cwd, true);
|
|
4799
|
+
} else {
|
|
4800
|
+
console.log("Not trusted: its MCP servers stay off and its allow rules are ignored. Use /trust later to change this.");
|
|
4801
|
+
}
|
|
4802
|
+
} finally {
|
|
4803
|
+
prompt.close();
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4806
|
+
var tui_default = runChat;
|
|
4807
|
+
export {
|
|
4808
|
+
tui_default as default,
|
|
4809
|
+
runChat
|
|
4810
|
+
};
|