@datalabrotterdam/nova-ai-cli 1.2.0-alpha.1 → 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.
@@ -1,52 +1,140 @@
1
1
  import {
2
- TuiAcpClient,
3
- createStore,
4
- readWorkspaceMcpConfiguration,
5
- startInProcessAgent,
6
- workspaceNeedsTrust
7
- } from "./chunk-ZKRZIF3S.js";
8
- import {
9
- INTERACTION_MODES,
10
- NovaAgent,
11
- OTHER_OPTION_ID,
12
- discoverSkills,
13
- isInteractionMode,
14
- listStoredSessions,
15
- loadStoredSession,
16
- replayHistory,
17
- resolveModelContextWindow,
18
- savePermissionMode,
19
- savedPermissionMode
20
- } from "./chunk-GZZ7PY3S.js";
2
+ checkForUpdate,
3
+ rememberUpdateAnswer,
4
+ shouldAskAboutUpdate
5
+ } from "./chunk-L77KQRGZ.js";
21
6
  import {
22
- readPackageVersion
23
- } from "./chunk-Q7WL3DXP.js";
7
+ CLI_PACKAGE_NAME,
8
+ cliVersion
9
+ } from "./chunk-FMHWW3E6.js";
24
10
  import {
25
- chatModels,
26
- createNovaClient,
27
- readCredentials,
28
- resolveDefaultModel,
29
- resolveModelSupportsImageInput,
30
- saveDefaultModel,
31
- setWorkspaceTrusted
32
- } from "./chunk-JAEXUFAS.js";
11
+ TuiAcpClient,
12
+ createStore
13
+ } from "./chunk-KDAUUQBE.js";
33
14
 
34
15
  // src/tui/index.ts
16
+ import { createInterface as createInterface2 } from "readline/promises";
17
+ import { readCredentials } from "@datalabrotterdam/nova-ai-agent/core/credentials.js";
18
+ import { listStoredSessions as listStoredSessions2, loadStoredSession as loadStoredSession2 } from "@datalabrotterdam/nova-ai-agent/core/sessions.js";
19
+ import { setWorkspaceTrusted } from "@datalabrotterdam/nova-ai-agent/core/nova-home.js";
20
+
21
+ // src/update-install.ts
22
+ import { spawnSync } from "child_process";
23
+ import { existsSync, realpathSync } from "fs";
24
+ import { dirname, join, sep } from "path";
35
25
  import { createInterface } from "readline/promises";
26
+ var START_WAIT_MS = 1500;
27
+ var UPDATED_FROM_ENV = "NOVA_UPDATED_FROM";
28
+ function globalNpmPrefix(options = {}) {
29
+ const script = options.scriptPath ?? process.argv[1];
30
+ if (!script) return null;
31
+ const windows = (options.platform ?? process.platform) === "win32";
32
+ const marker = `${sep}node_modules${sep}${CLI_PACKAGE_NAME.split("/").join(sep)}${sep}`;
33
+ const resolved = realPath(script);
34
+ const at = resolved.lastIndexOf(marker);
35
+ if (at < 0) return null;
36
+ const nodeModulesParent = resolved.slice(0, at);
37
+ const prefix = windows ? nodeModulesParent : dirname(nodeModulesParent);
38
+ if (!windows && nodeModulesParent !== join(prefix, "lib")) return null;
39
+ return existsSync(join(prefix, windows ? "nova-ai.cmd" : join("bin", "nova-ai"))) ? prefix : null;
40
+ }
41
+ async function withInstallability(update, options = {}) {
42
+ if (!update) return null;
43
+ return { ...update, installable: globalNpmPrefix(options) !== null };
44
+ }
45
+ function npmInvocation(args) {
46
+ return process.platform === "win32" ? [`npm ${args.map((arg) => /\s/.test(arg) ? `"${arg}"` : arg).join(" ")}`, [], true] : ["npm", args, false];
47
+ }
48
+ function realPath(path) {
49
+ try {
50
+ return realpathSync(path);
51
+ } catch {
52
+ return path;
53
+ }
54
+ }
55
+ function parseUpdateAnswer(answer) {
56
+ const value = answer.trim().toLowerCase();
57
+ if (value === "" || value === "y" || value === "yes") return "update";
58
+ if (value === "s" || value === "skip") return "skip";
59
+ return "later";
60
+ }
61
+ async function askToUpdate(update) {
62
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
63
+ try {
64
+ console.log(`
65
+ Update available: nova-ai ${update.currentVersion} \u2192 ${update.latestVersion}`);
66
+ return parseUpdateAnswer(
67
+ await prompt.question("Update now? [Y/n/s] (n: not today, s: skip this version) ")
68
+ );
69
+ } finally {
70
+ prompt.close();
71
+ }
72
+ }
73
+ async function offerUpdateAtStart(check, restartArgs) {
74
+ let timer;
75
+ const update = await Promise.race([
76
+ check,
77
+ new Promise((resolve2) => {
78
+ timer = setTimeout(() => resolve2(null), START_WAIT_MS);
79
+ })
80
+ ]);
81
+ clearTimeout(timer);
82
+ if (!update?.installable || !shouldAskAboutUpdate(update)) return null;
83
+ const answer = await askToUpdate(update);
84
+ if (answer !== "update") {
85
+ rememberUpdateAnswer(answer, update);
86
+ console.log(
87
+ answer === "skip" ? `Skipping ${update.latestVersion}; you'll be asked again for a newer version.` : "Not now. /update installs it any time."
88
+ );
89
+ return null;
90
+ }
91
+ return installAndRestart(update, restartArgs);
92
+ }
93
+ function installAndRestart(update, restartArgs) {
94
+ const prefix = globalNpmPrefix();
95
+ if (!prefix) return null;
96
+ console.log(`
97
+ Installing nova-ai ${update.latestVersion}\u2026`);
98
+ const [file, args, shell] = npmInvocation(["install", "-g", "--prefix", prefix, `${CLI_PACKAGE_NAME}@${update.tag}`]);
99
+ const install = spawnSync(file, args, { stdio: "inherit", shell });
100
+ if (install.status !== 0) {
101
+ console.error(`
102
+ The update did not install. Run it yourself: ${update.command}
103
+ `);
104
+ return null;
105
+ }
106
+ const script = process.argv[1];
107
+ if (!script) return null;
108
+ const restarted = spawnSync(process.execPath, [...process.execArgv, script, ...restartArgs], {
109
+ stdio: "inherit",
110
+ env: { ...process.env, [UPDATED_FROM_ENV]: update.currentVersion }
111
+ });
112
+ return restarted.status ?? 1;
113
+ }
36
114
 
37
115
  // src/tui/ink/app.tsx
38
- import { Box as Box6, render, Static, Text as Text6, useBoxMetrics, useWindowSize } from "ink";
39
- import { useEffect, useRef, useState as useState2, useSyncExternalStore as useSyncExternalStore2 } from "react";
116
+ import { Box as Box6, render, Text as Text6, useBoxMetrics, useInput as useInput3, useWindowSize } from "ink";
117
+ import { memo, useEffect, useRef, useState as useState2, useSyncExternalStore as useSyncExternalStore2 } from "react";
118
+ import { savedPermissionMode } from "@datalabrotterdam/nova-ai-agent/core/policy/settings.js";
40
119
 
41
120
  // src/tui/session/session-runner.ts
42
121
  import * as acp from "@agentclientprotocol/sdk";
122
+ import { NovaAgent } from "@datalabrotterdam/nova-ai-agent/acp/agent.js";
123
+ import { startInProcessAgent } from "@datalabrotterdam/nova-ai-agent/client/in-process.js";
124
+ import { replayHistory } from "@datalabrotterdam/nova-ai-agent/core/history.js";
125
+ import {
126
+ saveDefaultEffort,
127
+ saveDefaultModel
128
+ } from "@datalabrotterdam/nova-ai-agent/core/credentials.js";
43
129
  import {
44
- NovaAIError
45
- } from "@datalabrotterdam/nova-sdk";
130
+ savePermissionMode
131
+ } from "@datalabrotterdam/nova-ai-agent/core/policy/settings.js";
132
+ import { loadStoredSession } from "@datalabrotterdam/nova-ai-agent/core/sessions.js";
133
+ import { resolveEffort } from "@datalabrotterdam/nova-ai-agent/core/model-capabilities.js";
46
134
 
47
135
  // src/tui/files/file-mentions.ts
48
136
  import { statSync } from "fs";
49
- import { isAbsolute, relative, resolve, sep } from "path";
137
+ import { isAbsolute, relative, resolve, sep as sep2 } from "path";
50
138
  var MENTION_RE = /@(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
51
139
  var TRAILING_PUNCTUATION = /[.,;:!?\)\]\}]+$/;
