nou-cli 0.2.1 → 0.4.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 +13 -0
- package/README.md +47 -10
- package/dist/index.js +22 -2
- package/dist/src/agent/harness.js +54 -0
- package/dist/src/agent/index.js +162 -17
- package/dist/src/commands/index.js +54 -5
- package/dist/src/config/credentials.js +48 -0
- package/dist/src/config/index.js +4 -1
- 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/index.js +16 -0
- package/dist/src/providers/ollama.js +58 -1
- package/dist/src/providers/openai.js +26 -6
- package/dist/src/search/index.js +183 -0
- package/dist/src/setup/index.js +240 -0
- package/dist/src/syntax/index.js +11 -2
- package/dist/src/tools/index.js +72 -2
- package/dist/src/ui/App.js +452 -93
- package/dist/src/ui/Chat.js +19 -10
- package/dist/src/ui/CodeView.js +1 -1
- package/dist/src/ui/Editor.js +1 -1
- package/dist/src/ui/FileTree.js +12 -7
- package/dist/src/ui/Settings.js +91 -15
- package/dist/src/ui/clipboard.js +29 -0
- package/dist/src/ui/mouse.js +4 -2
- package/dist/src/ui/text.js +167 -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";
|
|
@@ -22,24 +24,41 @@ import { FileTree } from "./FileTree.js";
|
|
|
22
24
|
import { CodeView } from "./CodeView.js";
|
|
23
25
|
import { Editor } from "./Editor.js";
|
|
24
26
|
import { Chat } from "./Chat.js";
|
|
27
|
+
import { copyToClipboard } from "./clipboard.js";
|
|
25
28
|
import { Header } from "./Header.js";
|
|
26
29
|
import { QuickOpen, listAllFiles } from "./QuickOpen.js";
|
|
27
30
|
import { Settings } from "./Settings.js";
|
|
28
31
|
const FOCUS_ORDER = ["tree", "code", "chat"];
|
|
32
|
+
const MIN_TREE = 14;
|
|
33
|
+
const MIN_CODE = 20;
|
|
34
|
+
const MIN_CHAT = 24;
|
|
29
35
|
const NO_LINES = new Set();
|
|
30
36
|
const HELP = `Commands:
|
|
31
37
|
/settings models, preferences and plugins
|
|
32
38
|
/model show providers and the current model
|
|
33
39
|
/model <provider[:model]> switch, e.g. /model anthropic /model ollama:qwen3:8b
|
|
34
40
|
/models [provider] list models a provider offers
|
|
41
|
+
/connect <provider> enter an API key (and URL), e.g. /connect fireworks /connect litellm
|
|
42
|
+
/pull <model> download an Ollama model
|
|
43
|
+
/engine [name] which agent answers: nou, claude-code, codex or pi
|
|
44
|
+
/auto [ask|auto-edit|auto] how much Nou does without asking (also: Shift+Tab)
|
|
45
|
+
/copy [code|file] copy the last reply, its last code block, or the open file
|
|
46
|
+
/plugins your plugins and the starter ones you can add
|
|
47
|
+
/plugins add <name> add a starter plugin (copied to ~/.nou/plugins to edit)
|
|
48
|
+
/plugins new <name> start your own plugin in .nou/plugins/
|
|
35
49
|
/clear start a fresh conversation
|
|
50
|
+
/compact summarize the conversation so far, to free up context
|
|
36
51
|
/undo undo Nou's last change (also: u)
|
|
37
52
|
/rewind undo everything from Nou's last turn
|
|
38
53
|
/history recent changes
|
|
54
|
+
/diff [file] everything changed this session ([ ] step through files)
|
|
55
|
+
/sessions earlier sessions; /diff <n> shows one of them
|
|
39
56
|
/version which version of Nou this is
|
|
40
57
|
Keys:
|
|
41
58
|
Ctrl+P find a file Tab switch pane Esc stop / back
|
|
42
|
-
|
|
59
|
+
Shift+Tab ask / auto-edit / auto < > resize pane = reset
|
|
60
|
+
m mouse on/off (off: select text to copy; Shift+drag also works in most terminals)
|
|
61
|
+
e edit open file v diff (git / Nou / session) f follow mode
|
|
43
62
|
p problems u undo Nou's edit s settings
|
|
44
63
|
c (in files) show only changed files Ctrl+C twice quit
|
|
45
64
|
In the editor: Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
|
|
@@ -56,11 +75,30 @@ export function App({ initialModel, permContext }) {
|
|
|
56
75
|
const { columns, rows } = useWindowSize();
|
|
57
76
|
const [settings, setSettings] = useState(loadSettings);
|
|
58
77
|
const [view, setView] = useState("main");
|
|
78
|
+
const [connectTo, setConnectTo] = useState();
|
|
59
79
|
const [quickOpen, setQuickOpen] = useState(false);
|
|
60
80
|
const [focus, setFocus] = useState("chat");
|
|
61
81
|
const [follow, setFollow] = useState(settings.followMode);
|
|
62
82
|
const [entries, setEntries] = useState([]);
|
|
63
83
|
const [streaming, setStreaming] = useState("");
|
|
84
|
+
// Streamed text arrives a token at a time; redrawing the whole screen for each one is wasted work.
|
|
85
|
+
const streamed = useRef({ text: "", timer: null });
|
|
86
|
+
const showStream = (text) => {
|
|
87
|
+
const s = streamed.current;
|
|
88
|
+
s.text = text;
|
|
89
|
+
if (!text) {
|
|
90
|
+
if (s.timer)
|
|
91
|
+
clearTimeout(s.timer);
|
|
92
|
+
s.timer = null;
|
|
93
|
+
setStreaming("");
|
|
94
|
+
}
|
|
95
|
+
else if (!s.timer) {
|
|
96
|
+
s.timer = setTimeout(() => {
|
|
97
|
+
s.timer = null;
|
|
98
|
+
setStreaming(s.text);
|
|
99
|
+
}, 33);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
64
102
|
const [busy, setBusy] = useState(null);
|
|
65
103
|
const [choice, setChoice] = useState(initialModel);
|
|
66
104
|
const [approval, setApproval] = useState(null);
|
|
@@ -71,9 +109,13 @@ export function App({ initialModel, permContext }) {
|
|
|
71
109
|
// The last change Nou made per file, so we can flag edited lines and re-open the diff.
|
|
72
110
|
const [changes, setChanges] = useState(new Map());
|
|
73
111
|
const [diffMode, setDiffMode] = useState("off");
|
|
112
|
+
// Which session the "session" diff shows (null: this one), and a counter that bumps when history changes.
|
|
113
|
+
const [sessionView, setSessionView] = useState(null);
|
|
114
|
+
const [historyVersion, setHistoryVersion] = useState(0);
|
|
74
115
|
const [editing, setEditing] = useState(false);
|
|
75
116
|
const [git, setGit] = useState(() => gitStatus());
|
|
76
117
|
const [plugins, setPlugins] = useState([]);
|
|
118
|
+
const [starters, setStarters] = useState([]);
|
|
77
119
|
const [viewport, setViewport] = useState(null);
|
|
78
120
|
const [editorState, setEditorState] = useState(null);
|
|
79
121
|
// Unsaved editor text survives switching to a diff and back.
|
|
@@ -90,13 +132,21 @@ export function App({ initialModel, permContext }) {
|
|
|
90
132
|
const [treeClick, setTreeClick] = useState(null);
|
|
91
133
|
// Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
|
|
92
134
|
const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
|
|
135
|
+
/** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
|
|
136
|
+
const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
|
|
137
|
+
const paneWidthsRef = useRef(paneWidths);
|
|
138
|
+
paneWidthsRef.current = paneWidths;
|
|
139
|
+
const dragging = useRef(null);
|
|
140
|
+
const pullAbort = useRef(null);
|
|
93
141
|
const diagnosticsRef = useRef(null);
|
|
94
142
|
diagnosticsRef.current ??= new DiagnosticsManager();
|
|
95
143
|
const diagnostics = diagnosticsRef.current;
|
|
96
144
|
const timelineRef = useRef(null);
|
|
97
|
-
timelineRef.current ??= new Timeline();
|
|
145
|
+
timelineRef.current ??= new Timeline(() => setHistoryVersion((v) => v + 1));
|
|
98
146
|
const timeline = timelineRef.current;
|
|
99
147
|
const followRef = useRef(follow);
|
|
148
|
+
/** Whether the editor is open, for the agent hooks (which outlive renders). */
|
|
149
|
+
const draftActiveRef = useRef(false);
|
|
100
150
|
followRef.current = follow;
|
|
101
151
|
const settingsRef = useRef(settings);
|
|
102
152
|
settingsRef.current = settings;
|
|
@@ -114,10 +164,13 @@ export function App({ initialModel, permContext }) {
|
|
|
114
164
|
void diagnosticsRef.current.checkFile(filePath);
|
|
115
165
|
}, []);
|
|
116
166
|
const agentRef = useRef(null);
|
|
117
|
-
|
|
118
|
-
|
|
167
|
+
const hooksRef = useRef(null);
|
|
168
|
+
/** What the engine was last configured with, to configure a new one the same way. */
|
|
169
|
+
const engineConfig = useRef({});
|
|
170
|
+
if (!hooksRef.current) {
|
|
171
|
+
hooksRef.current = {
|
|
119
172
|
onEntry: (entry) => setEntries((prev) => [...prev, entry]),
|
|
120
|
-
onStream:
|
|
173
|
+
onStream: showStream,
|
|
121
174
|
onBusy: setBusy,
|
|
122
175
|
onRead: (filePath) => {
|
|
123
176
|
if (!followRef.current)
|
|
@@ -131,8 +184,8 @@ export function App({ initialModel, permContext }) {
|
|
|
131
184
|
}
|
|
132
185
|
const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
|
|
133
186
|
setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
|
|
134
|
-
// Diffs awaiting approval are
|
|
135
|
-
if (status === "pending" || followRef.current) {
|
|
187
|
+
// Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
|
|
188
|
+
if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
|
|
136
189
|
setOpenFile(change.path);
|
|
137
190
|
setContent(status === "applied" ? change.after : readText(change.path));
|
|
138
191
|
setDiffMode("off");
|
|
@@ -153,11 +206,12 @@ export function App({ initialModel, permContext }) {
|
|
|
153
206
|
}
|
|
154
207
|
return new Promise((resolve) => setApproval({ request, resolve }));
|
|
155
208
|
},
|
|
156
|
-
checkChange:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
209
|
+
checkChange: (change) => problemsIntroduced(diagnosticsRef.current, change),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (!agentRef.current) {
|
|
213
|
+
const saved = loadSettings().engine;
|
|
214
|
+
agentRef.current = createEngine(isEngineId(saved) ? saved : "nou", initialModel, buildSystemPrompt(permContext), hooksRef.current);
|
|
161
215
|
}
|
|
162
216
|
useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
|
|
163
217
|
useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
|
|
@@ -170,9 +224,11 @@ export function App({ initialModel, permContext }) {
|
|
|
170
224
|
const found = discoverPlugins();
|
|
171
225
|
const saved = loadSettings();
|
|
172
226
|
setPlugins(await Promise.all(found.map((p) => (isEnabled(p, saved) ? loadPlugin(p) : p))));
|
|
227
|
+
setStarters(await Promise.all(starterPlugins().map(loadPlugin)));
|
|
173
228
|
})();
|
|
174
229
|
}, []);
|
|
175
|
-
|
|
230
|
+
// Starter plugins are listed until you add them (or have your own plugin with the same name).
|
|
231
|
+
const allPlugins = useMemo(() => [...BUILTIN_PLUGINS, ...plugins, ...starters.filter((s) => !plugins.some((p) => p.id === s.id))], [plugins, starters]);
|
|
176
232
|
const activePlugins = useMemo(() => allPlugins.filter((p) => isEnabled(p, settings) && p.plugin), [allPlugins, settings.plugins, settings.trustedPlugins]);
|
|
177
233
|
// Checkers come from enabled plugins (built-in ones included).
|
|
178
234
|
const firstCheck = useRef(true);
|
|
@@ -185,53 +241,24 @@ export function App({ initialModel, permContext }) {
|
|
|
185
241
|
}, [activePlugins]);
|
|
186
242
|
// Keep the agent's system prompt and tools in sync with settings and plugins.
|
|
187
243
|
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 });
|
|
244
|
+
const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
|
|
245
|
+
engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, permContext].filter(Boolean), extraTools, effort: settings.effort };
|
|
246
|
+
agentRef.current.configure(engineConfig.current);
|
|
227
247
|
}, [settings.gitContext, settings.effort, activePlugins]);
|
|
228
248
|
// Pick up changes made outside Nou (other editors, git commands).
|
|
249
|
+
const polling = useRef(false);
|
|
229
250
|
useEffect(() => {
|
|
230
251
|
if (!repo)
|
|
231
252
|
return;
|
|
232
253
|
const timer = setInterval(() => {
|
|
233
|
-
if (
|
|
234
|
-
|
|
254
|
+
if (busy || polling.current)
|
|
255
|
+
return;
|
|
256
|
+
polling.current = true;
|
|
257
|
+
void gitStatusAsync().then((next) => {
|
|
258
|
+
polling.current = false;
|
|
259
|
+
// Only redraw when something changed.
|
|
260
|
+
setGit((prev) => (sameStatus(prev, next) ? prev : next));
|
|
261
|
+
});
|
|
235
262
|
}, 4000);
|
|
236
263
|
return () => clearInterval(timer);
|
|
237
264
|
}, [repo, busy]);
|
|
@@ -253,13 +280,95 @@ export function App({ initialModel, permContext }) {
|
|
|
253
280
|
setChoice(next);
|
|
254
281
|
saveModel(next);
|
|
255
282
|
setSettings(loadSettings());
|
|
256
|
-
|
|
283
|
+
const engine = agentRef.current;
|
|
284
|
+
const carries = engine.id === "nou" || engine.id === "claude-code";
|
|
285
|
+
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
286
|
return true;
|
|
258
287
|
};
|
|
288
|
+
/** Switches the agent engine, installing it first if needed (Esc stops the install). Starts a new conversation. */
|
|
289
|
+
const switchEngine = async (id) => {
|
|
290
|
+
if (busy)
|
|
291
|
+
return info("Wait for the current reply to finish (or press Esc) before switching engines.", "error");
|
|
292
|
+
const target = engineInfo(id);
|
|
293
|
+
if (agentRef.current.id === id)
|
|
294
|
+
return info(`Already using ${target.label}.`);
|
|
295
|
+
if (!isInstalled(target)) {
|
|
296
|
+
const controller = new AbortController();
|
|
297
|
+
pullAbort.current = controller;
|
|
298
|
+
info(`${target.label} isn't installed yet, so Nou is installing it now: npm install ${target.package} into ~/.nou/engines. This happens once and can take a minute. Esc to stop.`);
|
|
299
|
+
setBusy(`Installing the ${target.label} engine (npm)...`);
|
|
300
|
+
try {
|
|
301
|
+
await installEngine(target, (line) => setBusy(`Installing the ${target.label} engine (npm): ${line.slice(0, 70)}`), controller.signal);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
return info(controller.signal.aborted ? `Stopped installing ${target.label}. Nothing changed; you're still on ${engineInfo(agentRef.current.id).label}.` : `Couldn't install ${target.label}: ${error.message}`, "error");
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
pullAbort.current = null;
|
|
308
|
+
setBusy(null);
|
|
309
|
+
}
|
|
310
|
+
info(`Installed ${target.label} (${target.package}).`);
|
|
311
|
+
}
|
|
312
|
+
agentRef.current.dispose?.();
|
|
313
|
+
const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
|
|
314
|
+
engine.configure(engineConfig.current);
|
|
315
|
+
agentRef.current = engine;
|
|
316
|
+
updateSettings({ engine: id });
|
|
317
|
+
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." : ""}`);
|
|
318
|
+
};
|
|
319
|
+
/** Downloads an Ollama model, showing progress in the status line. Esc stops it. */
|
|
320
|
+
const pullModel = async (model) => {
|
|
321
|
+
const ollama = providers.find((p) => p.id === "ollama");
|
|
322
|
+
const controller = new AbortController();
|
|
323
|
+
pullAbort.current = controller;
|
|
324
|
+
setBusy(`Downloading ${model}...`);
|
|
325
|
+
try {
|
|
326
|
+
await ollama.pull(model, (status, percent) => setBusy(`Downloading ${model}: ${percent === null ? status : `${percent}%`}`), controller.signal);
|
|
327
|
+
info(`Downloaded ${model}.`);
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
info(controller.signal.aborted ? `Stopped downloading ${model}.` : `Couldn't download ${model}: ${error.message}`, "error");
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
pullAbort.current = null;
|
|
336
|
+
setBusy(null);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
/** Switches model, downloading it first if it's an Ollama model that isn't installed yet. */
|
|
340
|
+
const chooseModel = async (next) => {
|
|
341
|
+
if (next.provider.pull) {
|
|
342
|
+
const installed = await next.provider.listModels().catch(() => null);
|
|
343
|
+
if (installed && !installed.some((m) => sameModel(m, next.model))) {
|
|
344
|
+
info(`${next.model} isn't downloaded yet; downloading it now (Esc to stop).`);
|
|
345
|
+
if (!(await pullModel(next.model)))
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
switchModel(next);
|
|
350
|
+
};
|
|
351
|
+
/** Copies a starter plugin into ~/.nou/plugins/, switches it on and loads it. */
|
|
352
|
+
const addStarterPlugin = async (starter) => {
|
|
353
|
+
const added = addStarter(starter);
|
|
354
|
+
updateSettings(trustPatch(added, true, settings));
|
|
355
|
+
const loaded = await loadPlugin(added);
|
|
356
|
+
setPlugins((prev) => [...prev.filter((p) => p.id !== added.id), loaded]);
|
|
357
|
+
return loaded;
|
|
358
|
+
};
|
|
359
|
+
const rescanPlugins = () => {
|
|
360
|
+
const found = discoverPlugins();
|
|
361
|
+
setPlugins((prev) => found.map((f) => prev.find((p) => p.id === f.id && p.plugin) ?? f));
|
|
362
|
+
};
|
|
259
363
|
const togglePlugin = async (id, enabled) => {
|
|
260
364
|
const entry = allPlugins.find((p) => p.id === id);
|
|
261
365
|
if (!entry)
|
|
262
366
|
return;
|
|
367
|
+
if (entry.scope === "starter") {
|
|
368
|
+
if (enabled)
|
|
369
|
+
await addStarterPlugin(entry);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
263
372
|
updateSettings(trustPatch(entry, enabled, settings));
|
|
264
373
|
if (!enabled)
|
|
265
374
|
return;
|
|
@@ -272,11 +381,40 @@ export function App({ initialModel, permContext }) {
|
|
|
272
381
|
approval?.resolve(ok);
|
|
273
382
|
setApproval(null);
|
|
274
383
|
};
|
|
384
|
+
/** Shows the edit waiting for approval in the code view. */
|
|
385
|
+
const openPending = () => {
|
|
386
|
+
const file = approval?.request.kind === "change" ? approval.request.change.path : null;
|
|
387
|
+
if (!file || !settleDraft(file))
|
|
388
|
+
return;
|
|
389
|
+
setEditing(false);
|
|
390
|
+
open(file);
|
|
391
|
+
setReveal(file);
|
|
392
|
+
setFocus("code");
|
|
393
|
+
};
|
|
394
|
+
/** Ask → Auto-edit (edits apply without asking) → Auto (commands too) → Ask. Undo and diffs work the same in each. */
|
|
395
|
+
const autoMode = settings.autoApproveCommands ? "auto" : settings.autoApproveEdits ? "auto-edit" : "ask";
|
|
396
|
+
const cycleAutoMode = (to) => {
|
|
397
|
+
const next = to ?? (autoMode === "ask" ? "auto-edit" : autoMode === "auto-edit" ? "auto" : "ask");
|
|
398
|
+
updateSettings({ autoApproveEdits: next !== "ask", autoApproveCommands: next === "auto" });
|
|
399
|
+
// Whatever is waiting now falls under the new mode too.
|
|
400
|
+
const kind = approval?.request.kind;
|
|
401
|
+
if (kind && (next === "auto" || (next === "auto-edit" && kind === "change")))
|
|
402
|
+
answer(true);
|
|
403
|
+
info(next === "ask" ? "Ask mode: Nou asks before every edit and command."
|
|
404
|
+
: next === "auto-edit" ? "Auto-edit mode: edits apply without asking; Nou still asks before commands. Review with the diff view, u to undo."
|
|
405
|
+
: "Auto mode: edits and commands run without asking. Review with the diff view, u to undo. Shift+Tab to change.");
|
|
406
|
+
};
|
|
275
407
|
const record = openFile ? changes.get(openFile) : undefined;
|
|
276
408
|
const pendingChange = approval?.request.kind === "change" ? approval.request.change : null;
|
|
277
409
|
const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
|
|
278
|
-
const
|
|
279
|
-
const
|
|
410
|
+
const sessionId = sessionView?.id ?? agentRef.current.session;
|
|
411
|
+
const sessionFiles = useMemo(() => timeline.sessionChanges(sessionId), [sessionId, historyVersion]);
|
|
412
|
+
const sessionChange = openFile ? sessionFiles.find((c) => c.path === openFile) ?? null : null;
|
|
413
|
+
const diff = (pendingChange && pendingChange.path === openFile ? pendingChange : null) ?? (diffMode === "git" ? gitChange
|
|
414
|
+
: diffMode === "nou" && record ? record.change
|
|
415
|
+
: diffMode === "session" ? sessionChange : null);
|
|
416
|
+
const showEditor = editing && !!openFile && !(pendingChange && pendingChange.path === openFile);
|
|
417
|
+
draftActiveRef.current = showEditor;
|
|
280
418
|
// Check Nou's proposed edit before you approve it: does it add problems?
|
|
281
419
|
useEffect(() => {
|
|
282
420
|
setPendingNote(null);
|
|
@@ -317,14 +455,37 @@ export function App({ initialModel, permContext }) {
|
|
|
317
455
|
setExitArmed(true);
|
|
318
456
|
setTimeout(() => setExitArmed(false), 2000);
|
|
319
457
|
};
|
|
458
|
+
/** Moves a pane border to column `x` and keeps every pane usable. */
|
|
459
|
+
const moveBorder = (border, x) => {
|
|
460
|
+
const { treeWidth, codeWidth } = layout.current;
|
|
461
|
+
const chatWidth = columns - treeWidth - codeWidth;
|
|
462
|
+
const tree = border === "tree" ? Math.min(Math.max(x + 1, MIN_TREE), columns - chatWidth - MIN_CODE) : treeWidth;
|
|
463
|
+
const chat = border === "chat" ? Math.min(Math.max(columns - x, MIN_CHAT), columns - treeWidth - MIN_CODE) : chatWidth;
|
|
464
|
+
const next = { tree: tree / columns, chat: chat / columns };
|
|
465
|
+
setPaneWidths(next);
|
|
466
|
+
return next;
|
|
467
|
+
};
|
|
320
468
|
// Mouse: the wheel scrolls whatever pane is under the pointer; a click focuses that pane
|
|
321
|
-
// (and in the file tree, opens the clicked file).
|
|
469
|
+
// (and in the file tree, opens the clicked file). Dragging a border between panes resizes them.
|
|
322
470
|
useMouse((event) => {
|
|
323
471
|
if (view !== "main" || quickOpen)
|
|
324
472
|
return;
|
|
325
473
|
const { treeWidth, codeWidth, paneHeight } = layout.current;
|
|
474
|
+
if (event.kind === "drag")
|
|
475
|
+
return void (dragging.current && moveBorder(dragging.current, event.x));
|
|
476
|
+
if (event.kind === "release") {
|
|
477
|
+
if (dragging.current)
|
|
478
|
+
updateSettings({ paneWidths: paneWidthsRef.current });
|
|
479
|
+
dragging.current = null;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
326
482
|
if (event.y < 1 || event.y > paneHeight)
|
|
327
483
|
return; // header or status bar
|
|
484
|
+
// Each border is two columns: the left pane's edge and the right pane's.
|
|
485
|
+
if (event.kind === "click" && Math.abs(event.x - (treeWidth - 0.5)) <= 1)
|
|
486
|
+
return void (dragging.current = "tree");
|
|
487
|
+
if (event.kind === "click" && Math.abs(event.x - (treeWidth + codeWidth - 0.5)) <= 1)
|
|
488
|
+
return void (dragging.current = "chat");
|
|
328
489
|
const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
|
|
329
490
|
if (event.kind === "wheel") {
|
|
330
491
|
setWheel((w) => ({ pane, dir: event.dir, nonce: (w?.nonce ?? 0) + 1 }));
|
|
@@ -342,6 +503,7 @@ export function App({ initialModel, permContext }) {
|
|
|
342
503
|
if (!(key.ctrl && input === "c"))
|
|
343
504
|
return;
|
|
344
505
|
if (busy || approval) {
|
|
506
|
+
pullAbort.current?.abort();
|
|
345
507
|
agentRef.current?.cancel();
|
|
346
508
|
approval?.resolve(false);
|
|
347
509
|
setApproval(null);
|
|
@@ -395,6 +557,65 @@ export function App({ initialModel, permContext }) {
|
|
|
395
557
|
return ` ${time} ${c.source === "nou" ? "Nou" : "you"} ${c.before === null ? "created " : ""}${c.path}${c.undone ? " (undone)" : ""}`;
|
|
396
558
|
}).join("\n")}\nUndo with /undo (last) or /rewind (whole last turn).`);
|
|
397
559
|
};
|
|
560
|
+
/** Opens a file showing its net change over the session being viewed. */
|
|
561
|
+
const openSessionDiff = (file) => {
|
|
562
|
+
if (!settleDraft(file))
|
|
563
|
+
return;
|
|
564
|
+
open(file);
|
|
565
|
+
setReveal(file);
|
|
566
|
+
setDiffMode("session");
|
|
567
|
+
setFocus("code");
|
|
568
|
+
};
|
|
569
|
+
/** [ and ]: previous / next file in the session diff. */
|
|
570
|
+
const stepSessionFile = (step) => {
|
|
571
|
+
if (!sessionFiles.length)
|
|
572
|
+
return;
|
|
573
|
+
const at = sessionFiles.findIndex((c) => c.path === openFile);
|
|
574
|
+
const next = at < 0 ? 0 : (at + step + sessionFiles.length) % sessionFiles.length;
|
|
575
|
+
openSessionDiff(sessionFiles[next].path);
|
|
576
|
+
};
|
|
577
|
+
const sessionLabel = (s) => new Date(s.start).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
578
|
+
const showSessions = () => {
|
|
579
|
+
const list = timeline.sessions().slice(0, 15);
|
|
580
|
+
if (!list.length)
|
|
581
|
+
return info("No sessions with changes yet.");
|
|
582
|
+
info(`Sessions with changes (newest first):\n${list.map((s, i) => {
|
|
583
|
+
const current = s.id === agentRef.current.session ? " (this session)" : "";
|
|
584
|
+
return ` ${String(i + 1).padStart(2)}. ${sessionLabel(s)} ${s.files.length} file${s.files.length === 1 ? "" : "s"}${current}`;
|
|
585
|
+
}).join("\n")}\nShow one with /diff <number>.`);
|
|
586
|
+
};
|
|
587
|
+
/** /diff: this session; /diff <n>: the nth session from /sessions; /diff <file>: one file of the session being viewed. */
|
|
588
|
+
const showSessionDiff = (arg) => {
|
|
589
|
+
const current = agentRef.current.session;
|
|
590
|
+
let id = current;
|
|
591
|
+
let label = "this session";
|
|
592
|
+
let file = null;
|
|
593
|
+
if (/^\d+$/.test(arg)) {
|
|
594
|
+
const picked = timeline.sessions()[Number(arg) - 1];
|
|
595
|
+
if (!picked)
|
|
596
|
+
return info(`There's no session ${arg}. See /sessions.`, "error");
|
|
597
|
+
id = picked.id;
|
|
598
|
+
if (id !== current)
|
|
599
|
+
label = `session ${sessionLabel(picked)}`;
|
|
600
|
+
}
|
|
601
|
+
else if (arg) {
|
|
602
|
+
file = arg.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
603
|
+
if (sessionView)
|
|
604
|
+
({ id, label } = sessionView);
|
|
605
|
+
}
|
|
606
|
+
const files = timeline.sessionChanges(id);
|
|
607
|
+
setSessionView(id === current ? null : { id, label });
|
|
608
|
+
if (!files.length)
|
|
609
|
+
return info(`No changes in ${label}.`);
|
|
610
|
+
if (file && !files.some((c) => c.path === file))
|
|
611
|
+
return info(`${file} didn't change in ${label}.`, "error");
|
|
612
|
+
const lines = files.map((c) => {
|
|
613
|
+
const { added, removed } = lineCounts(c.before, c.after);
|
|
614
|
+
return ` ${c.isNew ? "A" : "M"} ${c.path} +${added} -${removed}`;
|
|
615
|
+
});
|
|
616
|
+
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.`);
|
|
617
|
+
openSessionDiff(file ?? files[0].path);
|
|
618
|
+
};
|
|
398
619
|
/**
|
|
399
620
|
* Before editing another file: an unsaved draft of a different file is saved (auto-save on) or kept and
|
|
400
621
|
* the switch refused (auto-save off). Never silently dropped. Returns whether it's OK to continue.
|
|
@@ -443,6 +664,8 @@ export function App({ initialModel, permContext }) {
|
|
|
443
664
|
order.push("git");
|
|
444
665
|
if (record?.status === "applied")
|
|
445
666
|
order.push("nou");
|
|
667
|
+
if (sessionChange)
|
|
668
|
+
order.push("session");
|
|
446
669
|
if (order.length === 1) {
|
|
447
670
|
setEntries((prev) => [...prev, { kind: "info", text: `${openFile} has no changes vs git HEAD.` }]);
|
|
448
671
|
return;
|
|
@@ -451,16 +674,17 @@ export function App({ initialModel, permContext }) {
|
|
|
451
674
|
setDiffMode((m) => order[(order.indexOf(m) + 1) % order.length]);
|
|
452
675
|
};
|
|
453
676
|
useInput((input, key) => {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
answer(false);
|
|
459
|
-
else if (key.escape) {
|
|
677
|
+
// A waiting approval doesn't freeze anything: y/n answer it from the tree or code view (the chat
|
|
678
|
+
// handles them itself when its input is empty), Esc stops the turn, and every other key works as usual.
|
|
679
|
+
if (approval && !(showEditor && focus === "code")) {
|
|
680
|
+
if (key.escape) {
|
|
460
681
|
agentRef.current?.cancel();
|
|
461
|
-
answer(false);
|
|
682
|
+
return answer(false);
|
|
462
683
|
}
|
|
463
|
-
|
|
684
|
+
if (focus !== "chat" && /^[yYnN]$/.test(input))
|
|
685
|
+
return answer(/[yY]/.test(input));
|
|
686
|
+
if (focus !== "chat" && input === "o" && pendingChange)
|
|
687
|
+
return openPending();
|
|
464
688
|
}
|
|
465
689
|
if (key.ctrl && input === "p") {
|
|
466
690
|
setQuickOpen(true);
|
|
@@ -468,16 +692,21 @@ export function App({ initialModel, permContext }) {
|
|
|
468
692
|
}
|
|
469
693
|
if (showEditor && focus === "code")
|
|
470
694
|
return; // the editor owns the keyboard
|
|
695
|
+
if (key.tab && key.shift)
|
|
696
|
+
return cycleAutoMode();
|
|
471
697
|
if (key.tab) {
|
|
472
|
-
|
|
473
|
-
setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + step + FOCUS_ORDER.length) % FOCUS_ORDER.length]);
|
|
698
|
+
setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]);
|
|
474
699
|
return;
|
|
475
700
|
}
|
|
476
701
|
if (key.escape) {
|
|
477
|
-
if (busy)
|
|
702
|
+
if (busy) {
|
|
703
|
+
pullAbort.current?.abort();
|
|
478
704
|
agentRef.current?.cancel();
|
|
479
|
-
|
|
705
|
+
}
|
|
706
|
+
else if (diffMode !== "off") {
|
|
480
707
|
setDiffMode("off");
|
|
708
|
+
setSessionView(null);
|
|
709
|
+
}
|
|
481
710
|
else
|
|
482
711
|
setFocus("chat");
|
|
483
712
|
return;
|
|
@@ -486,6 +715,10 @@ export function App({ initialModel, permContext }) {
|
|
|
486
715
|
return; // letters belong to the text box
|
|
487
716
|
if (input === "f")
|
|
488
717
|
setFollow((f) => !f);
|
|
718
|
+
else if (input === "m") {
|
|
719
|
+
updateSettings({ mouse: !settings.mouse });
|
|
720
|
+
info(settings.mouse ? "Mouse off: drag to select text and copy it as usual in your terminal. m turns it back on." : "Mouse on: wheel scrolls, click focuses, drag borders to resize.");
|
|
721
|
+
}
|
|
489
722
|
else if (input === "p") {
|
|
490
723
|
setCodePanel((p) => (p === "problems" ? "code" : "problems"));
|
|
491
724
|
setEditing(false);
|
|
@@ -493,8 +726,20 @@ export function App({ initialModel, permContext }) {
|
|
|
493
726
|
}
|
|
494
727
|
else if (input === "u")
|
|
495
728
|
undoLast();
|
|
729
|
+
else if (input === "<" || input === ">") {
|
|
730
|
+
// Moves the focused pane's inner border: the tree's right edge, or the code view's edge with the chat.
|
|
731
|
+
const { treeWidth, codeWidth } = layout.current;
|
|
732
|
+
const step = input === ">" ? 2 : -2;
|
|
733
|
+
updateSettings({ paneWidths: moveBorder(focus === "tree" ? "tree" : "chat", (focus === "tree" ? treeWidth - 1 : treeWidth + codeWidth) + step) });
|
|
734
|
+
}
|
|
735
|
+
else if (input === "=") {
|
|
736
|
+
setPaneWidths(null);
|
|
737
|
+
updateSettings({ paneWidths: null });
|
|
738
|
+
}
|
|
496
739
|
else if (input === "v")
|
|
497
740
|
cycleDiff();
|
|
741
|
+
else if ((input === "]" || input === "[") && diffMode === "session")
|
|
742
|
+
stepSessionFile(input === "]" ? 1 : -1);
|
|
498
743
|
else if (input === "e")
|
|
499
744
|
startEditing();
|
|
500
745
|
else if (input === "s" || input === ",")
|
|
@@ -534,6 +779,36 @@ ${snippet}
|
|
|
534
779
|
const contextChip = openFile
|
|
535
780
|
? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ✎ unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
|
|
536
781
|
: null;
|
|
782
|
+
const pluginsCommand = async ([action, name]) => {
|
|
783
|
+
if (action === "add") {
|
|
784
|
+
const starter = starters.find((s) => s.id === name);
|
|
785
|
+
if (!starter)
|
|
786
|
+
return info(`No starter plugin "${name ?? ""}". Starters: ${starters.map((s) => s.id).join(", ")}`, "error");
|
|
787
|
+
if (plugins.some((p) => p.id === starter.id))
|
|
788
|
+
return info(`You already have a plugin called ${starter.id}; see /settings → Plugins.`, "error");
|
|
789
|
+
const added = await addStarterPlugin(starter);
|
|
790
|
+
return added.error
|
|
791
|
+
? info(`Added ${added.file}, but it failed to load: ${added.error}`, "error")
|
|
792
|
+
: info(`Added and switched on ${added.plugin.name} (${added.file}). Edit that file to make it your own.`);
|
|
793
|
+
}
|
|
794
|
+
if (action === "new") {
|
|
795
|
+
try {
|
|
796
|
+
const file = scaffoldPlugin(name ?? "");
|
|
797
|
+
rescanPlugins();
|
|
798
|
+
return info(`Created ${path.relative(process.cwd(), file)}. Edit it, then switch it on in /settings → Plugins.`);
|
|
799
|
+
}
|
|
800
|
+
catch (error) {
|
|
801
|
+
return info(`/plugins new: ${error.message}`, "error");
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (action)
|
|
805
|
+
return info("Usage: /plugins, /plugins add <name>, /plugins new <name>", "error");
|
|
806
|
+
const mark = { on: "●", off: "○", changed: "⚠" };
|
|
807
|
+
const lines = allPlugins.map((p) => p.scope === "starter"
|
|
808
|
+
? ` + ${p.id.padEnd(16)} ${p.plugin?.description ?? ""}`
|
|
809
|
+
: ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✖ ${p.error}` : ""}`);
|
|
810
|
+
return info(`Plugins (● on, ○ off, + starter you can add with /plugins add <name>):\n${lines.join("\n")}\nWrite your own: /plugins new <name>`);
|
|
811
|
+
};
|
|
537
812
|
const runCommand = async (input) => {
|
|
538
813
|
const [command, ...rest] = input.slice(1).split(/\s+/);
|
|
539
814
|
const arg = rest.join(" ").trim();
|
|
@@ -544,13 +819,29 @@ ${snippet}
|
|
|
544
819
|
return rewind();
|
|
545
820
|
case "history":
|
|
546
821
|
return showHistory();
|
|
822
|
+
case "diff":
|
|
823
|
+
return showSessionDiff(arg);
|
|
824
|
+
case "sessions":
|
|
825
|
+
return showSessions();
|
|
547
826
|
case "problems":
|
|
548
827
|
setCodePanel("problems");
|
|
549
828
|
setFocus("code");
|
|
550
829
|
return;
|
|
551
830
|
case "settings":
|
|
831
|
+
setConnectTo(undefined);
|
|
832
|
+
setView("settings");
|
|
833
|
+
return;
|
|
834
|
+
case "connect": {
|
|
835
|
+
const provider = providers.find((p) => p.id === arg.toLowerCase());
|
|
836
|
+
const choices = providers.filter((p) => p.connect).map((p) => p.id).join(", ");
|
|
837
|
+
if (!provider)
|
|
838
|
+
return info(arg ? `Unknown provider "${arg}". Choose one of: ${choices}` : `Usage: /connect <provider>, one of: ${choices}`, "error");
|
|
839
|
+
if (!provider.connect)
|
|
840
|
+
return info(`${provider.label} needs nothing to connect.`);
|
|
841
|
+
setConnectTo(provider);
|
|
552
842
|
setView("settings");
|
|
553
843
|
return;
|
|
844
|
+
}
|
|
554
845
|
case "model": {
|
|
555
846
|
if (!arg) {
|
|
556
847
|
const list = providers.map((p) => {
|
|
@@ -560,7 +851,41 @@ ${snippet}
|
|
|
560
851
|
info(`Current: ${describe(choice)}\n${list.join("\n")}\nSwitch with /model <provider[:model]> or open /settings`);
|
|
561
852
|
return;
|
|
562
853
|
}
|
|
563
|
-
|
|
854
|
+
return chooseModel(resolveModel(arg));
|
|
855
|
+
}
|
|
856
|
+
case "copy": {
|
|
857
|
+
const reply = [...entries].reverse().find((e) => e.kind === "assistant")?.text;
|
|
858
|
+
const blocks = reply ? [...reply.matchAll(/```[^\n]*\n([\s\S]*?)```/g)].map((m) => m[1]) : [];
|
|
859
|
+
const what = arg === "file" ? (openFile ? readText(openFile) : null) : arg === "code" ? blocks[blocks.length - 1] ?? null : reply ?? null;
|
|
860
|
+
if (arg && arg !== "file" && arg !== "code")
|
|
861
|
+
return info("Usage: /copy (the last reply) · /copy code (its last code block) · /copy file (the open file)", "error");
|
|
862
|
+
if (!what)
|
|
863
|
+
return info(arg === "file" ? "No file is open." : arg === "code" ? "The last reply has no code block." : "Nothing to copy yet.", "error");
|
|
864
|
+
const ok = await copyToClipboard(what);
|
|
865
|
+
return info(ok ? `Copied ${arg === "file" ? openFile : arg === "code" ? "the code block" : "the last reply"} (${what.split("\n").length} lines).` : "Couldn't reach the clipboard. Press m to turn off the mouse and select the text yourself.", ok ? "info" : "error");
|
|
866
|
+
}
|
|
867
|
+
case "auto": {
|
|
868
|
+
if (!arg)
|
|
869
|
+
return cycleAutoMode();
|
|
870
|
+
if (arg === "ask" || arg === "auto-edit" || arg === "auto")
|
|
871
|
+
return cycleAutoMode(arg);
|
|
872
|
+
return info("Usage: /auto [ask|auto-edit|auto]", "error");
|
|
873
|
+
}
|
|
874
|
+
case "engine": {
|
|
875
|
+
if (arg && isEngineId(arg))
|
|
876
|
+
return switchEngine(arg);
|
|
877
|
+
if (arg)
|
|
878
|
+
return info(`Unknown engine ${arg}. Choose one of: ${ENGINES.map((e) => e.id).join(", ")}`, "error");
|
|
879
|
+
const current = agentRef.current.id;
|
|
880
|
+
const lines = ENGINES.map((e) => ` ${e.id === current ? "●" : "○"} ${e.id.padEnd(12)} ${e.description}${isInstalled(e) ? "" : ` (not installed: switching runs npm install ${e.package})`}`);
|
|
881
|
+
return info(`Engines (/engine <name> to switch, or /settings → Engine):\n${lines.join("\n")}`);
|
|
882
|
+
}
|
|
883
|
+
case "pull": {
|
|
884
|
+
if (!arg)
|
|
885
|
+
return info("Usage: /pull <model>, e.g. /pull qwen3:8b. Browse models at https://ollama.com/library", "error");
|
|
886
|
+
const model = arg.replace(/^ollama:/, "");
|
|
887
|
+
if (await pullModel(model))
|
|
888
|
+
info(`Use it with /model ollama:${model}`);
|
|
564
889
|
return;
|
|
565
890
|
}
|
|
566
891
|
case "models": {
|
|
@@ -580,10 +905,31 @@ ${snippet}
|
|
|
580
905
|
}
|
|
581
906
|
return;
|
|
582
907
|
}
|
|
908
|
+
case "compact": {
|
|
909
|
+
const engine = agentRef.current;
|
|
910
|
+
if (!engine.compact)
|
|
911
|
+
return info(`${engineInfo(engine.id).label} manages its own context.`);
|
|
912
|
+
if (busy)
|
|
913
|
+
return info("Wait for the current reply to finish first.", "error");
|
|
914
|
+
setBusy("Summarizing the conversation...");
|
|
915
|
+
try {
|
|
916
|
+
if (!(await engine.compact(true)))
|
|
917
|
+
info("Nothing to compact yet.");
|
|
918
|
+
}
|
|
919
|
+
catch (error) {
|
|
920
|
+
info(`Couldn't compact: ${error.message}`, "error");
|
|
921
|
+
}
|
|
922
|
+
finally {
|
|
923
|
+
setBusy(null);
|
|
924
|
+
}
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
583
927
|
case "clear":
|
|
584
928
|
agentRef.current.clear();
|
|
585
929
|
setEntries([]);
|
|
586
930
|
return;
|
|
931
|
+
case "plugins":
|
|
932
|
+
return pluginsCommand(rest);
|
|
587
933
|
case "version":
|
|
588
934
|
return info(`nou v${currentVersion()}${update ? ` · v${update} is available, run \`nou update\`` : " · up to date"}`);
|
|
589
935
|
case "help":
|
|
@@ -637,15 +983,15 @@ ${snippet}
|
|
|
637
983
|
}
|
|
638
984
|
}
|
|
639
985
|
if (view === "settings") {
|
|
640
|
-
return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, onModel: (next) =>
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
986
|
+
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: () => {
|
|
987
|
+
setConnectTo(undefined);
|
|
988
|
+
setView("main");
|
|
989
|
+
} }));
|
|
644
990
|
}
|
|
645
991
|
const height = Math.max(10, rows - 2);
|
|
646
|
-
const treeWidth = Math.max(24, Math.floor(columns * 0.2));
|
|
647
|
-
const chatWidth = Math.max(34, Math.floor(columns * 0.3));
|
|
648
|
-
const codeWidth = Math.max(
|
|
992
|
+
const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
|
|
993
|
+
const chatWidth = paneWidths ? Math.max(MIN_CHAT, Math.min(Math.round(columns * paneWidths.chat), columns - treeWidth - MIN_CODE)) : Math.max(34, Math.floor(columns * 0.3));
|
|
994
|
+
const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
|
|
649
995
|
layout.current = { treeWidth, codeWidth, paneHeight: height };
|
|
650
996
|
const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
|
|
651
997
|
const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
|
|
@@ -659,19 +1005,32 @@ ${snippet}
|
|
|
659
1005
|
: diff ? { label: "DIFF", color: C.accent2 }
|
|
660
1006
|
: busy ? { label: "WORKING", color: C.accent2 }
|
|
661
1007
|
: { label: focus.toUpperCase(), color: C.accent };
|
|
662
|
-
|
|
663
|
-
|
|
1008
|
+
// Shortcuts for the status bar, most useful first; the ones that don't fit are left out (all of them are in /help).
|
|
1009
|
+
const hints = approval && !(showEditor && focus === "code")
|
|
1010
|
+
? [["y", "accept"], ["n", "reject"], ...(pendingChange && diff !== pendingChange ? [["o", "show the edit"]] : []), ["Esc", "stop"], ["Shift+Tab", "auto mode"]]
|
|
664
1011
|
: showEditor && focus === "code"
|
|
665
|
-
?
|
|
1012
|
+
? [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Ctrl+Y", "redo"], ["Ctrl+K", "info"], ["Ctrl+G", "definition"]]
|
|
666
1013
|
: focus === "chat"
|
|
667
|
-
? busy
|
|
1014
|
+
? busy
|
|
1015
|
+
? [["Esc", "stop"], ["Shift+↑↓", "scroll"], ["Tab", "next pane"]]
|
|
1016
|
+
: [["Enter", "send"], ["/", "commands"], ["Tab", "next pane"], ["Ctrl+P", "find file"], ["Shift+↑↓", "scroll"]]
|
|
668
1017
|
: focus === "tree"
|
|
669
|
-
? "
|
|
670
|
-
: diff
|
|
671
|
-
|
|
1018
|
+
? [["Enter", "open"], ["e", "edit"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
|
|
1019
|
+
: diff
|
|
1020
|
+
? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
|
|
1021
|
+
: [["e", "edit"], ["v", "diff"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
|
|
1022
|
+
const statusUsed = mode.label.length + 2 + autoMode.length + 2 + 10 + ` ✖ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
|
|
1023
|
+
const shownHints = [];
|
|
1024
|
+
for (let used = statusUsed, i = 0; i < hints.length; i++) {
|
|
1025
|
+
used += hints[i][0].length + hints[i][1].length + 4;
|
|
1026
|
+
if (used > columns)
|
|
1027
|
+
break;
|
|
1028
|
+
shownHints.push(hints[i]);
|
|
1029
|
+
}
|
|
1030
|
+
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", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), wheel: wheelFor("tree"), click: treeClick, onOpen: (filePath) => {
|
|
672
1031
|
open(filePath);
|
|
673
1032
|
setFocus("code");
|
|
674
|
-
}, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code"
|
|
1033
|
+
}, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code", 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) => {
|
|
675
1034
|
if (!lspEnabled)
|
|
676
1035
|
return info("Go to definition needs a language server (see /settings → Plugins).", "error");
|
|
677
1036
|
void lsp.definition(openFile, text, line, col).then((target) => {
|
|
@@ -690,9 +1049,9 @@ ${snippet}
|
|
|
690
1049
|
draft.current = state.dirty
|
|
691
1050
|
? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
|
|
692
1051
|
: null;
|
|
693
|
-
} }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code"
|
|
1052
|
+
} }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === 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, onAnswer: answer })] }), _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: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _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
1053
|
? _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
|
-
: _jsxs(Text, { color: C.dim, children: ["
|
|
1054
|
+
: shownHints.map(([key, action], i) => (_jsxs(Text, { children: [i === 0 ? " " : _jsx(Text, { color: C.dim, children: " \u00B7 " }), _jsx(Text, { color: C.text, children: key }), _jsxs(Text, { color: C.dim, children: [" ", action] })] }, key)))] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
|
|
696
1055
|
setQuickOpen(false);
|
|
697
1056
|
open(file);
|
|
698
1057
|
setReveal(file);
|