@datalabrotterdam/nova-ai-cli 1.1.0 → 1.2.0-alpha.10

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 (52) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +211 -100
  3. package/dist/browser-setup-7SWHUQEL.js +57 -0
  4. package/dist/chunk-FMHWW3E6.js +11 -0
  5. package/dist/chunk-KDAUUQBE.js +643 -0
  6. package/dist/chunk-L77KQRGZ.js +170 -0
  7. package/dist/headless-BQXK7TNC.js +550 -0
  8. package/dist/index.js +193 -17
  9. package/dist/tui-TJFV6NC6.js +5185 -0
  10. package/dist/update-check-G3PYCRWM.js +23 -0
  11. package/package.json +35 -33
  12. package/dist/acp/agent.d.ts +0 -13
  13. package/dist/acp/agent.js +0 -242
  14. package/dist/acp/auth-server.d.ts +0 -10
  15. package/dist/acp/auth-server.js +0 -67
  16. package/dist/acp/credentials.d.ts +0 -11
  17. package/dist/acp/credentials.js +0 -30
  18. package/dist/acp/index.d.ts +0 -2
  19. package/dist/acp/index.js +0 -21
  20. package/dist/acp/mcp.d.ts +0 -11
  21. package/dist/acp/mcp.js +0 -90
  22. package/dist/acp/sessions.d.ts +0 -20
  23. package/dist/acp/sessions.js +0 -91
  24. package/dist/acp/tools/index.d.ts +0 -6
  25. package/dist/acp/tools/index.js +0 -5
  26. package/dist/acp/tools/marker.d.ts +0 -17
  27. package/dist/acp/tools/marker.js +0 -43
  28. package/dist/acp/tools/permission.d.ts +0 -2
  29. package/dist/acp/tools/permission.js +0 -20
  30. package/dist/acp/tools/read-file.d.ts +0 -2
  31. package/dist/acp/tools/read-file.js +0 -20
  32. package/dist/acp/tools/registry.d.ts +0 -4
  33. package/dist/acp/tools/registry.js +0 -10
  34. package/dist/acp/tools/reporting.d.ts +0 -3
  35. package/dist/acp/tools/reporting.js +0 -26
  36. package/dist/acp/tools/run-command.d.ts +0 -2
  37. package/dist/acp/tools/run-command.js +0 -29
  38. package/dist/acp/tools/system-prompt.d.ts +0 -2
  39. package/dist/acp/tools/system-prompt.js +0 -23
  40. package/dist/acp/tools/types.d.ts +0 -18
  41. package/dist/acp/tools/types.js +0 -1
  42. package/dist/acp/tools/write-file.d.ts +0 -2
  43. package/dist/acp/tools/write-file.js +0 -20
  44. package/dist/index.d.ts +0 -2
  45. package/dist/tui/index.d.ts +0 -7
  46. package/dist/tui/index.js +0 -11
  47. package/dist/webui/index.d.ts +0 -6
  48. package/dist/webui/index.js +0 -9
  49. package/src/acp/web/dist/assets/index-CSccI7lJ.css +0 -1
  50. package/src/acp/web/dist/assets/index-D2Z0sfLR.js +0 -1
  51. package/src/acp/web/dist/favicon.svg +0 -46
  52. package/src/acp/web/dist/index.html +0 -32
