nou-cli 0.2.1 → 0.3.0

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.
@@ -3,18 +3,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { readFileSync, writeFileSync } from "fs";
4
4
  import path from "path";
5
5
  import { Box, Text, useApp, useInput, useWindowSize } from "ink";
6
- import { Agent, buildSystemPrompt } from "../agent/index.js";
6
+ import { buildSystemPrompt } from "../agent/index.js";
7
+ import { ENGINES, createEngine, engineInfo, installEngine, isEngineId, isInstalled } from "../engines/index.js";
8
+ import { harness, problemsIntroduced } from "../agent/harness.js";
7
9
  import { describe, providers, resolveModel, saveModel } from "../providers/index.js";
10
+ import { sameModel } from "../providers/ollama.js";
8
11
  import { loadSettings, saveSettings } from "../config/index.js";
9
- import { branch as gitBranch, diffAgainstHead, isRepo, status as gitStatus, summary as gitSummary } from "../git/index.js";
10
- import { gitTools } from "../git/tools.js";
11
- import { BUILTIN_PLUGINS, discoverPlugins, isEnabled, loadPlugin, trustPatch } from "../plugins/index.js";
12
+ import { branch as gitBranch, diffAgainstHead, isRepo, sameStatus, status as gitStatus, statusAsync as gitStatusAsync } from "../git/index.js";
13
+ import { BUILTIN_PLUGINS, addStarter, discoverPlugins, isEnabled, loadPlugin, scaffoldPlugin, starterPlugins, trustPatch, trustState } from "../plugins/index.js";
12
14
  import { DiagnosticsManager, formatDiagnostic, newProblems } from "../diagnostics/index.js";
13
15
  import { Timeline } from "../history/index.js";
14
16
  import { lsp } from "../lsp/index.js";
15
17
  import { onGrammarLoaded, preload } from "../syntax/index.js";
16
18
  import { checkForUpdate, currentVersion } from "../update/index.js";
17
- import { changedLines } from "./text.js";
19
+ import { changedLines, lineCounts } from "./text.js";
18
20
  import { ProblemsView } from "./ProblemsView.js";
19
21
  import { useMouse } from "./mouse.js";
20
22
  import { C } from "./theme.js";
