nou-cli 0.2.0 → 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.
Files changed (41) hide show
  1. package/.env.example +12 -0
  2. package/README.md +42 -6
  3. package/dist/index.js +22 -2
  4. package/dist/src/agent/harness.js +54 -0
  5. package/dist/src/agent/index.js +159 -15
  6. package/dist/src/commands/index.js +54 -5
  7. package/dist/src/config/credentials.js +48 -0
  8. package/dist/src/config/index.js +3 -0
  9. package/dist/src/config/origins.js +14 -0
  10. package/dist/src/context/index.js +1 -1
  11. package/dist/src/engines/claude.js +147 -0
  12. package/dist/src/engines/codex.js +131 -0
  13. package/dist/src/engines/external.js +152 -0
  14. package/dist/src/engines/index.js +127 -0
  15. package/dist/src/engines/pi.js +161 -0
  16. package/dist/src/engines/types.js +1 -0
  17. package/dist/src/env.js +5 -1
  18. package/dist/src/git/index.js +14 -4
  19. package/dist/src/history/index.js +53 -1
  20. package/dist/src/plugins/index.js +109 -21
  21. package/dist/src/providers/anthropic.js +16 -1
  22. package/dist/src/providers/ollama.js +58 -1
  23. package/dist/src/providers/openai.js +22 -5
  24. package/dist/src/search/index.js +183 -0
  25. package/dist/src/setup/index.js +240 -0
  26. package/dist/src/tools/index.js +72 -2
  27. package/dist/src/ui/App.js +351 -72
  28. package/dist/src/ui/Chat.js +9 -3
  29. package/dist/src/ui/CodeView.js +5 -1
  30. package/dist/src/ui/Editor.js +5 -1
  31. package/dist/src/ui/FileTree.js +18 -1
  32. package/dist/src/ui/Header.js +3 -1
  33. package/dist/src/ui/ProblemsView.js +5 -1
  34. package/dist/src/ui/Settings.js +93 -14
  35. package/dist/src/ui/mouse.js +52 -0
  36. package/dist/src/ui/text.js +12 -0
  37. package/package.json +5 -3
  38. package/plugins/fetch-url.mjs +82 -0
  39. package/plugins/project-rules.mjs +43 -0
  40. package/plugins/run-tests.mjs +89 -0
  41. package/plugins/todos.mjs +81 -0
@@ -3,19 +3,22 @@ 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
- import { checkForUpdate } from "../update/index.js";
17
- import { changedLines } from "./text.js";
18
+ import { checkForUpdate, currentVersion } from "../update/index.js";
19
+ import { changedLines, lineCounts } from "./text.js";
18
20
  import { ProblemsView } from "./ProblemsView.js";
21
+ import { useMouse } from "./mouse.js";
19
22
  import { C } from "./theme.js";
20
23
  import { FileTree } from "./FileTree.js";
21
24
  import { CodeView } from "./CodeView.js";
