@datalabrotterdam/nova-ai-cli 1.2.0-alpha.11 → 1.2.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ # [@datalabrotterdam/nova-ai-cli-v1.2.0-alpha.13](https://github.com/Datalab-Rotterdam/nova-ai-cli/compare/@datalabrotterdam/nova-ai-cli@1.2.0-alpha.12...@datalabrotterdam/nova-ai-cli@1.2.0-alpha.13) (2026-10-24)
2
+
3
+
4
+ ### Features
5
+
6
+ * **cli:** Ctrl+S select mode and /copy ([bcb7e02](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/bcb7e02f05739caae7a6155d2c37b7d2e54d01fa))
7
+
8
+ # [@datalabrotterdam/nova-ai-cli-v1.2.0-alpha.12](https://github.com/Datalab-Rotterdam/nova-ai-cli/compare/@datalabrotterdam/nova-ai-cli@1.2.0-alpha.11...@datalabrotterdam/nova-ai-cli@1.2.0-alpha.12) (2026-10-24)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **cli:** show the model's thinking as one block ([4479cac](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/4479cac70bf1f9973401bdfd420c401e00c11099))
14
+
15
+
16
+ ### Features
17
+
18
+ * **cli:** collapse the model's thinking by default ([28f04c1](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/28f04c1c5141aac2468c0aa8ba86dc38d885bb4e))
19
+ * **cli:** say when a collapsed tool row has output ([51274a5](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/51274a597700cf3208d48d54c2a32262e8947a96))
20
+ * token and cost totals per session ([8e9aef8](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/8e9aef81b6345832f38d8a2639c35d4cb6fb3b47))
21
+
1
22
  # [@datalabrotterdam/nova-ai-cli-v1.2.0-alpha.11](https://github.com/Datalab-Rotterdam/nova-ai-cli/compare/@datalabrotterdam/nova-ai-cli@1.2.0-alpha.10...@datalabrotterdam/nova-ai-cli@1.2.0-alpha.11) (2026-10-24)
2
23
 
3
24
 
package/README.md CHANGED
@@ -75,7 +75,9 @@ The UI runs full screen (the terminal's alternate screen, like `less` or
75
75
  `vim`) and gives your shell its screen back on exit. The mouse wheel or
76
76
  PgUp/PgDn scroll the transcript, Esc jumps back to the newest output, and
77
77
  while you read an answer the prompt that asked for it stays pinned on the top
78
- row. To select text, hold Shift (Option in some macOS terminals) while dragging.
78
+ row. To select text, hold Shift (Option in some macOS terminals) while dragging,
79
+ or press Ctrl+S to turn the mouse over to your terminal (select mode; Ctrl+S
80
+ again gives the wheel back). `/copy` copies the last answer.
79
81
 
80
82
  - **Modes** (Shift+Tab): `agent` does the work; `plan` reads the code, asks
81
83
  questions and builds a task list but changes nothing; `ask` only answers;
@@ -101,11 +103,12 @@ row. To select text, hold Shift (Option in some macOS terminals) while dragging.
101
103
  | Ctrl+C twice | Exit; prints `nova-ai --resume <id>` |
102
104
  | Shift+Tab | Cycle agent / ask / plan / autopilot and your own agents |
103
105
  | Ctrl+R / Ctrl+P / Ctrl+K | Sessions / models / permission mode |
104
- | Ctrl+O / Ctrl+T / Ctrl+B | Verbose tool output / tool inspector / background jobs |
106
+ | Ctrl+O / Ctrl+T / Ctrl+B | Thinking and verbose tool output / tool inspector / background jobs |
107
+ | Ctrl+S | Select mode: the mouse selects text instead of scrolling (again to switch back) |
105
108
  | Alt+V | Paste a clipboard image |
106
109
 
107
110
  Slash commands: `/help`, `/model`, `/effort` (thinking models), `/mode`, `/permission`, `/session`,
