@datalabrotterdam/nova-ai-cli 1.2.0-alpha.6 → 1.2.0-alpha.8

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,25 @@
1
+ # [@datalabrotterdam/nova-ai-cli-v1.2.0-alpha.8](https://github.com/Datalab-Rotterdam/nova-ai-cli/compare/@datalabrotterdam/nova-ai-cli@1.2.0-alpha.7...@datalabrotterdam/nova-ai-cli@1.2.0-alpha.8) (2026-10-24)
2
+
3
+
4
+ ### Features
5
+
6
+ * slash commands from markdown files ([305f111](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/305f1112c6f8312ab8a0683954c4e5bc88d7b952))
7
+
8
+ # [@datalabrotterdam/nova-ai-cli-v1.2.0-alpha.7](https://github.com/Datalab-Rotterdam/nova-ai-cli/compare/@datalabrotterdam/nova-ai-cli@1.2.0-alpha.6...@datalabrotterdam/nova-ai-cli@1.2.0-alpha.7) (2026-10-24)
9
+
10
+
11
+ ### Features
12
+
13
+ * **agent:** Claude Code compatible hooks ([227fe8b](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/227fe8b273ec2e211e9d57674e76af5c42a473bd))
14
+ * **agent:** install the shared Nova skills ([64139dd](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/64139dd612933343f04f165de1a9ab78832d6864))
15
+ * **agent:** mark and protect bundled skills ([6ef52eb](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/6ef52eb6d048f8a3b4e75686244aabcfd0f62d6e))
16
+ * **agent:** thinking effort for reasoning models ([8551fea](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/8551feacff3e1675f369030e20fb2b5071f0db7f))
17
+ * **agent:** web_search tool ([12985eb](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/12985eb9ca06d6c814d4e0162465c651889d9370))
18
+ * **cli:** /effort slider for thinking models ([8ca67f2](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/8ca67f2f5dcc8f6bff8c36409a2baba5d43c4a02))
19
+ * **cli:** remember the thinking level ([883196c](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/883196c9fdb089f4f26ac82b557cc87dfc0b1c0b))
20
+ * OAuth sign-in for MCP servers ([ba57c79](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/ba57c79642f902ab2bc20f59e4627fb2b774bb4a))
21
+ * the user's agent files as session modes ([3386de8](https://github.com/Datalab-Rotterdam/nova-ai-cli/commit/3386de88d07b0351bd1e44960fcacfef26e3d45b))
22
+
1
23
  # [@datalabrotterdam/nova-ai-cli-v1.2.0-alpha.6](https://github.com/Datalab-Rotterdam/nova-ai-cli/compare/@datalabrotterdam/nova-ai-cli@1.2.0-alpha.5...@datalabrotterdam/nova-ai-cli@1.2.0-alpha.6) (2026-10-23)
2
24
 
3
25
 
package/README.md CHANGED
@@ -79,6 +79,8 @@ row. To select text, hold Shift (Option in some macOS terminals) while dragging.
79
79
 
80
80
  - **Modes** (Shift+Tab): `agent` does the work; `plan` reads the code, asks
81
81
  questions and builds a task list but changes nothing; `ask` only answers.
82
+ Your own agents (`~/.nova-ai/agents/<name>.agent.md`, the format the VS Code
83
+ extension uses) are extra modes after these; `/mode` lists them.
82
84
  - **Permissions**: before an edit or command you choose once, for this
83
85
  session, always (saved as an exact rule) or deny. Ctrl+K switches between
84
86
  asking, accepting edits, and bypass (never remembered).