@@ -0,0 +1,643 @@
1
+ // src/tui/session/tui-acp-client.ts
2
+ import { NOVA_NOTIFICATIONS } from "@datalabrotterdam/nova-ai-agent/acp/extensions.js";
3
+ import { openBrowser } from "@datalabrotterdam/nova-ai-agent/acp/auth-server.js";
4
+ import { readFile, writeFile, mkdir } from "fs/promises";
5
+ import { dirname } from "path";
6
+ import {
7
+ spawnCommand,
8
+ terminateProcessTree
9
+ } from "@datalabrotterdam/nova-ai-agent/core/process-tree.js";
10
+ import { resolveWorkspaceFile } from "@datalabrotterdam/nova-ai-agent/core/tools/workspace-paths.js";
11
+ import {
12
+ fromElicitationRequest,
13
+ toElicitationResponse
14
+ } from "@datalabrotterdam/nova-ai-agent/acp/user-questions.js";
15
+ import { INTERACTION_MODES } from "@datalabrotterdam/nova-ai-agent/core/interaction-modes.js";
16
+ var DEFAULT_OUTPUT_LIMIT = 1e5;
17
+ var nextMessageId = 0;
18
+ function uid() {
19
+ nextMessageId += 1;
20
+ return `m${nextMessageId}`;
21
+ }
22
+ var TuiAcpClient = class {
23
+ constructor(store, cwd, onBackgroundLifecycle, onInteractionModeChange) {
24
+ this.store = store;
25
+ this.cwd = cwd;
26
+ this.onBackgroundLifecycle = onBackgroundLifecycle;
27
+ this.onInteractionModeChange = onInteractionModeChange;
28
+ }
29
+ store;
30
+ cwd;
31
+ onBackgroundLifecycle;
32
+ onInteractionModeChange;
33
+ /** The modes the agent offers (built-in, then the user's agent files); set by the runner. */
34
+ modeIds = [...INTERACTION_MODES];
35
+ /** Slash commands the agent understands, such as the user's command files. */
36
+ agentCommands = [];
37
+ capabilities = {
38
+ fs: { readTextFile: true, writeTextFile: true },
39
+ terminal: true,
40
+ elicitation: { form: {} }
41
+ };
42
+ terminals = /* @__PURE__ */ new Map();
43
+ streamingMessageId = null;
44
+ nextTerminalId = 0;
45
+ /**
46
+ * Set while the runner loads a session whose transcript it restored itself;
47
+ * the agent's replay would show everything twice.
48
+ */
49
+ muteSessionUpdates = false;
50
+ /** The agent's latest prompt queue (from _nova/queue/changed or a response). */
51
+ queueSnapshot = [];
52
+ /** This client as the SDK's ACP Client, for a real (in-process) connection. */
53
+ asAcpClient() {
54
+ return {
55
+ sessionUpdate: (params) => this.muteSessionUpdates ? Promise.resolve() : this.sessionUpdate(params),
56
+ requestPermission: (params) => this.requestPermission(params),
57
+ readTextFile: (params) => this.readTextFile(params),
58
+ writeTextFile: async (params) => {
59
+ await this.writeTextFile(params);
60
+ return {};
61
+ },
62
+ createTerminal: (params) => this.createTerminal(params),
63
+ terminalOutput: (params) => this.terminalOutput(params),
64
+ waitForTerminalExit: (params) => this.waitForTerminalExit(params),
65
+ killTerminal: async (params) => {
66
+ await this.killTerminal(params);
67
+ return {};
68
+ },
69
+ releaseTerminal: async (params) => {
70
+ await this.releaseTerminal(params);
71
+ return {};
72
+ },
73
+ unstable_createElicitation: (params) => this.createElicitation(params),
74
+ extNotification: (method, params) => this.handleNotification(method, params)
75
+ };
76
+ }
77
+ context() {
78
+ return {
79
+ request: (method, params, options) => this.handleRequest(method, params, options),
80
+ notify: (method, params) => this.handleNotification(method, params)
81
+ };
82
+ }
83
+ resetStreaming() {
84
+ if (this.streamingMessageId) {
85
+ const id = this.streamingMessageId;
86
+ this.store.setState((state) => ({
87
+ messages: state.messages.map(
88
+ (message) => message.id === id && message.role === "assistant" ? { ...message, streaming: false } : message
89
+ )
90
+ }));
91
+ }
92
+ this.streamingMessageId = null;
93
+ }
94
+ appendUserMessage(text) {
95
+ this.appendMessage({ id: uid(), role: "user", text });
96
+ }
97
+ /** An MCP server's sign-in page: opened in the browser, and printed in case that fails. */
98
+ mcpAuthUrl(params) {
99
+ const { serverName, url } = params ?? {};
100
+ if (typeof serverName !== "string" || typeof url !== "string") return;
101
+ let page;
102
+ try {
103
+ page = new URL(url);
104
+ } catch {
105
+ return;
106
+ }
107
+ if (page.protocol !== "https:" && page.protocol !== "http:") {
108
+ this.appendError(`MCP server "${serverName}" sent a sign-in address that is not a web page; not opened.`);
109
+ return;
110
+ }
111
+ openBrowser(page.toString());
112
+ this.appendMessage({
113
+ id: uid(),
114
+ role: "assistant",
115
+ streaming: false,
116
+ text: `Sign in to MCP server "${serverName}" in your browser. If it did not open, visit:
117
+ ${page}`
118
+ });
119
+ }
120
+ appendError(text) {
121
+ this.appendMessage({ id: uid(), role: "error", text });
122
+ }
123
+ failPendingTools(reason) {
124
+ let count = 0;
125
+ this.store.setState((state) => ({
126
+ messages: state.messages.map((message) => {
127
+ if (message.role !== "tool" || message.call.status !== "pending")
128
+ return message;
129
+ count++;
130
+ const output = message.call.output ? `${message.call.output.replace(/\s+$/, "")}
131
+ ${reason}` : reason;
132
+ return {
133
+ ...message,
134
+ call: { ...message.call, status: "failed", output }
135
+ };
136
+ })
137
+ }));
138
+ return count;
139
+ }
140
+ async releaseAllTerminals() {
141
+ await Promise.all(
142
+ [...this.terminals.keys()].map(
143
+ (terminalId) => this.releaseTerminal({
144
+ sessionId: this.store.getState().sessionId,
145
+ terminalId
146
+ })
147
+ )
148
+ );
149
+ }
150
+ async readTextFile(params) {
151
+ let content = await readFile(await this.confine(params.path), "utf8");
152
+ if (params.line || params.limit) {
153
+ const start = Math.max((params.line ?? 1) - 1, 0);
154
+ const end = params.limit ? start + params.limit : void 0;
155
+ content = content.split(/\r?\n/).slice(start, end).join("\n");
156
+ }
157
+ return { content };
158
+ }
159
+ async writeTextFile(params) {
160
+ const path = await this.confine(params.path);
161
+ await mkdir(dirname(path), { recursive: true });
162
+ await writeFile(path, params.content, "utf8");
163
+ }
164
+ /**
165
+ * Second line of defence behind the agent's own path checks: this client
166
+ * only touches files inside its workspace, whatever the agent asks for.
167
+ */
168
+ async confine(path) {
169
+ const resolved = await resolveWorkspaceFile(this.cwd, path);
170
+ if ("error" in resolved) throw new Error(resolved.error);
171
+ return resolved.path;
172
+ }
173
+ async createTerminal(params) {
174
+ const terminalId = `tui-${++this.nextTerminalId}`;
175
+ const outputByteLimit = params.outputByteLimit ?? DEFAULT_OUTPUT_LIMIT;
176
+ const env = { ...process.env };
177
+ for (const variable of params.env ?? [])
178
+ env[variable.name] = variable.value;
179
+ const child = spawnCommand(params.command, {
180
+ cwd: params.cwd ?? this.cwd,
181
+ env,
182
+ args: params.args
183
+ });
184
+ const record = {
185
+ child,
186
+ command: params.command,
187
+ toolCallId: this.findPendingTerminalTool(params.command),
188
+ output: "",
189
+ truncated: false,
190
+ outputByteLimit,
191
+ previewTimer: null,
192
+ exitStatus: null,
193
+ exitPromise: Promise.resolve({})
194
+ };
195
+ const append = (chunk) => {
196
+ record.output += chunk.toString("utf8");
197
+ if (record.output.length > record.outputByteLimit) {
198
+ record.output = record.output.slice(
199
+ record.output.length - record.outputByteLimit
200
+ );
201
+ record.truncated = true;
202
+ }
203
+ this.scheduleTerminalPreview(record);
204
+ };
205
+ child.stdout?.on("data", append);
206
+ child.stderr?.on("data", append);
207
+ record.exitPromise = new Promise((resolve) => {
208
+ child.on("error", (err) => {
209
+ record.output += `
210
+ ${err.message}`;
211
+ this.flushTerminalPreview(record);
212
+ record.exitStatus = { exitCode: null, signal: "error" };
213
+ resolve(record.exitStatus);
214
+ });
215
+ child.on("close", (exitCode, signal) => {
216
+ this.flushTerminalPreview(record);
217
+ record.exitStatus = { exitCode, signal };
218
+ resolve(record.exitStatus);
219
+ });
220
+ });
221
+ this.terminals.set(terminalId, record);
222
+ return { terminalId };
223
+ }
224
+ async terminalOutput(params) {
225
+ const record = this.requireTerminal(params.terminalId);
226
+ return {
227
+ output: record.output,
228
+ truncated: record.truncated,
229
+ exitStatus: record.exitStatus
230
+ };
231
+ }
232
+ async waitForTerminalExit(params) {
233
+ return this.requireTerminal(params.terminalId).exitPromise;
234
+ }
235
+ async killTerminal(params) {
236
+ const record = this.requireTerminal(params.terminalId);
237
+ if (!record.exitStatus) await terminateProcessTree(record.child);
238
+ }
239
+ async releaseTerminal(params) {
240
+ const record = this.terminals.get(params.terminalId);
241
+ if (!record) return;
242
+ if (record.previewTimer) clearTimeout(record.previewTimer);
243
+ this.terminals.delete(params.terminalId);
244
+ if (!record.exitStatus) await terminateProcessTree(record.child);
245
+ }
246
+ /**
247
+ * Only shows the agent's question: rules, modes and remembered approvals
248
+ * are the agent's permission policy, so every ACP client behaves the same.
249
+ */
250
+ async requestPermission(params) {
251
+ const toolName = toolNameOf(params.toolCall) ?? "tool";
252
+ const kind = params.toolCall.kind ?? "other";
253
+ const args = toRecord(params.toolCall.rawInput);
254
+ return new Promise((resolve) => {
255
+ this.store.setState({
256
+ pendingPermission: {
257
+ toolCallId: params.toolCall.toolCallId,
258
+ toolName,
259
+ title: params.toolCall.title ?? toolName,
260
+ kind,
261
+ args,
262
+ resolve: (allow, scope) => {
263
+ this.store.setState({ pendingPermission: null });
264
+ const optionId = choosePermissionOption(params.options, allow, scope);
265
+ resolve(
266
+ optionId ? selected(optionId) : { outcome: { outcome: "cancelled" } }
267
+ );
268
+ }
269
+ }
270
+ });
271
+ });
272
+ }
273
+ async createElicitation(params, signal) {
274
+ const request = fromElicitationRequest(params);
275
+ if (!request) return { action: "decline" };
276
+ const id = `question-${uid()}`;
277
+ return new Promise((resolve) => {
278
+ let settled = false;
279
+ const finish = (response) => {
280
+ if (settled) return;
281
+ settled = true;
282
+ signal?.removeEventListener("abort", abort);
283
+ if (this.store.getState().pendingQuestion?.id === id) {
284
+ this.store.setState({ pendingQuestion: null });
285
+ }
286
+ resolve(toElicitationResponse(request, response));
287
+ };
288
+ const abort = () => finish({ action: "cancel", answers: [] });
289
+ signal?.addEventListener("abort", abort, { once: true });
290
+ if (signal?.aborted) {
291
+ abort();
292
+ return;
293
+ }
294
+ this.store.setState({
295
+ pendingQuestion: { id, request, resolve: finish }
296
+ });
297
+ });
298
+ }
299
+ async sessionUpdate(params) {
300
+ const update = params.update;
301
+ switch (update.sessionUpdate) {
302
+ case "user_message_chunk":
303
+ if (update.content.type === "text") {
304
+ this.resetStreaming();
305
+ this.appendUserMessage(update.content.text);
306
+ }
307
+ return;
308
+ case "agent_message_chunk":
309
+ if (update.content.type === "text")
310
+ this.appendStreamingText(update.content.text);
311
+ return;
312
+ case "agent_thought_chunk":
313
+ if (update.content.type === "text" && update.content.text.trim()) {
314
+ this.resetStreaming();
315
+ this.appendMessage({
316
+ id: uid(),
317
+ role: "assistant",
318
+ text: update.content.text,
319
+ streaming: false
320
+ });
321
+ }
322
+ return;
323
+ case "available_commands_update":
324
+ this.agentCommands = update.availableCommands.map(({ name, description, input }) => ({
325
+ name,
326
+ description,
327
+ ...input && "hint" in input && input.hint ? { hint: input.hint } : {}
328
+ }));
329
+ return;
330
+ case "current_mode_update":
331
+ if (this.modeIds.includes(update.currentModeId)) {
332
+ this.onInteractionModeChange?.(update.currentModeId);
333
+ }
334
+ return;
335
+ case "plan":
336
+ this.store.setState({
337
+ plan: update.entries.map(
338
+ ({ content, priority, status }) => ({
339
+ content,
340
+ priority,
341
+ status
342
+ })
343
+ )
344
+ });
345
+ return;
346
+ case "tool_call":
347
+ this.resetStreaming();
348
+ if (toolNameOf(update) === "update_plan") return;
349
+ this.appendMessage({
350
+ id: uid(),
351
+ role: "tool",
352
+ call: {
353
+ toolCallId: update.toolCallId,
354
+ name: toolNameOf(update) ?? update.title,
355
+ mutating: typeof update._meta?.["nova-ai-cli/mutating"] === "boolean" ? update._meta["nova-ai-cli/mutating"] : update.kind !== "read" && update.kind !== "search",
356
+ kind: update.kind ?? "other",
357
+ args: toRecord(update.rawInput),
358
+ status: normalizeToolStatus(update.status),
359
+ output: null,
360
+ diff: null
361
+ }
362
+ });
363
+ return;
364
+ case "tool_call_update":
365
+ this.patchToolCall(update.toolCallId, {
366
+ status: normalizeToolStatus(update.status),
367
+ output: extractToolOutput(update),
368
+ diff: extractToolDiff(update)
369
+ });
370
+ return;
371
+ default:
372
+ return;
373
+ }
374
+ }
375
+ async backgroundUpdate(params) {
376
+ if (!params || typeof params !== "object") return;
377
+ const event = "event" in params ? String(params.event) : "background";
378
+ const job = parseBackgroundJob("job" in params ? params.job : null);
379
+ if (!job) return;
380
+ const preview = job.outputPath && event !== "started" ? await readPreview(job.outputPath) : "";
381
+ this.upsertBackgroundMessage({ ...job, preview });
382
+ this.store.setState({ statusLine: `Background ${event}: ${job.title}` });
383
+ await this.onBackgroundLifecycle?.(event, job);
384
+ }
385
+ queueChanged(params) {
386
+ if (!params || typeof params !== "object") return;
387
+ const rawEntries = "entries" in params && Array.isArray(params.entries) ? params.entries : [];
388
+ const entries = rawEntries.flatMap((value) => {
389
+ if (!value || typeof value !== "object") return [];
390
+ const entry = value;
391
+ if (typeof entry.id !== "string" || typeof entry.text !== "string" || entry.kind !== "steer" && entry.kind !== "followup") {
392
+ return [];
393
+ }
394
+ return [{ id: entry.id, text: entry.text, kind: entry.kind }];
395
+ });
396
+ this.queueSnapshot = rawEntries.filter(
397
+ (value) => !!value && typeof value === "object" && typeof value.id === "string" && typeof value.text === "string"
398
+ );
399
+ this.store.setState((state) => {
400
+ const transcript = state.messages.filter(
401
+ (message) => !(message.role === "user" && message.queued)
402
+ );
403
+ const queued = entries.map((entry) => ({
404
+ id: entry.id,
405
+ role: "user",
406
+ text: entry.text,
407
+ queued: entry.kind
408
+ }));
409
+ return {
410
+ messages: [...transcript, ...queued],
411
+ queuedCount: queued.length
412
+ };
413
+ });
414
+ }
415
+ async handleRequest(method, params, options) {
416
+ options?.cancellationSignal?.throwIfAborted();
417
+ switch (method) {
418
+ case "fs/read_text_file":
419
+ return this.readTextFile(params);
420
+ case "fs/write_text_file":
421
+ return this.writeTextFile(params);
422
+ case "terminal/create":
423
+ return this.createTerminal(params);
424
+ case "terminal/output":
425
+ return this.terminalOutput(params);
426
+ case "terminal/wait_for_exit":
427
+ return this.waitForTerminalExit(params);
428
+ case "terminal/kill":
429
+ return this.killTerminal(params);
430
+ case "terminal/release":
431
+ return this.releaseTerminal(params);
432
+ case "session/request_permission":
433
+ return this.requestPermission(params);
434
+ case "elicitation/create":
435
+ return this.createElicitation(
436
+ params,
437
+ options?.cancellationSignal
438
+ );
439
+ default:
440
+ throw new Error(`Unsupported ACP client request: ${method}`);
441
+ }
442
+ }
443
+ async handleNotification(method, params) {
444
+ switch (method) {
445
+ case "session/update":
446
+ return this.sessionUpdate(params);
447
+ case NOVA_NOTIFICATIONS.backgroundUpdate:
448
+ return this.backgroundUpdate(params);
449
+ case NOVA_NOTIFICATIONS.queueChanged:
450
+ return this.queueChanged(params);
451
+ case NOVA_NOTIFICATIONS.mcpAuthUrl:
452
+ return this.mcpAuthUrl(params);
453
+ default:
454
+ return;
455
+ }
456
+ }
457
+ requireTerminal(terminalId) {
458
+ const record = this.terminals.get(terminalId);
459
+ if (!record) throw new Error(`Terminal ${terminalId} not found`);
460
+ return record;
461
+ }
462
+ findPendingTerminalTool(command) {
463
+ const messages = this.store.getState().messages;
464
+ for (let index = messages.length - 1; index >= 0; index--) {
465
+ const message = messages[index];
466
+ if (!message || message.role !== "tool" || message.call.status !== "pending")
467
+ continue;
468
+ if (message.call.name === "run_package_script")
469
+ return message.call.toolCallId;
470
+ if (message.call.name === "run_command" && message.call.args.command === command)
471
+ return message.call.toolCallId;
472
+ }
473
+ return null;
474
+ }
475
+ scheduleTerminalPreview(record) {
476
+ if (!record.toolCallId || record.previewTimer) return;
477
+ record.previewTimer = setTimeout(() => {
478
+ record.previewTimer = null;
479
+ this.patchToolCall(record.toolCallId, { output: record.output });
480
+ }, 50);
481
+ record.previewTimer.unref?.();
482
+ }
483
+ flushTerminalPreview(record) {
484
+ if (record.previewTimer) {
485
+ clearTimeout(record.previewTimer);
486
+ record.previewTimer = null;
487
+ }
488
+ if (record.toolCallId && record.output)
489
+ this.patchToolCall(record.toolCallId, { output: record.output });
490
+ }
491
+ appendStreamingText(text) {
492
+ if (!this.streamingMessageId) {
493
+ const id = uid();
494
+ this.streamingMessageId = id;
495
+ this.appendMessage({ id, role: "assistant", text, streaming: true });
496
+ return;
497
+ }
498
+ this.store.setState((state) => ({
499
+ messages: state.messages.map(
500
+ (message) => message.id === this.streamingMessageId && message.role === "assistant" ? { ...message, text: message.text + text } : message
501
+ )
502
+ }));
503
+ }
504
+ patchToolCall(toolCallId, patch) {
505
+ this.store.setState((state) => ({
506
+ messages: state.messages.map(
507
+ (message) => message.role === "tool" && message.call.toolCallId === toolCallId ? { ...message, call: { ...message.call, ...patch } } : message
508
+ )
509
+ }));
510
+ }
511
+ appendMessage(message) {
512
+ this.store.setState((state) => ({
513
+ messages: insertBeforeQueuedMessages(state.messages, message)
514
+ }));
515
+ }
516
+ upsertBackgroundMessage(job) {
517
+ this.store.setState((state) => {
518
+ const existing = state.messages.find(
519
+ (message) => message.role === "background" && message.job.jobId === job.jobId
520
+ );
521
+ if (!existing)
522
+ return {
523
+ messages: insertBeforeQueuedMessages(state.messages, {
524
+ id: uid(),
525
+ role: "background",
526
+ job
527
+ })
528
+ };
529
+ return {
530
+ messages: state.messages.map(
531
+ (message) => message.role === "background" && message.job.jobId === job.jobId ? { ...message, job } : message
532
+ )
533
+ };
534
+ });
535
+ }
536
+ };
537
+ function insertBeforeQueuedMessages(messages, message) {
538
+ const queuedIndex = messages.findIndex(
539
+ (item) => item.role === "user" && item.queued !== void 0
540
+ );
541
+ if (queuedIndex < 0) return [...messages, message];
542
+ return [
543
+ ...messages.slice(0, queuedIndex),
544
+ message,
545
+ ...messages.slice(queuedIndex)
546
+ ];
547
+ }
548
+ function choosePermissionOption(options, allow, scope) {
549
+ const preferred = !allow ? ["reject_once"] : scope === "always" ? ["allow_always", "allow_session", "allow_once"] : scope === "session" ? ["allow_session", "allow_once"] : ["allow_once"];
550
+ for (const id of preferred) {
551
+ const byId = options.find((option) => option.optionId === id);
552
+ if (byId) return byId.optionId;
553
+ }
554
+ const kinds = !allow ? ["reject_once", "reject_always"] : scope === "once" ? ["allow_once"] : ["allow_always", "allow_once"];
555
+ for (const kind of kinds) {
556
+ const byKind = options.find((option) => option.kind === kind);
557
+ if (byKind) return byKind.optionId;
558
+ }
559
+ return null;
560
+ }
561
+ function toolNameOf(call) {
562
+ const fromMeta = call._meta?.["nova-ai-cli/tool"];
563
+ return typeof fromMeta === "string" ? fromMeta : call.title ?? void 0;
564
+ }
565
+ function selected(optionId) {
566
+ return { outcome: { outcome: "selected", optionId } };
567
+ }
568
+ function toRecord(value) {
569
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
570
+ }
571
+ function normalizeToolStatus(status) {
572
+ if (status === "completed" || status === "failed") return status;
573
+ return "pending";
574
+ }
575
+ function extractToolOutput(update) {
576
+ if (typeof update.rawOutput === "object" && update.rawOutput && "output" in update.rawOutput) {
577
+ return String(update.rawOutput.output ?? "");
578
+ }
579
+ const content = update.content?.find(
580
+ (item) => item.type === "content" && item.content.type === "text"
581
+ );
582
+ return content && content.type === "content" && content.content.type === "text" ? content.content.text : null;
583
+ }
584
+ function extractToolDiff(update) {
585
+ const diff = update.content?.find((item) => item.type === "diff");
586
+ if (!diff || diff.type !== "diff") return null;
587
+ return {
588
+ path: diff.path,
589
+ oldText: diff.oldText ?? null,
590
+ newText: diff.newText
591
+ };
592
+ }
593
+ function parseBackgroundJob(value) {
594
+ if (!value || typeof value !== "object") return null;
595
+ const job = value;
596
+ const jobId = typeof job.jobId === "string" ? job.jobId : null;
597
+ const kind = job.kind === "terminal" || job.kind === "prompt" ? job.kind : null;
598
+ const title = typeof job.title === "string" ? job.title : "background job";
599
+ const status = job.status === "running" || job.status === "completed" || job.status === "failed" || job.status === "killed" || job.status === "released" ? job.status : null;
600
+ if (!jobId || !kind || !status) return null;
601
+ return {
602
+ jobId,
603
+ kind,
604
+ title,
605
+ status,
606
+ outputPath: typeof job.outputPath === "string" ? job.outputPath : void 0,
607
+ preview: ""
608
+ };
609
+ }
610
+ async function readPreview(path) {
611
+ try {
612
+ const raw = await readFile(path, "utf8");
613
+ return raw.split(/\r?\n/).slice(0, 12).join("\n");
614
+ } catch {
615
+ return "";
616
+ }
617
+ }
618
+
619
+ // src/tui/state/store.ts
620
+ function createStore(initial) {
621
+ let state = initial;
622
+ const listeners = /* @__PURE__ */ new Set();
623
+ return {
624
+ getState() {
625
+ return state;
626
+ },
627
+ setState(update) {
628
+ const partial = typeof update === "function" ? update(state) : update;
629
+ state = { ...state, ...partial };
630
+ for (const listener of listeners) listener();
631
+ },
632
+ subscribe(listener) {
633
+ listeners.add(listener);
634
+ return () => listeners.delete(listener);
635
+ }
636
+ };
637
+ }
638
+
639
+ export {
640
+ TuiAcpClient,
641
+ choosePermissionOption,
642
+ createStore
643
+ };