108
- `/resume`, `/clear`, `/compact`, `/rewind [n]`, `/queue`, `/steer`, `/usage`,
111
+ `/resume`, `/clear`, `/compact`, `/copy`, `/rewind [n]`, `/queue`, `/steer`, `/usage` (context, and the session's tokens and estimated cost; the status line shows `≈ €0.0123` once a priced model answered),
109
112
  `/tools`, `/mcp`, `/skills`, `/shell` and `/agent` (background jobs), `/trust`
110
113
  and `/exit`. Your own commands are markdown files in `~/.nova-ai/commands/` or
111
114
  the repository's `.nova-ai/commands/` (Claude Code's format, so
@@ -1,4 +1,5 @@
1
1
  // src/tui/session/tui-acp-client.ts
2
+ import { parseUsageTotals } from "@datalabrotterdam/nova-ai-agent/core/usage.js";
2
3
  import { NOVA_NOTIFICATIONS } from "@datalabrotterdam/nova-ai-agent/acp/extensions.js";
3
4
  import { openBrowser } from "@datalabrotterdam/nova-ai-agent/acp/auth-server.js";
4
5
  import { readFile, writeFile, mkdir } from "fs/promises";
@@ -310,16 +311,15 @@ ${err.message}`;
310
311
  this.appendStreamingText(update.content.text);
311
312
  return;
312
313
  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
- });
314
+ if (update.content.type === "text" && update.content.text) {
315
+ this.appendStreamingText(update.content.text, true);
321
316
  }
322
317
  return;
318
+ case "usage_update": {
319
+ const totals = parseUsageTotals(update._meta?.["nova-ai-cli/usageTotals"]);
320
+ if (totals) this.store.setState({ usageTotals: totals });
321
+ return;
322
+ }
323
323
  case "available_commands_update":
324
324
  this.agentCommands = update.availableCommands.map(({ name, description, input }) => ({
325
325
  name,
@@ -488,11 +488,13 @@ ${err.message}`;
488
488
  if (record.toolCallId && record.output)
489
489
  this.patchToolCall(record.toolCallId, { output: record.output });
490
490
  }
491
- appendStreamingText(text) {
491
+ appendStreamingText(text, thinking = false) {
492
+ const current = this.streamingMessageId ? this.store.getState().messages.find((message) => message.id === this.streamingMessageId) : void 0;
493
+ if (current?.role === "assistant" && Boolean(current.thinking) !== thinking) this.resetStreaming();
492
494
  if (!this.streamingMessageId) {
493
495
  const id = uid();
494
496
  this.streamingMessageId = id;
495
- this.appendMessage({ id, role: "assistant", text, streaming: true });
497
+ this.appendMessage({ id, role: "assistant", text, streaming: true, ...thinking ? { thinking: true } : {} });
496
498
  return;
497
499
  }
498
500
  this.store.setState((state) => ({
@@ -2,7 +2,7 @@ import {
2
2
  TuiAcpClient,
3
3
  choosePermissionOption,
4
4
  createStore
5
- } from "./chunk-KDAUUQBE.js";
5
+ } from "./chunk-KRC3RH3Y.js";
6
6
 
7
7
  // src/headless/index.ts
8
8
  import { resolve } from "path";
package/dist/index.js CHANGED
@@ -144,7 +144,7 @@ ${MAIN_HELP}
144
144
  }
145
145
  case "print": {
146
146
  const update = startUpdateCheck();
147
- const { default: runHeadless } = await import("./headless-BQXK7TNC.js");
147
+ const { default: runHeadless } = await import("./headless-XRIRZB7Z.js");
148
148
  const code = await runHeadless(command.args);
149
149
  if (update) await noticeUpdate(update, 500);
150
150
  return code;
@@ -185,7 +185,7 @@ ${MAIN_HELP}
185
185
  );
186
186
  return 2;
187
187
  }
188
- const { default: runChat } = await import("./tui-TJFV6NC6.js");
188
+ const { default: runChat } = await import("./tui-NBVDJQIA.js");
189
189
  return runChat(options);
190
190
  }
191
191
  }
@@ -10,7 +10,7 @@ import {
10
10
  import {
11
11
  TuiAcpClient,
12
12
  createStore
13
- } from "./chunk-KDAUUQBE.js";
13
+ } from "./chunk-KRC3RH3Y.js";
14
14
 
15
15
  // src/tui/index.ts
16
16
  import { createInterface as createInterface2 } from "readline/promises";