@@ -32,14 +34,23 @@ const HELP = `Commands:
32
34
  /model show providers and the current model
33
35
  /model <provider[:model]> switch, e.g. /model anthropic /model ollama:qwen3:8b
34
36
  /models [provider] list models a provider offers
37
+ /connect <provider> enter an API key (and URL), e.g. /connect fireworks /connect litellm
38
+ /pull <model> download an Ollama model
39
+ /engine [name] which agent answers: nou, claude-code, codex or pi
40
+ /plugins your plugins and the starter ones you can add
41
+ /plugins add <name> add a starter plugin (copied to ~/.nou/plugins to edit)
42
+ /plugins new <name> start your own plugin in .nou/plugins/
35
43
  /clear start a fresh conversation
44
+ /compact summarize the conversation so far, to free up context
36
45
  /undo undo Nou's last change (also: u)
37
46
  /rewind undo everything from Nou's last turn
38
47
  /history recent changes
48
+ /diff [file] everything changed this session ([ ] step through files)
49
+ /sessions earlier sessions; /diff <n> shows one of them
39
50
  /version which version of Nou this is
40
51
  Keys:
41
52
  Ctrl+P find a file Tab switch pane Esc stop / back
42
- e edit open file v diff (git / Nou) f follow mode
53
+ e edit open file v diff (git / Nou / session) f follow mode
43
54
  p problems u undo Nou's edit s settings
44
55
  c (in files) show only changed files Ctrl+C twice quit
45
56
  In the editor: Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
@@ -56,11 +67,30 @@ export function App({ initialModel, permContext }) {
56
67
  const { columns, rows } = useWindowSize();
57
68
  const [settings, setSettings] = useState(loadSettings);
58
69
  const [view, setView] = useState("main");
70
+ const [connectTo, setConnectTo] = useState();
59
71
  const [quickOpen, setQuickOpen] = useState(false);
60
72
  const [focus, setFocus] = useState("chat");
61
73
  const [follow, setFollow] = useState(settings.followMode);
62
74
  const [entries, setEntries] = useState([]);
63
75
  const [streaming, setStreaming] = useState("");
76
+ // Streamed text arrives a token at a time; redrawing the whole screen for each one is wasted work.
77
+ const streamed = useRef({ text: "", timer: null });
78
+ const showStream = (text) => {
79
+ const s = streamed.current;
80
+ s.text = text;
81
+ if (!text) {
82
+ if (s.timer)
83
+ clearTimeout(s.timer);
84
+ s.timer = null;
85
+ setStreaming("");
86
+ }
87
+ else if (!s.timer) {
88
+ s.timer = setTimeout(() => {
89
+ s.timer = null;
90
+ setStreaming(s.text);
91
+ }, 33);
92
+ }
93
+ };
64
94
  const [busy, setBusy] = useState(null);
65
95
  const [choice, setChoice] = useState(initialModel);
66
96
  const [approval, setApproval] = useState(null);
@@ -71,9 +101,13 @@ export function App({ initialModel, permContext }) {
71
101
  // The last change Nou made per file, so we can flag edited lines and re-open the diff.
72
102
  const [changes, setChanges] = useState(new Map());
73
103
  const [diffMode, setDiffMode] = useState("off");
104
+ // Which session the "session" diff shows (null: this one), and a counter that bumps when history changes.
105
+ const [sessionView, setSessionView] = useState(null);
106
+ const [historyVersion, setHistoryVersion] = useState(0);
74
107
  const [editing, setEditing] = useState(false);
75
108
  const [git, setGit] = useState(() => gitStatus());
76
109
  const [plugins, setPlugins] = useState([]);
110
+ const [starters, setStarters] = useState([]);
77
111
  const [viewport, setViewport] = useState(null);
78
112
  const [editorState, setEditorState] = useState(null);
79
113
  // Unsaved editor text survives switching to a diff and back.
@@ -90,11 +124,12 @@ export function App({ initialModel, permContext }) {
90
124
  const [treeClick, setTreeClick] = useState(null);
91
125
  // Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
92
126
  const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
127
+ const pullAbort = useRef(null);
93
128
  const diagnosticsRef = useRef(null);
94
129
  diagnosticsRef.current ??= new DiagnosticsManager();
95
130
  const diagnostics = diagnosticsRef.current;
96
131
  const timelineRef = useRef(null);
97
- timelineRef.current ??= new Timeline();
132
+ timelineRef.current ??= new Timeline(() => setHistoryVersion((v) => v + 1));
98
133
  const timeline = timelineRef.current;
99
134
  const followRef = useRef(follow);
100
135
  followRef.current = follow;
@@ -114,10 +149,13 @@ export function App({ initialModel, permContext }) {
114
149
  void diagnosticsRef.current.checkFile(filePath);
115
150
  }, []);
116
151
  const agentRef = useRef(null);
117
- if (!agentRef.current) {
118
- agentRef.current = new Agent(initialModel, buildSystemPrompt(permContext), {
152
+ const hooksRef = useRef(null);
153
+ /** What the engine was last configured with, to configure a new one the same way. */
154
+ const engineConfig = useRef({});
155
+ if (!hooksRef.current) {
156
+ hooksRef.current = {
119
157
  onEntry: (entry) => setEntries((prev) => [...prev, entry]),
120
- onStream: setStreaming,
158
+ onStream: showStream,
121
159
  onBusy: setBusy,
122
160
  onRead: (filePath) => {
123
161
  if (!followRef.current)
@@ -153,11 +191,12 @@ export function App({ initialModel, permContext }) {
153
191
  }
154
192
  return new Promise((resolve) => setApproval({ request, resolve }));
155
193
  },
156
- checkChange: async (change) => {
157
- const introduced = await diagnosticsRef.current.introducedBy(change.path, change.before, change.after);
158
- return introduced.length ? introduced.slice(0, 15).map((d) => formatDiagnostic(d)).join("\n") : null;
159
- },
160
- });
194
+ checkChange: (change) => problemsIntroduced(diagnosticsRef.current, change),
195
+ };
196
+ }
197
+ if (!agentRef.current) {
198
+ const saved = loadSettings().engine;
199
+ agentRef.current = createEngine(isEngineId(saved) ? saved : "nou", initialModel, buildSystemPrompt(permContext), hooksRef.current);
161
200
  }
162
201
  useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
163
202
  useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
@@ -170,9 +209,11 @@ export function App({ initialModel, permContext }) {
170
209
  const found = discoverPlugins();
171
210
  const saved = loadSettings();
172
211
  setPlugins(await Promise.all(found.map((p) => (isEnabled(p, saved) ? loadPlugin(p) : p))));
212
+ setStarters(await Promise.all(starterPlugins().map(loadPlugin)));
173
213
  })();
174
214
  }, []);
175
- const allPlugins = useMemo(() => [...BUILTIN_PLUGINS, ...plugins], [plugins]);
215
+ // Starter plugins are listed until you add them (or have your own plugin with the same name).
216
+ const allPlugins = useMemo(() => [...BUILTIN_PLUGINS, ...plugins, ...starters.filter((s) => !plugins.some((p) => p.id === s.id))], [plugins, starters]);
176
217
  const activePlugins = useMemo(() => allPlugins.filter((p) => isEnabled(p, settings) && p.plugin), [allPlugins, settings.plugins, settings.trustedPlugins]);
177
218
  // Checkers come from enabled plugins (built-in ones included).
178
219
  const firstCheck = useRef(true);
@@ -185,53 +226,24 @@ export function App({ initialModel, permContext }) {
185
226
  }, [activePlugins]);
186
227
  // Keep the agent's system prompt and tools in sync with settings and plugins.
187
228
  useEffect(() => {
188
- const extras = [];
189
- const extraTools = [];
190
- if (settings.gitContext && repo) {
191
- const summary = gitSummary();
192
- if (summary)
193
- extras.push(summary);
194
- extraTools.push(...gitTools());
195
- }
196
- for (const { plugin } of activePlugins) {
197
- if (plugin.systemPrompt)
198
- extras.push(`Plugin ${plugin.name}: ${plugin.systemPrompt}`);
199
- for (const tool of plugin.tools ?? []) {
200
- extraTools.push({
201
- definition: tool.definition,
202
- readOnly: !!tool.readOnly,
203
- source: plugin.name,
204
- run: (args) => tool.run(args, { cwd: process.cwd() }),
205
- });
206
- }
207
- }
208
- extraTools.push({
209
- source: "problems",
210
- readOnly: true,
211
- definition: {
212
- name: "get_diagnostics",
213
- description: "Get errors and warnings from the project's linters, type checker and language servers, for one file or the whole project.",
214
- parameters: { type: "object", properties: { path: { type: "string", description: "Optional file. Omit for the whole project." } }, required: [] },
215
- },
216
- run: async (args) => {
217
- const file = typeof args.path === "string" ? args.path : null;
218
- if (file)
219
- await diagnostics.checkFile(file);
220
- else
221
- await diagnostics.checkProject();
222
- const list = file ? diagnostics.get(file) : diagnostics.all();
223
- return list.length ? list.slice(0, 100).map((d) => formatDiagnostic(d, true)).join("\n") : "No problems found.";
224
- },
225
- });
226
- agentRef.current.configure({ system: buildSystemPrompt(permContext, extras), extraTools, effort: settings.effort });
229
+ const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
230
+ engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, permContext].filter(Boolean), extraTools, effort: settings.effort };
231
+ agentRef.current.configure(engineConfig.current);
227
232
  }, [settings.gitContext, settings.effort, activePlugins]);
228
233
  // Pick up changes made outside Nou (other editors, git commands).
234
+ const polling = useRef(false);
229
235
  useEffect(() => {
230
236
  if (!repo)
231
237
  return;
232
238
  const timer = setInterval(() => {
233
- if (!busy)
234
- setGit(gitStatus());
239
+ if (busy || polling.current)
240
+ return;
241
+ polling.current = true;
242
+ void gitStatusAsync().then((next) => {
243
+ polling.current = false;
244
+ // Only redraw when something changed.
245
+ setGit((prev) => (sameStatus(prev, next) ? prev : next));
246
+ });
235
247
  }, 4000);
236
248
  return () => clearInterval(timer);
237
249
  }, [repo, busy]);
@@ -253,13 +265,94 @@ export function App({ initialModel, permContext }) {
253
265
  setChoice(next);
254
266
  saveModel(next);
255
267
  setSettings(loadSettings());
256
- setEntries((prev) => [...prev, { kind: "info", text: `Now using ${describe(next)}. The conversation carries over.` }]);
268
+ const engine = agentRef.current;
269
+ const carries = engine.id === "nou" || engine.id === "claude-code";
270
+ setEntries((prev) => [...prev, { kind: "info", text: `Now using ${engine.id === "nou" ? describe(next) : `${engineInfo(engine.id).label} with ${engineInfo(engine.id).model(next)}`}. ${carries ? "The conversation carries over." : "This starts a new conversation."}` }]);
257
271
  return true;
258
272
  };
273
+ /** Switches the agent engine, installing it first if needed (Esc stops the install). Starts a new conversation. */
274
+ const switchEngine = async (id) => {
275
+ if (busy)
276
+ return info("Wait for the current reply to finish (or press Esc) before switching engines.", "error");
277
+ const target = engineInfo(id);
278
+ if (agentRef.current.id === id)
279
+ return info(`Already using ${target.label}.`);
280
+ if (!isInstalled(target)) {
281
+ const controller = new AbortController();
282
+ pullAbort.current = controller;
283
+ info(`Installing ${target.label} (${target.package}) into ~/.nou/engines. Esc to stop.`);
284
+ setBusy(`Installing ${target.label}...`);
285
+ try {
286
+ await installEngine(target, (line) => setBusy(`Installing ${target.label}: ${line.slice(0, 80)}`), controller.signal);
287
+ }
288
+ catch (error) {
289
+ return info(controller.signal.aborted ? `Stopped installing ${target.label}.` : `Couldn't install ${target.label}: ${error.message}`, "error");
290
+ }
291
+ finally {
292
+ pullAbort.current = null;
293
+ setBusy(null);
294
+ }
295
+ }
296
+ agentRef.current.dispose?.();
297
+ const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
298
+ engine.configure(engineConfig.current);
299
+ agentRef.current = engine;
300
+ updateSettings({ engine: id });
301
+ info(`Now using the ${target.label} engine (model: ${target.model(choice)}). This starts a new conversation.${target.approvals === "after" ? " It edits without asking first; review its changes in the diff view and press u to undo." : ""}`);
302
+ };
303
+ /** Downloads an Ollama model, showing progress in the status line. Esc stops it. */
304
+ const pullModel = async (model) => {
305
+ const ollama = providers.find((p) => p.id === "ollama");
306
+ const controller = new AbortController();
307
+ pullAbort.current = controller;
308
+ setBusy(`Downloading ${model}...`);
309
+ try {
310
+ await ollama.pull(model, (status, percent) => setBusy(`Downloading ${model}: ${percent === null ? status : `${percent}%`}`), controller.signal);
311
+ info(`Downloaded ${model}.`);
312
+ return true;
313
+ }
314
+ catch (error) {
315
+ info(controller.signal.aborted ? `Stopped downloading ${model}.` : `Couldn't download ${model}: ${error.message}`, "error");
316
+ return false;
317
+ }
318
+ finally {
319
+ pullAbort.current = null;
320
+ setBusy(null);
321
+ }
322
+ };
323
+ /** Switches model, downloading it first if it's an Ollama model that isn't installed yet. */
324
+ const chooseModel = async (next) => {
325
+ if (next.provider.pull) {
326
+ const installed = await next.provider.listModels().catch(() => null);
327
+ if (installed && !installed.some((m) => sameModel(m, next.model))) {
328
+ info(`${next.model} isn't downloaded yet; downloading it now (Esc to stop).`);
329
+ if (!(await pullModel(next.model)))
330
+ return;
331
+ }
332
+ }
333
+ switchModel(next);
334
+ };
335
+ /** Copies a starter plugin into ~/.nou/plugins/, switches it on and loads it. */
336
+ const addStarterPlugin = async (starter) => {
337
+ const added = addStarter(starter);
338
+ updateSettings(trustPatch(added, true, settings));
339
+ const loaded = await loadPlugin(added);
340
+ setPlugins((prev) => [...prev.filter((p) => p.id !== added.id), loaded]);
341
+ return loaded;
342
+ };
343
+ const rescanPlugins = () => {
344
+ const found = discoverPlugins();
345
+ setPlugins((prev) => found.map((f) => prev.find((p) => p.id === f.id && p.plugin) ?? f));
346
+ };
259
347
  const togglePlugin = async (id, enabled) => {
260
348
  const entry = allPlugins.find((p) => p.id === id);
261
349
  if (!entry)
262
350
  return;
351
+ if (entry.scope === "starter") {
352
+ if (enabled)
353
+ await addStarterPlugin(entry);
354
+ return;
355
+ }
263
356
  updateSettings(trustPatch(entry, enabled, settings));
264
357
  if (!enabled)
265
358
  return;
@@ -275,7 +368,12 @@ export function App({ initialModel, permContext }) {
275
368
  const record = openFile ? changes.get(openFile) : undefined;
276
369
  const pendingChange = approval?.request.kind === "change" ? approval.request.change : null;
277
370
  const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
278
- const diff = pendingChange ?? (diffMode === "git" ? gitChange : diffMode === "nou" && record ? record.change : null);
371
+ const sessionId = sessionView?.id ?? agentRef.current.session;
372
+ const sessionFiles = useMemo(() => timeline.sessionChanges(sessionId), [sessionId, historyVersion]);
373
+ const sessionChange = openFile ? sessionFiles.find((c) => c.path === openFile) ?? null : null;
374
+ const diff = pendingChange ?? (diffMode === "git" ? gitChange
375
+ : diffMode === "nou" && record ? record.change
376
+ : diffMode === "session" ? sessionChange : null);
279
377
  const showEditor = editing && !!openFile && !pendingChange;
280
378
  // Check Nou's proposed edit before you approve it: does it add problems?
281
379
  useEffect(() => {
@@ -342,6 +440,7 @@ export function App({ initialModel, permContext }) {
342
440
  if (!(key.ctrl && input === "c"))
343
441
  return;
344
442
  if (busy || approval) {
443
+ pullAbort.current?.abort();
345
444
  agentRef.current?.cancel();
346
445
  approval?.resolve(false);
347
446
  setApproval(null);
@@ -395,6 +494,65 @@ export function App({ initialModel, permContext }) {
395
494
  return ` ${time} ${c.source === "nou" ? "Nou" : "you"} ${c.before === null ? "created " : ""}${c.path}${c.undone ? " (undone)" : ""}`;
396
495
  }).join("\n")}\nUndo with /undo (last) or /rewind (whole last turn).`);
397
496
  };
497
+ /** Opens a file showing its net change over the session being viewed. */
498
+ const openSessionDiff = (file) => {
499
+ if (!settleDraft(file))
500
+ return;
501
+ open(file);
502
+ setReveal(file);
503
+ setDiffMode("session");
504
+ setFocus("code");
505
+ };
506
+ /** [ and ]: previous / next file in the session diff. */
507
+ const stepSessionFile = (step) => {
508
+ if (!sessionFiles.length)
509
+ return;
510
+ const at = sessionFiles.findIndex((c) => c.path === openFile);
511
+ const next = at < 0 ? 0 : (at + step + sessionFiles.length) % sessionFiles.length;
512
+ openSessionDiff(sessionFiles[next].path);
513
+ };
514
+ const sessionLabel = (s) => new Date(s.start).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
515
+ const showSessions = () => {
516
+ const list = timeline.sessions().slice(0, 15);
517
+ if (!list.length)
518
+ return info("No sessions with changes yet.");
519
+ info(`Sessions with changes (newest first):\n${list.map((s, i) => {
520
+ const current = s.id === agentRef.current.session ? " (this session)" : "";
521
+ return ` ${String(i + 1).padStart(2)}. ${sessionLabel(s)} ${s.files.length} file${s.files.length === 1 ? "" : "s"}${current}`;
522
+ }).join("\n")}\nShow one with /diff <number>.`);
523
+ };
524
+ /** /diff: this session; /diff <n>: the nth session from /sessions; /diff <file>: one file of the session being viewed. */
525
+ const showSessionDiff = (arg) => {
526
+ const current = agentRef.current.session;
527
+ let id = current;
528
+ let label = "this session";
529
+ let file = null;
530
+ if (/^\d+$/.test(arg)) {
531
+ const picked = timeline.sessions()[Number(arg) - 1];
532
+ if (!picked)
533
+ return info(`There's no session ${arg}. See /sessions.`, "error");
534
+ id = picked.id;
535
+ if (id !== current)
536
+ label = `session ${sessionLabel(picked)}`;
537
+ }
538
+ else if (arg) {
539
+ file = arg.replace(/\\/g, "/").replace(/^\.\//, "");
540
+ if (sessionView)
541
+ ({ id, label } = sessionView);
542
+ }
543
+ const files = timeline.sessionChanges(id);
544
+ setSessionView(id === current ? null : { id, label });
545
+ if (!files.length)
546
+ return info(`No changes in ${label}.`);
547
+ if (file && !files.some((c) => c.path === file))
548
+ return info(`${file} didn't change in ${label}.`, "error");
549
+ const lines = files.map((c) => {
550
+ const { added, removed } = lineCounts(c.before, c.after);
551
+ return ` ${c.isNew ? "A" : "M"} ${c.path} +${added} -${removed}`;
552
+ });
553
+ info(`Changes in ${label} (yours and Nou's):\n${lines.join("\n")}\nShowing ${file ?? files[0].path}. [ ] previous/next file · v cycle diffs · Esc close.`);
554
+ openSessionDiff(file ?? files[0].path);
555
+ };
398
556
  /**
399
557
  * Before editing another file: an unsaved draft of a different file is saved (auto-save on) or kept and
400
558
  * the switch refused (auto-save off). Never silently dropped. Returns whether it's OK to continue.
@@ -443,6 +601,8 @@ export function App({ initialModel, permContext }) {
443
601
  order.push("git");
444
602
  if (record?.status === "applied")
445
603
  order.push("nou");
604
+ if (sessionChange)
605
+ order.push("session");
446
606
  if (order.length === 1) {
447
607
  setEntries((prev) => [...prev, { kind: "info", text: `${openFile} has no changes vs git HEAD.` }]);
448
608
  return;
@@ -474,10 +634,14 @@ export function App({ initialModel, permContext }) {
474
634
  return;
475
635
  }
476
636
  if (key.escape) {
477
- if (busy)
637
+ if (busy) {
638
+ pullAbort.current?.abort();
478
639
  agentRef.current?.cancel();
479
- else if (diffMode !== "off")
640
+ }
641
+ else if (diffMode !== "off") {
480
642
  setDiffMode("off");
643
+ setSessionView(null);
644
+ }
481
645
  else
482
646
  setFocus("chat");
483
647
  return;
@@ -495,6 +659,8 @@ export function App({ initialModel, permContext }) {
495
659
  undoLast();
496
660
  else if (input === "v")
497
661
  cycleDiff();
662
+ else if ((input === "]" || input === "[") && diffMode === "session")
663
+ stepSessionFile(input === "]" ? 1 : -1);
498
664
  else if (input === "e")
499
665
  startEditing();
500
666
  else if (input === "s" || input === ",")
@@ -534,6 +700,36 @@ ${snippet}
534
700
  const contextChip = openFile
535
701
  ? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ✎ unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
536
702
  : null;
703
+ const pluginsCommand = async ([action, name]) => {
704
+ if (action === "add") {
705
+ const starter = starters.find((s) => s.id === name);
706
+ if (!starter)
707
+ return info(`No starter plugin "${name ?? ""}". Starters: ${starters.map((s) => s.id).join(", ")}`, "error");
708
+ if (plugins.some((p) => p.id === starter.id))
709
+ return info(`You already have a plugin called ${starter.id}; see /settings → Plugins.`, "error");
710
+ const added = await addStarterPlugin(starter);
711
+ return added.error
712
+ ? info(`Added ${added.file}, but it failed to load: ${added.error}`, "error")
713
+ : info(`Added and switched on ${added.plugin.name} (${added.file}). Edit that file to make it your own.`);
714
+ }
715
+ if (action === "new") {
716
+ try {
717
+ const file = scaffoldPlugin(name ?? "");
718
+ rescanPlugins();
719
+ return info(`Created ${path.relative(process.cwd(), file)}. Edit it, then switch it on in /settings → Plugins.`);
720
+ }
721
+ catch (error) {
722
+ return info(`/plugins new: ${error.message}`, "error");
723
+ }
724
+ }
725
+ if (action)
726
+ return info("Usage: /plugins, /plugins add <name>, /plugins new <name>", "error");
727
+ const mark = { on: "●", off: "○", changed: "⚠" };
728
+ const lines = allPlugins.map((p) => p.scope === "starter"
729
+ ? ` + ${p.id.padEnd(16)} ${p.plugin?.description ?? ""}`
730
+ : ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✖ ${p.error}` : ""}`);
731
+ return info(`Plugins (● on, ○ off, + starter you can add with /plugins add <name>):\n${lines.join("\n")}\nWrite your own: /plugins new <name>`);
732
+ };
537
733
  const runCommand = async (input) => {
538
734
  const [command, ...rest] = input.slice(1).split(/\s+/);
539
735
  const arg = rest.join(" ").trim();
@@ -544,13 +740,29 @@ ${snippet}
544
740
  return rewind();
545
741
  case "history":
546
742
  return showHistory();
743
+ case "diff":
744
+ return showSessionDiff(arg);
745
+ case "sessions":
746
+ return showSessions();
547
747
  case "problems":
548
748
  setCodePanel("problems");
549
749
  setFocus("code");
550
750
  return;
551
751
  case "settings":
752
+ setConnectTo(undefined);
753
+ setView("settings");
754
+ return;
755
+ case "connect": {
756
+ const provider = providers.find((p) => p.id === arg.toLowerCase());
757
+ const choices = providers.filter((p) => p.connect).map((p) => p.id).join(", ");
758
+ if (!provider)
759
+ return info(arg ? `Unknown provider "${arg}". Choose one of: ${choices}` : `Usage: /connect <provider>, one of: ${choices}`, "error");
760
+ if (!provider.connect)
761
+ return info(`${provider.label} needs nothing to connect.`);
762
+ setConnectTo(provider);
552
763
  setView("settings");
553
764
  return;
765
+ }
554
766
  case "model": {
555
767
  if (!arg) {
556
768
  const list = providers.map((p) => {
@@ -560,7 +772,23 @@ ${snippet}
560
772
  info(`Current: ${describe(choice)}\n${list.join("\n")}\nSwitch with /model <provider[:model]> or open /settings`);
561
773
  return;
562
774
  }
563
- switchModel(resolveModel(arg));
775
+ return chooseModel(resolveModel(arg));
776
+ }
777
+ case "engine": {
778
+ if (arg && isEngineId(arg))
779
+ return switchEngine(arg);
780
+ if (arg)
781
+ return info(`Unknown engine ${arg}. Choose one of: ${ENGINES.map((e) => e.id).join(", ")}`, "error");
782
+ const current = agentRef.current.id;
783
+ const lines = ENGINES.map((e) => ` ${e.id === current ? "●" : "○"} ${e.id.padEnd(12)} ${e.description}${isInstalled(e) ? "" : " (installs on first use)"}`);
784
+ return info(`Engines (/engine <name> to switch, or /settings → Engine):\n${lines.join("\n")}`);
785
+ }
786
+ case "pull": {
787
+ if (!arg)
788
+ return info("Usage: /pull <model>, e.g. /pull qwen3:8b. Browse models at https://ollama.com/library", "error");
789
+ const model = arg.replace(/^ollama:/, "");
790
+ if (await pullModel(model))
791
+ info(`Use it with /model ollama:${model}`);
564
792
  return;
565
793
  }
566
794
  case "models": {
@@ -580,10 +808,31 @@ ${snippet}
580
808
  }
581
809
  return;
582
810
  }
811
+ case "compact": {
812
+ const engine = agentRef.current;
813
+ if (!engine.compact)
814
+ return info(`${engineInfo(engine.id).label} manages its own context.`);
815
+ if (busy)
816
+ return info("Wait for the current reply to finish first.", "error");
817
+ setBusy("Summarizing the conversation...");
818
+ try {
819
+ if (!(await engine.compact(true)))
820
+ info("Nothing to compact yet.");
821
+ }
822
+ catch (error) {
823
+ info(`Couldn't compact: ${error.message}`, "error");
824
+ }
825
+ finally {
826
+ setBusy(null);
827
+ }
828
+ return;
829
+ }
583
830
  case "clear":
584
831
  agentRef.current.clear();
585
832
  setEntries([]);
586
833
  return;
834
+ case "plugins":
835
+ return pluginsCommand(rest);
587
836
  case "version":
588
837
  return info(`nou v${currentVersion()}${update ? ` · v${update} is available, run \`nou update\`` : " · up to date"}`);
589
838
  case "help":
@@ -637,10 +886,10 @@ ${snippet}
637
886
  }
638
887
  }
639
888
  if (view === "settings") {
640
- return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, onModel: (next) => switchModel(next), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: () => {
641
- const found = discoverPlugins();
642
- setPlugins((prev) => found.map((f) => prev.find((p) => p.id === f.id && p.plugin) ?? f));
643
- }, onClose: () => setView("main") }));
889
+ return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, ...(connectTo ? { connectTo } : {}), onModel: (next) => void chooseModel(next), onEngine: (id) => void switchEngine(id), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: rescanPlugins, onClose: () => {
890
+ setConnectTo(undefined);
891
+ setView("main");
892
+ } }));
644
893
  }
645
894
  const height = Math.max(10, rows - 2);
646
895
  const treeWidth = Math.max(24, Math.floor(columns * 0.2));
@@ -667,8 +916,8 @@ ${snippet}
667
916
  ? busy ? "Esc stop · Shift+↑↓ scroll chat · Tab switch pane" : "Enter send · Shift+↑↓ / PgUp scroll · / commands · Ctrl+P find file · Tab switch pane"
668
917
  : focus === "tree"
669
918
  ? "↑↓ move · Enter open · c changes · e edit · p problems · u undo · Ctrl+P find · s settings"
670
- : diff ? "n/N next/prev change · v cycle diff · Esc close diff" : "↑↓ scroll · e edit · v diff · p problems · u undo · s settings";
671
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: describe(choice), local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), wheel: wheelFor("tree"), click: treeClick, onOpen: (filePath) => {
919
+ : diff ? `n/N next/prev change${diffMode === "session" ? " · [ ] prev/next file" : ""} · v cycle diff · Esc close diff` : "↑↓ scroll · e edit · v diff · p problems · u undo · s settings";
920
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), wheel: wheelFor("tree"), click: treeClick, onOpen: (filePath) => {
672
921
  open(filePath);
673
922
  setFocus("code");
674
923
  }, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code" && !approval, problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
@@ -690,7 +939,7 @@ ${snippet}
690
939
  draft.current = state.dirty
691
940
  ? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
692
941
  : null;
693
- } }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code" && !approval, filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : "Nou's edit", pending: !!pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), onSubmit: send })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
942
+ } }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code" && !approval, filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), onSubmit: send })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
694
943
  ? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
695
944
  : _jsxs(Text, { color: C.dim, children: [" ", hints] })] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
696
945
  setQuickOpen(false);