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.
- package/.env.example +12 -0
- package/README.md +40 -6
- package/dist/index.js +22 -2
- package/dist/src/agent/harness.js +54 -0
- package/dist/src/agent/index.js +159 -15
- package/dist/src/commands/index.js +54 -5
- package/dist/src/config/credentials.js +48 -0
- package/dist/src/config/index.js +2 -0
- package/dist/src/config/origins.js +14 -0
- package/dist/src/context/index.js +1 -1
- package/dist/src/engines/claude.js +147 -0
- package/dist/src/engines/codex.js +131 -0
- package/dist/src/engines/external.js +152 -0
- package/dist/src/engines/index.js +127 -0
- package/dist/src/engines/pi.js +161 -0
- package/dist/src/engines/types.js +1 -0
- package/dist/src/env.js +5 -1
- package/dist/src/git/index.js +14 -4
- package/dist/src/history/index.js +53 -1
- package/dist/src/plugins/index.js +109 -21
- package/dist/src/providers/anthropic.js +16 -1
- package/dist/src/providers/ollama.js +58 -1
- package/dist/src/providers/openai.js +22 -5
- package/dist/src/search/index.js +183 -0
- package/dist/src/setup/index.js +240 -0
- package/dist/src/tools/index.js +72 -2
- package/dist/src/ui/App.js +318 -69
- package/dist/src/ui/Settings.js +92 -14
- package/dist/src/ui/text.js +12 -0
- package/package.json +4 -2
- package/plugins/fetch-url.mjs +82 -0
- package/plugins/project-rules.mjs +43 -0
- package/plugins/run-tests.mjs +89 -0
- package/plugins/todos.mjs +81 -0
package/dist/src/ui/App.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
10
|
-
import {
|
|
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
|
-
|
|
118
|
-
|
|
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:
|
|
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:
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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 (
|
|
234
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
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 ?
|
|
671
|
-
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: describe(choice)
|
|
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);
|