@@ -591,7 +591,7 @@ var SessionRunner = class {
591
591
  this.title = null;
592
592
  this.promptActive = false;
593
593
  this.agentSessionLoaded = false;
594
- this.store.setState({ contextUsage: null, plan: [] });
594
+ this.store.setState({ contextUsage: null, usageTotals: null, plan: [] });
595
595
  if (previousSessionId)
596
596
  await this.nova.closeSession({ sessionId: previousSessionId });
597
597
  this.sessionReady = this.createSession();
@@ -1351,6 +1351,17 @@ function formatElapsedTime(elapsedMs) {
1351
1351
  if (minutes > 0) return `${minutes}m ${seconds}s`;
1352
1352
  return `${seconds}s`;
1353
1353
  }
1354
+ function formatCost(amount, currency = "EUR") {
1355
+ try {
1356
+ return new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: amount < 1 ? 4 : 2 }).format(amount);
1357
+ } catch {
1358
+ return `${amount.toFixed(4)} ${currency}`;
1359
+ }
1360
+ }
1361
+ function formatUsageTotals(totals) {
1362
+ const cost = totals.cost !== void 0 ? ` \xB7 \u2248 ${formatCost(totals.cost, totals.currency)} (from the models' prices per 1M tokens; cached tokens count as input)` : "";
1363
+ return `This session: ${formatTokenCount(totals.promptTokens)} in${totals.cachedTokens ? ` (${formatTokenCount(totals.cachedTokens)} cached)` : ""} \xB7 ${formatTokenCount(totals.completionTokens)} out${cost}`;
1364
+ }
1354
1365
  function formatTokenCount(value) {
1355
1366
  if (value >= 1e6) {
1356
1367
  const millions = value / 1e6;
@@ -1543,10 +1554,18 @@ function StatusLine({
1543
1554
  ui.permissionMode,
1544
1555
  separator,
1545
1556
  ui.busy ? /* @__PURE__ */ jsx2(Text2, { color: palette.warning, children: "working" }) : /* @__PURE__ */ jsx2(Text2, { color: palette.success, children: "ready" }),
1557
+ ui.selectMode ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
1558
+ separator,
1559
+ /* @__PURE__ */ jsx2(Text2, { color: palette.warning, children: "select \xB7 Ctrl+S" })
1560
+ ] }) : null,
1546
1561
  usage ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
1547
1562
  separator,
1548
1563
  usage
1549
1564
  ] }) : null,
1565
+ ui.usageTotals?.cost !== void 0 ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
1566
+ separator,
1567
+ `\u2248 ${formatCost(ui.usageTotals.cost, ui.usageTotals.currency)}`
1568
+ ] }) : null,
1550
1569
  ui.queuedCount ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
1551
1570
  separator,
1552
1571
  `queued:${ui.queuedCount}`
@@ -1741,6 +1760,10 @@ function TranscriptBlockView({ block, frame = 0 }) {
1741
1760
  }
1742
1761
  case "assistant": {
1743
1762
  const text = block.first ? block.text.replace(/^\s+/, "") : block.text;
1763
+ if (block.thinking) {
1764
+ const lines = text.split("\n").filter((line) => line.trim()).length;
1765
+ return /* @__PURE__ */ jsx4(Prefixed, { prefix: " ", color: palette.faint, children: /* @__PURE__ */ jsx4(Text4, { color: palette.muted, italic: true, children: block.expanded ? `thinking: ${text.trim()}` : block.streaming ? "\u25B8 thinking\u2026" : `\u25B8 thinking (${lines} ${lines === 1 ? "line" : "lines"}) \xB7 Ctrl+O to show` }) });
1766
+ }
1744
1767
  return /* @__PURE__ */ jsx4(Prefixed, { prefix: block.first ? `${ASSISTANT_MARKER} ` : " ", color: palette.accent, children: /* @__PURE__ */ jsx4(Markdown, { text: text.trimEnd() }) });
1745
1768
  }
1746
1769
  case "error":
@@ -1808,7 +1831,10 @@ function ToolCallBlock({
1808
1831
  path: call.diff.path,
1809
1832
  limit: expanded ? void 0 : COLLAPSED_DIFF_LINES
1810
1833
  }
1811
- ) : expanded && !isCompactTool(call) ? /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { color: palette.muted, children: formatToolCallDetails(call) }) }) : null
1834
+ ) : expanded && !isCompactTool(call) ? /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { color: palette.muted, children: formatToolCallDetails(call) }) }) : call.status === "completed" && outputLines(call.output).length ? (
1835
+ // Collapsed output says it is there, like collapsed thinking.
1836
+ /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { color: palette.faint, children: `\u25B8 ${outputLines(call.output).length} ${outputLines(call.output).length === 1 ? "line" : "lines"} of output \xB7 ${isCompactTool(call) ? "Ctrl+T to inspect" : "Ctrl+O to show"}` }) })
1837
+ ) : null
1812
1838
  ] });
1813
1839
  }