@@ -95,15 +97,18 @@ row. To select text, hold Shift (Option in some macOS terminals) while dragging.
95
97
  | Enter / Shift+Enter | Send / new line (Shift+Enter where the terminal supports the kitty keyboard protocol: VS Code, kitty, WezTerm, Ghostty, foot, Alacritty, recent iTerm2; Alt+Enter, Ctrl+J or a trailing `\` work everywhere) |
96
98
  | Esc | Cancel the running turn |
97
99
  | Ctrl+C twice | Exit; prints `nova-ai --resume <id>` |
98
- | Shift+Tab | Cycle agent / ask / plan |
100
+ | Shift+Tab | Cycle agent / ask / plan and your own agents |
99
101
  | Ctrl+R / Ctrl+P / Ctrl+K | Sessions / models / permission mode |
100
102
  | Ctrl+O / Ctrl+T / Ctrl+B | Verbose tool output / tool inspector / background jobs |
101
103
  | Alt+V | Paste a clipboard image |
102
104
 
103
- Slash commands: `/help`, `/model`, `/mode`, `/permission`, `/session`,
105
+ Slash commands: `/help`, `/model`, `/effort` (thinking models), `/mode`, `/permission`, `/session`,
104
106
  `/resume`, `/clear`, `/compact`, `/rewind [n]`, `/queue`, `/steer`, `/usage`,
105
107
  `/tools`, `/mcp`, `/skills`, `/shell` and `/agent` (background jobs), `/trust`
106
- and `/exit`. Details:
108
+ and `/exit`. Your own commands are markdown files in `~/.nova-ai/commands/` or
109
+ the repository's `.nova-ai/commands/` (Claude Code's format, so
110
+ `.claude/commands` works too): the file is the prompt, `$ARGUMENTS` what you
111
+ type after the command, and they show up in the `/` menu. Details:
107
112
  [`src/tui/README.md`](https://github.com/Datalab-Rotterdam/nova-ai-cli/blob/main/packages/cli/src/tui/README.md).
108
113
 
109
114
  ## Headless
@@ -169,7 +174,10 @@ need ACP. Everything is documented in
169
174
  and nested shells need an exact rule.
170
175
  - **Web pages are fetched only with approval.** `fetch_url` asks every time
171
176
  unless a rule allows that URL (a URL can carry data out), and a redirect to
172
- another site needs its own approval.
177
+ another site needs its own approval. `web_search` asks the same way, since
178
+ the query goes to the search providers (You.com, DuckDuckGo; your own
179
+ SearXNG server first when `~/.nova-ai/settings.json` has
180
+ `"webSearch": { "searxngUrl": "https://…" }`).
173
181
  - **File access stays in the workspace** (symlinks resolved); commands run in
174
182
  their own process group with a timeout, and the whole tree is stopped on
175
183
  cancel.
@@ -194,7 +202,22 @@ VS Code extension: settings, per-project trust, sessions and memory.
194
202
  skills folders are read too). `/skills` lists them; `/skills off <name>`
195
203
  switches one off in this project (`--global`: everywhere). Only names and
196
204
  short descriptions go into the prompt; Nova loads a skill when it fits.
205
+ Nova installs its own skills for Word, Excel, PowerPoint and PDF files
206
+ (`nova-docx`, `nova-xlsx`, `nova-pptx`, `nova-pdf`, from
207
+ [nova-ai-skills](https://github.com/Datalab-Rotterdam/nova-ai-skills)) in
208
+ `~/.nova-ai/skills` and keeps them up to date; they need Python 3.
209
+ - **Hooks**: Claude Code's `hooks` format in the same settings files (a
210
+ repository's own only once you trust the workspace): `PreToolUse`,
211
+ `PostToolUse`, `UserPromptSubmit`, `SessionStart` and `Stop`, with Claude
212
+ Code's tool names as matchers, so hooks from `.claude/settings.json` work
213
+ when copied over.
197
214
  - **MCP servers**: `.mcp.json` or `mcpServers` in `.nova-ai/settings.json`.
215
+ HTTP servers that use OAuth show "needs sign-in" in `/mcp`; `/mcp login
216
+ <server>` opens the sign-in page in your browser (tokens are kept in
217
+ `~/.nova-ai/mcp-auth`, refreshed automatically), `/mcp logout <server>`
218
+ forgets them. A server that wants a client registered by hand takes
219
+ `"oauth": { "clientId", "clientSecret", "scope", "callbackPort" }` in
220
+ `.mcp.json`.
198
221
 
199
222
  The complete layout and rule syntax: [docs/NOVA_HOME.md](https://github.com/Datalab-Rotterdam/nova-ai-cli/blob/main/docs/NOVA_HOME.md).
200
223
 
@@ -207,6 +230,12 @@ to a text protocol for models that don't support it. When the context window
207
230
  fills up, older messages are summarized automatically (`/compact` does it by
208
231
  hand).
209
232
 
233
+ Thinking models are marked ◆ in the model list. For those with levels the
234
+ status line shows the current one (`· thinking medium`) and `/effort` opens a
235
+ slider (←/→, Enter), or sets one directly (`/effort high`, `/effort off`). The
236
+ choice is remembered like the model; a model without that level uses its own
237
+ default.
238
+
210
239
  | Environment | |
211
240
  |---|---|
212
241
  | `NOVA_API_KEY`, `NOVA_MODEL` | Use this key / model instead of the stored ones |
@@ -1,5 +1,6 @@
1
1
  // src/tui/session/tui-acp-client.ts
2
2
  import { NOVA_NOTIFICATIONS } from "@datalabrotterdam/nova-ai-agent/acp/extensions.js";
3
+ import { openBrowser } from "@datalabrotterdam/nova-ai-agent/acp/auth-server.js";
3
4
  import { readFile, writeFile, mkdir } from "fs/promises";
4
5
  import { dirname } from "path";
5
6
  import {
@@ -7,13 +8,11 @@ import {
7
8
  terminateProcessTree
8
9
  } from "@datalabrotterdam/nova-ai-agent/core/process-tree.js";
9
10
  import { resolveWorkspaceFile } from "@datalabrotterdam/nova-ai-agent/core/tools/workspace-paths.js";
10
- import {
11
- isInteractionMode
12
- } from "@datalabrotterdam/nova-ai-agent/core/interaction-modes.js";
13
11
  import {
14
12
  fromElicitationRequest,
15
13
  toElicitationResponse
16
14
  } from "@datalabrotterdam/nova-ai-agent/acp/user-questions.js";
15
+ import { INTERACTION_MODES } from "@datalabrotterdam/nova-ai-agent/core/interaction-modes.js";
17
16
  var DEFAULT_OUTPUT_LIMIT = 1e5;
18
17
  var nextMessageId = 0;
19
18
  function uid() {
@@ -31,6 +30,10 @@ var TuiAcpClient = class {
31
30
  cwd;
32
31
  onBackgroundLifecycle;
33
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 = [];
34
37
  capabilities = {
35
38
  fs: { readTextFile: true, writeTextFile: true },
36
39
  terminal: true,
@@ -91,6 +94,29 @@ var TuiAcpClient = class {
91
94
  appendUserMessage(text) {
92
95
  this.appendMessage({ id: uid(), role: "user", text });
93
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
+ }
94
120
  appendError(text) {
95
121
  this.appendMessage({ id: uid(), role: "error", text });
96
122
  }
@@ -294,8 +320,15 @@ ${err.message}`;
294
320
  });
295
321
  }
296
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;
297
330
  case "current_mode_update":
