nou-cli 0.1.0 → 0.2.1
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/README.md +142 -31
- package/dist/grammars/tree-sitter-bash.wasm +0 -0
- package/dist/grammars/tree-sitter-c.wasm +0 -0
- package/dist/grammars/tree-sitter-css.wasm +0 -0
- package/dist/grammars/tree-sitter-go.wasm +0 -0
- package/dist/grammars/tree-sitter-html.wasm +0 -0
- package/dist/grammars/tree-sitter-java.wasm +0 -0
- package/dist/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/grammars/tree-sitter-json.wasm +0 -0
- package/dist/grammars/tree-sitter-python.wasm +0 -0
- package/dist/grammars/tree-sitter-rust.wasm +0 -0
- package/dist/grammars/tree-sitter-toml.wasm +0 -0
- package/dist/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/grammars/tree-sitter-yaml.wasm +0 -0
- package/dist/index.js +55 -6
- package/dist/src/agent/index.js +37 -5
- package/dist/src/commands/index.js +16 -6
- package/dist/src/config/index.js +6 -2
- package/dist/src/diagnostics/index.js +235 -0
- package/dist/src/diagnostics/process.js +64 -0
- package/dist/src/diagnostics/providers.js +103 -0
- package/dist/src/diagnostics/types.js +1 -0
- package/dist/src/env.js +12 -0
- package/dist/src/history/index.js +113 -0
- package/dist/src/lsp/client.js +169 -0
- package/dist/src/lsp/index.js +169 -0
- package/dist/src/plugins/index.js +94 -3
- package/dist/src/providers/ids.js +3 -0
- package/dist/src/providers/ollama.js +2 -1
- package/dist/src/providers/openai.js +3 -2
- package/dist/src/syntax/index.js +240 -0
- package/dist/src/ui/App.js +299 -30
- package/dist/src/ui/Chat.js +19 -6
- package/dist/src/ui/ChatInput.js +128 -0
- package/dist/src/ui/CodeView.js +25 -5
- package/dist/src/ui/Editor.js +94 -21
- package/dist/src/ui/FileTree.js +23 -4
- package/dist/src/ui/Header.js +4 -2
- package/dist/src/ui/ProblemsView.js +75 -0
- package/dist/src/ui/Settings.js +31 -15
- package/dist/src/ui/mouse.js +52 -0
- package/dist/src/ui/text.js +6 -1
- package/dist/src/update/index.js +102 -0
- package/package.json +10 -5
package/dist/src/ui/App.js
CHANGED
|
@@ -8,8 +8,15 @@ import { describe, providers, resolveModel, saveModel } from "../providers/index
|
|
|
8
8
|
import { loadSettings, saveSettings } from "../config/index.js";
|
|
9
9
|
import { branch as gitBranch, diffAgainstHead, isRepo, status as gitStatus, summary as gitSummary } from "../git/index.js";
|
|
10
10
|
import { gitTools } from "../git/tools.js";
|
|
11
|
-
import { discoverPlugins, loadPlugin } from "../plugins/index.js";
|
|
11
|
+
import { BUILTIN_PLUGINS, discoverPlugins, isEnabled, loadPlugin, trustPatch } from "../plugins/index.js";
|
|
12
|
+
import { DiagnosticsManager, formatDiagnostic, newProblems } from "../diagnostics/index.js";
|
|
13
|
+
import { Timeline } from "../history/index.js";
|
|
14
|
+
import { lsp } from "../lsp/index.js";
|
|
15
|
+
import { onGrammarLoaded, preload } from "../syntax/index.js";
|
|
16
|
+
import { checkForUpdate, currentVersion } from "../update/index.js";
|
|
12
17
|
import { changedLines } from "./text.js";
|
|
18
|
+
import { ProblemsView } from "./ProblemsView.js";
|
|
19
|
+
import { useMouse } from "./mouse.js";
|
|
13
20
|
import { C } from "./theme.js";
|
|
14
21
|
import { FileTree } from "./FileTree.js";
|
|
15
22
|
import { CodeView } from "./CodeView.js";
|
|
@@ -26,10 +33,16 @@ const HELP = `Commands:
|
|
|
26
33
|
/model <provider[:model]> switch, e.g. /model anthropic /model ollama:qwen3:8b
|
|
27
34
|
/models [provider] list models a provider offers
|
|
28
35
|
/clear start a fresh conversation
|
|
36
|
+
/undo undo Nou's last change (also: u)
|
|
37
|
+
/rewind undo everything from Nou's last turn
|
|
38
|
+
/history recent changes
|
|
39
|
+
/version which version of Nou this is
|
|
29
40
|
Keys:
|
|
30
41
|
Ctrl+P find a file Tab switch pane Esc stop / back
|
|
31
42
|
e edit open file v diff (git / Nou) f follow mode
|
|
32
|
-
|
|
43
|
+
p problems u undo Nou's edit s settings
|
|
44
|
+
c (in files) show only changed files Ctrl+C twice quit
|
|
45
|
+
In the editor: Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
|
|
33
46
|
function readText(filePath) {
|
|
34
47
|
try {
|
|
35
48
|
return readFileSync(path.resolve(process.cwd(), filePath), "utf-8");
|
|
@@ -65,6 +78,24 @@ export function App({ initialModel, permContext }) {
|
|
|
65
78
|
const [editorState, setEditorState] = useState(null);
|
|
66
79
|
// Unsaved editor text survives switching to a diff and back.
|
|
67
80
|
const draft = useRef(null);
|
|
81
|
+
const [codePanel, setCodePanel] = useState("code");
|
|
82
|
+
const [, setDiagVersion] = useState(0);
|
|
83
|
+
const [syntaxVersion, setSyntaxVersion] = useState(0);
|
|
84
|
+
const [focusLine, setFocusLine] = useState(null);
|
|
85
|
+
const [editorStart, setEditorStart] = useState({ line: 1, nonce: 0 });
|
|
86
|
+
const [pendingNote, setPendingNote] = useState(null);
|
|
87
|
+
const [exitArmed, setExitArmed] = useState(false);
|
|
88
|
+
const [update, setUpdate] = useState(null);
|
|
89
|
+
const [wheel, setWheel] = useState(null);
|
|
90
|
+
const [treeClick, setTreeClick] = useState(null);
|
|
91
|
+
// Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
|
|
92
|
+
const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
|
|
93
|
+
const diagnosticsRef = useRef(null);
|
|
94
|
+
diagnosticsRef.current ??= new DiagnosticsManager();
|
|
95
|
+
const diagnostics = diagnosticsRef.current;
|
|
96
|
+
const timelineRef = useRef(null);
|
|
97
|
+
timelineRef.current ??= new Timeline();
|
|
98
|
+
const timeline = timelineRef.current;
|
|
68
99
|
const followRef = useRef(follow);
|
|
69
100
|
followRef.current = follow;
|
|
70
101
|
const settingsRef = useRef(settings);
|
|
@@ -78,6 +109,9 @@ export function App({ initialModel, permContext }) {
|
|
|
78
109
|
setContent(readText(filePath));
|
|
79
110
|
setDiffMode("off");
|
|
80
111
|
setEditing(false);
|
|
112
|
+
setCodePanel("code");
|
|
113
|
+
preload(filePath);
|
|
114
|
+
void diagnosticsRef.current.checkFile(filePath);
|
|
81
115
|
}, []);
|
|
82
116
|
const agentRef = useRef(null);
|
|
83
117
|
if (!agentRef.current) {
|
|
@@ -91,7 +125,10 @@ export function App({ initialModel, permContext }) {
|
|
|
91
125
|
open(filePath);
|
|
92
126
|
setReveal(filePath);
|
|
93
127
|
},
|
|
94
|
-
onChange: (change, status) => {
|
|
128
|
+
onChange: (change, status, turn) => {
|
|
129
|
+
if (status === "applied") {
|
|
130
|
+
timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
|
|
131
|
+
}
|
|
95
132
|
const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
|
|
96
133
|
setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
|
|
97
134
|
// Diffs awaiting approval are always shown, follow mode or not.
|
|
@@ -116,17 +153,36 @@ export function App({ initialModel, permContext }) {
|
|
|
116
153
|
}
|
|
117
154
|
return new Promise((resolve) => setApproval({ request, resolve }));
|
|
118
155
|
},
|
|
156
|
+
checkChange: async (change) => {
|
|
157
|
+
const introduced = await diagnosticsRef.current.introducedBy(change.path, change.before, change.after);
|
|
158
|
+
return introduced.length ? introduced.slice(0, 15).map((d) => formatDiagnostic(d)).join("\n") : null;
|
|
159
|
+
},
|
|
119
160
|
});
|
|
120
161
|
}
|
|
162
|
+
useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
|
|
163
|
+
useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
void checkForUpdate().then(setUpdate);
|
|
166
|
+
}, []);
|
|
121
167
|
// Discover plugins once and load the enabled ones.
|
|
122
168
|
useEffect(() => {
|
|
123
169
|
void (async () => {
|
|
124
170
|
const found = discoverPlugins();
|
|
125
|
-
const
|
|
126
|
-
setPlugins(await Promise.all(found.map((p) => (
|
|
171
|
+
const saved = loadSettings();
|
|
172
|
+
setPlugins(await Promise.all(found.map((p) => (isEnabled(p, saved) ? loadPlugin(p) : p))));
|
|
127
173
|
})();
|
|
128
174
|
}, []);
|
|
129
|
-
const
|
|
175
|
+
const allPlugins = useMemo(() => [...BUILTIN_PLUGINS, ...plugins], [plugins]);
|
|
176
|
+
const activePlugins = useMemo(() => allPlugins.filter((p) => isEnabled(p, settings) && p.plugin), [allPlugins, settings.plugins, settings.trustedPlugins]);
|
|
177
|
+
// Checkers come from enabled plugins (built-in ones included).
|
|
178
|
+
const firstCheck = useRef(true);
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
diagnostics.setProviders(activePlugins.flatMap((p) => p.plugin.diagnostics ?? []));
|
|
181
|
+
if (firstCheck.current) {
|
|
182
|
+
firstCheck.current = false;
|
|
183
|
+
setTimeout(() => void diagnostics.checkProject(), 1500);
|
|
184
|
+
}
|
|
185
|
+
}, [activePlugins]);
|
|
130
186
|
// Keep the agent's system prompt and tools in sync with settings and plugins.
|
|
131
187
|
useEffect(() => {
|
|
132
188
|
const extras = [];
|
|
@@ -149,6 +205,24 @@ export function App({ initialModel, permContext }) {
|
|
|
149
205
|
});
|
|
150
206
|
}
|
|
151
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
|
+
});
|
|
152
226
|
agentRef.current.configure({ system: buildSystemPrompt(permContext, extras), extraTools, effort: settings.effort });
|
|
153
227
|
}, [settings.gitContext, settings.effort, activePlugins]);
|
|
154
228
|
// Pick up changes made outside Nou (other editors, git commands).
|
|
@@ -183,11 +257,13 @@ export function App({ initialModel, permContext }) {
|
|
|
183
257
|
return true;
|
|
184
258
|
};
|
|
185
259
|
const togglePlugin = async (id, enabled) => {
|
|
186
|
-
|
|
260
|
+
const entry = allPlugins.find((p) => p.id === id);
|
|
261
|
+
if (!entry)
|
|
262
|
+
return;
|
|
263
|
+
updateSettings(trustPatch(entry, enabled, settings));
|
|
187
264
|
if (!enabled)
|
|
188
265
|
return;
|
|
189
|
-
|
|
190
|
-
if (entry && !entry.plugin) {
|
|
266
|
+
if (!entry.plugin) {
|
|
191
267
|
const loaded = await loadPlugin(entry);
|
|
192
268
|
setPlugins((prev) => prev.map((p) => (p.id === id ? loaded : p)));
|
|
193
269
|
}
|
|
@@ -201,12 +277,161 @@ export function App({ initialModel, permContext }) {
|
|
|
201
277
|
const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
|
|
202
278
|
const diff = pendingChange ?? (diffMode === "git" ? gitChange : diffMode === "nou" && record ? record.change : null);
|
|
203
279
|
const showEditor = editing && !!openFile && !pendingChange;
|
|
280
|
+
// Check Nou's proposed edit before you approve it: does it add problems?
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
setPendingNote(null);
|
|
283
|
+
if (!pendingChange)
|
|
284
|
+
return;
|
|
285
|
+
let cancelled = false;
|
|
286
|
+
void (async () => {
|
|
287
|
+
const before = await diagnostics.checkText(pendingChange.path, pendingChange.before);
|
|
288
|
+
const after = await diagnostics.checkText(pendingChange.path, pendingChange.after);
|
|
289
|
+
if (cancelled)
|
|
290
|
+
return;
|
|
291
|
+
const added = newProblems(before, after);
|
|
292
|
+
setPendingNote(added.length
|
|
293
|
+
? { short: `⚠ +${added.length} problem${added.length === 1 ? "" : "s"}`, detail: added.slice(0, 3).map((d) => formatDiagnostic(d)).join("\n") }
|
|
294
|
+
: { short: "✓ no new problems", detail: null });
|
|
295
|
+
})();
|
|
296
|
+
return () => {
|
|
297
|
+
cancelled = true;
|
|
298
|
+
};
|
|
299
|
+
}, [pendingChange]);
|
|
300
|
+
// Live problems while you type (debounced).
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (!showEditor || !openFile || !editorState)
|
|
303
|
+
return;
|
|
304
|
+
const timer = setTimeout(() => void diagnostics.checkFile(openFile, editorState.lines.join("\n")), 700);
|
|
305
|
+
return () => clearTimeout(timer);
|
|
306
|
+
}, [editorState?.lines]);
|
|
307
|
+
/** Quit, saving the editor's unsaved text first if auto-save is on. */
|
|
308
|
+
const quit = () => {
|
|
309
|
+
if (draft.current && settings.autoSave !== "off")
|
|
310
|
+
saveFromEditor(draft.current.text, draft.current.path);
|
|
311
|
+
exit();
|
|
312
|
+
};
|
|
313
|
+
/** Quitting always takes two presses (Ctrl+C or q) within 2 seconds, so a stray key never loses work. */
|
|
314
|
+
const requestQuit = () => {
|
|
315
|
+
if (exitArmed)
|
|
316
|
+
return quit();
|
|
317
|
+
setExitArmed(true);
|
|
318
|
+
setTimeout(() => setExitArmed(false), 2000);
|
|
319
|
+
};
|
|
320
|
+
// 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).
|
|
322
|
+
useMouse((event) => {
|
|
323
|
+
if (view !== "main" || quickOpen)
|
|
324
|
+
return;
|
|
325
|
+
const { treeWidth, codeWidth, paneHeight } = layout.current;
|
|
326
|
+
if (event.y < 1 || event.y > paneHeight)
|
|
327
|
+
return; // header or status bar
|
|
328
|
+
const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
|
|
329
|
+
if (event.kind === "wheel") {
|
|
330
|
+
setWheel((w) => ({ pane, dir: event.dir, nonce: (w?.nonce ?? 0) + 1 }));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (approval)
|
|
334
|
+
return;
|
|
335
|
+
setFocus(pane);
|
|
336
|
+
// Rows start under the pane's top border (y=1) and title (y=2).
|
|
337
|
+
if (pane === "tree" && event.y >= 3)
|
|
338
|
+
setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
|
|
339
|
+
}, settings.mouse);
|
|
340
|
+
// Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
|
|
341
|
+
useInput((input, key) => {
|
|
342
|
+
if (!(key.ctrl && input === "c"))
|
|
343
|
+
return;
|
|
344
|
+
if (busy || approval) {
|
|
345
|
+
agentRef.current?.cancel();
|
|
346
|
+
approval?.resolve(false);
|
|
347
|
+
setApproval(null);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
requestQuit();
|
|
351
|
+
});
|
|
352
|
+
const info = (text, kind = "info") => setEntries((prev) => [...prev, { kind, text }]);
|
|
353
|
+
/** After a file changes outside the normal flow (undo), refresh everything that shows it. */
|
|
354
|
+
const reload = (file) => {
|
|
355
|
+
if (file === openFile)
|
|
356
|
+
setContent(readText(file));
|
|
357
|
+
setTreeVersion((v) => v + 1);
|
|
358
|
+
refreshGit();
|
|
359
|
+
void diagnostics.checkFile(file);
|
|
360
|
+
};
|
|
361
|
+
const undoCheckpoints = (list) => {
|
|
362
|
+
const done = [];
|
|
363
|
+
for (const cp of list) {
|
|
364
|
+
const result = timeline.undo(cp);
|
|
365
|
+
if (result.ok) {
|
|
366
|
+
done.push(cp.path);
|
|
367
|
+
info(`↶ Undid Nou's change to ${cp.path}${result.merged ? " (kept your later edits)" : ""}`);
|
|
368
|
+
reload(cp.path);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
info(`Couldn't undo ${cp.path}: ${result.reason}. Press v on the file to see the diff.`, "error");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (done.length)
|
|
375
|
+
agentRef.current.note(`The user undid your changes to ${done.join(", ")}; those files are back to how they were before your edits.`);
|
|
376
|
+
};
|
|
377
|
+
const undoLast = () => {
|
|
378
|
+
const last = timeline.lastNouChange();
|
|
379
|
+
if (!last)
|
|
380
|
+
return info("Nothing of Nou's to undo.");
|
|
381
|
+
undoCheckpoints([last]);
|
|
382
|
+
};
|
|
383
|
+
const rewind = () => {
|
|
384
|
+
const turn = timeline.lastTurn();
|
|
385
|
+
if (!turn.length)
|
|
386
|
+
return info("Nothing of Nou's to rewind.");
|
|
387
|
+
undoCheckpoints(turn);
|
|
388
|
+
};
|
|
389
|
+
const showHistory = () => {
|
|
390
|
+
const items = timeline.all.slice(-15).reverse();
|
|
391
|
+
if (!items.length)
|
|
392
|
+
return info("No changes recorded yet.");
|
|
393
|
+
info(`Recent changes (newest first):\n${items.map((c) => {
|
|
394
|
+
const time = new Date(c.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
395
|
+
return ` ${time} ${c.source === "nou" ? "Nou" : "you"} ${c.before === null ? "created " : ""}${c.path}${c.undone ? " (undone)" : ""}`;
|
|
396
|
+
}).join("\n")}\nUndo with /undo (last) or /rewind (whole last turn).`);
|
|
397
|
+
};
|
|
398
|
+
/**
|
|
399
|
+
* Before editing another file: an unsaved draft of a different file is saved (auto-save on) or kept and
|
|
400
|
+
* the switch refused (auto-save off). Never silently dropped. Returns whether it's OK to continue.
|
|
401
|
+
*/
|
|
402
|
+
const settleDraft = (target) => {
|
|
403
|
+
if (!draft.current || draft.current.path === target)
|
|
404
|
+
return true;
|
|
405
|
+
if (settings.autoSave !== "off") {
|
|
406
|
+
saveFromEditor(draft.current.text, draft.current.path);
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
info(`You have unsaved edits in ${draft.current.path}. Open it and save (Ctrl+S) first, or turn on auto-save in settings.`, "error");
|
|
410
|
+
return false;
|
|
411
|
+
};
|
|
412
|
+
/** Opens a file at a line, in the editor if `edit` (used by the Problems list and go-to-definition). */
|
|
413
|
+
const openAt = (file, line, edit = false) => {
|
|
414
|
+
if (!settleDraft(file))
|
|
415
|
+
return;
|
|
416
|
+
if (file !== openFile)
|
|
417
|
+
open(file);
|
|
418
|
+
setReveal(file);
|
|
419
|
+
setCodePanel("code");
|
|
420
|
+
if (edit) {
|
|
421
|
+
setEditorStart((s) => ({ line, nonce: s.nonce + 1 }));
|
|
422
|
+
setEditing(true);
|
|
423
|
+
setFocus("code");
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
setFocusLine((f) => ({ line, nonce: (f?.nonce ?? 0) + 1 }));
|
|
427
|
+
}
|
|
428
|
+
};
|
|
204
429
|
const startEditing = () => {
|
|
205
|
-
if (!openFile)
|
|
430
|
+
if (!openFile || !settleDraft(openFile))
|
|
206
431
|
return;
|
|
207
|
-
if (draft.current && draft.current.path !== openFile)
|
|
208
|
-
draft.current = null;
|
|
209
432
|
setDiffMode("off");
|
|
433
|
+
setCodePanel("code");
|
|
434
|
+
setEditorStart((s) => ({ line: viewport?.top ?? 1, nonce: s.nonce + 1 }));
|
|
210
435
|
setEditing(true);
|
|
211
436
|
setFocus("code");
|
|
212
437
|
};
|
|
@@ -261,6 +486,13 @@ export function App({ initialModel, permContext }) {
|
|
|
261
486
|
return; // letters belong to the text box
|
|
262
487
|
if (input === "f")
|
|
263
488
|
setFollow((f) => !f);
|
|
489
|
+
else if (input === "p") {
|
|
490
|
+
setCodePanel((p) => (p === "problems" ? "code" : "problems"));
|
|
491
|
+
setEditing(false);
|
|
492
|
+
setFocus("code");
|
|
493
|
+
}
|
|
494
|
+
else if (input === "u")
|
|
495
|
+
undoLast();
|
|
264
496
|
else if (input === "v")
|
|
265
497
|
cycleDiff();
|
|
266
498
|
else if (input === "e")
|
|
@@ -272,8 +504,8 @@ export function App({ initialModel, permContext }) {
|
|
|
272
504
|
setTreeVersion((v) => v + 1);
|
|
273
505
|
}
|
|
274
506
|
else if (input === "q")
|
|
275
|
-
|
|
276
|
-
}, { isActive: view === "main" && !quickOpen });
|
|
507
|
+
requestQuit();
|
|
508
|
+
}, { isActive: view === "main" && !quickOpen && !(codePanel === "problems" && focus === "code") });
|
|
277
509
|
/** What the user is looking at, attached to each message so "this file" / "here" make sense. */
|
|
278
510
|
const editorContext = () => {
|
|
279
511
|
if (!openFile)
|
|
@@ -305,8 +537,17 @@ ${snippet}
|
|
|
305
537
|
const runCommand = async (input) => {
|
|
306
538
|
const [command, ...rest] = input.slice(1).split(/\s+/);
|
|
307
539
|
const arg = rest.join(" ").trim();
|
|
308
|
-
const info = (text, kind = "info") => setEntries((prev) => [...prev, { kind, text }]);
|
|
309
540
|
switch (command) {
|
|
541
|
+
case "undo":
|
|
542
|
+
return undoLast();
|
|
543
|
+
case "rewind":
|
|
544
|
+
return rewind();
|
|
545
|
+
case "history":
|
|
546
|
+
return showHistory();
|
|
547
|
+
case "problems":
|
|
548
|
+
setCodePanel("problems");
|
|
549
|
+
setFocus("code");
|
|
550
|
+
return;
|
|
310
551
|
case "settings":
|
|
311
552
|
setView("settings");
|
|
312
553
|
return;
|
|
@@ -343,6 +584,8 @@ ${snippet}
|
|
|
343
584
|
agentRef.current.clear();
|
|
344
585
|
setEntries([]);
|
|
345
586
|
return;
|
|
587
|
+
case "version":
|
|
588
|
+
return info(`nou v${currentVersion()}${update ? ` · v${update} is available, run \`nou update\`` : " · up to date"}`);
|
|
346
589
|
case "help":
|
|
347
590
|
return info(HELP);
|
|
348
591
|
default: {
|
|
@@ -372,22 +615,29 @@ ${snippet}
|
|
|
372
615
|
setEntries((prev) => [...prev, { kind: "user", text }]);
|
|
373
616
|
void agentRef.current.send(text, editorContext() ?? undefined);
|
|
374
617
|
};
|
|
375
|
-
|
|
376
|
-
if (!
|
|
618
|
+
function saveFromEditor(text, file = openFile) {
|
|
619
|
+
if (!file)
|
|
377
620
|
return;
|
|
378
621
|
try {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
622
|
+
const full = path.resolve(process.cwd(), file);
|
|
623
|
+
const before = readText(file);
|
|
624
|
+
writeFileSync(full, text, "utf-8");
|
|
625
|
+
timeline.record({ path: file, before, after: text, source: "you", turn: agentRef.current.turnId });
|
|
626
|
+
if (file === openFile)
|
|
627
|
+
setContent(text);
|
|
628
|
+
if (draft.current?.path === file)
|
|
629
|
+
draft.current = null;
|
|
382
630
|
setTreeVersion((v) => v + 1);
|
|
383
631
|
refreshGit();
|
|
632
|
+
void diagnostics.checkFile(file);
|
|
633
|
+
diagnostics.scheduleProject();
|
|
384
634
|
}
|
|
385
635
|
catch (error) {
|
|
386
|
-
setEntries((prev) => [...prev, { kind: "error", text: `Couldn't save ${
|
|
636
|
+
setEntries((prev) => [...prev, { kind: "error", text: `Couldn't save ${file}: ${error.message}` }]);
|
|
387
637
|
}
|
|
388
|
-
}
|
|
638
|
+
}
|
|
389
639
|
if (view === "settings") {
|
|
390
|
-
return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins:
|
|
640
|
+
return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, onModel: (next) => switchModel(next), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: () => {
|
|
391
641
|
const found = discoverPlugins();
|
|
392
642
|
setPlugins((prev) => found.map((f) => prev.find((p) => p.id === f.id && p.plugin) ?? f));
|
|
393
643
|
}, onClose: () => setView("main") }));
|
|
@@ -396,7 +646,13 @@ ${snippet}
|
|
|
396
646
|
const treeWidth = Math.max(24, Math.floor(columns * 0.2));
|
|
397
647
|
const chatWidth = Math.max(34, Math.floor(columns * 0.3));
|
|
398
648
|
const codeWidth = Math.max(20, columns - treeWidth - chatWidth);
|
|
649
|
+
layout.current = { treeWidth, codeWidth, paneHeight: height };
|
|
650
|
+
const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
|
|
399
651
|
const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
|
|
652
|
+
const fileProblems = openFile ? diagnostics.get(openFile) : [];
|
|
653
|
+
const totals = diagnostics.counts();
|
|
654
|
+
const checking = diagnostics.busy;
|
|
655
|
+
const lspEnabled = activePlugins.some((p) => p.id === "lsp");
|
|
400
656
|
const mode = approval
|
|
401
657
|
? { label: "APPROVE", color: C.yellow }
|
|
402
658
|
: showEditor ? { label: "EDIT", color: C.green }
|
|
@@ -406,16 +662,27 @@ ${snippet}
|
|
|
406
662
|
const hints = approval
|
|
407
663
|
? "y accept · n reject · Esc stop"
|
|
408
664
|
: showEditor && focus === "code"
|
|
409
|
-
?
|
|
665
|
+
? `Ctrl+S save${settings.autoSave === "off" ? "" : " (auto-save on)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close`
|
|
410
666
|
: focus === "chat"
|
|
411
|
-
? busy ? "Esc stop · Tab switch pane" : "Enter send · / commands · Ctrl+P find file · Tab switch pane"
|
|
667
|
+
? busy ? "Esc stop · Shift+↑↓ scroll chat · Tab switch pane" : "Enter send · Shift+↑↓ / PgUp scroll · / commands · Ctrl+P find file · Tab switch pane"
|
|
412
668
|
: focus === "tree"
|
|
413
|
-
? "↑↓ move · Enter open · c changes · e edit · Ctrl+P find · s settings
|
|
414
|
-
: diff ? "n/N next/prev change · v cycle diff · Esc close diff" : "↑↓ scroll · e edit · v diff ·
|
|
415
|
-
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 }), _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, onOpen: (filePath) => {
|
|
669
|
+
? "↑↓ move · Enter open · c changes · e edit · p problems · u undo · Ctrl+P find · s settings"
|
|
670
|
+
: diff ? "n/N next/prev change · v cycle diff · Esc close diff" : "↑↓ scroll · e edit · v diff · p problems · u undo · s settings";
|
|
671
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: describe(choice), local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), wheel: wheelFor("tree"), click: treeClick, onOpen: (filePath) => {
|
|
416
672
|
open(filePath);
|
|
417
673
|
setFocus("code");
|
|
418
|
-
}, onHover: () => { } }), 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,
|
|
674
|
+
}, 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) => {
|
|
675
|
+
if (!lspEnabled)
|
|
676
|
+
return info("Go to definition needs a language server (see /settings → Plugins).", "error");
|
|
677
|
+
void lsp.definition(openFile, text, line, col).then((target) => {
|
|
678
|
+
if (!target)
|
|
679
|
+
return info("No definition found here.");
|
|
680
|
+
// Keep unsaved text when jumping within the same file.
|
|
681
|
+
if (target.file === openFile && draft.current)
|
|
682
|
+
draft.current = { ...draft.current, text };
|
|
683
|
+
openAt(target.file, target.line, true);
|
|
684
|
+
});
|
|
685
|
+
}, onSave: (text) => saveFromEditor(text), onExit: () => {
|
|
419
686
|
draft.current = null;
|
|
420
687
|
setEditing(false);
|
|
421
688
|
}, onState: (state) => {
|
|
@@ -423,7 +690,9 @@ ${snippet}
|
|
|
423
690
|
draft.current = state.dirty
|
|
424
691
|
? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
|
|
425
692
|
: null;
|
|
426
|
-
} }, openFile)) : (_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, 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, 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 " }),
|
|
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
|
|
694
|
+
? _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: [" ", hints] })] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
|
|
427
696
|
setQuickOpen(false);
|
|
428
697
|
open(file);
|
|
429
698
|
setReveal(file);
|
package/dist/src/ui/Chat.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
|
-
import TextInput from "ink-text-input";
|
|
5
4
|
import { wrap } from "./text.js";
|
|
5
|
+
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
6
6
|
import { C, SPINNER } from "./theme.js";
|
|
7
7
|
import { Pane } from "./Pane.js";
|
|
8
|
-
export const COMMANDS = ["/model", "/models", "/settings", "/clear", "/help"];
|
|
9
|
-
export function Chat({ width, height, focused, entries, streaming, busy, approval, contextChip, onSubmit }) {
|
|
8
|
+
export const COMMANDS = ["/model", "/models", "/settings", "/undo", "/rewind", "/history", "/problems", "/version", "/clear", "/help"];
|
|
9
|
+
export function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, onSubmit }) {
|
|
10
10
|
const [input, setInput] = useState("");
|
|
11
11
|
const [scrollBack, setScrollBack] = useState(0);
|
|
12
12
|
const { frame } = useAnimation({ interval: 80, isActive: !!busy });
|
|
@@ -55,6 +55,11 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
55
55
|
}
|
|
56
56
|
else if (approval?.kind === "change") {
|
|
57
57
|
footer.push({ text: `✎ Review ${approval.change.path} →`, color: C.yellow, bold: true });
|
|
58
|
+
if (approvalNote) {
|
|
59
|
+
footer.push({ text: "⚠ This edit adds problems:", color: C.red, bold: true });
|
|
60
|
+
for (const line of wrap(approvalNote, inner - 2).slice(0, 6))
|
|
61
|
+
footer.push({ text: ` ${line}`, color: C.red });
|
|
62
|
+
}
|
|
58
63
|
footer.push({ text: " [y] accept [n] reject [Esc] stop", color: C.yellow });
|
|
59
64
|
}
|
|
60
65
|
else if (busy) {
|
|
@@ -63,10 +68,16 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
63
68
|
const suggestions = input.startsWith("/") && !input.includes(" ")
|
|
64
69
|
? COMMANDS.filter((c) => c.startsWith(input) && c !== input)
|
|
65
70
|
: [];
|
|
66
|
-
const
|
|
71
|
+
const inputRows = inputHeight(input, inner);
|
|
72
|
+
const rows = Math.max(1, height - 3 - footer.length - 2 - inputRows);
|
|
73
|
+
const history = useMemo(() => entries.filter((e) => e.kind === "user").map((e) => e.text), [entries]);
|
|
67
74
|
const maxBack = Math.max(0, lines.length - rows);
|
|
68
75
|
const back = Math.min(scrollBack, maxBack);
|
|
69
76
|
const visible = lines.slice(Math.max(0, lines.length - rows - back), lines.length - back);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (wheel)
|
|
79
|
+
setScrollBack((b) => Math.max(0, Math.min(maxBack, b - wheel.dir * 3)));
|
|
80
|
+
}, [wheel?.nonce]);
|
|
70
81
|
useInput((_input, key) => {
|
|
71
82
|
if (key.pageUp || (key.upArrow && key.shift))
|
|
72
83
|
setScrollBack((b) => Math.min(maxBack, b + Math.max(1, rows - 2)));
|
|
@@ -83,8 +94,10 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
83
94
|
};
|
|
84
95
|
const render = (line, key) => (_jsxs(Text, { wrap: "truncate-end", children: [line.bar ? _jsx(Text, { color: line.bar, children: "\u258C " }) : null, _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
|
|
85
96
|
const empty = entries.length === 0 && !streaming;
|
|
86
|
-
const title = _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
|
|
97
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
|
|
98
|
+
? _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines \u00B7 Shift+\u2193 / PgDn / wheel to go back"] })
|
|
99
|
+
: maxBack > 0 ? _jsx(Text, { color: C.dim, children: " Shift+\u2191 / PgUp / wheel to scroll" }) : null] }));
|
|
87
100
|
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsx(Box, { flexDirection: "column", height: rows, children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : visible.map((line, i) => render(line, i)) }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), _jsx(Text, { wrap: "truncate-end", color: C.dim, children: suggestions.length
|
|
88
101
|
? _jsxs(Text, { color: C.accent, children: [suggestions.join(" "), " ", _jsx(Text, { color: C.dim, children: suggestions.length === 1 ? "Enter ↵" : "" })] })
|
|
89
|
-
: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " }), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner)) }),
|
|
102
|
+
: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " }), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !approval, width: inner, history: history, placeholder: busy ? "working… Esc to stop" : "Ask anything · / for commands · ↑ history" })] }));
|
|
90
103
|
}
|