1814
1840
  function ActivityBlock({
@@ -2070,6 +2096,13 @@ var builtinCommands = [
2070
2096
  ctx.print(await ctx.switchSkill(name, action === "on", global));
2071
2097
  }
2072
2098
  },
2099
+ {
2100
+ name: "copy",
2101
+ description: "Copy the last answer to the clipboard (Ctrl+S selects text with the mouse)",
2102
+ async run(ctx) {
2103
+ ctx.print(await ctx.copyLastAnswer());
2104
+ }
2105
+ },
2073
2106
  {
2074
2107
  name: "usage",
2075
2108
  description: "Inspect estimated context usage by category",
@@ -2364,6 +2397,35 @@ const png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG,
2364
2397
  console.log(ObjC.unwrap(png.base64EncodedStringWithOptions(0)));
2365
2398
  `;
2366
2399
 
2400
+ // src/tui/files/clipboard-text.ts
2401
+ import { spawn } from "child_process";
2402
+ async function copyText(text, write, tools = systemTools()) {
2403
+ write(`\x1B]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`);
2404
+ for (const [command, args] of tools) {
2405
+ if (await pipeTo(command, args, text)) return command;
2406
+ }
2407
+ return null;
2408
+ }
2409
+ function systemTools() {
2410
+ if (process.platform === "darwin") return [["pbcopy", []]];
2411
+ if (process.platform === "win32") return [["clip", []]];
2412
+ return [["wl-copy", []], ["xclip", ["-selection", "clipboard"]]];
2413
+ }
2414
+ function pipeTo(command, args, text) {
2415
+ return new Promise((resolve2) => {
2416
+ let child;
2417
+ try {
2418
+ child = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"] });
2419
+ } catch {
2420
+ resolve2(false);
2421
+ return;
2422
+ }
2423
+ child.on("error", () => resolve2(false));
2424
+ child.on("close", (code) => resolve2(code === 0));
2425
+ child.stdin.end(text);
2426
+ });
2427
+ }
2428
+
2367
2429
  // src/tui/files/find-fd.ts
2368
2430
  import { spawnSync as spawnSync2 } from "child_process";
2369
2431
  var FD_CANDIDATES = ["fd", "fdfind"];
@@ -2432,7 +2494,7 @@ var DraftImageAttachments = class {
2432
2494
  };
2433
2495
 
2434
2496
  // src/tui/files/workspace-autocomplete.ts
2435
- import { spawn } from "child_process";
2497
+ import { spawn as spawn2 } from "child_process";
2436
2498
  import { readdirSync as readdirSync2, statSync as statSync2 } from "fs";
2437
2499
  import { homedir } from "os";
2438
2500
  import { basename, dirname as dirname2, join as join3 } from "path";
@@ -2643,7 +2705,7 @@ function walkWithFd(cwd, fdPath, query, signal) {
2643
2705
  resolve2([]);
2644
2706
  return;
2645
2707
  }
2646
- const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "ignore"] });
2708
+ const child = spawn2(fdPath, args, { stdio: ["ignore", "pipe", "ignore"] });
2647
2709
  let stdout = "";
2648
2710
  let settled = false;
2649
2711
  const finish = (entries) => {
@@ -3770,7 +3832,7 @@ var TranscriptCommitter = class {
3770
3832
  );
3771
3833
  break;
3772
3834
  case "assistant":
3773
- this.emitAssistant(message, committing, emit);
3835
+ this.emitAssistant(message, committing, emit, expanded);
3774
3836
  if (message.streaming) committing = false;
3775
3837
  break;
3776
3838
  }
@@ -3782,14 +3844,22 @@ var TranscriptCommitter = class {
3782
3844
  this.snapshot = { epoch: this.epoch, committed: this.committedView, live };
3783
3845
  return this.snapshot;
3784
3846
  }
3785
- emitAssistant(message, committing, emit) {
3847
+ emitAssistant(message, committing, emit, expanded) {
3848
+ if (message.thinking) {
3849
+ const block2 = { key: message.id, kind: "assistant", text: message.text, first: true, streaming: message.streaming, thinking: true, expanded };
3850
+ if (message.streaming) emit(block2, false, []);
3851
+ else if (message.text.trim()) emit(block2, true, [message.id]);
3852
+ else if (committing) this.committedIds.push(message.id);
3853
+ return;
3854
+ }
3786
3855
  let offset = this.partial?.id === message.id ? this.partial.offset : 0;
3787
3856
  const block = (text, streaming) => ({
3788
3857
  key: `${message.id}:${offset}`,
3789
3858
  kind: "assistant",
3790
3859
  text,
3791
3860
  first: offset === 0,
3792
- streaming
3861
+ streaming,
3862
+ ...message.thinking ? { thinking: true } : {}
3793
3863
  });
3794
3864
  if (!message.streaming) {
3795
3865
  const rest2 = message.text.slice(offset);
@@ -4021,6 +4091,9 @@ var TuiController = class {
4021
4091
  case "o":
4022
4092
  this.toggleToolDetails();
4023
4093
  return true;
4094
+ case "s":
4095
+ this.toggleSelectMode();
4096
+ return true;
4024
4097
  case "t":
4025
4098
  this.openToolInspector();
4026
4099
  return true;
@@ -4269,7 +4342,8 @@ ${skill.path}`
4269
4342
  this.showText("Context usage", "Calculating context usage...");
4270
4343
  try {
4271
4344
  const usage = await this.runner.refreshContextUsage();
4272
- this.showText("Context usage", formatContextUsage(usage));
4345
+ const totals = this.store.getState().usageTotals;
4346
+ this.showText("Context usage", [formatContextUsage(usage), ...totals ? [formatUsageTotals(totals)] : []].join("\n\n"));
4273
4347
  } catch (error) {
4274
4348
  this.showText("Context usage", errorMessage(error, "Failed to calculate context usage."));
4275
4349
  }
@@ -4294,10 +4368,31 @@ ${skill.path}`
4294
4368
  this.scroll = next;
4295
4369
  this.refresh();
4296
4370
  }
4371
+ /**
4372
+ * Select mode: mouse reporting off, so the terminal selects and copies text
4373
+ * natively (the wheel no longer scrolls the transcript; PgUp/PgDn still do).
4374
+ * For terminals where Shift/Option + drag does not get past mouse reporting.
4375
+ */
4376
+ toggleSelectMode() {
4377
+ const selectMode = !this.store.getState().selectMode;
4378
+ this.io.write(selectMode ? MOUSE_REPORTING_OFF : MOUSE_REPORTING_ON);
4379
+ this.store.setState({
4380
+ selectMode,
4381
+ statusLine: selectMode ? "Select mode: drag to select text, your terminal copies it. PgUp/PgDn scroll; Ctrl+S turns the mouse wheel back on." : "Mouse wheel scrolls the transcript again. Ctrl+S to select text."
4382
+ });
4383
+ }
4384
+ /** /copy: the last answer (not its thinking) to the clipboard. */
4385
+ async copyLastAnswer() {
4386
+ const answer = [...this.store.getState().messages].reverse().find((message) => message.role === "assistant" && !message.thinking && message.text.trim());
4387
+ if (answer?.role !== "assistant") return "There is no answer to copy yet.";
4388
+ const tool = await copyText(answer.text.trim(), (data) => this.io.write(data));
4389
+ const size = `${answer.text.trim().length} characters`;
4390
+ return tool ? `Copied the last answer (${size}).` : `Sent the last answer (${size}) to the terminal's clipboard; if nothing was copied, use Ctrl+S to select it.`;
4391
+ }
4297
4392
  toggleToolDetails() {
4298
4393
  this.expandedTools = !this.expandedTools;
4299
4394
  this.store.setState({
4300
- statusLine: this.expandedTools ? "Verbose tool details expanded; read/search stay compact." : "Verbose tool details collapsed."
4395
+ statusLine: this.expandedTools ? "Thinking and verbose tool details expanded; read/search stay compact." : "Thinking and verbose tool details collapsed."
4301
4396
  });
4302
4397
  }