52
140
  function findWorkspaceFileMentions(text, cwd) {
@@ -56,10 +144,10 @@ function findWorkspaceFileMentions(text, cwd) {
56
144
  const raw = match[1] ?? match[2] ?? match[3] ?? "";
57
145
  const candidates = [raw, raw.replace(TRAILING_PUNCTUATION, "")].filter((value, index, all) => value && all.indexOf(value) === index);
58
146
  for (const candidate of candidates) {
59
- const normalized = candidate.replace(/[\\/]/g, sep);
147
+ const normalized = candidate.replace(/[\\/]/g, sep2);
60
148
  const absolutePath = resolve(workspaceRoot, normalized);
61
149
  const relativePath = relative(workspaceRoot, absolutePath);
62
- if (relativePath.startsWith(`..${sep}`) || relativePath === ".." || isAbsolute(relativePath)) continue;
150
+ if (relativePath.startsWith(`..${sep2}`) || relativePath === ".." || isAbsolute(relativePath)) continue;
63
151
  try {
64
152
  if (!statSync(absolutePath).isFile()) continue;
65
153
  } catch {
@@ -67,7 +155,7 @@ function findWorkspaceFileMentions(text, cwd) {
67
155
  }
68
156
  mentions.set(absolutePath.toLowerCase(), {
69
157
  absolutePath,
70
- relativePath: relativePath.split(sep).join("/")
158
+ relativePath: relativePath.split(sep2).join("/")
71
159
  });
72
160
  break;
73
161
  }
@@ -187,15 +275,6 @@ var SessionRunner = class {
187
275
  constructor(store, credentials, cwd) {
188
276
  this.store = store;
189
277
  this.credentials = credentials;
190
- this.loadMcpConfiguration(cwd);
191
- this.skills = discoverSkills(cwd).map(
192
- ({ name, description, source, path }) => ({
193
- name,
194
- description,
195
- source,
196
- path
197
- })
198
- );
199
278
  this.cwd = cwd;
200
279
  this.acpClient = new TuiAcpClient(
201
280
  store,
@@ -210,13 +289,8 @@ var SessionRunner = class {
210
289
  clientCapabilities: this.acpClient.capabilities,
211
290
  clientInfo: { name: "nova-ai-cli-tui", version: "1" }
212
291
  });
213
- this.novaClient = createNovaClient(credentials.apiKey);
214
- const model = credentials.defaultModel ?? process.env.NOVA_MODEL;
215
- if (!model)
216
- throw new Error(
217
- "No Nova model configured. Re-run authentication or set NOVA_MODEL."
218
- );
219
- this.model = model;
292
+ this.model = credentials.defaultModel ?? process.env.NOVA_MODEL ?? "";
293
+ this.effort = credentials.defaultEffort ?? null;
220
294
  this.sessionId = crypto.randomUUID();
221
295
  this.sessionReady = Promise.resolve();
222
296
  }
@@ -227,21 +301,26 @@ var SessionRunner = class {
227
301
  title = null;
228
302
  model;
229
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
+ }
230
312
  /** The agent, served in-process over a real ACP connection (tests may stub it). */
231
313
  agent = new NovaAgent();
232
314
  acpClient;
233
315
  connection;
234
316
  nova;
235
317
  initialized;
236
- novaClient;
237
- mcpServers = [];
238
- mcpConfigurationFailures = [];
239
318
  mcpStatus = {
240
319
  configured: [],
241
320
  connected: [],
242
321
  failures: []
243
322
  };
244
- skills;
323
+ skills = [];
245
324
  sessionReady;
246
325
  agentSessionLoaded = false;
247
326
  promptActive = false;
@@ -249,10 +328,10 @@ var SessionRunner = class {
249
328
  handoffActive = false;
250
329
  queuedAttachments = /* @__PURE__ */ new Map();
251
330
  editingQueuedMessageId = null;
252
- imageSupportByModel = /* @__PURE__ */ new Map();
253
- contextWindow = null;
254
- contextWindowModel = null;
255
- contextWindowRequest = 0;
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;
256
335
  contextUsageRequest = 0;
257
336
  modelCheck = null;
258
337
  resumeFrom(storedSessionId) {
@@ -265,15 +344,7 @@ var SessionRunner = class {
265
344
  this.sessionId = stored.sessionId;
266
345
  this.cwd = stored.cwd;
267
346
  this.title = stored.title;
268
- this.loadMcpConfiguration(stored.cwd);
269
- this.skills = discoverSkills(stored.cwd).map(
270
- ({ name, description, source, path }) => ({
271
- name,
272
- description,
273
- source,
274
- path
275
- })
276
- );
347
+ this.skills = [];
277
348
  const messages = restoreSessionMessages(stored.messages);
278
349
  this.store.setState({
279
350
  messages,
@@ -292,12 +363,13 @@ var SessionRunner = class {
292
363
  return await this.nova.loadSession({
293
364
  sessionId: stored.sessionId,
294
365
  cwd: stored.cwd,
295
- mcpServers: this.mcpServers
366
+ mcpServers: []
296
367
  });
297
368
  } finally {
298
369
  this.acpClient.muteSessionUpdates = false;
299
370
  }
300
371
  }).then((response) => {
372
+ this.applyModes(response.modes);
301
373
  this.applyMcpStatus(response._meta);
302
374
  this.applySkillStatus(response._meta);
303
375
  this.agentSessionLoaded = true;
@@ -305,6 +377,7 @@ var SessionRunner = class {
305
377
  void this.nova.setSessionMode({ sessionId: this.sessionId, modeId: this.interactionMode }).catch(() => {
306
378
  });
307
379
  this.applyPermissionMode(this.sessionId);
380
+ this.applyEffort(this.sessionId);
308
381
  void this.updateContextUsage();
309
382
  void this.refreshContextUsage().catch(() => {
310
383
  });
@@ -417,9 +490,6 @@ var SessionRunner = class {
417
490
  void this.enqueueQueuedPrompt(value, [], [], "steer", true);
418
491
  return true;
419
492
  }
420
- configuredMcpServers() {
421
- return [...this.mcpServers];
422
- }
423
493
  mcpSessionStatus() {
424
494
  return {
425
495
  configured: this.mcpStatus.configured.map((server) => ({ ...server })),
@@ -430,27 +500,37 @@ var SessionRunner = class {
430
500
  skillSessionStatus() {
431
501
  return this.skills.map((skill) => ({ ...skill }));
432
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
+ }
511
+ async trustWorkspace() {
512
+ await this.initialized;
513
+ await this.nova.workspaceTrust({ cwd: this.cwd, trusted: true });
514
+ }
515
+ /** Every skill of the workspace with its on/off state, from the agent. */
516
+ async listSkills() {
517
+ await this.initialized;
518
+ return (await this.nova.skillsList({ cwd: this.cwd })).skills;
519
+ }
520
+ async setSkillEnabled(name, enabled, global) {
521
+ await this.initialized;
522
+ return (await this.nova.skillsSetEnabled({
523
+ cwd: this.cwd,
524
+ name,
525
+ enabled,
526
+ scope: global ? "global" : "project"
527
+ })).skills;
528
+ }
433
529
  contextUsageSnapshot() {
434
530
  return this.store.getState().contextUsage;
435
531
  }
436
532
  async refreshContextUsage() {
437
533
  await this.ensureSession();
438
- const sessionId = this.sessionId;
439
- const model = this.model;
440
- if (this.contextWindowModel !== model) {
441
- const request = ++this.contextWindowRequest;
442
- const contextWindow = await resolveModelContextWindow(
443
- this.novaClient,
444
- model
445
- );
446
- if (request === this.contextWindowRequest && model === this.model) {
447
- this.contextWindow = contextWindow;
448
- this.contextWindowModel = model;
449
- }
450
- }
451
- if (sessionId !== this.sessionId || !this.agentSessionLoaded) {
452
- return this.refreshContextUsage();
453
- }
454
534
  const usage = await this.updateContextUsage();
455
535
  if (!usage)
456
536
  throw new Error(
@@ -466,13 +546,8 @@ var SessionRunner = class {
466
546
  }
467
547
  setModel(model) {
468
548
  this.model = model;
469
- this.contextWindow = null;
470
- this.contextWindowModel = null;
471
- this.contextWindowRequest++;
472
549
  saveDefaultModel(model);
473
550
  if (this.agentSessionLoaded) void this.updateContextUsage();
474
- void this.refreshContextUsage().catch(() => {
475
- });
476
551
  }
477
552
  /**
478
553
  * Hands the mode to the agent's permission policy (which decides) and
@@ -530,27 +605,34 @@ var SessionRunner = class {
530
605
  }
531
606
  /** Chat models only: embedding, speech and transcription models can't run a session. */
532
607
  async listModels() {
533
- const { data } = await this.novaClient.models.list({ limit: 100 });
534
- return chatModels(data).map((model) => ({
535
- id: model.id,
536
- name: model.name ?? null
537
- }));
608
+ await this.initialized;
609
+ const { models } = await this.nova.modelsList();
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;
538
616
  }
539
617
  /**
540
- * Replaces a saved model that cannot chat (older logins saved the first
541
- * model of the list, e.g. an embedding model) and saves the replacement.
618
+ * Takes the account's default model when none is set, and replaces a saved
619
+ * one that cannot chat (older logins saved the first model of the list,
620
+ * e.g. an embedding model); the agent decides, the choice is saved.
542
621
  * Runs once; a failed lookup keeps the model and the prompt reports errors.
543
622
  */
544
623
  ensureUsableModel() {
545
624
  this.modelCheck ??= (async () => {
546
625
  try {
547
- const { data } = await this.novaClient.models.list({ limit: 100 });
548
- const model = resolveDefaultModel(data, this.model);
549
- if (!model || model === this.model) return;
626
+ await this.initialized;
627
+ const { models, defaultModel } = await this.nova.modelsList(
628
+ this.model ? { preferred: this.model } : {}
629
+ );
630
+ for (const model of models) this.modelInfo.set(model.id, model);
631
+ if (!defaultModel || defaultModel === this.model) return;
550
632
  const previous = this.model;
551
- this.setModel(model);
633
+ this.setModel(defaultModel);
552
634
  this.store.setState({
553
- statusLine: `${previous} is not a chat model; switched to ${model} (Ctrl+P to pick another).`
635
+ statusLine: previous ? `${previous} is not a chat model; switched to ${defaultModel} (Ctrl+P to pick another).` : `Using model ${defaultModel} (Ctrl+P switches).`
554
636
  });
555
637
  } catch {
556
638
  }
@@ -558,14 +640,41 @@ var SessionRunner = class {
558
640
  return this.modelCheck;
559
641
  }
560
642
  async supportsImageInput(model = this.model) {
561
- const cached = this.imageSupportByModel.get(model);
562
- if (cached !== void 0) return cached;
563
- const supported = await resolveModelSupportsImageInput(
564
- this.novaClient,
565
- model
566
- );
567
- this.imageSupportByModel.set(model, supported);
568
- return supported;
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
+ });
569
678
  }
570
679
  async compactContext() {
571
680
  if (this.store.getState().busy || this.promptActive) {
@@ -689,7 +798,7 @@ var SessionRunner = class {
689
798
  });
690
799
  }
691
800
  } catch (err) {
692
- const message = err instanceof NovaAIError ? `Nova AI request failed (status ${err.status}, requestId ${err.requestId}): ${err.message}` : err instanceof Error ? err.message : "Unknown error.";
801
+ const message = err instanceof Error ? err.message : "Unknown error.";
693
802
  pendingToolFailure = `Request failed: ${message}`;
694
803
  this.acpClient.appendError(message);
695
804
  this.store.setState({ statusLine: "Request failed." });
@@ -843,13 +952,13 @@ var SessionRunner = class {
843
952
  ];
844
953
  }
845
954
  async createSession() {
846
- this.loadMcpConfiguration(this.cwd);
847
955
  await this.initialized;
848
- const { sessionId, _meta } = await this.nova.newSession({
956
+ const { sessionId, _meta, modes } = await this.nova.newSession({
849
957
  cwd: this.cwd,
850
- mcpServers: this.mcpServers
958
+ mcpServers: []
851
959
  });
852
960
  this.sessionId = sessionId;
961
+ this.applyModes(modes);
853
962
  this.applyMcpStatus(_meta);
854
963
  this.applySkillStatus(_meta);
855
964
  this.agentSessionLoaded = true;
@@ -857,6 +966,7 @@ var SessionRunner = class {
857
966
  void this.nova.setSessionMode({ sessionId, modeId: this.interactionMode }).catch(() => {
858
967
  });
859
968
  this.applyPermissionMode(sessionId);
969
+ this.applyEffort(sessionId);
860
970
  void this.updateContextUsage();
861
971
  void this.refreshContextUsage().catch(() => {
862
972
  });
@@ -871,10 +981,20 @@ var SessionRunner = class {
871
981
  const value = meta?.["nova-ai-cli/mcp"];
872
982
  if (!value || typeof value !== "object") return;
873
983
  const record = value;
984
+ const strings = (value2) => Array.isArray(value2) ? value2.filter((entry) => typeof entry === "string") : void 0;
874
985
  const configured = Array.isArray(record.configured) ? record.configured.flatMap((entry) => {
875
986
  if (!entry || typeof entry !== "object") return [];
876
987
  const item = entry;
877
- return typeof item.name === "string" && typeof item.transport === "string" ? [{ name: item.name, transport: item.transport }] : [];
988
+ return typeof item.name === "string" && typeof item.transport === "string" ? [
989
+ {
990
+ name: item.name,
991
+ transport: item.transport,
992
+ ...typeof item.url === "string" ? { url: item.url } : {},
993
+ ...typeof item.command === "string" ? { command: item.command } : {},
994
+ ...strings(item.args) ? { args: strings(item.args) } : {},
995
+ ...strings(item.env) ? { env: strings(item.env) } : {}
996
+ }
997
+ ] : [];
878
998
  }) : [];
879
999
  const connected = Array.isArray(record.connected) ? record.connected.filter(
880
1000
  (entry) => typeof entry === "string"
@@ -882,26 +1002,15 @@ var SessionRunner = class {
882
1002
  const failures = Array.isArray(record.failures) ? record.failures.flatMap((entry) => {
883
1003
  if (!entry || typeof entry !== "object") return [];
884
1004
  const item = entry;
885
- 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
+ ] : [];
886
1012
  }) : [];
887
- this.mcpStatus = {
888
- configured,
889
- connected,
890
- failures: [...this.mcpConfigurationFailures, ...failures]
891
- };
892
- }
893
- loadMcpConfiguration(cwd) {
894
- const configuration = readWorkspaceMcpConfiguration(cwd);
895
- this.mcpServers = configuration.servers;
896
- this.mcpConfigurationFailures = configuration.failures;
897
- this.mcpStatus = {
898
- configured: this.mcpServers.map((server) => ({
899
- name: server.name,
900
- transport: "type" in server ? server.type : "stdio"
901
- })),
902
- connected: [],
903
- failures: [...this.mcpConfigurationFailures]
904
- };
1013
+ this.mcpStatus = { configured, connected, failures };
905
1014
  }
906
1015
  applySkillStatus(meta) {
907
1016
  const value = meta?.["nova-ai-cli/skills"];
@@ -919,14 +1028,34 @@ var SessionRunner = class {
919
1028
  ] : [];
920
1029
  });
921
1030
  }
1031
+ /**
1032
+ * Starts the session ahead of the first prompt, so the agent's MCP and
1033
+ * skill status is there for /mcp and the status line. A failure is left to
1034
+ * the first prompt, which tries again and reports it.
1035
+ */
1036
+ async prepareSession() {
1037
+ try {
1038
+ await this.ensureSession();
1039
+ } catch {
1040
+ this.sessionReady = Promise.resolve();
1041
+ }
1042
+ }
1043
+ get sessionLoaded() {
1044
+ return this.agentSessionLoaded;
1045
+ }
922
1046
  async ensureSession() {
923
1047
  await this.ensureUsableModel();
924
- await this.sessionReady;
925
- if (!this.agentSessionLoaded) {
926
- this.sessionReady = this.createSession();
927
- await this.sessionReady;
1048
+ for (; ; ) {
1049
+ const ready = this.sessionReady;
1050
+ await ready;
1051
+ if (this.agentSessionLoaded) return;
1052
+ if (this.sessionReady === ready) this.sessionReady = this.createSession();
928
1053
  }
929
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
+ }
930
1059
  applyInteractionMode(mode, statusLine) {
931
1060
  this.interactionMode = mode;
932
1061
  this.store.setState({ interactionMode: mode, statusLine });
@@ -940,7 +1069,7 @@ var SessionRunner = class {
940
1069
  try {
941
1070
  const usage = await this.nova.sessionContextUsage({
942
1071
  sessionId,
943
- contextWindow: this.contextWindow,
1072
+ ...this.model ? { model: this.model } : {},
944
1073
  mode: this.interactionMode
945
1074
  });
946
1075
  if (request === this.contextUsageRequest && sessionId === this.sessionId) {
@@ -1013,82 +1142,6 @@ function fromAgentPermissionMode(mode) {
1013
1142
  return mode === "bypassPermissions" ? "bypassAll" : mode === "acceptEdits" ? "acceptEdits" : "ask";
1014
1143
  }
1015
1144
 
1016
- // src/tui/update-check.ts
1017
- var PACKAGE_NAME = "@datalabrotterdam/nova-ai-cli";
1018
- var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME.replace("/", "%2F")}/latest`;
1019
- var DEFAULT_TIMEOUT_MS = 3e3;
1020
- var UPDATE_COMMAND = "npm install -g @datalabrotterdam/nova-ai-cli@latest";
1021
- async function checkForUpdate(options = {}) {
1022
- try {
1023
- const currentVersion = options.currentVersion ?? readInstalledVersion();
1024
- if (!currentVersion) return null;
1025
- const response = await (options.fetchImpl ?? fetch)(REGISTRY_URL, {
1026
- headers: { accept: "application/json" },
1027
- signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
1028
- });
1029
- if (!response.ok) return null;
1030
- const payload = await response.json();
1031
- if (!isVersionPayload(payload)) return null;
1032
- if (!isNewerVersion(payload.version, currentVersion)) return null;
1033
- return {
1034
- currentVersion,
1035
- latestVersion: payload.version,
1036
- command: UPDATE_COMMAND
1037
- };
1038
- } catch {
1039
- return null;
1040
- }
1041
- }
1042
- function readInstalledVersion() {
1043
- return readPackageVersion();
1044
- }
1045
- function isNewerVersion(candidate, current) {
1046
- const next = parseVersion(candidate);
1047
- const installed = parseVersion(current);
1048
- if (!next || !installed) return false;
1049
- for (let index = 0; index < 3; index++) {
1050
- const difference = next.core[index] - installed.core[index];
1051
- if (difference !== 0) return difference > 0;
1052
- }
1053
- return comparePrerelease(next.prerelease, installed.prerelease) > 0;
1054
- }
1055
- function isVersionPayload(value) {
1056
- return typeof value === "object" && value !== null && "version" in value && typeof value.version === "string";
1057
- }
1058
- function parseVersion(value) {
1059
- const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
1060
- value
1061
- );
1062
- if (!match) return null;
1063
- return {
1064
- core: [Number(match[1]), Number(match[2]), Number(match[3])],
1065
- prerelease: match[4]?.split(".") ?? []
1066
- };
1067
- }
1068
- function comparePrerelease(left, right) {
1069
- if (left.length === 0 || right.length === 0) {
1070
- if (left.length === right.length) return 0;
1071
- return left.length === 0 ? 1 : -1;
1072
- }
1073
- const length = Math.max(left.length, right.length);
1074
- for (let index = 0; index < length; index++) {
1075
- const leftPart = left[index];
1076
- const rightPart = right[index];
1077
- if (leftPart === void 0 || rightPart === void 0) {
1078
- return leftPart === rightPart ? 0 : leftPart === void 0 ? -1 : 1;
1079
- }
1080
- if (leftPart === rightPart) continue;
1081
- const leftNumber = /^\d+$/.test(leftPart) ? Number(leftPart) : null;
1082
- const rightNumber = /^\d+$/.test(rightPart) ? Number(rightPart) : null;
1083
- if (leftNumber !== null && rightNumber !== null)
1084
- return leftNumber - rightNumber;
1085
- if (leftNumber !== null || rightNumber !== null)
1086
- return leftNumber !== null ? -1 : 1;
1087
- return leftPart < rightPart ? -1 : 1;
1088
- }
1089
- return 0;
1090
- }
1091
-
1092
1145
  // src/tui/ink/terminal-keyboard.ts
1093
1146
  var KITTY_QUERY = "\x1B[?u";
1094
1147
  var DEVICE_ATTRIBUTES_QUERY = "\x1B[c";
@@ -1130,6 +1183,17 @@ function kittyKeyboardOverride(env = process.env) {
1130
1183
  function isLateTerminalReply(input) {
1131
1184
  return /^\u001b?\[\?[\d;]*[uc]$/.test(input);
1132
1185
  }
1186
+ var MOUSE_REPORTING_ON = "\x1B[?1000h\x1B[?1006h";
1187
+ var MOUSE_REPORTING_OFF = "\x1B[?1000l\x1B[?1006l";
1188
+ var MOUSE_INPUT = /^\u001b?\[<(\d+);\d+;\d+[Mm]$/;
1189
+ function isMouseInput(input) {
1190
+ return MOUSE_INPUT.test(input);
1191
+ }
1192
+ function wheelRows(input) {
1193
+ const button = Number(MOUSE_INPUT.exec(input)?.[1] ?? -1);
1194
+ if (button < 0 || (button & 64) === 0) return 0;
1195
+ return button & 1 ? -3 : 3;
1196
+ }
1133
1197
 
1134
1198
  // src/tui/ink/components/prompt-input.tsx
1135
1199
  import { Box, Text, useInput, usePaste } from "ink";
@@ -1147,7 +1211,8 @@ var palette = {
1147
1211
  faint: "#64748b",
1148
1212
  code: "#fcd34d",
1149
1213
  diffAddBg: "#1b3a24",
1150
- diffRemoveBg: "#3d1f1f"
1214
+ diffRemoveBg: "#3d1f1f",
1215
+ stickyBackground: "#1e293b"
1151
1216
  };
1152
1217
  var colors = {
1153
1218
  primary: chalk.hex(palette.primary),
@@ -1174,7 +1239,7 @@ function PromptInput({
1174
1239
  }) {
1175
1240
  useSyncExternalStore(editor.subscribe, editor.getVersion);
1176
1241
  useInput((input, key) => {
1177
- if (onKey(input, key)) return;
1242
+ if (isMouseInput(input) || onKey(input, key)) return;
1178
1243
  editor.handleKey(input, key);
1179
1244
  });
1180
1245
  usePaste((text) => editor.paste(text));
@@ -1460,15 +1525,18 @@ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-run
1460
1525
  function StatusLine({
1461
1526
  ui,
1462
1527
  model,
1463
- mcp
1528
+ effort = null,
1529
+ mcp,
1530
+ scroll = 0
1464
1531
  }) {
1465
1532
  const separator = /* @__PURE__ */ jsx2(Text2, { color: palette.faint, children: " | " });
1466
1533
  const usage = ui.contextUsage ? `ctx:${formatTokenCount(ui.contextUsage.totalTokens)}${ui.contextUsage.contextWindow ? `/${formatTokenCount(ui.contextUsage.contextWindow)}` : ""}` : null;
1467
1534
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
1468
- ui.updateAvailable ? /* @__PURE__ */ jsx2(Text2, { color: palette.warning, children: `Update ${ui.updateAvailable.latestVersion} available (current ${ui.updateAvailable.currentVersion}). Run: ${ui.updateAvailable.command}` }) : null,
1535
+ ui.updateAvailable ? /* @__PURE__ */ jsx2(Text2, { color: palette.warning, children: `Update ${ui.updateAvailable.latestVersion} available (current ${ui.updateAvailable.currentVersion}). ${ui.updateAvailable.installable ? "Type /update to install it." : `Run: ${ui.updateAvailable.command}`}` }) : null,
1469
1536
  ui.statusLine ? /* @__PURE__ */ jsx2(Text2, { color: palette.muted, children: ui.statusLine }) : null,
1470
1537
  /* @__PURE__ */ jsxs2(Text2, { wrap: "truncate-end", children: [
1471
1538
  /* @__PURE__ */ jsx2(Text2, { color: palette.muted, children: model }),
1539
+ effort ? /* @__PURE__ */ jsx2(Text2, { color: palette.muted, children: ` \xB7 thinking ${effort.toLowerCase()}` }) : null,
1472
1540
  separator,
1473
1541
  ui.interactionMode,
1474
1542
  separator,
@@ -1488,6 +1556,10 @@ function StatusLine({
1488
1556
  `mcp:${mcp.connected}/${mcp.configured}`,
1489
1557
  mcp.failed ? /* @__PURE__ */ jsx2(Text2, { color: palette.danger, children: ` !${mcp.failed}` }) : null
1490
1558
  ] }) : null,
1559
+ scroll ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
1560
+ separator,
1561
+ /* @__PURE__ */ jsx2(Text2, { color: palette.warning, children: `\u2191${scroll} lines \xB7 PgDn/Esc` })
1562
+ ] }) : null,
1491
1563
  mcp.skills ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
1492
1564
  separator,
1493
1565
  `skills:${mcp.skills}`
@@ -1814,7 +1886,7 @@ var builtinCommands = [
1814
1886
  name: "help",
1815
1887
  description: "Show available commands",
1816
1888
  run(ctx) {
1817
- 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}`);
1818
1890
  ctx.print(lines.join("\n"));
1819
1891
  }
1820
1892
  },
@@ -1880,22 +1952,30 @@ var builtinCommands = [
1880
1952
  },
1881
1953
  {
1882
1954
  name: "exit",
1883
- description: "Exit nova-ai-cli",
1955
+ description: "Exit nova-ai",
1884
1956
  run(ctx) {
1885
1957
  ctx.exit();
1886
1958
  }
1887
1959
  },
1960
+ {
1961
+ name: "update",
1962
+ description: "Install the newer nova-ai from npm and restart in this session",
1963
+ run(ctx) {
1964
+ const message = ctx.requestUpdate();
1965
+ if (message) ctx.print(message);
1966
+ }
1967
+ },
1888
1968
  {
1889
1969
  name: "mode",
1890
- description: "Switch interaction mode (/mode agent|ask|plan)",
1970
+ description: "Switch interaction mode (/mode agent|ask|plan|<agent file>)",
1891
1971
  run(ctx, args) {
1892
1972
  const mode = args.trim();
1893
1973
  if (!mode) {
1894
- ctx.print(`Current mode: ${ctx.getInteractionMode()}`);
1974
+ ctx.print(`Current mode: ${ctx.getInteractionMode()} (available: ${ctx.getInteractionModes().join(", ")})`);
1895
1975
  return;
1896
1976
  }
1897
- if (!isInteractionMode(mode)) {
1898
- ctx.print("Usage: /mode agent|ask|plan");
1977
+ if (!ctx.getInteractionModes().includes(mode)) {
1978
+ ctx.print(`Usage: /mode ${ctx.getInteractionModes().join("|")}`);
1899
1979
  return;
1900
1980
  }
1901
1981
  ctx.setInteractionMode(mode);
@@ -1920,8 +2000,8 @@ var builtinCommands = [
1920
2000
  {
1921
2001
  name: "trust",
1922
2002
  description: "Trust this workspace: start the MCP servers it declares and apply its .nova-ai allow rules",
1923
- run(ctx) {
1924
- ctx.print(ctx.trustWorkspace());
2003
+ async run(ctx) {
2004
+ ctx.print(await ctx.trustWorkspace());
1925
2005
  }
1926
2006
  },
1927
2007
  {
@@ -1937,6 +2017,16 @@ var builtinCommands = [
1937
2017
  ctx.openModelPicker();
1938
2018
  }
1939
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
+ },
1940
2030
  {
1941
2031
  name: "tools",
1942
2032
  description: "Inspect tool calls from this session",
@@ -1946,16 +2036,38 @@ var builtinCommands = [
1946
2036
  },
1947
2037
  {
1948
2038
  name: "mcp",
1949
- description: "Inspect configured MCP servers",
1950
- run(ctx) {
1951
- 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"));
1952
2053
  }
1953
2054
  },
1954
2055
  {
1955
2056
  name: "skills",
1956
- description: "Inspect discovered user and workspace skills",
1957
- run(ctx) {
1958
- ctx.openSkillInspector();
2057
+ description: "List skills, or switch one: /skills on|off <name> [--global]",
2058
+ async run(ctx, args) {
2059
+ const words = args.trim().split(/\s+/).filter(Boolean);
2060
+ if (!words.length) {
2061
+ await ctx.openSkillInspector();
2062
+ return;
2063
+ }
2064
+ const global = words.includes("--global");
2065
+ const [action, name] = words.filter((word) => word !== "--global");
2066
+ if (action !== "on" && action !== "off" || !name) {
2067
+ ctx.print("Usage: /skills, or /skills on|off <name> [--global]");
2068
+ return;
2069
+ }
2070
+ ctx.print(await ctx.switchSkill(name, action === "on", global));
1959
2071
  }
1960
2072
  },
1961
2073
  {
@@ -2124,8 +2236,8 @@ function formatJob(job) {
2124
2236
 
2125
2237
  // src/tui/commands/index.ts
2126
2238
  var COMMANDS = [...builtinCommands];
2127
- function allCommands() {
2128
- return COMMANDS;
2239
+ function allCommands(ctx) {
2240
+ return ctx ? COMMANDS.filter((c) => c.available?.(ctx) ?? true) : COMMANDS;
2129
2241
  }
2130
2242
  function findCommand(name) {
2131
2243
  return COMMANDS.find((c) => c.name === name);
@@ -2253,10 +2365,10 @@ console.log(ObjC.unwrap(png.base64EncodedStringWithOptions(0)));
2253
2365
  `;
2254
2366
 
2255
2367
  // src/tui/files/find-fd.ts
2256
- import { spawnSync } from "child_process";
2368
+ import { spawnSync as spawnSync2 } from "child_process";
2257
2369
  var FD_CANDIDATES = ["fd", "fdfind"];
2258
2370
  var cachedPath;
2259
- function resolveFdPath(run = (command, args) => spawnSync(command, args, { stdio: "ignore" })) {
2371
+ function resolveFdPath(run = (command, args) => spawnSync2(command, args, { stdio: "ignore" })) {
2260
2372
  if (cachedPath !== void 0) return cachedPath;
2261
2373
  for (const candidate of FD_CANDIDATES) {
2262
2374
  const result = run(candidate, ["--version"]);
@@ -2271,7 +2383,7 @@ function resolveFdPath(run = (command, args) => spawnSync(command, args, { stdio
2271
2383
 
2272
2384
  // src/tui/files/list-workspace-files.ts
2273
2385
  import { readdirSync } from "fs";
2274
- import { join, relative as relative2, sep as sep2 } from "path";
2386
+ import { join as join2, relative as relative2, sep as sep3 } from "path";
2275
2387
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".nova-ai", "out", "coverage", ".next", ".turbo"]);
2276
2388
  function listWorkspaceFiles(cwd, max = 2e3) {
2277
2389
  const results = [];
@@ -2287,12 +2399,12 @@ function listWorkspaceFiles(cwd, max = 2e3) {
2287
2399
  for (const entry of entries) {
2288
2400
  if (results.length >= max) break;
2289
2401
  if (entry.isSymbolicLink()) continue;
2290
- const full = join(dir, entry.name);
2402
+ const full = join2(dir, entry.name);
2291
2403
  if (entry.isDirectory()) {
2292
2404
  if (SKIP_DIRS.has(entry.name)) continue;
2293
2405
  stack.push(full);
2294
2406
  } else if (entry.isFile()) {
2295
- results.push(relative2(cwd, full).split(sep2).join("/"));
2407
+ results.push(relative2(cwd, full).split(sep3).join("/"));
2296
2408
  }
2297
2409
  }
2298
2410
  }
@@ -2323,7 +2435,7 @@ var DraftImageAttachments = class {
2323
2435
  import { spawn } from "child_process";
2324
2436
  import { readdirSync as readdirSync2, statSync as statSync2 } from "fs";
2325
2437
  import { homedir } from "os";
2326
- import { basename, dirname, join as join2 } from "path";
2438
+ import { basename, dirname as dirname2, join as join3 } from "path";
2327
2439
  var PATH_DELIMITERS = /* @__PURE__ */ new Set([" ", " ", '"', "'", "="]);
2328
2440
  var MAX_SUGGESTIONS2 = 20;
2329
2441
  var WorkspaceAutocompleteProvider = class {
@@ -2343,7 +2455,8 @@ var WorkspaceAutocompleteProvider = class {
2343
2455
  const mention = extractMentionPrefix(before);
2344
2456
  if (mention) return this.mentionSuggestions(mention, options.signal);
2345
2457
  if (cursorLine === 0 && before.startsWith("/") && !/\s/.test(before)) {
2346
- 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));
2347
2460
  return items2.length ? { items: items2, prefix: before } : null;
2348
2461
  }
2349
2462
  if (!options.force) return null;
@@ -2404,14 +2517,14 @@ var WorkspaceAutocompleteProvider = class {
2404
2517
  pathSuggestions(prefix) {
2405
2518
  const quoted = prefix.startsWith('"');
2406
2519
  const raw = quoted ? prefix.slice(1) : prefix;
2407
- const expanded = raw === "~" || raw.startsWith("~/") ? join2(homedir(), raw.slice(1)) : raw;
2520
+ const expanded = raw === "~" || raw.startsWith("~/") ? join3(homedir(), raw.slice(1)) : raw;
2408
2521
  const absolute = expanded.startsWith("/") || /^[A-Za-z]:[\\/]/.test(expanded);
2409
2522
  const separator = Math.max(raw.lastIndexOf("/"), raw.lastIndexOf("\\"));
2410
2523
  const listsDirectory = raw === "" || raw === "~" || separator === raw.length - 1;
2411
- const directoryPart = listsDirectory ? expanded : dirname(expanded);
2524
+ const directoryPart = listsDirectory ? expanded : dirname2(expanded);
2412
2525
  const namePrefix = listsDirectory ? "" : basename(expanded);
2413
2526
  const displayDirectory = raw === "~" ? "~/" : separator >= 0 ? raw.slice(0, separator + 1) : "";
2414
- const searchDirectory = absolute ? directoryPart : join2(this.cwd, directoryPart);
2527
+ const searchDirectory = absolute ? directoryPart : join3(this.cwd, directoryPart);
2415
2528
  let entries;
2416
2529
  try {
2417
2530
  entries = readdirSync2(searchDirectory, { withFileTypes: true });
@@ -2425,7 +2538,7 @@ var WorkspaceAutocompleteProvider = class {
2425
2538
  if (!isDirectory && entry.isSymbolicLink()) {
2426
2539
  try {
2427
2540
  isDirectory = statSync2(
2428
- join2(searchDirectory, entry.name)
2541
+ join3(searchDirectory, entry.name)
2429
2542
  ).isDirectory();
2430
2543
  } catch {
2431
2544
  }
@@ -2561,6 +2674,7 @@ function walkWithFd(cwd, fdPath, query, signal) {
2561
2674
  }
2562
2675
 
2563
2676
  // src/tui/session/session-picker.ts
2677
+ import { listStoredSessions } from "@datalabrotterdam/nova-ai-agent/core/sessions.js";
2564
2678
  function listSessionsForCwd(cwd) {
2565
2679
  return listStoredSessions(cwd);
2566
2680
  }
@@ -2609,6 +2723,9 @@ function formatDateTime(updatedAt) {
2609
2723
  return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : "unknown";
2610
2724
  }
2611
2725
 
2726
+ // src/tui/ink/dialogs/state.ts
2727
+ import { OTHER_OPTION_ID } from "@datalabrotterdam/nova-ai-agent/core/user-questions.js";
2728
+
2612
2729
  // src/tui/ink/editor/buffer.ts
2613
2730
  var segmenter2 = new Intl.Segmenter(void 0, { granularity: "grapheme" });
2614
2731
  function boundaries(text) {
@@ -2862,6 +2979,40 @@ var SelectionState = class {
2862
2979
  return "changed";
2863
2980
  }
2864
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
+ };
2865
3016
  var ScrollState = class {
2866
3017
  constructor(onClose) {
2867
3018
  this.onClose = onClose;
@@ -3110,6 +3261,9 @@ function permissionKey(request, input, key) {
3110
3261
  return "closed";
3111
3262
  }
3112
3263
 
3264
+ // src/tui/ink/controller.ts
3265
+ import { effortLabel } from "@datalabrotterdam/nova-ai-agent/core/model-capabilities.js";
3266
+
3113
3267
  // src/tui/ink/editor/prompt-editor.ts
3114
3268
  var MAX_HISTORY = 200;
3115
3269
  var PromptEditor = class {
@@ -3545,17 +3699,11 @@ var TranscriptCommitter = class {
3545
3699
  planSignature = null;
3546
3700
  snapshot = null;
3547
3701
  lastState = null;
3548
- redrawRequested = false;
3549
- /** The next sync starts a new epoch, e.g. after the terminal width changed. */
3550
- requestRedraw() {
3551
- this.redrawRequested = true;
3552
- this.lastState = null;
3553
- }
3554
3702
  sync(state, expanded) {
3555
3703
  if (this.snapshot && state === this.lastState && expanded === this.expanded)
3556
3704
  return this.snapshot;
3557
3705
  this.lastState = state;
3558
- if (this.redrawRequested || state.sessionId !== this.sessionId || expanded !== this.expanded || !this.prefixIntact(state.messages)) {
3706
+ if (state.sessionId !== this.sessionId || expanded !== this.expanded || !this.prefixIntact(state.messages)) {
3559
3707
  this.startEpoch(state, expanded);
3560
3708
  }
3561
3709
  const messages = state.messages;
@@ -3708,7 +3856,6 @@ var TranscriptCommitter = class {
3708
3856
  return true;
3709
3857
  }
3710
3858
  startEpoch(state, expanded) {
3711
- this.redrawRequested = false;
3712
3859
  this.epoch++;
3713
3860
  this.sessionId = state.sessionId;
3714
3861
  this.expanded = expanded;
@@ -3754,7 +3901,6 @@ function safeSplitOffset(text, from) {
3754
3901
  }
3755
3902
 
3756
3903
  // src/tui/ink/controller.ts
3757
- var CLEAR_SCREEN_AND_SCROLLBACK = "\x1B[2J\x1B[3J\x1B[H";
3758
3904
  var EXIT_CONFIRM_MS = 2e3;
3759
3905
  var TuiController = class {
3760
3906
  constructor(store, runner, io) {
@@ -3779,6 +3925,8 @@ var TuiController = class {
3779
3925
  exiting = false;
3780
3926
  view;
3781
3927
  lastEpoch = 0;
3928
+ scroll = 0;
3929
+ viewport = { rows: 10, contentRows: 0 };
3782
3930
  exitArmed = false;
3783
3931
  exitTimer;
3784
3932
  animationTimer;
@@ -3790,6 +3938,8 @@ var TuiController = class {
3790
3938
  unsubscribeStore;
3791
3939
  resolveExit = () => {
3792
3940
  };
3941
+ /** Set by /update: the caller installs and restarts after the TUI exits. */
3942
+ updateRequested = false;
3793
3943
  /** Settles once the user asked to quit; `run` then tears the view down. */
3794
3944
  exitRequested = new Promise((resolve2) => {
3795
3945
  this.resolveExit = resolve2;
@@ -3800,7 +3950,7 @@ var TuiController = class {
3800
3950
  };
3801
3951
  getView = () => this.view;
3802
3952
  start() {
3803
- void this.runner.ensureUsableModel().then(() => this.refresh());
3953
+ void this.runner.prepareSession().then(() => this.refresh());
3804
3954
  this.syncWindowTitle();
3805
3955
  this.animationTimer = setInterval(() => this.syncWindowTitle(true), 120);
3806
3956
  this.animationTimer.unref?.();
@@ -3835,8 +3985,17 @@ var TuiController = class {
3835
3985
  this.runner.cancel();
3836
3986
  return true;
3837
3987
  }
3988
+ if (this.scroll > 0) {
3989
+ this.scrollTo(0);
3990
+ return true;
3991
+ }
3838
3992
  return false;
3839
3993
  }
3994
+ if (key.pageUp || key.pageDown) {
3995
+ const page = Math.max(1, this.viewport.rows - 2);
3996
+ this.scrollBy(key.pageUp ? page : -page);
3997
+ return true;
3998
+ }
3840
3999
  if (key.tab && key.shift) {
3841
4000
  this.cycleInteractionMode();
3842
4001
  return true;
@@ -3938,8 +4097,8 @@ var TuiController = class {
3938
4097
  "Models",
3939
4098
  models.map((model) => ({
3940
4099
  value: model.id,
3941
- label: model.id,
3942
- 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
3943
4102
  })),
3944
4103
  "model-picker",
3945
4104
  (model) => this.setModel(model)
@@ -3948,6 +4107,58 @@ var TuiController = class {
3948
4107
  this.showText("Models", errorMessage(error, "Failed to list models."));
3949
4108
  }
3950
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
+ };
3951
4162
  openPermissionModes = () => {
3952
4163
  const modes = [
3953
4164
  { value: "ask", label: "ask", description: "Prompt before tools make changes" },
@@ -4001,9 +4212,13 @@ ${result.output}${result.truncated ? "\n\n[output truncated]" : ""}`
4001
4212
  state: new ToolInspectorState(() => this.toolCalls(), this.closeOverlay)
4002
4213
  });
4003
4214
  };
4004
- openMcpInspector = () => {
4005
- const servers = this.runner.configuredMcpServers();
4215
+ openMcpInspector = async () => {
4216
+ if (!this.runner.sessionLoaded) {
4217
+ this.showText("MCP servers", "Connecting MCP servers...");
4218
+ await this.runner.prepareSession();
4219
+ }
4006
4220
  const connection = this.runner.mcpSessionStatus();
4221
+ const servers = connection.configured;
4007
4222
  const configuredNames = new Set(servers.map((server) => server.name));
4008
4223
  const configurationFailures = connection.failures.filter((failure) => !configuredNames.has(failure.serverName)).map((failure) => `${failure.serverName}
4009
4224
  status: failed - ${failure.message}`);
@@ -4016,15 +4231,40 @@ status: failed - ${failure.message}`);
4016
4231
  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."
4017
4232
  );
4018
4233
  };
4019
- openSkillInspector = () => {
4020
- const skills = this.runner.skillSessionStatus();
4234
+ /** Every discovered skill with its state; switched-off skills stay listed. */
4235
+ openSkillInspector = async () => {
4236
+ let skills;
4237
+ try {
4238
+ skills = await this.runner.listSkills();
4239
+ } catch (error) {
4240
+ this.showText("Skills", errorMessage(error, "Could not list the skills."));
4241
+ return;
4242
+ }
4243
+ const on = skills.filter((skill) => skill.enabled).length;
4021
4244
  this.showText(
4022
- `Skills (${skills.length})`,
4023
- skills.length ? skills.map((skill) => `${skill.name} [${skill.source}]
4245
+ `Skills (${on} on, ${skills.length - on} off)`,
4246
+ skills.length ? [
4247
+ ...skills.map(
4248
+ (skill) => `${skill.name} [${skill.bundledBy ? `bundled with ${skill.bundledBy}` : skill.source}]${skill.enabled ? "" : " (off)"}
4024
4249
  ${skill.description}
4025
- ${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."
4250
+ ${skill.path}`
4251
+ ),
4252
+ "/skills off <name> switches one off in this project (--global: everywhere); /skills on <name> switches it back on."
4253
+ ].join("\n\n") : "No skills discovered. Add SKILL.md under .nova-ai/skills (or .agents, .claude, .codex skills) in the workspace, or ~/.nova-ai/skills for all projects."
4026
4254
  );
4027
4255
  };
4256
+ /** /skills on|off <name> [--global]: applies from the next message. */
4257
+ switchSkill = async (name, enabled, global) => {
4258
+ let skills;
4259
+ try {
4260
+ skills = await this.runner.setSkillEnabled(name, enabled, global);
4261
+ } catch (error) {
4262
+ const message = errorMessage(error, "Could not change the skill.");
4263
+ return message.startsWith("No skill named") ? `${message} /skills lists them.` : message;
4264
+ }
4265
+ const stillOff = enabled && skills.some((skill) => skill.name === name && !skill.enabled);
4266
+ return stillOff ? `Switched "${name}" on ${global ? "globally" : "in this project"}, but it is still off ${global ? "in this project" : "globally"} (use ${global ? "/skills on " + name : "/skills on " + name + " --global"}).` : `Skill "${name}" is ${enabled ? "on" : "off"} ${global ? "everywhere" : "in this project"} from the next message.`;
4267
+ };
4028
4268
  openUsageInspector = async () => {
4029
4269
  this.showText("Context usage", "Calculating context usage...");
4030
4270
  try {
@@ -4035,14 +4275,23 @@ ${skill.path}`).join("\n\n") : "No skills discovered. Add SKILL.md under .agents
4035
4275
  }
4036
4276
  };
4037
4277
  /**
4038
- * Prints the whole transcript again. After the width changed, the terminal
4039
- * re-wrapped what is on screen on its own and the live area can't be
4040
- * erased exactly (Ink leaves stale prompt borders), and old scrollback keeps
4041
- * the old wrapping.
4278
+ * The view reports its transcript size after each layout. While scrolled
4279
+ * up, new output below grows the offset so the rows being read stay put.
4042
4280
  */
4043
- redrawAll() {
4044
- if (this.stopped || this.exiting) return;
4045
- this.committer.requestRedraw();
4281
+ setViewport(rows, contentRows) {
4282
+ const grown = contentRows - this.viewport.contentRows;
4283
+ this.viewport = { rows, contentRows };
4284
+ this.scrollTo(this.scroll > 0 && grown > 0 ? this.scroll + grown : this.scroll);
4285
+ }
4286
+ /** Positive scrolls toward older output. */
4287
+ scrollBy(rows) {
4288
+ this.scrollTo(this.scroll + rows);
4289
+ }
4290
+ scrollTo(offset) {
4291
+ const max = Math.max(0, this.viewport.contentRows - this.viewport.rows);
4292
+ const next = Math.max(0, Math.min(offset, max));
4293
+ if (next === this.scroll) return;
4294
+ this.scroll = next;
4046
4295
  this.refresh();
4047
4296
  }
4048
4297
  toggleToolDetails() {
@@ -4067,8 +4316,8 @@ ${skill.path}`).join("\n\n") : "No skills discovered. Add SKILL.md under .agents
4067
4316
  this.runner.setInteractionMode(mode);
4068
4317
  }
4069
4318
  cycleInteractionMode() {
4070
- const current = INTERACTION_MODES.indexOf(this.runner.interactionMode);
4071
- 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]);
4072
4321
  }
4073
4322
  appendAssistant(text) {
4074
4323
  this.store.setState((state) => ({
@@ -4091,26 +4340,60 @@ ${skill.path}`).join("\n\n") : "No skills discovered. Add SKILL.md under .agents
4091
4340
  clear: () => void this.runner.startNewSession(),
4092
4341
  newSession: () => this.runner.startNewSession(),
4093
4342
  exit: () => this.requestExit(),
4343
+ requestUpdate: () => {
4344
+ const update = this.store.getState().updateAvailable;
4345
+ if (!update) return "No newer nova-ai found on npm.";
4346
+ if (!update.installable) {
4347
+ return `nova-ai ${update.latestVersion} is available, but this copy wasn't installed with npm -g, so it can't update itself. Run: ${update.command}`;
4348
+ }
4349
+ if (this.store.getState().busy) {
4350
+ return "Wait for the current request to finish (or press Esc), then run /update again.";
4351
+ }
4352
+ this.updateRequested = true;
4353
+ this.requestExit();
4354
+ return null;
4355
+ },
4094
4356
  resumeSession: (sessionId) => {
4095
4357
  const id = sessionId ?? listSessionsForCwd(this.runner.cwd)[0]?.sessionId;
4096
4358
  return id ? this.runner.resumeFrom(id) : false;
4097
4359
  },
4098
4360
  openSessionSwitcher: this.openSessions,
4099
4361
  openModelPicker: () => void this.openModels(),
4362
+ hasThinkingLevels: () => this.runner.hasThinkingLevels(),
4363
+ openEffortPicker: () => this.openEffort(),
4364
+ setEffort: (level) => this.setEffort(level),
4100
4365
  getModel: () => this.runner.model,
4101
4366
  setModel: (model) => this.setModel(model),
4102
4367
  getInteractionMode: () => this.runner.interactionMode,
4368
+ getInteractionModes: () => this.runner.modeIds,
4369
+ agentCommands: () => this.agentCommands(),
4103
4370
  setInteractionMode: (mode) => this.setInteractionMode(mode),
4104
4371
  getPermissionMode: () => this.store.getState().permissionMode,
4105
4372
  setPermissionMode: (mode) => this.setPermissionMode(mode),
4106
4373
  openPermissionPicker: this.openPermissionModes,
4107
- trustWorkspace: () => {
4108
- setWorkspaceTrusted(this.runner.cwd, true);
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
+ },
4385
+ trustWorkspace: async () => {
4386
+ try {
4387
+ await this.runner.trustWorkspace();
4388
+ } catch (error) {
4389
+ return errorMessage(error, "Could not trust the workspace.");
4390
+ }
4109
4391
  return "Workspace trusted: its allow rules apply now; MCP servers it declares start with the next session (/clear).";
4110
4392
  },
4111
4393
  openToolInspector: this.openToolInspector,
4112
4394
  openMcpInspector: this.openMcpInspector,
4113
4395
  openSkillInspector: this.openSkillInspector,
4396
+ switchSkill: this.switchSkill,
4114
4397
  openUsageInspector: this.openUsageInspector,
4115
4398
  steerMessage: (message) => this.runner.steer(message),
4116
4399
  queuedMessages: () => this.runner.queuedMessages(),
@@ -4125,10 +4408,15 @@ ${skill.path}`).join("\n\n") : "No skills discovered. Add SKILL.md under .agents
4125
4408
  killBackgroundJob: (jobId) => this.runner.killBackgroundJob(jobId),
4126
4409
  releaseBackgroundJob: (jobId) => this.runner.releaseBackgroundJob(jobId)
4127
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
+ }
4128
4415
  // ------------------------------------------------------------ prompt
4129
4416
  submit = async (text, images = [], pastes = []) => {
4130
4417
  const value = text.trim();
4131
4418
  if (!value) return;
4419
+ this.scroll = 0;
4132
4420
  this.editor.addToHistory(value);
4133
4421
  this.store.setState((state) => ({
4134
4422
  inputHistory: [...state.inputHistory, value],
@@ -4143,6 +4431,10 @@ ${skill.path}`).join("\n\n") : "No skills discovered. Add SKILL.md under .agents
4143
4431
  const name = match?.[1] ?? "";
4144
4432
  const command = findCommand(name);
4145
4433
  if (!command) {
4434
+ if (this.agentCommands().some((agentCommand) => agentCommand.name === name)) {
4435
+ await this.runner.submit(value, images, pastes);
4436
+ return;
4437
+ }
4146
4438
  this.appendError(`Unknown command: /${name}`);
4147
4439
  return;
4148
4440
  }
@@ -4204,7 +4496,7 @@ ${skill.path}`).join("\n\n") : "No skills discovered. Add SKILL.md under .agents
4204
4496
  const fdPath = resolveFdPath();
4205
4497
  editor.setAutocompleteProvider(
4206
4498
  new WorkspaceAutocompleteProvider(
4207
- allCommands().map((command) => ({
4499
+ () => [...allCommands(this.commandContext), ...this.agentCommands()].map((command) => ({
4208
4500
  name: command.name,
4209
4501
  description: command.description
4210
4502
  })),
@@ -4230,7 +4522,10 @@ ${skill.path}`).join("\n\n") : "No skills discovered. Add SKILL.md under .agents
4230
4522
  this.view = this.buildView();
4231
4523
  if (this.view.transcript.epoch !== this.lastEpoch) {
4232
4524
  this.lastEpoch = this.view.transcript.epoch;
4233
- this.io.write(CLEAR_SCREEN_AND_SCROLLBACK);
4525
+ if (this.scroll) {
4526
+ this.scroll = 0;
4527
+ this.view = this.buildView();
4528
+ }
4234
4529
  }
4235
4530
  for (const listener of this.listeners) listener();
4236
4531
  }
@@ -4242,9 +4537,11 @@ ${skill.path}`).join("\n\n") : "No skills discovered. Add SKILL.md under .agents
4242
4537
  overlay: this.overlay,
4243
4538
  question: this.questionState(ui.pendingQuestion),
4244
4539
  model: this.runner.model,
4540
+ effort: this.effortName(),
4245
4541
  mcp: mcpCounts(this.runner),
4246
4542
  expandedTools: this.expandedTools,
4247
- exiting: this.exiting
4543
+ exiting: this.exiting,
4544
+ scroll: this.scroll
4248
4545
  };
4249
4546
  }
4250
4547
  questionState(pending) {
@@ -4276,18 +4573,16 @@ ${skill.path}`).join("\n\n") : "No skills discovered. Add SKILL.md under .agents
4276
4573
  }
4277
4574
  };
4278
4575
  function formatMcpServer(server, status) {
4279
- const type = "type" in server ? server.type : "stdio";
4280
- const lines = [`${server.name} [${type}]`];
4281
- if ("url" in server) lines.push(`url: ${server.url}`);
4282
- if ("command" in server) {
4576
+ const lines = [`${server.name} [${server.transport}]`];
4577
+ if (server.url) lines.push(`url: ${server.url}`);
4578
+ if (server.command) {
4283
4579
  lines.push(`command: ${server.command}`);
4284
- if (server.args.length) lines.push(`args: ${server.args.join(" ")}`);
4285
- if (server.env.length)
4286
- lines.push(`env: ${server.env.map((entry) => entry.name).join(", ")} (values hidden)`);
4580
+ if (server.args?.length) lines.push(`args: ${server.args.join(" ")}`);
4581
+ if (server.env?.length) lines.push(`env: ${server.env.join(", ")} (values hidden)`);
4287
4582
  }
4288
4583
  const failure = status.failures.find((item) => item.serverName === server.name);
4289
4584
  lines.push(
4290
- 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"
4291
4586
  );
4292
4587
  return lines.join("\n");
4293
4588
  }
@@ -4308,12 +4603,13 @@ function errorMessage(error, fallback) {
4308
4603
  import { Box as Box5, Text as Text5, useInput as useInput2, usePaste as usePaste2 } from "ink";
4309
4604
  import stringWidth2 from "string-width";
4310
4605
  import { useMemo, useState } from "react";
4606
+ import { OTHER_OPTION_ID as OTHER_OPTION_ID2 } from "@datalabrotterdam/nova-ai-agent/core/user-questions.js";
4311
4607
  import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
4312
4608
  function useDialogInput(handle, isActive) {
4313
4609
  const [, setTick] = useState(0);
4314
4610
  useInput2(
4315
4611
  (input, key) => {
4316
- if (handle(input, key) === "changed") setTick((tick) => tick + 1);
4612
+ if (!isMouseInput(input) && handle(input, key) === "changed") setTick((tick) => tick + 1);
4317
4613
  },
4318
4614
  { isActive }
4319
4615
  );
@@ -4325,6 +4621,25 @@ function Frame({ title, children, footer }) {
4325
4621
  footer ? /* @__PURE__ */ jsx5(Text5, { color: palette.faint, wrap: "truncate-end", children: footer }) : null
4326
4622
  ] });
4327
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
+ }
4328
4643
  function SelectionDialog({
4329
4644
  title,
4330
4645
  state,
@@ -4455,7 +4770,7 @@ function QuestionDialog({
4455
4770
  const [, setTick] = useState(0);
4456
4771
  useInput2(
4457
4772
  (input, key) => {
4458
- if (state.handleKey(input, key) === "changed") setTick((tick) => tick + 1);
4773
+ if (!isMouseInput(input) && state.handleKey(input, key) === "changed") setTick((tick) => tick + 1);
4459
4774
  },
4460
4775
  { isActive }
4461
4776
  );
@@ -4520,7 +4835,7 @@ function QuestionDialog({
4520
4835
  /* @__PURE__ */ jsxs5(Text5, { wrap: "truncate-end", children: [
4521
4836
  /* @__PURE__ */ jsx5(Text5, { color: palette.primary, children: state.selected === otherIndex ? ">" : " " }),
4522
4837
  " ",
4523
- state.isSelected(question.id, OTHER_OPTION_ID) ? /* @__PURE__ */ jsx5(Text5, { color: palette.success, children: "[x]" }) : "[ ]",
4838
+ state.isSelected(question.id, OTHER_OPTION_ID2) ? /* @__PURE__ */ jsx5(Text5, { color: palette.success, children: "[x]" }) : "[ ]",
4524
4839
  " ",
4525
4840
  otherAnswer ? `Other: ${otherAnswer}` : "Other... (write your own answer)"
4526
4841
  ] }),
@@ -4537,7 +4852,7 @@ function PermissionDialog({
4537
4852
  }) {
4538
4853
  useInput2(
4539
4854
  (input, key) => {
4540
- if (permissionKey(request, input, key) === "closed") onClose();
4855
+ if (!isMouseInput(input) && permissionKey(request, input, key) === "closed") onClose();
4541
4856
  },
4542
4857
  { isActive }
4543
4858
  );
@@ -4588,9 +4903,8 @@ function wrapPlain(text, width) {
4588
4903
  }
4589
4904
 
4590
4905
  // src/tui/ink/app.tsx
4591
- import { Fragment as Fragment4, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
4906
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
4592
4907
  var FRAME_MS = 120;
4593
- var RESIZE_SETTLE_MS = 100;
4594
4908
  async function runInkTui(credentials, cwd, args, options = {}) {
4595
4909
  const store = createStore({
4596
4910
  messages: [],
@@ -4632,22 +4946,15 @@ async function runInkTui(credentials, cwd, args, options = {}) {
4632
4946
  // Ink otherwise turns non-interactive whenever CI is set in the
4633
4947
  // environment; the TUI only runs on a terminal anyway.
4634
4948
  interactive: Boolean(stdout.isTTY),
4949
+ // Full screen: the transcript scrolls inside the app (PgUp/PgDn) and the
4950
+ // terminal gets its own screen back on exit.
4951
+ alternateScreen: Boolean(stdout.isTTY),
4635
4952
  kittyKeyboard: { mode: kittyKeyboard ? "enabled" : "disabled" }
4636
4953
  });
4637
4954
  controller.start();
4955
+ if (stdout.isTTY) stdout.write(MOUSE_REPORTING_ON);
4638
4956
  const onSignal = () => controller.requestExit();
4639
4957
  process.once("SIGTERM", onSignal);
4640
- let printedColumns = stdout.columns;
4641
- let resizeTimer;
4642
- const onResize = () => {
4643
- if (resizeTimer) clearTimeout(resizeTimer);
4644
- resizeTimer = setTimeout(() => {
4645
- if (stdout.columns === printedColumns) return;
4646
- printedColumns = stdout.columns;
4647
- controller.redrawAll();
4648
- }, RESIZE_SETTLE_MS);
4649
- };
4650
- stdout.on("resize", onResize);
4651
4958
  void (options.checkForUpdate ?? checkForUpdate)().then((updateAvailable) => {
4652
4959
  if (updateAvailable) store.setState({ updateAvailable });
4653
4960
  });
@@ -4657,45 +4964,99 @@ async function runInkTui(credentials, cwd, args, options = {}) {
4657
4964
  await controller.stop();
4658
4965
  } finally {
4659
4966
  process.off("SIGTERM", onSignal);
4660
- stdout.off("resize", onResize);
4661
- if (resizeTimer) clearTimeout(resizeTimer);
4967
+ if (stdout.isTTY) stdout.write(MOUSE_REPORTING_OFF);
4662
4968
  instance.unmount();
4663
4969
  await instance.waitUntilExit().catch(() => {
4664
4970
  });
4665
4971
  }
4666
4972
  stdout.write(`Resume this session with: nova-ai --resume ${runner.sessionId}
4667
4973
  `);
4974
+ return { sessionId: runner.sessionId, updateRequested: controller.updateRequested };
4668
4975
  }
4669
4976
  function App({ controller }) {
4670
4977
  const view = useSyncExternalStore2(controller.subscribe, controller.getView);
4671
4978
  const { rows, columns } = useWindowSize();
4672
4979
  const bottomRef = useRef(null);
4673
4980
  const bottom = useBoxMetrics(bottomRef);
4981
+ const contentRef = useRef(null);
4982
+ const content = useBoxMetrics(contentRef);
4674
4983
  const working = useWorking(view);
4984
+ const [userTops, setUserTops] = useState2(/* @__PURE__ */ new Map());
4985
+ useInput3((input) => {
4986
+ const rows2 = wheelRows(input);
4987
+ if (rows2) controller.scrollBy(rows2);
4988
+ });
4989
+ const height = Math.max(4, rows - 1);
4990
+ const viewportRows = Math.max(3, height - (bottom.hasMeasured ? bottom.height : 6));
4991
+ useEffect(() => {
4992
+ if (content.hasMeasured) controller.setViewport(viewportRows, content.height);
4993
+ }, [controller, viewportRows, content.hasMeasured, content.height]);
4994
+ if (view.exiting) return null;
4675
4995
  const { transcript } = view;
4676
- const scrollback = (
4677
- // <Static> is laid out as an absolute box; without a width its text
4678
- // wraps against its content and runs past the terminal edge.
4679
- /* @__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)
4680
- );
4681
- if (view.exiting) return scrollback;
4682
- const liveRows = Math.max(3, rows - (bottom.hasMeasured ? bottom.height : 6) - 2);
4683
- const dialogRows = Math.max(6, rows - 4);
4684
- return /* @__PURE__ */ jsxs6(Fragment4, { children: [
4685
- scrollback,
4686
- /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", maxHeight: liveRows, overflowY: "hidden", justifyContent: "flex-end", children: /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", flexShrink: 0, children: [
4687
- transcript.live.map((block) => /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(TranscriptBlockView, { block, frame: working.frame }) }, block.key)),
4688
- working.line ? /* @__PURE__ */ jsx6(Box6, { marginTop: 1, paddingLeft: 1, children: /* @__PURE__ */ jsxs6(Text6, { children: [
4689
- /* @__PURE__ */ jsx6(Text6, { color: palette.accent, children: WORKING_FRAMES[working.frame] }),
4690
- /* @__PURE__ */ jsx6(Text6, { color: palette.muted, children: ` ${working.line}` })
4691
- ] }) }) : null
4692
- ] }) }),
4693
- /* @__PURE__ */ jsxs6(Box6, { ref: bottomRef, flexDirection: "column", marginTop: 1, children: [
4694
- /* @__PURE__ */ jsx6(Surface, { controller, view, rows: dialogRows, columns }),
4695
- /* @__PURE__ */ jsx6(StatusLine, { ui: view.ui, model: view.model, mcp: view.mcp })
4996
+ const blocks = [...transcript.committed, ...transcript.live];
4997
+ const liveKeys = new Set(transcript.live.map((block) => block.key));
4998
+ const onUserTop = (key, top) => setUserTops((tops) => tops.get(key) === top ? tops : new Map(tops).set(key, top));
4999
+ const firstVisibleRow = content.height - viewportRows - view.scroll;
5000
+ let sticky = null;
5001
+ for (const block of blocks) {
5002
+ if (block.kind !== "user") continue;
5003
+ const top = userTops.get(block.key);
5004
+ if (top !== void 0 && top < firstVisibleRow) sticky = block.text;
5005
+ }
5006
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", height, children: [
5007
+ /* @__PURE__ */ jsxs6(
5008
+ Box6,
5009
+ {
5010
+ flexDirection: "column",
5011
+ height: viewportRows,
5012
+ flexShrink: 0,
5013
+ overflowY: "hidden",
5014
+ justifyContent: "flex-end",
5015
+ children: [
5016
+ /* @__PURE__ */ jsxs6(Box6, { ref: contentRef, flexDirection: "column", flexShrink: 0, marginBottom: -view.scroll, children: [
5017
+ blocks.map(
5018
+ (block) => liveKeys.has(block.key) ? /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(TranscriptBlockView, { block, frame: working.frame }) }, block.key) : /* @__PURE__ */ jsx6(CommittedBlock, { block, onUserTop }, block.key)
5019
+ ),
5020
+ working.line ? /* @__PURE__ */ jsx6(Box6, { marginTop: 1, paddingLeft: 1, children: /* @__PURE__ */ jsxs6(Text6, { children: [
5021
+ /* @__PURE__ */ jsx6(Text6, { color: palette.accent, children: WORKING_FRAMES[working.frame] }),
5022
+ /* @__PURE__ */ jsx6(Text6, { color: palette.muted, children: ` ${working.line}` })
5023
+ ] }) }) : null
5024
+ ] }),
5025
+ sticky !== null ? /* @__PURE__ */ jsx6(StickyPrompt, { text: sticky, columns }) : null
5026
+ ]
5027
+ }
5028
+ ),
5029
+ /* @__PURE__ */ jsxs6(Box6, { ref: bottomRef, flexDirection: "column", flexShrink: 0, children: [
5030
+ /* @__PURE__ */ jsx6(Surface, { controller, view, rows: Math.max(6, rows - 4), columns }),
5031
+ /* @__PURE__ */ jsx6(StatusLine, { ui: view.ui, model: view.model, effort: view.effort, mcp: view.mcp, scroll: view.scroll })
4696
5032
  ] })
4697
5033
  ] });
4698
5034
  }
5035
+ var CommittedBlock = memo(function CommittedBlock2({
5036
+ block,
5037
+ onUserTop
5038
+ }) {
5039
+ if (block.kind !== "user") {
5040
+ return /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(TranscriptBlockView, { block }) });
5041
+ }
5042
+ return /* @__PURE__ */ jsx6(UserAnchor, { block, onUserTop, children: /* @__PURE__ */ jsx6(TranscriptBlockView, { block }) });
5043
+ }, (before, after) => before.block === after.block);
5044
+ function UserAnchor({
5045
+ block,
5046
+ onUserTop,
5047
+ children
5048
+ }) {
5049
+ const ref = useRef(null);
5050
+ const { top, hasMeasured } = useBoxMetrics(ref);
5051
+ useEffect(() => {
5052
+ if (hasMeasured) onUserTop(block.key, top);
5053
+ }, [block.key, top, hasMeasured]);
5054
+ return /* @__PURE__ */ jsx6(Box6, { ref, marginTop: 1, children });
5055
+ }
5056
+ function StickyPrompt({ text, columns }) {
5057
+ const line = `> ${text.replace(/\s+/g, " ").trim()}`;
5058
+ return /* @__PURE__ */ jsx6(Box6, { position: "absolute", top: 0, left: 0, width: columns, children: /* @__PURE__ */ jsx6(Text6, { color: palette.primary, backgroundColor: palette.stickyBackground, wrap: "truncate-end", children: line.padEnd(columns) }) });
5059
+ }
4699
5060
  function Surface({
4700
5061
  controller,
4701
5062
  view,
@@ -4710,6 +5071,7 @@ function Surface({
4710
5071
  const overlay = view.overlay;
4711
5072
  if (overlay?.kind === "selection")
4712
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 });
4713
5075
  if (overlay?.kind === "text")
4714
5076
  return /* @__PURE__ */ jsx6(
4715
5077
  ScrollPanel,
@@ -4756,17 +5118,28 @@ function useWorking(view) {
4756
5118
  }
4757
5119
 
4758
5120
  // src/tui/index.ts
5121
+ import { workspaceNeedsTrust } from "@datalabrotterdam/nova-ai-agent/core/workspace-mcp.js";
4759
5122
  async function runChat(options) {
4760
5123
  const stored = readCredentials();
4761
5124
  if (!stored) {
4762
5125
  console.error("nova-ai isn't connected to a Nova AI account yet. Run `nova-ai login` first.");
4763
5126
  return 1;
4764
5127
  }
4765
- const credentials = options.model ? { ...stored, defaultModel: options.model } : stored;
5128
+ const model = options.model ?? stored.defaultModel ?? process.env.NOVA_MODEL;
5129
+ const credentials = { ...stored, defaultModel: model };
5130
+ const updateCheck = checkForUpdate().then((update) => withInstallability(update));
5131
+ const updatedFrom = process.env[UPDATED_FROM_ENV];
5132
+ if (updatedFrom) {
5133
+ delete process.env[UPDATED_FROM_ENV];
5134
+ console.log(`Updated nova-ai ${updatedFrom} \u2192 ${cliVersion() ?? "?"}.`);
5135
+ } else {
5136
+ const restartedCode = await offerUpdateAtStart(updateCheck, process.argv.slice(2));
5137
+ if (restartedCode !== null) return restartedCode;
5138
+ }
4766
5139
  const cwd = process.cwd();
4767
5140
  let resume = options.resume;
4768
5141
  if (options.continueLast) {
4769
- resume = listStoredSessions(cwd)[0]?.sessionId ?? null;
5142
+ resume = listStoredSessions2(cwd)[0]?.sessionId ?? null;
4770
5143
  if (!resume) {
4771
5144
  console.error(`No earlier session in ${cwd} to continue.`);
4772
5145
  return 1;
@@ -4774,11 +5147,22 @@ async function runChat(options) {
4774
5147
  }
4775
5148
  if (workspaceNeedsTrust(cwd)) await askToTrust(cwd);
4776
5149
  const args = resume ? ["--resume", resume] : [];
4777
- await runInkTui(credentials, cwd, args);
5150
+ const result = await runInkTui(credentials, cwd, args, { checkForUpdate: () => updateCheck });
5151
+ if (result.updateRequested) {
5152
+ const update = await updateCheck;
5153
+ if (update) {
5154
+ const restartArgs = [
5155
+ ...loadStoredSession2(result.sessionId) ? ["--resume", result.sessionId] : [],
5156
+ ...options.model ? ["--model", options.model] : []
5157
+ ];
5158
+ const restartedCode = installAndRestart(update, restartArgs);
5159
+ if (restartedCode !== null) return restartedCode;
5160
+ }
5161
+ }
4778
5162
  return 0;
4779
5163
  }
4780
5164
  async function askToTrust(cwd) {
4781
- const prompt = createInterface({ input: process.stdin, output: process.stdout });
5165
+ const prompt = createInterface2({ input: process.stdin, output: process.stdout });
4782
5166
  try {
4783
5167
  console.log(`
4784
5168
  ${cwd}`);