@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +160 -101
  3. package/dist/chunk-NHGIUEIR.js +754 -0
  4. package/dist/chunk-SKAW2KXC.js +10 -0
  5. package/dist/headless-OFJFVJ3O.js +543 -0
  6. package/dist/index.js +155 -17
  7. package/dist/tui-NJWDD5X7.js +4810 -0
  8. package/dist/webui-L4GGMXJ3.js +9 -0
  9. package/package.json +32 -32
  10. package/dist/acp/agent.d.ts +0 -13
  11. package/dist/acp/agent.js +0 -242
  12. package/dist/acp/auth-server.d.ts +0 -10
  13. package/dist/acp/auth-server.js +0 -67
  14. package/dist/acp/credentials.d.ts +0 -11
  15. package/dist/acp/credentials.js +0 -30
  16. package/dist/acp/index.d.ts +0 -2
  17. package/dist/acp/index.js +0 -21
  18. package/dist/acp/mcp.d.ts +0 -11
  19. package/dist/acp/mcp.js +0 -90
  20. package/dist/acp/sessions.d.ts +0 -20
  21. package/dist/acp/sessions.js +0 -91
  22. package/dist/acp/tools/index.d.ts +0 -6
  23. package/dist/acp/tools/index.js +0 -5
  24. package/dist/acp/tools/marker.d.ts +0 -17
  25. package/dist/acp/tools/marker.js +0 -43
  26. package/dist/acp/tools/permission.d.ts +0 -2
  27. package/dist/acp/tools/permission.js +0 -20
  28. package/dist/acp/tools/read-file.d.ts +0 -2
  29. package/dist/acp/tools/read-file.js +0 -20
  30. package/dist/acp/tools/registry.d.ts +0 -4
  31. package/dist/acp/tools/registry.js +0 -10
  32. package/dist/acp/tools/reporting.d.ts +0 -3
  33. package/dist/acp/tools/reporting.js +0 -26
  34. package/dist/acp/tools/run-command.d.ts +0 -2
  35. package/dist/acp/tools/run-command.js +0 -29
  36. package/dist/acp/tools/system-prompt.d.ts +0 -2
  37. package/dist/acp/tools/system-prompt.js +0 -23
  38. package/dist/acp/tools/types.d.ts +0 -18
  39. package/dist/acp/tools/types.js +0 -1
  40. package/dist/acp/tools/write-file.d.ts +0 -2
  41. package/dist/acp/tools/write-file.js +0 -20
  42. package/dist/index.d.ts +0 -2
  43. package/dist/tui/index.d.ts +0 -7
  44. package/dist/tui/index.js +0 -11
  45. package/dist/webui/index.d.ts +0 -6
  46. package/dist/webui/index.js +0 -9
  47. package/src/acp/web/dist/assets/index-CSccI7lJ.css +0 -1
  48. package/src/acp/web/dist/assets/index-D2Z0sfLR.js +0 -1
  49. package/src/acp/web/dist/favicon.svg +0 -46
  50. 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
+ };