4303
4398
  toolCalls() {
@@ -4366,6 +4461,7 @@ ${skill.path}`
4366
4461
  setModel: (model) => this.setModel(model),
4367
4462
  getInteractionMode: () => this.runner.interactionMode,
4368
4463
  getInteractionModes: () => this.runner.modeIds,
4464
+ copyLastAnswer: () => this.copyLastAnswer(),
4369
4465
  agentCommands: () => this.agentCommands(),
4370
4466
  setInteractionMode: (mode) => this.setInteractionMode(mode),
4371
4467
  getPermissionMode: () => this.store.getState().permissionMode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalabrotterdam/nova-ai-cli",
3
- "version": "1.2.0-alpha.11",
3
+ "version": "1.2.0-alpha.13",
4
4
  "description": "Agentic coding with DataLab Rotterdam's Nova AI in your terminal: interactive TUI, headless mode, and an ACP agent for editors",
5
5
  "license": "Apache-2.0",
6
6
  "author": "DataLab Rotterdam",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@agentclientprotocol/sdk": "^0.29.0",
42
- "@datalabrotterdam/nova-ai-agent": "^1.0.0-alpha.8",
42
+ "@datalabrotterdam/nova-ai-agent": "^1.0.0-alpha.9",
43
43
  "@datalabrotterdam/nova-ai-browser-host": "^1.0.0",
44
44
  "chalk": "^5.3.0",
45
45
  "cli-highlight": "^2.1.11",