@@ -31,13 +34,23 @@ const HELP = `Commands:
31
34
  /model show providers and the current model
32
35
  /model <provider[:model]> switch, e.g. /model anthropic /model ollama:qwen3:8b
33
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/
34
43
  /clear start a fresh conversation
44
+ /compact summarize the conversation so far, to free up context
35
45
  /undo undo Nou's last change (also: u)
36
46
  /rewind undo everything from Nou's last turn
37
47
  /history recent changes
48
+ /diff [file] everything changed this session ([ ] step through files)
49
+ /sessions earlier sessions; /diff <n> shows one of them
50
+ /version which version of Nou this is
38
51
  Keys:
39
52
  Ctrl+P find a file Tab switch pane Esc stop / back
40
- e edit open file v diff (git / Nou) f follow mode
53
+ e edit open file v diff (git / Nou / session) f follow mode
41
54
  p problems u undo Nou's edit s settings
42
55
  c (in files) show only changed files Ctrl+C twice quit
43
56
  In the editor: Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
@@ -54,11 +67,30 @@ export function App({ initialModel, permContext }) {
54
67
  const { columns, rows } = useWindowSize();
55
68
  const [settings, setSettings] = useState(loadSettings);
56
69
  const [view, setView] = useState("main");
70
+ const [connectTo, setConnectTo] = useState();
57
71
  const [quickOpen, setQuickOpen] = useState(false);
58
72
  const [focus, setFocus] = useState("chat");
59
73
  const [follow, setFollow] = useState(settings.followMode);
60
74
  const [entries, setEntries] = useState([]);
61
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
+ };
62
94
  const [busy, setBusy] = useState(null);
63
95
  const [choice, setChoice] = useState(initialModel);
64
96
  const [approval, setApproval] = useState(null);
@@ -69,9 +101,13 @@ export function App({ initialModel, permContext }) {
69
101
  // The last change Nou made per file, so we can flag edited lines and re-open the diff.
70
102
  const [changes, setChanges] = useState(new Map());
71
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);
72
107
  const [editing, setEditing] = useState(false);
73
108
  const [git, setGit] = useState(() => gitStatus());
74
109
  const [plugins, setPlugins] = useState([]);
110
+ const [starters, setStarters] = useState([]);
75
111
  const [viewport, setViewport] = useState(null);
76
112
  const [editorState, setEditorState] = useState(null);
77
113
  // Unsaved editor text survives switching to a diff and back.
@@ -84,11 +120,16 @@ export function App({ initialModel, permContext }) {
84
120
  const [pendingNote, setPendingNote] = useState(null);
85
121
  const [exitArmed, setExitArmed] = useState(false);
86
122
  const [update, setUpdate] = useState(null);
123
+ const [wheel, setWheel] = useState(null);
124
+ const [treeClick, setTreeClick] = useState(null);
125
+ // Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
126
+ const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
127
+ const pullAbort = useRef(null);
87
128
  const diagnosticsRef = useRef(null);
88
129
  diagnosticsRef.current ??= new DiagnosticsManager();
89
130
  const diagnostics = diagnosticsRef.current;
90
131
  const timelineRef = useRef(null);
91
- timelineRef.current ??= new Timeline();
132
+ timelineRef.current ??= new Timeline(() => setHistoryVersion((v) => v + 1));
92
133
  const timeline = timelineRef.current;
93
134
  const followRef = useRef(follow);
94
135
  followRef.current = follow;
@@ -108,10 +149,13 @@ export function App({ initialModel, permContext }) {
108
149
  void diagnosticsRef.current.checkFile(filePath);
109
150
  }, []);
110
151
  const agentRef = useRef(null);
111
- if (!agentRef.current) {
112
- 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 = {
113
157
  onEntry: (entry) => setEntries((prev) => [...prev, entry]),
114
- onStream: setStreaming,
158
+ onStream: showStream,
115
159
  onBusy: setBusy,
116
160
  onRead: (filePath) => {
117
161
  if (!followRef.current)
@@ -147,11 +191,12 @@ export function App({ initialModel, permContext }) {
147
191
  }
148
192
  return new Promise((resolve) => setApproval({ request, resolve }));
149
193
  },
150
- checkChange: async (change) => {
151
- const introduced = await diagnosticsRef.current.introducedBy(change.path, change.before, change.after);
152
- return introduced.length ? introduced.slice(0, 15).map((d) => formatDiagnostic(d)).join("\n") : null;
153
- },
154
- });
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);
155
200
  }
156
201
  useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
157
202
  useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
@@ -164,9 +209,11 @@ export function App({ initialModel, permContext }) {
164
209
  const found = discoverPlugins();
165
210
  const saved = loadSettings();
166
211
  setPlugins(await Promise.all(found.map((p) => (isEnabled(p, saved) ? loadPlugin(p) : p))));
212
+ setStarters(await Promise.all(starterPlugins().map(loadPlugin)));
167
213
  })();
168
214
  }, []);
169
- 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]);
170
217
  const activePlugins = useMemo(() => allPlugins.filter((p) => isEnabled(p, settings) && p.plugin), [allPlugins, settings.plugins, settings.trustedPlugins]);
171
218
  // Checkers come from enabled plugins (built-in ones included).
172
219
  const firstCheck = useRef(true);
@@ -179,53 +226,24 @@ export function App({ initialModel, permContext }) {
179
226
  }, [activePlugins]);
180
227
  // Keep the agent's system prompt and tools in sync with settings and plugins.
181
228
  useEffect(() => {
182
- const extras = [];
183
- const extraTools = [];
184
- if (settings.gitContext && repo) {
185
- const summary = gitSummary();
186
- if (summary)
187
- extras.push(summary);
188
- extraTools.push(...gitTools());
189
- }
190
- for (const { plugin } of activePlugins) {
191
- if (plugin.systemPrompt)
192
- extras.push(`Plugin ${plugin.name}: ${plugin.systemPrompt}`);
193
- for (const tool of plugin.tools ?? []) {
194
- extraTools.push({
195
- definition: tool.definition,
196
- readOnly: !!tool.readOnly,
197
- source: plugin.name,
198
- run: (args) => tool.run(args, { cwd: process.cwd() }),
199
- });
200
- }
201
- }
202
- extraTools.push({
203
- source: "problems",
204
- readOnly: true,
205
- definition: {
206
- name: "get_diagnostics",
207
- description: "Get errors and warnings from the project's linters, type checker and language servers, for one file or the whole project.",
208
- parameters: { type: "object", properties: { path: { type: "string", description: "Optional file. Omit for the whole project." } }, required: [] },
209
- },
210
- run: async (args) => {
211
- const file = typeof args.path === "string" ? args.path : null;
212
- if (file)
213
- await diagnostics.checkFile(file);
214
- else
215
- await diagnostics.checkProject();
216
- const list = file ? diagnostics.get(file) : diagnostics.all();
217
- return list.length ? list.slice(0, 100).map((d) => formatDiagnostic(d, true)).join("\n") : "No problems found.";
218
- },
219
- });
220
- 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);
221
232
  }, [settings.gitContext, settings.effort, activePlugins]);
222
233
  // Pick up changes made outside Nou (other editors, git commands).
234
+ const polling = useRef(false);
223
235
  useEffect(() => {
224
236
  if (!repo)
225
237
  return;
226
238
  const timer = setInterval(() => {
227
- if (!busy)
228
- 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
+ });
229
247
  }, 4000);
230
248
  return () => clearInterval(timer);
231
249
  }, [repo, busy]);
@@ -247,13 +265,94 @@ export function App({ initialModel, permContext }) {
247
265
  setChoice(next);
248
266
  saveModel(next);
249
267
  setSettings(loadSettings());
250
- 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."}` }]);
251
271
  return true;