298
- if (isInteractionMode(update.currentModeId)) {
331
+ if (this.modeIds.includes(update.currentModeId)) {
299
332
  this.onInteractionModeChange?.(update.currentModeId);
300
333
  }
301
334
  return;
@@ -415,6 +448,8 @@ ${err.message}`;
415
448
  return this.backgroundUpdate(params);
416
449
  case NOVA_NOTIFICATIONS.queueChanged:
417
450
  return this.queueChanged(params);
451
+ case NOVA_NOTIFICATIONS.mcpAuthUrl:
452
+ return this.mcpAuthUrl(params);
418
453
  default:
419
454
  return;
420
455
  }
@@ -2,7 +2,7 @@ import {
2
2
  TuiAcpClient,
3
3
  choosePermissionOption,
4
4
  createStore
5
- } from "./chunk-QSNU4H55.js";
5
+ } from "./chunk-KDAUUQBE.js";
6
6
 
7
7
  // src/headless/index.ts
8
8
  import { resolve } from "path";
package/dist/index.js CHANGED
@@ -17,8 +17,7 @@ var MODE_FLAGS = {
17
17
  "--headless": "print",
18
18
  "--setup": "login",
19
19
  "--version": "version",
20
- "-v": "version",
21
- "--web": "web"
20
+ "-v": "version"
22
21
  };
23
22
  function parseCli(argv) {
24
23
  const first = argv[0];
@@ -34,7 +33,7 @@ function parseCli(argv) {
34
33
  const mode = [...modes][0];
35
34
  const wantsHelp = options.includes("--help") || options.includes("-h");
36
35
  if (!mode) return wantsHelp ? { mode: "help", args: [] } : { mode: "tui", args: argv };
37
- const drop = /* @__PURE__ */ new Set(["--acp", "--headless", "--setup", "--version", "-v", "--web"]);
36
+ const drop = /* @__PURE__ */ new Set(["--acp", "--headless", "--setup", "--version", "-v"]);
38
37
  return { mode, args: argv.filter((arg, index) => !(drop.has(arg) && (end < 0 || index < end))) };
39
38
  }
40
39
  function versionText() {
@@ -140,7 +139,7 @@ ${MAIN_HELP}
140
139
  }
141
140
  case "print": {
142
141
  const update = startUpdateCheck();
143
- const { default: runHeadless } = await import("./headless-C36E4XSQ.js");
142
+ const { default: runHeadless } = await import("./headless-BQXK7TNC.js");
144
143
  const code = await runHeadless(command.args);
145
144
  if (update) await noticeUpdate(update, 500);
146
145
  return code;
@@ -153,11 +152,6 @@ ${MAIN_HELP}
153
152
  const { runLogout } = await import("@datalabrotterdam/nova-ai-agent/commands/login.js");
154
153
  return runLogout();
155
154
  }
156
- case "web": {
157
- const { default: runWebUi } = await import("./webui-L4GGMXJ3.js");
158
- await runWebUi();
159
- return 0;
160
- }
161
155
  case "tui": {
162
156
  let options;
163
157
  try {
@@ -175,7 +169,7 @@ ${MAIN_HELP}
175
169
  );
176
170
  return 2;
177
171
  }
178
- const { default: runChat } = await import("./tui-TRADKND7.js");
172
+ const { default: runChat } = await import("./tui-TJFV6NC6.js");
179
173
  return runChat(options);
180
174
  }
181
175
  }
@@ -10,7 +10,7 @@ import {
10
10
  import {
11
11
  TuiAcpClient,
12
12
  createStore
13
- } from "./chunk-QSNU4H55.js";
13
+ } from "./chunk-KDAUUQBE.js";
14
14
 
15
15
  // src/tui/index.ts
16
16
  import { createInterface as createInterface2 } from "readline/promises";
@@ -122,11 +122,15 @@ import * as acp from "@agentclientprotocol/sdk";
122
122
  import { NovaAgent } from "@datalabrotterdam/nova-ai-agent/acp/agent.js";
123
123
  import { startInProcessAgent } from "@datalabrotterdam/nova-ai-agent/client/in-process.js";
124
124
  import { replayHistory } from "@datalabrotterdam/nova-ai-agent/core/history.js";
125
- import { saveDefaultModel } from "@datalabrotterdam/nova-ai-agent/core/credentials.js";
125
+ import {
126
+ saveDefaultEffort,
127
+ saveDefaultModel
128
+ } from "@datalabrotterdam/nova-ai-agent/core/credentials.js";
126
129
  import {
127
130
  savePermissionMode
128
131
  } from "@datalabrotterdam/nova-ai-agent/core/policy/settings.js";
129
132
  import { loadStoredSession } from "@datalabrotterdam/nova-ai-agent/core/sessions.js";
133
+ import { resolveEffort } from "@datalabrotterdam/nova-ai-agent/core/model-capabilities.js";
130
134
 
131
135
  // src/tui/files/file-mentions.ts
132
136
  import { statSync } from "fs";
@@ -286,6 +290,7 @@ var SessionRunner = class {
286
290
  clientInfo: { name: "nova-ai-cli-tui", version: "1" }
287
291
  });
288
292
  this.model = credentials.defaultModel ?? process.env.NOVA_MODEL ?? "";
293
+ this.effort = credentials.defaultEffort ?? null;
289
294
  this.sessionId = crypto.randomUUID();
290
295
  this.sessionReady = Promise.resolve();
291
296
  }
@@ -296,6 +301,14 @@ var SessionRunner = class {
296
301
  title = null;
297
302
  model;
298
303
  interactionMode = "agent";
304
+ /** The modes the agent offers: the built-in ones, then the user's agent files. */
305
+ get modeIds() {
306
+ return this.acpClient.modeIds;
307
+ }
308
+ /** The agent's slash commands (the user's command files); sent as a prompt. */
309
+ get agentCommands() {
310
+ return this.acpClient.agentCommands;
311
+ }
299
312
  /** The agent, served in-process over a real ACP connection (tests may stub it). */
300
313
  agent = new NovaAgent();
301
314
  acpClient;
@@ -315,7 +328,10 @@ var SessionRunner = class {
315
328
  handoffActive = false;
316
329
  queuedAttachments = /* @__PURE__ */ new Map();
317
330
  editingQueuedMessageId = null;
318
- imageSupportByModel = /* @__PURE__ */ new Map();
331
+ /** From the agent's model list: image input, thinking levels. */
332
+ modelInfo = /* @__PURE__ */ new Map();
333
+ /** The thinking level the user chose (saved); null: each model's default. */
334
+ effort;
319
335
  contextUsageRequest = 0;
320
336
  modelCheck = null;
321
337
  resumeFrom(storedSessionId) {
@@ -353,6 +369,7 @@ var SessionRunner = class {
353
369
  this.acpClient.muteSessionUpdates = false;
354
370
  }
355
371
  }).then((response) => {
372
+ this.applyModes(response.modes);
356
373
  this.applyMcpStatus(response._meta);
357
374
  this.applySkillStatus(response._meta);
358
375
  this.agentSessionLoaded = true;
@@ -360,6 +377,7 @@ var SessionRunner = class {
360
377
  void this.nova.setSessionMode({ sessionId: this.sessionId, modeId: this.interactionMode }).catch(() => {
361
378
  });
362
379
  this.applyPermissionMode(this.sessionId);
380
+ this.applyEffort(this.sessionId);
363
381
  void this.updateContextUsage();
364
382
  void this.refreshContextUsage().catch(() => {
365
383
  });
@@ -482,6 +500,14 @@ var SessionRunner = class {
482
500
  skillSessionStatus() {
483
501
  return this.skills.map((skill) => ({ ...skill }));
484
502
  }
503
+ /** Signs in to (or out of) an HTTP MCP server; the agent reconnects it and reports the new status. */
504
+ async mcpSignIn(serverName, signIn) {
505
+ await this.ensureSession();
506
+ const params = { sessionId: this.sessionId, serverName };
507
+ const meta = signIn ? await this.nova.mcpLogin(params) : await this.nova.mcpLogout(params);
508
+ this.applyMcpStatus(meta);
509
+ return this.mcpSessionStatus();
510
+ }
485
511
  async trustWorkspace() {
486
512
  await this.initialized;
487
513
  await this.nova.workspaceTrust({ cwd: this.cwd, trusted: true });
@@ -581,8 +607,12 @@ var SessionRunner = class {
581
607
  async listModels() {
582
608
  await this.initialized;
583
609
  const { models } = await this.nova.modelsList();
584
- for (const model of models) this.imageSupportByModel.set(model.id, model.imageInput);
585
- return models.map((model) => ({ id: model.id, name: model.name }));
610
+ for (const model of models) this.modelInfo.set(model.id, model);
611
+ return models.map((model) => ({ id: model.id, name: model.name, thinking: model.thinking }));
612
+ }
613
+ /** Sync, from the loaded model list: false until it has loaded. */
614
+ hasThinkingLevels(model = this.model) {
615
+ return (this.modelInfo.get(model)?.efforts.length ?? 0) > 0;
586
616
  }
587
617
  /**
588
618
  * Takes the account's default model when none is set, and replaces a saved
@@ -597,7 +627,7 @@ var SessionRunner = class {
597
627
  const { models, defaultModel } = await this.nova.modelsList(
598
628
  this.model ? { preferred: this.model } : {}
599
629
  );
600
- for (const model of models) this.imageSupportByModel.set(model.id, model.imageInput);
630
+ for (const model of models) this.modelInfo.set(model.id, model);
601
631
  if (!defaultModel || defaultModel === this.model) return;
602
632
  const previous = this.model;
603
633
  this.setModel(defaultModel);
@@ -610,8 +640,41 @@ var SessionRunner = class {
610
640
  return this.modelCheck;
611
641
  }
612
642
  async supportsImageInput(model = this.model) {
613
- if (!this.imageSupportByModel.has(model)) await this.listModels();
614
- return this.imageSupportByModel.get(model) ?? false;
643
+ return (await this.modelDetails(model))?.imageInput ?? false;
644
+ }
645
+ /** What the agent's model list says about `model`; undefined when it isn't listed. */
646
+ async modelDetails(model = this.model) {
647
+ if (!this.modelInfo.has(model)) await this.listModels();
648
+ return this.modelInfo.get(model);
649
+ }
650
+ /**
651
+ * The level the current model thinks at, as the agent picks it (the
652
+ * chosen one when the model has it); null for a model without levels
653
+ * or before the model list has loaded.
654
+ */
655
+ currentEffort() {
656
+ const info = this.modelInfo.get(this.model);
657
+ if (!info?.efforts.length) return null;
658
+ return resolveEffort(
659
+ { reasoning_efforts: info.efforts, reasoning_default: info.reasoningDefault },
660
+ this.effort
661
+ ) ?? null;
662
+ }
663
+ /** The current model's levels from the loaded list; empty when it has none. */
664
+ modelEffortLevels() {
665
+ return this.modelInfo.get(this.model)?.efforts ?? [];
666
+ }
667
+ /** Saved and kept across models and sessions; a model without the level uses its default. */
668
+ setEffort(level) {
669
+ this.effort = level;
670
+ saveDefaultEffort(level);
671
+ if (this.agentSessionLoaded) this.applyEffort(this.sessionId);
672
+ }
673
+ applyEffort(sessionId) {
674
+ if (!this.effort) return;
675
+ void this.nova.setSessionConfigOption({ sessionId, configId: "thought_level", value: this.effort }).catch(() => {
676
+ this.acpClient.appendError("Could not change the thinking level.");
677
+ });
615
678
  }
616
679
  async compactContext() {
617
680
  if (this.store.getState().busy || this.promptActive) {
@@ -890,11 +953,12 @@ var SessionRunner = class {
890
953
  }
891
954
  async createSession() {
892
955
  await this.initialized;
893
- const { sessionId, _meta } = await this.nova.newSession({
956
+ const { sessionId, _meta, modes } = await this.nova.newSession({
894
957
  cwd: this.cwd,
895
958
  mcpServers: []
896
959
  });
897
960
  this.sessionId = sessionId;
961
+ this.applyModes(modes);
898
962
  this.applyMcpStatus(_meta);
899
963
  this.applySkillStatus(_meta);
900
964
  this.agentSessionLoaded = true;
@@ -902,6 +966,7 @@ var SessionRunner = class {
902
966
  void this.nova.setSessionMode({ sessionId, modeId: this.interactionMode }).catch(() => {
903
967
  });
904
968
  this.applyPermissionMode(sessionId);
969
+ this.applyEffort(sessionId);
905
970
  void this.updateContextUsage();
906
971
  void this.refreshContextUsage().catch(() => {
907
972
  });
@@ -937,7 +1002,13 @@ var SessionRunner = class {
937
1002
  const failures = Array.isArray(record.failures) ? record.failures.flatMap((entry) => {
938
1003
  if (!entry || typeof entry !== "object") return [];
939
1004
  const item = entry;
940
- return typeof item.serverName === "string" && typeof item.message === "string" ? [{ serverName: item.serverName, message: item.message }] : [];
1005
+ return typeof item.serverName === "string" && typeof item.message === "string" ? [
1006
+ {
1007
+ serverName: item.serverName,
1008
+ message: item.message,
1009
+ ...item.needsAuth === true ? { needsAuth: true } : {}
1010
+ }
1011
+ ] : [];
941
1012
  }) : [];
942
1013
  this.mcpStatus = { configured, connected, failures };
943
1014
  }
@@ -981,6 +1052,10 @@ var SessionRunner = class {
981
1052
  if (this.sessionReady === ready) this.sessionReady = this.createSession();
982
1053
  }
983
1054
  }
1055
+ applyModes(modes) {
1056
+ if (modes?.availableModes.length) this.acpClient.modeIds = modes.availableModes.map((mode) => mode.id);
1057
+ if (!this.modeIds.includes(this.interactionMode)) this.applyInteractionMode("agent", "Mode: agent");
1058
+ }
984
1059
  applyInteractionMode(mode, statusLine) {
985
1060
  this.interactionMode = mode;
986
1061
  this.store.setState({ interactionMode: mode, statusLine });
@@ -1450,6 +1525,7 @@ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-run
1450
1525
  function StatusLine({
1451
1526
  ui,
1452
1527
  model,
1528
+ effort = null,
1453
1529
  mcp,
1454
1530
  scroll = 0
1455
1531
  }) {
@@ -1460,6 +1536,7 @@ function StatusLine({
1460
1536
  ui.statusLine ? /* @__PURE__ */ jsx2(Text2, { color: palette.muted, children: ui.statusLine }) : null,
1461
1537
  /* @__PURE__ */ jsxs2(Text2, { wrap: "truncate-end", children: [
1462
1538
  /* @__PURE__ */ jsx2(Text2, { color: palette.muted, children: model }),
1539
+ effort ? /* @__PURE__ */ jsx2(Text2, { color: palette.muted, children: ` \xB7 thinking ${effort.toLowerCase()}` }) : null,
1463
1540
  separator,
1464
1541
  ui.interactionMode,
1465
1542
  separator,
@@ -1802,18 +1879,14 @@ function PlanView({
1802
1879
  ] });
1803
1880
  }
1804
1881
 
1805
- // src/tui/ink/controller.ts
1806
- import { INTERACTION_MODES } from "@datalabrotterdam/nova-ai-agent/core/interaction-modes.js";
1807
-
1808
1882
  // src/tui/commands/builtins.ts
1809
- import { isInteractionMode } from "@datalabrotterdam/nova-ai-agent/core/interaction-modes.js";
1810
1883
  var permissionModes = ["ask", "acceptEdits", "bypassAll"];
1811
1884
  var builtinCommands = [
1812
1885
  {
1813
1886
  name: "help",
1814
1887
  description: "Show available commands",
1815
1888
  run(ctx) {
1816
- const lines = builtinCommands.map((c) => `/${c.name} \u2014 ${c.description}`);
1889
+ const lines = [...builtinCommands.filter((c) => c.available?.(ctx) ?? true), ...ctx.agentCommands()].map((c) => `/${c.name} \u2014 ${c.description}`);
1817
1890
  ctx.print(lines.join("\n"));
1818
1891
  }
1819
1892
  },
@@ -1894,15 +1967,15 @@ var builtinCommands = [
1894
1967
  },
1895
1968
  {
1896
1969
  name: "mode",
1897
- description: "Switch interaction mode (/mode agent|ask|plan)",
1970
+ description: "Switch interaction mode (/mode agent|ask|plan|<agent file>)",
1898
1971
  run(ctx, args) {
1899
1972
  const mode = args.trim();
1900
1973
  if (!mode) {
1901
- ctx.print(`Current mode: ${ctx.getInteractionMode()}`);
1974
+ ctx.print(`Current mode: ${ctx.getInteractionMode()} (available: ${ctx.getInteractionModes().join(", ")})`);
1902
1975
  return;
1903
1976
  }
1904
- if (!isInteractionMode(mode)) {
1905
- ctx.print("Usage: /mode agent|ask|plan");
1977
+ if (!ctx.getInteractionModes().includes(mode)) {
1978
+ ctx.print(`Usage: /mode ${ctx.getInteractionModes().join("|")}`);
1906
1979
  return;
1907
1980
  }
1908
1981
  ctx.setInteractionMode(mode);
@@ -1944,6 +2017,16 @@ var builtinCommands = [
1944
2017
  ctx.openModelPicker();
1945
2018
  }
1946
2019
  },
2020
+ {
2021
+ name: "effort",
2022
+ description: "Set how hard the model thinks (/effort <level>, or no args for a slider)",
2023
+ available: (ctx) => ctx.hasThinkingLevels(),
2024
+ async run(ctx, args) {
2025
+ const level = args.trim();
2026
+ if (level) ctx.print(await ctx.setEffort(level));
2027
+ else await ctx.openEffortPicker();
2028
+ }
2029
+ },
1947
2030
  {
1948
2031
  name: "tools",
1949
2032
  description: "Inspect tool calls from this session",
@@ -1953,9 +2036,20 @@ var builtinCommands = [
1953
2036
  },
1954
2037
  {
1955
2038
  name: "mcp",
1956
- description: "Inspect configured MCP servers",
1957
- run(ctx) {
1958
- return ctx.openMcpInspector();
2039
+ description: "Inspect MCP servers, or sign in to one: /mcp login|logout <server>",
2040
+ async run(ctx, args) {
2041
+ const [action, ...rest] = args.trim().split(/\s+/).filter(Boolean);
2042
+ const name = rest.join(" ");
2043
+ if (!action) {
2044
+ await ctx.openMcpInspector();
2045
+ return;
2046
+ }
2047
+ if (action !== "login" && action !== "logout" || !name) {
2048
+ ctx.print("Usage: /mcp, or /mcp login|logout <server>");
2049
+ return;
2050
+ }
2051
+ if (action === "login") ctx.print(`Signing in to "${name}"\u2026 (waits up to 5 minutes for the browser)`);
2052
+ ctx.print(await ctx.mcpSignIn(name, action === "login"));
1959
2053
  }
1960
2054
  },
1961
2055
  {
@@ -2142,8 +2236,8 @@ function formatJob(job) {
2142
2236
 
2143
2237
  // src/tui/commands/index.ts
2144
2238
  var COMMANDS = [...builtinCommands];
2145
- function allCommands() {
2146
- return COMMANDS;
2239
+ function allCommands(ctx) {
2240
+ return ctx ? COMMANDS.filter((c) => c.available?.(ctx) ?? true) : COMMANDS;
2147
2241
  }
2148
2242
  function findCommand(name) {
2149
2243
  return COMMANDS.find((c) => c.name === name);
@@ -2361,7 +2455,8 @@ var WorkspaceAutocompleteProvider = class {
2361
2455
  const mention = extractMentionPrefix(before);
2362
2456
  if (mention) return this.mentionSuggestions(mention, options.signal);
2363
2457
  if (cursorLine === 0 && before.startsWith("/") && !/\s/.test(before)) {
2364
- const items2 = filterCommands(this.commands, before.slice(1));
2458
+ const commands = typeof this.commands === "function" ? this.commands() : this.commands;
2459
+ const items2 = filterCommands(commands, before.slice(1));
2365
2460
  return items2.length ? { items: items2, prefix: before } : null;
2366
2461
  }
2367
2462
  if (!options.force) return null;
@@ -2884,6 +2979,40 @@ var SelectionState = class {
2884
2979
  return "changed";
2885
2980
  }
2886
2981
  };
2982
+ var SliderState = class {
2983
+ constructor(levels, selected, onSelect, onCancel) {
2984
+ this.levels = levels;
2985
+ this.selected = selected;
2986
+ this.onSelect = onSelect;
2987
+ this.onCancel = onCancel;
2988
+ }
2989
+ levels;
2990
+ selected;
2991
+ onSelect;
2992
+ onCancel;
2993
+ handleKey(input, key) {
2994
+ if (isCancel(input, key)) {
2995
+ this.onCancel();
2996
+ return "closed";
2997
+ }
2998
+ if (key.return) {
2999
+ const level = this.levels[this.selected];
3000
+ if (!level) return "ignored";
3001
+ this.onSelect(level);
3002
+ return "closed";
3003
+ }
3004
+ const last = this.levels.length - 1;
3005
+ let next = this.selected;
3006
+ if (key.leftArrow || key.downArrow) next = Math.max(0, this.selected - 1);
3007
+ else if (key.rightArrow || key.upArrow) next = Math.min(last, this.selected + 1);
3008
+ else if (key.home) next = 0;
3009
+ else if (key.end) next = last;
3010
+ else return "ignored";
3011
+ if (next === this.selected) return "ignored";
3012
+ this.selected = next;
3013
+ return "changed";
3014
+ }
3015
+ };
2887
3016
  var ScrollState = class {
2888
3017
  constructor(onClose) {
2889
3018
  this.onClose = onClose;
@@ -3132,6 +3261,9 @@ function permissionKey(request, input, key) {
3132
3261
  return "closed";
3133
3262
  }
3134
3263
 
3264
+ // src/tui/ink/controller.ts
3265
+ import { effortLabel } from "@datalabrotterdam/nova-ai-agent/core/model-capabilities.js";
3266
+
3135
3267
  // src/tui/ink/editor/prompt-editor.ts
3136
3268
  var MAX_HISTORY = 200;
3137
3269
  var PromptEditor = class {
@@ -3965,8 +4097,8 @@ var TuiController = class {
3965
4097
  "Models",
3966
4098
  models.map((model) => ({
3967
4099
  value: model.id,
3968
- label: model.id,
3969
- description: model.name ?? void 0
4100
+ label: model.thinking ? `${model.id} \u25C6` : model.id,
4101
+ description: [model.name, model.thinking ? "thinking" : null].filter(Boolean).join(" \xB7 ") || void 0
3970
4102
  })),
3971
4103
  "model-picker",
3972
4104
  (model) => this.setModel(model)
@@ -3975,6 +4107,58 @@ var TuiController = class {
3975
4107
  this.showText("Models", errorMessage(error, "Failed to list models."));
3976
4108
  }
3977
4109
  };
4110
+ effortName() {
4111
+ const level = this.runner.currentEffort();
4112
+ const efforts = level ? this.runner.modelEffortLevels() : [];
4113
+ return level ? effortLabel(level, efforts) : null;
4114
+ }
4115
+ /** The selected model's levels and current one, or why it has none. */
4116
+ async effortLevels() {
4117
+ let info;
4118
+ try {
4119
+ info = await this.runner.modelDetails();
4120
+ } catch (error) {
4121
+ return errorMessage(error, "Could not load the model list.");
4122
+ }
4123
+ const model = this.runner.model || "This model";
4124
+ if (!info?.efforts.length) {
4125
+ return info?.thinking ? `${model} thinks at a fixed depth; there is no level to choose.` : `${model} is not a thinking model; /model lists the ones marked \u25C6.`;
4126
+ }
4127
+ return { efforts: info.efforts, current: this.runner.currentEffort() ?? info.efforts[0] };
4128
+ }
4129
+ /** /effort: a slider over the selected model's levels. */
4130
+ openEffort = async () => {
4131
+ const levels = await this.effortLevels();
4132
+ if (typeof levels === "string") {
4133
+ this.showText("Thinking effort", levels);
4134
+ return;
4135
+ }
4136
+ const state = new SliderState(
4137
+ levels.efforts.map((value) => ({ value, label: effortLabel(value, levels.efforts) })),
4138
+ Math.max(0, levels.efforts.indexOf(levels.current)),
4139
+ (item) => {
4140
+ this.closeOverlay();
4141
+ this.runner.setEffort(item.value);
4142
+ this.store.setState({ statusLine: `Thinking effort: ${item.label}.` });
4143
+ },
4144
+ this.closeOverlay
4145
+ );
4146
+ this.showOverlay({ kind: "slider", title: `Thinking effort \xB7 ${this.runner.model}`, state });
4147
+ };
4148
+ /** /effort <level>: by id ("high") or name ("Extra high", "off"). */
4149
+ setEffort = async (level) => {
4150
+ const levels = await this.effortLevels();
4151
+ if (typeof levels === "string") return levels;
4152
+ const wanted = level.trim().toLowerCase();
4153
+ const value = levels.efforts.find(
4154
+ (candidate) => candidate === wanted || effortLabel(candidate, levels.efforts).toLowerCase() === wanted
4155
+ );
4156
+ const names = levels.efforts.map((candidate) => effortLabel(candidate, levels.efforts).toLowerCase());
4157
+ if (!value) return `${this.runner.model} has these levels: ${names.join(", ")}.`;
4158
+ this.runner.setEffort(value);
4159
+ this.refresh();
4160
+ return `Thinking effort: ${effortLabel(value, levels.efforts)}.`;
4161
+ };
3978
4162
  openPermissionModes = () => {
3979
4163
  const modes = [
3980
4164
  { value: "ask", label: "ask", description: "Prompt before tools make changes" },
@@ -4061,7 +4245,7 @@ status: failed - ${failure.message}`);
4061
4245
  `Skills (${on} on, ${skills.length - on} off)`,
