nou-cli 0.0.0-stage → 0.2.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 +24 -0
- package/README.md +163 -2
- 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 +84 -0
- package/dist/src/agent/index.js +246 -0
- package/dist/src/commands/index.js +125 -0
- package/dist/src/config/index.js +39 -0
- package/dist/src/context/index.js +53 -0
- 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/git/index.js +94 -0
- package/dist/src/git/tools.js +52 -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 +134 -0
- package/dist/src/providers/anthropic.js +109 -0
- package/dist/src/providers/ids.js +3 -0
- package/dist/src/providers/index.js +46 -0
- package/dist/src/providers/ollama.js +58 -0
- package/dist/src/providers/openai.js +107 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/syntax/index.js +240 -0
- package/dist/src/tools/index.js +100 -0
- package/dist/src/ui/App.js +671 -0
- package/dist/src/ui/Chat.js +97 -0
- package/dist/src/ui/ChatInput.js +128 -0
- package/dist/src/ui/CodeView.js +100 -0
- package/dist/src/ui/Editor.js +234 -0
- package/dist/src/ui/FileTree.js +143 -0
- package/dist/src/ui/Header.js +9 -0
- package/dist/src/ui/Pane.js +8 -0
- package/dist/src/ui/ProblemsView.js +71 -0
- package/dist/src/ui/QuickOpen.js +91 -0
- package/dist/src/ui/Settings.js +165 -0
- package/dist/src/ui/text.js +100 -0
- package/dist/src/ui/theme.js +41 -0
- package/dist/src/update/index.js +102 -0
- package/dist/src/util/index.js +9 -0
- package/package.json +69 -4
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { Box, Text, useApp, useInput, useWindowSize } from "ink";
|
|
6
|
+
import { Agent, buildSystemPrompt } from "../agent/index.js";
|
|
7
|
+
import { describe, providers, resolveModel, saveModel } from "../providers/index.js";
|
|
8
|
+
import { loadSettings, saveSettings } from "../config/index.js";
|
|
9
|
+
import { branch as gitBranch, diffAgainstHead, isRepo, status as gitStatus, summary as gitSummary } from "../git/index.js";
|
|
10
|
+
import { gitTools } from "../git/tools.js";
|
|
11
|
+
import { BUILTIN_PLUGINS, discoverPlugins, isEnabled, loadPlugin, trustPatch } from "../plugins/index.js";
|
|
12
|
+
import { 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 } from "../update/index.js";
|
|
17
|
+
import { changedLines } from "./text.js";
|
|
18
|
+
import { ProblemsView } from "./ProblemsView.js";
|
|
19
|
+
import { C } from "./theme.js";
|
|
20
|
+
import { FileTree } from "./FileTree.js";
|
|
21
|
+
import { CodeView } from "./CodeView.js";
|
|
22
|
+
import { Editor } from "./Editor.js";
|
|
23
|
+
import { Chat } from "./Chat.js";
|
|
24
|
+
import { Header } from "./Header.js";
|
|
25
|
+
import { QuickOpen, listAllFiles } from "./QuickOpen.js";
|
|
26
|
+
import { Settings } from "./Settings.js";
|
|
27
|
+
const FOCUS_ORDER = ["tree", "code", "chat"];
|
|
28
|
+
const NO_LINES = new Set();
|
|
29
|
+
const HELP = `Commands:
|
|
30
|
+
/settings models, preferences and plugins
|
|
31
|
+
/model show providers and the current model
|
|
32
|
+
/model <provider[:model]> switch, e.g. /model anthropic /model ollama:qwen3:8b
|
|
33
|
+
/models [provider] list models a provider offers
|
|
34
|
+
/clear start a fresh conversation
|
|
35
|
+
/undo undo Nou's last change (also: u)
|
|
36
|
+
/rewind undo everything from Nou's last turn
|
|
37
|
+
/history recent changes
|
|
38
|
+
Keys:
|
|
39
|
+
Ctrl+P find a file Tab switch pane Esc stop / back
|
|
40
|
+
e edit open file v diff (git / Nou) f follow mode
|
|
41
|
+
p problems u undo Nou's edit s settings
|
|
42
|
+
c (in files) show only changed files Ctrl+C twice quit
|
|
43
|
+
In the editor: Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
|
|
44
|
+
function readText(filePath) {
|
|
45
|
+
try {
|
|
46
|
+
return readFileSync(path.resolve(process.cwd(), filePath), "utf-8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function App({ initialModel, permContext }) {
|
|
53
|
+
const { exit } = useApp();
|
|
54
|
+
const { columns, rows } = useWindowSize();
|
|
55
|
+
const [settings, setSettings] = useState(loadSettings);
|
|
56
|
+
const [view, setView] = useState("main");
|
|
57
|
+
const [quickOpen, setQuickOpen] = useState(false);
|
|
58
|
+
const [focus, setFocus] = useState("chat");
|
|
59
|
+
const [follow, setFollow] = useState(settings.followMode);
|
|
60
|
+
const [entries, setEntries] = useState([]);
|
|
61
|
+
const [streaming, setStreaming] = useState("");
|
|
62
|
+
const [busy, setBusy] = useState(null);
|
|
63
|
+
const [choice, setChoice] = useState(initialModel);
|
|
64
|
+
const [approval, setApproval] = useState(null);
|
|
65
|
+
const [openFile, setOpenFile] = useState(null);
|
|
66
|
+
const [content, setContent] = useState("");
|
|
67
|
+
const [reveal, setReveal] = useState(null);
|
|
68
|
+
const [treeVersion, setTreeVersion] = useState(0);
|
|
69
|
+
// The last change Nou made per file, so we can flag edited lines and re-open the diff.
|
|
70
|
+
const [changes, setChanges] = useState(new Map());
|
|
71
|
+
const [diffMode, setDiffMode] = useState("off");
|
|
72
|
+
const [editing, setEditing] = useState(false);
|
|
73
|
+
const [git, setGit] = useState(() => gitStatus());
|
|
74
|
+
const [plugins, setPlugins] = useState([]);
|
|
75
|
+
const [viewport, setViewport] = useState(null);
|
|
76
|
+
const [editorState, setEditorState] = useState(null);
|
|
77
|
+
// Unsaved editor text survives switching to a diff and back.
|
|
78
|
+
const draft = useRef(null);
|
|
79
|
+
const [codePanel, setCodePanel] = useState("code");
|
|
80
|
+
const [, setDiagVersion] = useState(0);
|
|
81
|
+
const [syntaxVersion, setSyntaxVersion] = useState(0);
|
|
82
|
+
const [focusLine, setFocusLine] = useState(null);
|
|
83
|
+
const [editorStart, setEditorStart] = useState({ line: 1, nonce: 0 });
|
|
84
|
+
const [pendingNote, setPendingNote] = useState(null);
|
|
85
|
+
const [exitArmed, setExitArmed] = useState(false);
|
|
86
|
+
const [update, setUpdate] = useState(null);
|
|
87
|
+
const diagnosticsRef = useRef(null);
|
|
88
|
+
diagnosticsRef.current ??= new DiagnosticsManager();
|
|
89
|
+
const diagnostics = diagnosticsRef.current;
|
|
90
|
+
const timelineRef = useRef(null);
|
|
91
|
+
timelineRef.current ??= new Timeline();
|
|
92
|
+
const timeline = timelineRef.current;
|
|
93
|
+
const followRef = useRef(follow);
|
|
94
|
+
followRef.current = follow;
|
|
95
|
+
const settingsRef = useRef(settings);
|
|
96
|
+
settingsRef.current = settings;
|
|
97
|
+
const refreshGit = useCallback(() => setGit(gitStatus()), []);
|
|
98
|
+
const allFiles = useMemo(() => (quickOpen ? listAllFiles() : []), [quickOpen]);
|
|
99
|
+
const repo = useMemo(() => isRepo(), []);
|
|
100
|
+
const branch = useMemo(() => (repo ? gitBranch() : null), [repo, git]);
|
|
101
|
+
const open = useCallback((filePath) => {
|
|
102
|
+
setOpenFile(filePath);
|
|
103
|
+
setContent(readText(filePath));
|
|
104
|
+
setDiffMode("off");
|
|
105
|
+
setEditing(false);
|
|
106
|
+
setCodePanel("code");
|
|
107
|
+
preload(filePath);
|
|
108
|
+
void diagnosticsRef.current.checkFile(filePath);
|
|
109
|
+
}, []);
|
|
110
|
+
const agentRef = useRef(null);
|
|
111
|
+
if (!agentRef.current) {
|
|
112
|
+
agentRef.current = new Agent(initialModel, buildSystemPrompt(permContext), {
|
|
113
|
+
onEntry: (entry) => setEntries((prev) => [...prev, entry]),
|
|
114
|
+
onStream: setStreaming,
|
|
115
|
+
onBusy: setBusy,
|
|
116
|
+
onRead: (filePath) => {
|
|
117
|
+
if (!followRef.current)
|
|
118
|
+
return;
|
|
119
|
+
open(filePath);
|
|
120
|
+
setReveal(filePath);
|
|
121
|
+
},
|
|
122
|
+
onChange: (change, status, turn) => {
|
|
123
|
+
if (status === "applied") {
|
|
124
|
+
timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
|
|
125
|
+
}
|
|
126
|
+
const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
|
|
127
|
+
setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
|
|
128
|
+
// Diffs awaiting approval are always shown, follow mode or not.
|
|
129
|
+
if (status === "pending" || followRef.current) {
|
|
130
|
+
setOpenFile(change.path);
|
|
131
|
+
setContent(status === "applied" ? change.after : readText(change.path));
|
|
132
|
+
setDiffMode("off");
|
|
133
|
+
setReveal(change.path);
|
|
134
|
+
}
|
|
135
|
+
if (status === "applied") {
|
|
136
|
+
setTreeVersion((v) => v + 1);
|
|
137
|
+
setGit(gitStatus());
|
|
138
|
+
if (draft.current?.path === change.path) {
|
|
139
|
+
setEntries((prev) => [...prev, { kind: "info", text: `Heads up: you have unsaved edits in ${change.path}; saving them will overwrite Nou's change.` }]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
approve: (request) => {
|
|
144
|
+
const s = settingsRef.current;
|
|
145
|
+
if ((request.kind === "change" && s.autoApproveEdits) || (request.kind !== "change" && s.autoApproveCommands)) {
|
|
146
|
+
return Promise.resolve(true);
|
|
147
|
+
}
|
|
148
|
+
return new Promise((resolve) => setApproval({ request, resolve }));
|
|
149
|
+
},
|
|
150
|
+
checkChange: async (change) => {
|
|
151
|
+
const introduced = await diagnosticsRef.current.introducedBy(change.path, change.before, change.after);
|
|
152
|
+
return introduced.length ? introduced.slice(0, 15).map((d) => formatDiagnostic(d)).join("\n") : null;
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
|
|
157
|
+
useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
void checkForUpdate().then(setUpdate);
|
|
160
|
+
}, []);
|
|
161
|
+
// Discover plugins once and load the enabled ones.
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
void (async () => {
|
|
164
|
+
const found = discoverPlugins();
|
|
165
|
+
const saved = loadSettings();
|
|
166
|
+
setPlugins(await Promise.all(found.map((p) => (isEnabled(p, saved) ? loadPlugin(p) : p))));
|
|
167
|
+
})();
|
|
168
|
+
}, []);
|
|
169
|
+
const allPlugins = useMemo(() => [...BUILTIN_PLUGINS, ...plugins], [plugins]);
|
|
170
|
+
const activePlugins = useMemo(() => allPlugins.filter((p) => isEnabled(p, settings) && p.plugin), [allPlugins, settings.plugins, settings.trustedPlugins]);
|
|
171
|
+
// Checkers come from enabled plugins (built-in ones included).
|
|
172
|
+
const firstCheck = useRef(true);
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
diagnostics.setProviders(activePlugins.flatMap((p) => p.plugin.diagnostics ?? []));
|
|
175
|
+
if (firstCheck.current) {
|
|
176
|
+
firstCheck.current = false;
|
|
177
|
+
setTimeout(() => void diagnostics.checkProject(), 1500);
|
|
178
|
+
}
|
|
179
|
+
}, [activePlugins]);
|
|
180
|
+
// Keep the agent's system prompt and tools in sync with settings and plugins.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const extras = [];
|
|
183
|
+
const extraTools = [];
|
|
184
|
+
if (settings.gitContext && repo) {
|
|
185
|
+
const summary = gitSummary();
|
|
186
|
+
if (summary)
|
|
187
|
+
extras.push(summary);
|
|
188
|
+
extraTools.push(...gitTools());
|
|
189
|
+
}
|
|
190
|
+
for (const { plugin } of activePlugins) {
|
|
191
|
+
if (plugin.systemPrompt)
|
|
192
|
+
extras.push(`Plugin ${plugin.name}: ${plugin.systemPrompt}`);
|
|
193
|
+
for (const tool of plugin.tools ?? []) {
|
|
194
|
+
extraTools.push({
|
|
195
|
+
definition: tool.definition,
|
|
196
|
+
readOnly: !!tool.readOnly,
|
|
197
|
+
source: plugin.name,
|
|
198
|
+
run: (args) => tool.run(args, { cwd: process.cwd() }),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
extraTools.push({
|
|
203
|
+
source: "problems",
|
|
204
|
+
readOnly: true,
|
|
205
|
+
definition: {
|
|
206
|
+
name: "get_diagnostics",
|
|
207
|
+
description: "Get errors and warnings from the project's linters, type checker and language servers, for one file or the whole project.",
|
|
208
|
+
parameters: { type: "object", properties: { path: { type: "string", description: "Optional file. Omit for the whole project." } }, required: [] },
|
|
209
|
+
},
|
|
210
|
+
run: async (args) => {
|
|
211
|
+
const file = typeof args.path === "string" ? args.path : null;
|
|
212
|
+
if (file)
|
|
213
|
+
await diagnostics.checkFile(file);
|
|
214
|
+
else
|
|
215
|
+
await diagnostics.checkProject();
|
|
216
|
+
const list = file ? diagnostics.get(file) : diagnostics.all();
|
|
217
|
+
return list.length ? list.slice(0, 100).map((d) => formatDiagnostic(d, true)).join("\n") : "No problems found.";
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
agentRef.current.configure({ system: buildSystemPrompt(permContext, extras), extraTools, effort: settings.effort });
|
|
221
|
+
}, [settings.gitContext, settings.effort, activePlugins]);
|
|
222
|
+
// Pick up changes made outside Nou (other editors, git commands).
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (!repo)
|
|
225
|
+
return;
|
|
226
|
+
const timer = setInterval(() => {
|
|
227
|
+
if (!busy)
|
|
228
|
+
setGit(gitStatus());
|
|
229
|
+
}, 4000);
|
|
230
|
+
return () => clearInterval(timer);
|
|
231
|
+
}, [repo, busy]);
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (busy)
|
|
234
|
+
return;
|
|
235
|
+
refreshGit();
|
|
236
|
+
if (openFile && diffMode === "off" && !editing)
|
|
237
|
+
setContent(readText(openFile));
|
|
238
|
+
}, [busy]);
|
|
239
|
+
const updateSettings = (patch) => setSettings(saveSettings(patch));
|
|
240
|
+
const switchModel = (next) => {
|
|
241
|
+
const problem = next.provider.unavailable();
|
|
242
|
+
if (problem) {
|
|
243
|
+
setEntries((prev) => [...prev, { kind: "error", text: `${next.provider.label} isn't set up: ${problem}` }]);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
agentRef.current.setModel(next);
|
|
247
|
+
setChoice(next);
|
|
248
|
+
saveModel(next);
|
|
249
|
+
setSettings(loadSettings());
|
|
250
|
+
setEntries((prev) => [...prev, { kind: "info", text: `Now using ${describe(next)}. The conversation carries over.` }]);
|
|
251
|
+
return true;
|
|
252
|
+
};
|
|
253
|
+
const togglePlugin = async (id, enabled) => {
|
|
254
|
+
const entry = allPlugins.find((p) => p.id === id);
|
|
255
|
+
if (!entry)
|
|
256
|
+
return;
|
|
257
|
+
updateSettings(trustPatch(entry, enabled, settings));
|
|
258
|
+
if (!enabled)
|
|
259
|
+
return;
|
|
260
|
+
if (!entry.plugin) {
|
|
261
|
+
const loaded = await loadPlugin(entry);
|
|
262
|
+
setPlugins((prev) => prev.map((p) => (p.id === id ? loaded : p)));
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const answer = (ok) => {
|
|
266
|
+
approval?.resolve(ok);
|
|
267
|
+
setApproval(null);
|
|
268
|
+
};
|
|
269
|
+
const record = openFile ? changes.get(openFile) : undefined;
|
|
270
|
+
const pendingChange = approval?.request.kind === "change" ? approval.request.change : null;
|
|
271
|
+
const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
|
|
272
|
+
const diff = pendingChange ?? (diffMode === "git" ? gitChange : diffMode === "nou" && record ? record.change : null);
|
|
273
|
+
const showEditor = editing && !!openFile && !pendingChange;
|
|
274
|
+
// Check Nou's proposed edit before you approve it: does it add problems?
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
setPendingNote(null);
|
|
277
|
+
if (!pendingChange)
|
|
278
|
+
return;
|
|
279
|
+
let cancelled = false;
|
|
280
|
+
void (async () => {
|
|
281
|
+
const before = await diagnostics.checkText(pendingChange.path, pendingChange.before);
|
|
282
|
+
const after = await diagnostics.checkText(pendingChange.path, pendingChange.after);
|
|
283
|
+
if (cancelled)
|
|
284
|
+
return;
|
|
285
|
+
const added = newProblems(before, after);
|
|
286
|
+
setPendingNote(added.length
|
|
287
|
+
? { short: `⚠ +${added.length} problem${added.length === 1 ? "" : "s"}`, detail: added.slice(0, 3).map((d) => formatDiagnostic(d)).join("\n") }
|
|
288
|
+
: { short: "✓ no new problems", detail: null });
|
|
289
|
+
})();
|
|
290
|
+
return () => {
|
|
291
|
+
cancelled = true;
|
|
292
|
+
};
|
|
293
|
+
}, [pendingChange]);
|
|
294
|
+
// Live problems while you type (debounced).
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (!showEditor || !openFile || !editorState)
|
|
297
|
+
return;
|
|
298
|
+
const timer = setTimeout(() => void diagnostics.checkFile(openFile, editorState.lines.join("\n")), 700);
|
|
299
|
+
return () => clearTimeout(timer);
|
|
300
|
+
}, [editorState?.lines]);
|
|
301
|
+
/** Quit, saving the editor's unsaved text first if auto-save is on. */
|
|
302
|
+
const quit = () => {
|
|
303
|
+
if (draft.current && settings.autoSave !== "off")
|
|
304
|
+
saveFromEditor(draft.current.text, draft.current.path);
|
|
305
|
+
exit();
|
|
306
|
+
};
|
|
307
|
+
/** Quitting always takes two presses (Ctrl+C or q) within 2 seconds, so a stray key never loses work. */
|
|
308
|
+
const requestQuit = () => {
|
|
309
|
+
if (exitArmed)
|
|
310
|
+
return quit();
|
|
311
|
+
setExitArmed(true);
|
|
312
|
+
setTimeout(() => setExitArmed(false), 2000);
|
|
313
|
+
};
|
|
314
|
+
// Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
|
|
315
|
+
useInput((input, key) => {
|
|
316
|
+
if (!(key.ctrl && input === "c"))
|
|
317
|
+
return;
|
|
318
|
+
if (busy || approval) {
|
|
319
|
+
agentRef.current?.cancel();
|
|
320
|
+
approval?.resolve(false);
|
|
321
|
+
setApproval(null);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
requestQuit();
|
|
325
|
+
});
|
|
326
|
+
const info = (text, kind = "info") => setEntries((prev) => [...prev, { kind, text }]);
|
|
327
|
+
/** After a file changes outside the normal flow (undo), refresh everything that shows it. */
|
|
328
|
+
const reload = (file) => {
|
|
329
|
+
if (file === openFile)
|
|
330
|
+
setContent(readText(file));
|
|
331
|
+
setTreeVersion((v) => v + 1);
|
|
332
|
+
refreshGit();
|
|
333
|
+
void diagnostics.checkFile(file);
|
|
334
|
+
};
|
|
335
|
+
const undoCheckpoints = (list) => {
|
|
336
|
+
const done = [];
|
|
337
|
+
for (const cp of list) {
|
|
338
|
+
const result = timeline.undo(cp);
|
|
339
|
+
if (result.ok) {
|
|
340
|
+
done.push(cp.path);
|
|
341
|
+
info(`↶ Undid Nou's change to ${cp.path}${result.merged ? " (kept your later edits)" : ""}`);
|
|
342
|
+
reload(cp.path);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
info(`Couldn't undo ${cp.path}: ${result.reason}. Press v on the file to see the diff.`, "error");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (done.length)
|
|
349
|
+
agentRef.current.note(`The user undid your changes to ${done.join(", ")}; those files are back to how they were before your edits.`);
|
|
350
|
+
};
|
|
351
|
+
const undoLast = () => {
|
|
352
|
+
const last = timeline.lastNouChange();
|
|
353
|
+
if (!last)
|
|
354
|
+
return info("Nothing of Nou's to undo.");
|
|
355
|
+
undoCheckpoints([last]);
|
|
356
|
+
};
|
|
357
|
+
const rewind = () => {
|
|
358
|
+
const turn = timeline.lastTurn();
|
|
359
|
+
if (!turn.length)
|
|
360
|
+
return info("Nothing of Nou's to rewind.");
|
|
361
|
+
undoCheckpoints(turn);
|
|
362
|
+
};
|
|
363
|
+
const showHistory = () => {
|
|
364
|
+
const items = timeline.all.slice(-15).reverse();
|
|
365
|
+
if (!items.length)
|
|
366
|
+
return info("No changes recorded yet.");
|
|
367
|
+
info(`Recent changes (newest first):\n${items.map((c) => {
|
|
368
|
+
const time = new Date(c.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
369
|
+
return ` ${time} ${c.source === "nou" ? "Nou" : "you"} ${c.before === null ? "created " : ""}${c.path}${c.undone ? " (undone)" : ""}`;
|
|
370
|
+
}).join("\n")}\nUndo with /undo (last) or /rewind (whole last turn).`);
|
|
371
|
+
};
|
|
372
|
+
/**
|
|
373
|
+
* Before editing another file: an unsaved draft of a different file is saved (auto-save on) or kept and
|
|
374
|
+
* the switch refused (auto-save off). Never silently dropped. Returns whether it's OK to continue.
|
|
375
|
+
*/
|
|
376
|
+
const settleDraft = (target) => {
|
|
377
|
+
if (!draft.current || draft.current.path === target)
|
|
378
|
+
return true;
|
|
379
|
+
if (settings.autoSave !== "off") {
|
|
380
|
+
saveFromEditor(draft.current.text, draft.current.path);
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
info(`You have unsaved edits in ${draft.current.path}. Open it and save (Ctrl+S) first, or turn on auto-save in settings.`, "error");
|
|
384
|
+
return false;
|
|
385
|
+
};
|
|
386
|
+
/** Opens a file at a line, in the editor if `edit` (used by the Problems list and go-to-definition). */
|
|
387
|
+
const openAt = (file, line, edit = false) => {
|
|
388
|
+
if (!settleDraft(file))
|
|
389
|
+
return;
|
|
390
|
+
if (file !== openFile)
|
|
391
|
+
open(file);
|
|
392
|
+
setReveal(file);
|
|
393
|
+
setCodePanel("code");
|
|
394
|
+
if (edit) {
|
|
395
|
+
setEditorStart((s) => ({ line, nonce: s.nonce + 1 }));
|
|
396
|
+
setEditing(true);
|
|
397
|
+
setFocus("code");
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
setFocusLine((f) => ({ line, nonce: (f?.nonce ?? 0) + 1 }));
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
const startEditing = () => {
|
|
404
|
+
if (!openFile || !settleDraft(openFile))
|
|
405
|
+
return;
|
|
406
|
+
setDiffMode("off");
|
|
407
|
+
setCodePanel("code");
|
|
408
|
+
setEditorStart((s) => ({ line: viewport?.top ?? 1, nonce: s.nonce + 1 }));
|
|
409
|
+
setEditing(true);
|
|
410
|
+
setFocus("code");
|
|
411
|
+
};
|
|
412
|
+
const cycleDiff = () => {
|
|
413
|
+
if (!openFile)
|
|
414
|
+
return;
|
|
415
|
+
const order = ["off"];
|
|
416
|
+
if (git.has(openFile))
|
|
417
|
+
order.push("git");
|
|
418
|
+
if (record?.status === "applied")
|
|
419
|
+
order.push("nou");
|
|
420
|
+
if (order.length === 1) {
|
|
421
|
+
setEntries((prev) => [...prev, { kind: "info", text: `${openFile} has no changes vs git HEAD.` }]);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
setEditing(false);
|
|
425
|
+
setDiffMode((m) => order[(order.indexOf(m) + 1) % order.length]);
|
|
426
|
+
};
|
|
427
|
+
useInput((input, key) => {
|
|
428
|
+
if (approval) {
|
|
429
|
+
if (input === "y" || input === "Y")
|
|
430
|
+
answer(true);
|
|
431
|
+
else if (input === "n" || input === "N")
|
|
432
|
+
answer(false);
|
|
433
|
+
else if (key.escape) {
|
|
434
|
+
agentRef.current?.cancel();
|
|
435
|
+
answer(false);
|
|
436
|
+
}
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (key.ctrl && input === "p") {
|
|
440
|
+
setQuickOpen(true);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (showEditor && focus === "code")
|
|
444
|
+
return; // the editor owns the keyboard
|
|
445
|
+
if (key.tab) {
|
|
446
|
+
const step = key.shift ? -1 : 1;
|
|
447
|
+
setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + step + FOCUS_ORDER.length) % FOCUS_ORDER.length]);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (key.escape) {
|
|
451
|
+
if (busy)
|
|
452
|
+
agentRef.current?.cancel();
|
|
453
|
+
else if (diffMode !== "off")
|
|
454
|
+
setDiffMode("off");
|
|
455
|
+
else
|
|
456
|
+
setFocus("chat");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (focus === "chat")
|
|
460
|
+
return; // letters belong to the text box
|
|
461
|
+
if (input === "f")
|
|
462
|
+
setFollow((f) => !f);
|
|
463
|
+
else if (input === "p") {
|
|
464
|
+
setCodePanel((p) => (p === "problems" ? "code" : "problems"));
|
|
465
|
+
setEditing(false);
|
|
466
|
+
setFocus("code");
|
|
467
|
+
}
|
|
468
|
+
else if (input === "u")
|
|
469
|
+
undoLast();
|
|
470
|
+
else if (input === "v")
|
|
471
|
+
cycleDiff();
|
|
472
|
+
else if (input === "e")
|
|
473
|
+
startEditing();
|
|
474
|
+
else if (input === "s" || input === ",")
|
|
475
|
+
setView("settings");
|
|
476
|
+
else if (input === "r") {
|
|
477
|
+
refreshGit();
|
|
478
|
+
setTreeVersion((v) => v + 1);
|
|
479
|
+
}
|
|
480
|
+
else if (input === "q")
|
|
481
|
+
requestQuit();
|
|
482
|
+
}, { isActive: view === "main" && !quickOpen && !(codePanel === "problems" && focus === "code") });
|
|
483
|
+
/** What the user is looking at, attached to each message so "this file" / "here" make sense. */
|
|
484
|
+
const editorContext = () => {
|
|
485
|
+
if (!openFile)
|
|
486
|
+
return null;
|
|
487
|
+
const unsaved = showEditor && editorState?.dirty;
|
|
488
|
+
const lines = showEditor && editorState ? editorState.lines : content.replace(/\r\n/g, "\n").split("\n");
|
|
489
|
+
let from;
|
|
490
|
+
let to;
|
|
491
|
+
let where;
|
|
492
|
+
if (showEditor && editorState) {
|
|
493
|
+
from = Math.max(1, editorState.line - 15);
|
|
494
|
+
to = Math.min(lines.length, editorState.line + 15);
|
|
495
|
+
where = `cursor on line ${editorState.line}`;
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
from = viewport?.top ?? 1;
|
|
499
|
+
to = Math.min(lines.length, viewport?.bottom ?? 40, from + 80);
|
|
500
|
+
where = `lines ${from}-${to} visible`;
|
|
501
|
+
}
|
|
502
|
+
const snippet = lines.slice(from - 1, to).map((l, i) => `${String(from + i).padStart(4)}| ${l}`).join("\n");
|
|
503
|
+
return `<editor_context>
|
|
504
|
+
The user has ${openFile} open in the editor (${where}${unsaved ? "; they have unsaved edits, shown below" : ""}${git.get(openFile) ? `; git status ${git.get(openFile)}` : ""}).
|
|
505
|
+
${snippet}
|
|
506
|
+
</editor_context>`;
|
|
507
|
+
};
|
|
508
|
+
const contextChip = openFile
|
|
509
|
+
? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ✎ unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
|
|
510
|
+
: null;
|
|
511
|
+
const runCommand = async (input) => {
|
|
512
|
+
const [command, ...rest] = input.slice(1).split(/\s+/);
|
|
513
|
+
const arg = rest.join(" ").trim();
|
|
514
|
+
switch (command) {
|
|
515
|
+
case "undo":
|
|
516
|
+
return undoLast();
|
|
517
|
+
case "rewind":
|
|
518
|
+
return rewind();
|
|
519
|
+
case "history":
|
|
520
|
+
return showHistory();
|
|
521
|
+
case "problems":
|
|
522
|
+
setCodePanel("problems");
|
|
523
|
+
setFocus("code");
|
|
524
|
+
return;
|
|
525
|
+
case "settings":
|
|
526
|
+
setView("settings");
|
|
527
|
+
return;
|
|
528
|
+
case "model": {
|
|
529
|
+
if (!arg) {
|
|
530
|
+
const list = providers.map((p) => {
|
|
531
|
+
const problem = p.unavailable();
|
|
532
|
+
return `${p.id === choice.provider.id ? "▸" : " "} ${p.id.padEnd(10)} ${p.local ? "local " : "cloud "} ${problem ? `(${problem})` : `default ${p.defaultModel}`}`;
|
|
533
|
+
});
|
|
534
|
+
info(`Current: ${describe(choice)}\n${list.join("\n")}\nSwitch with /model <provider[:model]> or open /settings`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
switchModel(resolveModel(arg));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
case "models": {
|
|
541
|
+
const provider = arg ? providers.find((p) => p.id === arg) : choice.provider;
|
|
542
|
+
if (!provider)
|
|
543
|
+
return info(`Unknown provider "${arg}". Try /model to see them.`, "error");
|
|
544
|
+
setBusy(`Listing ${provider.id} models...`);
|
|
545
|
+
try {
|
|
546
|
+
const models = await provider.listModels();
|
|
547
|
+
info(models.length ? `${provider.id} models:\n${models.slice(0, 60).map((m) => ` ${provider.id}:${m}`).join("\n")}` : `${provider.id} has no models.`);
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
info(`Couldn't list ${provider.id} models: ${error.message}`, "error");
|
|
551
|
+
}
|
|
552
|
+
finally {
|
|
553
|
+
setBusy(null);
|
|
554
|
+
}
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
case "clear":
|
|
558
|
+
agentRef.current.clear();
|
|
559
|
+
setEntries([]);
|
|
560
|
+
return;
|
|
561
|
+
case "help":
|
|
562
|
+
return info(HELP);
|
|
563
|
+
default: {
|
|
564
|
+
for (const { plugin } of activePlugins) {
|
|
565
|
+
const found = plugin.commands?.find((c) => c.name === command);
|
|
566
|
+
if (!found)
|
|
567
|
+
continue;
|
|
568
|
+
try {
|
|
569
|
+
const out = await found.run(arg, { cwd: process.cwd() });
|
|
570
|
+
if (out)
|
|
571
|
+
info(out);
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
info(`/${command}: ${error.message}`, "error");
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
return info(`Unknown command /${command}. Type /help.`, "error");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
const send = (text) => {
|
|
583
|
+
if (text.startsWith("/")) {
|
|
584
|
+
void runCommand(text);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
setEntries((prev) => [...prev, { kind: "user", text }]);
|
|
588
|
+
void agentRef.current.send(text, editorContext() ?? undefined);
|
|
589
|
+
};
|
|
590
|
+
function saveFromEditor(text, file = openFile) {
|
|
591
|
+
if (!file)
|
|
592
|
+
return;
|
|
593
|
+
try {
|
|
594
|
+
const full = path.resolve(process.cwd(), file);
|
|
595
|
+
const before = readText(file);
|
|
596
|
+
writeFileSync(full, text, "utf-8");
|
|
597
|
+
timeline.record({ path: file, before, after: text, source: "you", turn: agentRef.current.turnId });
|
|
598
|
+
if (file === openFile)
|
|
599
|
+
setContent(text);
|
|
600
|
+
if (draft.current?.path === file)
|
|
601
|
+
draft.current = null;
|
|
602
|
+
setTreeVersion((v) => v + 1);
|
|
603
|
+
refreshGit();
|
|
604
|
+
void diagnostics.checkFile(file);
|
|
605
|
+
diagnostics.scheduleProject();
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
setEntries((prev) => [...prev, { kind: "error", text: `Couldn't save ${file}: ${error.message}` }]);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (view === "settings") {
|
|
612
|
+
return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, onModel: (next) => switchModel(next), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: () => {
|
|
613
|
+
const found = discoverPlugins();
|
|
614
|
+
setPlugins((prev) => found.map((f) => prev.find((p) => p.id === f.id && p.plugin) ?? f));
|
|
615
|
+
}, onClose: () => setView("main") }));
|
|
616
|
+
}
|
|
617
|
+
const height = Math.max(10, rows - 2);
|
|
618
|
+
const treeWidth = Math.max(24, Math.floor(columns * 0.2));
|
|
619
|
+
const chatWidth = Math.max(34, Math.floor(columns * 0.3));
|
|
620
|
+
const codeWidth = Math.max(20, columns - treeWidth - chatWidth);
|
|
621
|
+
const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
|
|
622
|
+
const fileProblems = openFile ? diagnostics.get(openFile) : [];
|
|
623
|
+
const totals = diagnostics.counts();
|
|
624
|
+
const checking = diagnostics.busy;
|
|
625
|
+
const lspEnabled = activePlugins.some((p) => p.id === "lsp");
|
|
626
|
+
const mode = approval
|
|
627
|
+
? { label: "APPROVE", color: C.yellow }
|
|
628
|
+
: showEditor ? { label: "EDIT", color: C.green }
|
|
629
|
+
: diff ? { label: "DIFF", color: C.accent2 }
|
|
630
|
+
: busy ? { label: "WORKING", color: C.accent2 }
|
|
631
|
+
: { label: focus.toUpperCase(), color: C.accent };
|
|
632
|
+
const hints = approval
|
|
633
|
+
? "y accept · n reject · Esc stop"
|
|
634
|
+
: showEditor && focus === "code"
|
|
635
|
+
? `Ctrl+S save${settings.autoSave === "off" ? "" : " (auto-save on)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close`
|
|
636
|
+
: focus === "chat"
|
|
637
|
+
? busy ? "Esc stop · Tab switch pane" : "Enter send · / commands · Ctrl+P find file · Tab switch pane"
|
|
638
|
+
: focus === "tree"
|
|
639
|
+
? "↑↓ move · Enter open · c changes · e edit · p problems · u undo · Ctrl+P find · s settings"
|
|
640
|
+
: diff ? "n/N next/prev change · v cycle diff · Esc close diff" : "↑↓ scroll · e edit · v diff · p problems · u undo · s settings";
|
|
641
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: describe(choice), local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), onOpen: (filePath) => {
|
|
642
|
+
open(filePath);
|
|
643
|
+
setFocus("code");
|
|
644
|
+
}, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code" && !approval, problems: diagnostics.all(), checking: checking, onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
|
|
645
|
+
if (!lspEnabled)
|
|
646
|
+
return info("Go to definition needs a language server (see /settings → Plugins).", "error");
|
|
647
|
+
void lsp.definition(openFile, text, line, col).then((target) => {
|
|
648
|
+
if (!target)
|
|
649
|
+
return info("No definition found here.");
|
|
650
|
+
// Keep unsaved text when jumping within the same file.
|
|
651
|
+
if (target.file === openFile && draft.current)
|
|
652
|
+
draft.current = { ...draft.current, text };
|
|
653
|
+
openAt(target.file, target.line, true);
|
|
654
|
+
});
|
|
655
|
+
}, onSave: (text) => saveFromEditor(text), onExit: () => {
|
|
656
|
+
draft.current = null;
|
|
657
|
+
setEditing(false);
|
|
658
|
+
}, onState: (state) => {
|
|
659
|
+
setEditorState(state);
|
|
660
|
+
draft.current = state.dirty
|
|
661
|
+
? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
|
|
662
|
+
: null;
|
|
663
|
+
} }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code" && !approval, filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : "Nou's edit", pending: !!pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, onSubmit: send })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
|
|
664
|
+
? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
|
|
665
|
+
: _jsxs(Text, { color: C.dim, children: [" ", hints] })] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
|
|
666
|
+
setQuickOpen(false);
|
|
667
|
+
open(file);
|
|
668
|
+
setReveal(file);
|
|
669
|
+
setFocus("code");
|
|
670
|
+
}, onClose: () => setQuickOpen(false) }))] }));
|
|
671
|
+
}
|