252
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
+ };
253
347
  const togglePlugin = async (id, enabled) => {
254
348
  const entry = allPlugins.find((p) => p.id === id);
255
349
  if (!entry)
256
350
  return;
351
+ if (entry.scope === "starter") {
352
+ if (enabled)
353
+ await addStarterPlugin(entry);
354
+ return;
355
+ }
257
356
  updateSettings(trustPatch(entry, enabled, settings));
258
357
  if (!enabled)
259
358
  return;
@@ -269,7 +368,12 @@ export function App({ initialModel, permContext }) {
269
368
  const record = openFile ? changes.get(openFile) : undefined;
270
369
  const pendingChange = approval?.request.kind === "change" ? approval.request.change : null;
271
370
  const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
272
- 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);
273
377
  const showEditor = editing && !!openFile && !pendingChange;
274
378
  // Check Nou's proposed edit before you approve it: does it add problems?
275
379
  useEffect(() => {
@@ -311,11 +415,32 @@ export function App({ initialModel, permContext }) {
311
415
  setExitArmed(true);
312
416
  setTimeout(() => setExitArmed(false), 2000);
313
417
  };
418
+ // Mouse: the wheel scrolls whatever pane is under the pointer; a click focuses that pane
419
+ // (and in the file tree, opens the clicked file).
420
+ useMouse((event) => {
421
+ if (view !== "main" || quickOpen)
422
+ return;
423
+ const { treeWidth, codeWidth, paneHeight } = layout.current;
424
+ if (event.y < 1 || event.y > paneHeight)
425
+ return; // header or status bar
426
+ const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
427
+ if (event.kind === "wheel") {
428
+ setWheel((w) => ({ pane, dir: event.dir, nonce: (w?.nonce ?? 0) + 1 }));
429
+ return;
430
+ }
431
+ if (approval)
432
+ return;
433
+ setFocus(pane);
434
+ // Rows start under the pane's top border (y=1) and title (y=2).
435
+ if (pane === "tree" && event.y >= 3)
436
+ setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
437
+ }, settings.mouse);
314
438
  // Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
315
439
  useInput((input, key) => {
316
440
  if (!(key.ctrl && input === "c"))
317
441
  return;
318
442
  if (busy || approval) {
443
+ pullAbort.current?.abort();
319
444
  agentRef.current?.cancel();
320
445
  approval?.resolve(false);
321
446
  setApproval(null);
@@ -369,6 +494,65 @@ export function App({ initialModel, permContext }) {
369
494
  return ` ${time} ${c.source === "nou" ? "Nou" : "you"} ${c.before === null ? "created " : ""}${c.path}${c.undone ? " (undone)" : ""}`;
370
495
  }).join("\n")}\nUndo with /undo (last) or /rewind (whole last turn).`);
371
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
+ };
372
556
  /**
373
557
  * Before editing another file: an unsaved draft of a different file is saved (auto-save on) or kept and
374
558
  * the switch refused (auto-save off). Never silently dropped. Returns whether it's OK to continue.
@@ -417,6 +601,8 @@ export function App({ initialModel, permContext }) {
417
601
  order.push("git");
418
602
  if (record?.status === "applied")
419
603
  order.push("nou");
604
+ if (sessionChange)
605
+ order.push("session");
420
606
  if (order.length === 1) {
421
607
  setEntries((prev) => [...prev, { kind: "info", text: `${openFile} has no changes vs git HEAD.` }]);
422
608
  return;
@@ -448,10 +634,14 @@ export function App({ initialModel, permContext }) {
448
634
  return;
449
635
  }
450
636
  if (key.escape) {
451
- if (busy)
637
+ if (busy) {
638
+ pullAbort.current?.abort();
452
639
  agentRef.current?.cancel();
453
- else if (diffMode !== "off")
640
+ }
641
+ else if (diffMode !== "off") {
454
642
  setDiffMode("off");
643
+ setSessionView(null);
644
+ }
455
645
  else
456
646
  setFocus("chat");
457
647
  return;
@@ -469,6 +659,8 @@ export function App({ initialModel, permContext }) {
469
659
  undoLast();
470
660
  else if (input === "v")
471
661
  cycleDiff();
662
+ else if ((input === "]" || input === "[") && diffMode === "session")
663
+ stepSessionFile(input === "]" ? 1 : -1);
472
664
  else if (input === "e")
473
665
  startEditing();
474
666
  else if (input === "s" || input === ",")
@@ -508,6 +700,36 @@ ${snippet}
508
700
  const contextChip = openFile
509
701
  ? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ✎ unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
510
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
+ };
511
733
  const runCommand = async (input) => {
512
734
  const [command, ...rest] = input.slice(1).split(/\s+/);
513
735
  const arg = rest.join(" ").trim();
@@ -518,13 +740,29 @@ ${snippet}
518
740
  return rewind();
519
741
  case "history":
520
742
  return showHistory();
743
+ case "diff":
744
+ return showSessionDiff(arg);
745
+ case "sessions":
746
+ return showSessions();
521
747
  case "problems":
522
748
  setCodePanel("problems");
523
749
  setFocus("code");
524
750
  return;
525
751
  case "settings":
752
+ setConnectTo(undefined);
526
753
  setView("settings");
527
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);
763
+ setView("settings");
764
+ return;
765
+ }
528
766
  case "model": {
529
767
  if (!arg) {
530
768
  const list = providers.map((p) => {
@@ -534,7 +772,23 @@ ${snippet}
534
772
  info(`Current: ${describe(choice)}\n${list.join("\n")}\nSwitch with /model <provider[:model]> or open /settings`);
535
773
  return;
536
774
  }
537
- 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}`);
538
792
  return;
539
793
  }
540
794
  case "models": {
@@ -554,10 +808,33 @@ ${snippet}
554
808
  }
555
809
  return;
556
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
+ }
557
830
  case "clear":
558
831
  agentRef.current.clear();
559
832
  setEntries([]);
560
833
  return;
834
+ case "plugins":
835
+ return pluginsCommand(rest);
836
+ case "version":
837
+ return info(`nou v${currentVersion()}${update ? ` · v${update} is available, run \`nou update\`` : " · up to date"}`);
561
838
  case "help":
562
839
  return info(HELP);
563
840
  default: {
@@ -609,15 +886,17 @@ ${snippet}
609
886
  }
610
887
  }
611
888
  if (view === "settings") {
612
- 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: () => {
613
- const found = discoverPlugins();
614
- setPlugins((prev) => found.map((f) => prev.find((p) => p.id === f.id && p.plugin) ?? f));
615
- }, 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
+ } }));
616
893
  }
617
894
  const height = Math.max(10, rows - 2);
618
895
  const treeWidth = Math.max(24, Math.floor(columns * 0.2));
619
896
  const chatWidth = Math.max(34, Math.floor(columns * 0.3));
620
897
  const codeWidth = Math.max(20, columns - treeWidth - chatWidth);
898
+ layout.current = { treeWidth, codeWidth, paneHeight: height };
899
+ const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
621
900
  const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
622
901
  const fileProblems = openFile ? diagnostics.get(openFile) : [];
623
902
  const totals = diagnostics.counts();
@@ -634,14 +913,14 @@ ${snippet}
634
913
  : showEditor && focus === "code"
635
914
  ? `Ctrl+S save${settings.autoSave === "off" ? "" : " (auto-save on)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close`
636
915
  : focus === "chat"
637
- ? busy ? "Esc stop · Tab switch pane" : "Enter send · / commands · Ctrl+P find file · Tab switch pane"
916
+ ? busy ? "Esc stop · Shift+↑↓ scroll chat · Tab switch pane" : "Enter send · Shift+↑↓ / PgUp scroll · / commands · Ctrl+P find file · Tab switch pane"
638
917
  : focus === "tree"
639
918
  ? "↑↓ move · Enter open · c changes · e edit · p problems · u undo · Ctrl+P find · s settings"
640
- : diff ? "n/N next/prev change · v cycle diff · Esc close diff" : "↑↓ scroll · e edit · v diff · p problems · u undo · s settings";
641
- 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(), 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) => {
642
921
  open(filePath);
643
922
  setFocus("code");
644
- }, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code" && !approval, problems: diagnostics.all(), checking: checking, 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, onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
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) => {
645
924
  if (!lspEnabled)
646
925
  return info("Go to definition needs a language server (see /settings → Plugins).", "error");
647
926
  void lsp.definition(openFile, text, line, col).then((target) => {
@@ -660,7 +939,7 @@ ${snippet}
660
939
  draft.current = state.dirty
661
940
  ? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
662
941
  : null;
663
- } }, `${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, 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, 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
664
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)` : ""] })
665
944
  : _jsxs(Text, { color: C.dim, children: [" ", hints] })] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
666
945
  setQuickOpen(false);