4062
4246
  skills.length ? [
4063
4247
  ...skills.map(
4064
- (skill) => `${skill.name} [${skill.source}]${skill.enabled ? "" : " (off)"}
4248
+ (skill) => `${skill.name} [${skill.bundledBy ? `bundled with ${skill.bundledBy}` : skill.source}]${skill.enabled ? "" : " (off)"}
4065
4249
  ${skill.description}
4066
4250
  ${skill.path}`
4067
4251
  ),
@@ -4132,8 +4316,8 @@ ${skill.path}`
4132
4316
  this.runner.setInteractionMode(mode);
4133
4317
  }
4134
4318
  cycleInteractionMode() {
4135
- const current = INTERACTION_MODES.indexOf(this.runner.interactionMode);
4136
- this.setInteractionMode(INTERACTION_MODES[(current + 1) % INTERACTION_MODES.length]);
4319
+ const modes = this.runner.modeIds;
4320
+ this.setInteractionMode(modes[(modes.indexOf(this.runner.interactionMode) + 1) % modes.length]);
4137
4321
  }
4138
4322
  appendAssistant(text) {
4139
4323
  this.store.setState((state) => ({
@@ -4175,13 +4359,29 @@ ${skill.path}`
4175
4359
  },
4176
4360
  openSessionSwitcher: this.openSessions,
4177
4361
  openModelPicker: () => void this.openModels(),
4362
+ hasThinkingLevels: () => this.runner.hasThinkingLevels(),
4363
+ openEffortPicker: () => this.openEffort(),
4364
+ setEffort: (level) => this.setEffort(level),
4178
4365
  getModel: () => this.runner.model,
4179
4366
  setModel: (model) => this.setModel(model),
4180
4367
  getInteractionMode: () => this.runner.interactionMode,
4368
+ getInteractionModes: () => this.runner.modeIds,
4369
+ agentCommands: () => this.agentCommands(),
4181
4370
  setInteractionMode: (mode) => this.setInteractionMode(mode),
4182
4371
  getPermissionMode: () => this.store.getState().permissionMode,
4183
4372
  setPermissionMode: (mode) => this.setPermissionMode(mode),
4184
4373
  openPermissionPicker: this.openPermissionModes,
4374
+ mcpSignIn: async (serverName, signIn) => {
4375
+ try {
4376
+ const status = await this.runner.mcpSignIn(serverName, signIn);
4377
+ this.refresh();
4378
+ const failure = status.failures.find((item) => item.serverName === serverName);
4379
+ if (!signIn) return `Signed out of "${serverName}".`;
4380
+ return failure ? `Signed in, but "${serverName}" did not connect: ${failure.message}` : `Signed in to "${serverName}"; its tools are available from the next message.`;
4381
+ } catch (error) {
4382
+ return errorMessage(error, signIn ? "Sign-in failed." : "Sign-out failed.");
4383
+ }
4384
+ },
4185
4385
  trustWorkspace: async () => {
4186
4386
  try {
4187
4387
  await this.runner.trustWorkspace();
@@ -4208,6 +4408,10 @@ ${skill.path}`
4208
4408
  killBackgroundJob: (jobId) => this.runner.killBackgroundJob(jobId),
4209
4409
  releaseBackgroundJob: (jobId) => this.runner.releaseBackgroundJob(jobId)
4210
4410
  };
4411
+ /** The agent's commands the CLI doesn't have itself; a hint is shown after the description. */
4412
+ agentCommands() {
4413
+ return this.runner.agentCommands.filter((command) => !findCommand(command.name)).map(({ name, description, hint }) => ({ name, description: hint ? `${description} ${hint}` : description }));
4414
+ }
4211
4415
  // ------------------------------------------------------------ prompt
4212
4416
  submit = async (text, images = [], pastes = []) => {
4213
4417
  const value = text.trim();
@@ -4227,6 +4431,10 @@ ${skill.path}`
4227
4431
  const name = match?.[1] ?? "";
4228
4432
  const command = findCommand(name);
4229
4433
  if (!command) {
4434
+ if (this.agentCommands().some((agentCommand) => agentCommand.name === name)) {
4435
+ await this.runner.submit(value, images, pastes);
4436
+ return;
4437
+ }
4230
4438
  this.appendError(`Unknown command: /${name}`);
4231
4439
  return;
4232
4440
  }
@@ -4288,7 +4496,7 @@ ${skill.path}`
4288
4496
  const fdPath = resolveFdPath();
4289
4497
  editor.setAutocompleteProvider(
4290
4498
  new WorkspaceAutocompleteProvider(
4291
- allCommands().map((command) => ({
4499
+ () => [...allCommands(this.commandContext), ...this.agentCommands()].map((command) => ({
4292
4500
  name: command.name,
4293
4501
  description: command.description
4294
4502
  })),
@@ -4329,6 +4537,7 @@ ${skill.path}`
4329
4537
  overlay: this.overlay,
4330
4538
  question: this.questionState(ui.pendingQuestion),
4331
4539
  model: this.runner.model,
4540
+ effort: this.effortName(),
4332
4541
  mcp: mcpCounts(this.runner),
4333
4542
  expandedTools: this.expandedTools,
4334
4543
  exiting: this.exiting,
@@ -4373,7 +4582,7 @@ function formatMcpServer(server, status) {
4373
4582
  }
4374
4583
  const failure = status.failures.find((item) => item.serverName === server.name);
4375
4584
  lines.push(
4376
- failure ? `status: failed - ${failure.message}` : status.connected.includes(server.name) ? "status: connected" : "status: configured; connection starts with the ACP session"
4585
+ failure?.needsAuth ? `status: needs sign-in - /mcp login ${server.name}` : failure ? `status: failed - ${failure.message}` : status.connected.includes(server.name) ? "status: connected" : "status: configured; connection starts with the ACP session"
4377
4586
  );
4378
4587
  return lines.join("\n");
4379
4588
  }
@@ -4412,6 +4621,25 @@ function Frame({ title, children, footer }) {
4412
4621
  footer ? /* @__PURE__ */ jsx5(Text5, { color: palette.faint, wrap: "truncate-end", children: footer }) : null
4413
4622
  ] });
4414
4623
  }
4624
+ function SliderDialog({
4625
+ title,
4626
+ state,
4627
+ isActive = true
4628
+ }) {
4629
+ useDialogInput((input, key) => state.handleKey(input, key), isActive);
4630
+ const current = state.levels[state.selected];
4631
+ return /* @__PURE__ */ jsxs5(Frame, { title, footer: "\u2190\u2192 adjust \xB7 Enter apply \xB7 Esc cancel", children: [
4632
+ /* @__PURE__ */ jsx5(Text5, { wrap: "truncate-end", children: state.levels.map((level, index) => {
4633
+ const selected = index === state.selected;
4634
+ return /* @__PURE__ */ jsxs5(Text5, { children: [
4635
+ index > 0 ? /* @__PURE__ */ jsx5(Text5, { color: palette.faint, children: " \u2500\u2500 " }) : null,
4636
+ /* @__PURE__ */ jsx5(Text5, { color: selected ? palette.primary : palette.faint, children: selected ? "\u25CF " : "\u25CB " }),
4637
+ /* @__PURE__ */ jsx5(Text5, { bold: selected, color: selected ? void 0 : palette.muted, children: level.label })
4638
+ ] }, level.value);
4639
+ }) }),
4640
+ current?.description ? /* @__PURE__ */ jsx5(Text5, { color: palette.muted, children: current.description }) : null
4641
+ ] });
4642
+ }
4415
4643
  function SelectionDialog({
4416
4644
  title,
4417
4645
  state,
@@ -4800,7 +5028,7 @@ function App({ controller }) {
4800
5028
  ),
4801
5029
  /* @__PURE__ */ jsxs6(Box6, { ref: bottomRef, flexDirection: "column", flexShrink: 0, children: [
4802
5030
  /* @__PURE__ */ jsx6(Surface, { controller, view, rows: Math.max(6, rows - 4), columns }),
4803
- /* @__PURE__ */ jsx6(StatusLine, { ui: view.ui, model: view.model, mcp: view.mcp, scroll: view.scroll })
5031
+ /* @__PURE__ */ jsx6(StatusLine, { ui: view.ui, model: view.model, effort: view.effort, mcp: view.mcp, scroll: view.scroll })
4804
5032
  ] })
4805
5033
  ] });
4806
5034
  }
@@ -4843,6 +5071,7 @@ function Surface({
4843
5071
  const overlay = view.overlay;
4844
5072
  if (overlay?.kind === "selection")
4845
5073
  return /* @__PURE__ */ jsx6(SelectionDialog, { title: overlay.title, state: overlay.state, height: rows });
5074
+ if (overlay?.kind === "slider") return /* @__PURE__ */ jsx6(SliderDialog, { title: overlay.title, state: overlay.state });
4846
5075
  if (overlay?.kind === "text")
4847
5076
  return /* @__PURE__ */ jsx6(
4848
5077
  ScrollPanel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalabrotterdam/nova-ai-cli",
3
- "version": "1.2.0-alpha.6",
3
+ "version": "1.2.0-alpha.8",
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.3",
42
+ "@datalabrotterdam/nova-ai-agent": "^1.0.0-alpha.5",
43
43
  "chalk": "^5.3.0",
44
44
  "cli-highlight": "^2.1.11",
45
45
  "diff": "^9.0.0",
@@ -76,6 +76,6 @@
76
76
  ]
77
77
  },
78
78
  "devDependencies": {
79
- "@datalabrotterdam/nova-sdk": "^1.3.2"
79
+ "@datalabrotterdam/nova-sdk": "1.4.0-k8s.3"
80
80
  }
81
81
  }
@@ -1,9 +0,0 @@
1
- // src/webui/index.ts
2
- async function runWebUi() {
3
- console.log("nova-ai --web is a work in progress \u2014 the browser UI isn't built yet.");
4
- console.log("Use `nova-ai` for the terminal UI, `nova-ai -p` headless, or `nova-ai --acp` for editors.");
5
- }
6
- var webui_default = runWebUi;
7
- export {
8
- webui_default as default
9
- };