nou-cli 0.0.0-stage → 0.1.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 +54 -2
- package/dist/index.js +35 -0
- package/dist/src/agent/index.js +214 -0
- package/dist/src/commands/index.js +115 -0
- package/dist/src/config/index.js +36 -0
- package/dist/src/context/index.js +53 -0
- package/dist/src/git/index.js +94 -0
- package/dist/src/git/tools.js +52 -0
- package/dist/src/plugins/index.js +43 -0
- package/dist/src/providers/anthropic.js +109 -0
- package/dist/src/providers/index.js +46 -0
- package/dist/src/providers/ollama.js +57 -0
- package/dist/src/providers/openai.js +106 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/tools/index.js +100 -0
- package/dist/src/ui/App.js +432 -0
- package/dist/src/ui/Chat.js +90 -0
- package/dist/src/ui/CodeView.js +84 -0
- package/dist/src/ui/Editor.js +165 -0
- package/dist/src/ui/FileTree.js +141 -0
- package/dist/src/ui/Header.js +9 -0
- package/dist/src/ui/Pane.js +8 -0
- package/dist/src/ui/QuickOpen.js +91 -0
- package/dist/src/ui/Settings.js +150 -0
- package/dist/src/ui/text.js +95 -0
- package/dist/src/ui/theme.js +41 -0
- package/dist/src/util/index.js +9 -0
- package/package.json +64 -4
|
@@ -0,0 +1,432 @@
|
|
|
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 { discoverPlugins, loadPlugin } from "../plugins/index.js";
|
|
12
|
+
import { changedLines } from "./text.js";
|
|
13
|
+
import { C } from "./theme.js";
|
|
14
|
+
import { FileTree } from "./FileTree.js";
|
|
15
|
+
import { CodeView } from "./CodeView.js";
|
|
16
|
+
import { Editor } from "./Editor.js";
|
|
17
|
+
import { Chat } from "./Chat.js";
|
|
18
|
+
import { Header } from "./Header.js";
|
|
19
|
+
import { QuickOpen, listAllFiles } from "./QuickOpen.js";
|
|
20
|
+
import { Settings } from "./Settings.js";
|
|
21
|
+
const FOCUS_ORDER = ["tree", "code", "chat"];
|
|
22
|
+
const NO_LINES = new Set();
|
|
23
|
+
const HELP = `Commands:
|
|
24
|
+
/settings models, preferences and plugins
|
|
25
|
+
/model show providers and the current model
|
|
26
|
+
/model <provider[:model]> switch, e.g. /model anthropic /model ollama:qwen3:8b
|
|
27
|
+
/models [provider] list models a provider offers
|
|
28
|
+
/clear start a fresh conversation
|
|
29
|
+
Keys:
|
|
30
|
+
Ctrl+P find a file Tab switch pane Esc stop / back
|
|
31
|
+
e edit open file v diff (git / Nou) f follow mode
|
|
32
|
+
c (in files) show only changed files s settings`;
|
|
33
|
+
function readText(filePath) {
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(path.resolve(process.cwd(), filePath), "utf-8");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function App({ initialModel, permContext }) {
|
|
42
|
+
const { exit } = useApp();
|
|
43
|
+
const { columns, rows } = useWindowSize();
|
|
44
|
+
const [settings, setSettings] = useState(loadSettings);
|
|
45
|
+
const [view, setView] = useState("main");
|
|
46
|
+
const [quickOpen, setQuickOpen] = useState(false);
|
|
47
|
+
const [focus, setFocus] = useState("chat");
|
|
48
|
+
const [follow, setFollow] = useState(settings.followMode);
|
|
49
|
+
const [entries, setEntries] = useState([]);
|
|
50
|
+
const [streaming, setStreaming] = useState("");
|
|
51
|
+
const [busy, setBusy] = useState(null);
|
|
52
|
+
const [choice, setChoice] = useState(initialModel);
|
|
53
|
+
const [approval, setApproval] = useState(null);
|
|
54
|
+
const [openFile, setOpenFile] = useState(null);
|
|
55
|
+
const [content, setContent] = useState("");
|
|
56
|
+
const [reveal, setReveal] = useState(null);
|
|
57
|
+
const [treeVersion, setTreeVersion] = useState(0);
|
|
58
|
+
// The last change Nou made per file, so we can flag edited lines and re-open the diff.
|
|
59
|
+
const [changes, setChanges] = useState(new Map());
|
|
60
|
+
const [diffMode, setDiffMode] = useState("off");
|
|
61
|
+
const [editing, setEditing] = useState(false);
|
|
62
|
+
const [git, setGit] = useState(() => gitStatus());
|
|
63
|
+
const [plugins, setPlugins] = useState([]);
|
|
64
|
+
const [viewport, setViewport] = useState(null);
|
|
65
|
+
const [editorState, setEditorState] = useState(null);
|
|
66
|
+
// Unsaved editor text survives switching to a diff and back.
|
|
67
|
+
const draft = useRef(null);
|
|
68
|
+
const followRef = useRef(follow);
|
|
69
|
+
followRef.current = follow;
|
|
70
|
+
const settingsRef = useRef(settings);
|
|
71
|
+
settingsRef.current = settings;
|
|
72
|
+
const refreshGit = useCallback(() => setGit(gitStatus()), []);
|
|
73
|
+
const allFiles = useMemo(() => (quickOpen ? listAllFiles() : []), [quickOpen]);
|
|
74
|
+
const repo = useMemo(() => isRepo(), []);
|
|
75
|
+
const branch = useMemo(() => (repo ? gitBranch() : null), [repo, git]);
|
|
76
|
+
const open = useCallback((filePath) => {
|
|
77
|
+
setOpenFile(filePath);
|
|
78
|
+
setContent(readText(filePath));
|
|
79
|
+
setDiffMode("off");
|
|
80
|
+
setEditing(false);
|
|
81
|
+
}, []);
|
|
82
|
+
const agentRef = useRef(null);
|
|
83
|
+
if (!agentRef.current) {
|
|
84
|
+
agentRef.current = new Agent(initialModel, buildSystemPrompt(permContext), {
|
|
85
|
+
onEntry: (entry) => setEntries((prev) => [...prev, entry]),
|
|
86
|
+
onStream: setStreaming,
|
|
87
|
+
onBusy: setBusy,
|
|
88
|
+
onRead: (filePath) => {
|
|
89
|
+
if (!followRef.current)
|
|
90
|
+
return;
|
|
91
|
+
open(filePath);
|
|
92
|
+
setReveal(filePath);
|
|
93
|
+
},
|
|
94
|
+
onChange: (change, status) => {
|
|
95
|
+
const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
|
|
96
|
+
setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
|
|
97
|
+
// Diffs awaiting approval are always shown, follow mode or not.
|
|
98
|
+
if (status === "pending" || followRef.current) {
|
|
99
|
+
setOpenFile(change.path);
|
|
100
|
+
setContent(status === "applied" ? change.after : readText(change.path));
|
|
101
|
+
setDiffMode("off");
|
|
102
|
+
setReveal(change.path);
|
|
103
|
+
}
|
|
104
|
+
if (status === "applied") {
|
|
105
|
+
setTreeVersion((v) => v + 1);
|
|
106
|
+
setGit(gitStatus());
|
|
107
|
+
if (draft.current?.path === change.path) {
|
|
108
|
+
setEntries((prev) => [...prev, { kind: "info", text: `Heads up: you have unsaved edits in ${change.path}; saving them will overwrite Nou's change.` }]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
approve: (request) => {
|
|
113
|
+
const s = settingsRef.current;
|
|
114
|
+
if ((request.kind === "change" && s.autoApproveEdits) || (request.kind !== "change" && s.autoApproveCommands)) {
|
|
115
|
+
return Promise.resolve(true);
|
|
116
|
+
}
|
|
117
|
+
return new Promise((resolve) => setApproval({ request, resolve }));
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// Discover plugins once and load the enabled ones.
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
void (async () => {
|
|
124
|
+
const found = discoverPlugins();
|
|
125
|
+
const enabled = loadSettings().plugins;
|
|
126
|
+
setPlugins(await Promise.all(found.map((p) => (enabled[p.id] ? loadPlugin(p) : p))));
|
|
127
|
+
})();
|
|
128
|
+
}, []);
|
|
129
|
+
const activePlugins = useMemo(() => plugins.filter((p) => settings.plugins[p.id] && p.plugin), [plugins, settings.plugins]);
|
|
130
|
+
// Keep the agent's system prompt and tools in sync with settings and plugins.
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const extras = [];
|
|
133
|
+
const extraTools = [];
|
|
134
|
+
if (settings.gitContext && repo) {
|
|
135
|
+
const summary = gitSummary();
|
|
136
|
+
if (summary)
|
|
137
|
+
extras.push(summary);
|
|
138
|
+
extraTools.push(...gitTools());
|
|
139
|
+
}
|
|
140
|
+
for (const { plugin } of activePlugins) {
|
|
141
|
+
if (plugin.systemPrompt)
|
|
142
|
+
extras.push(`Plugin ${plugin.name}: ${plugin.systemPrompt}`);
|
|
143
|
+
for (const tool of plugin.tools ?? []) {
|
|
144
|
+
extraTools.push({
|
|
145
|
+
definition: tool.definition,
|
|
146
|
+
readOnly: !!tool.readOnly,
|
|
147
|
+
source: plugin.name,
|
|
148
|
+
run: (args) => tool.run(args, { cwd: process.cwd() }),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
agentRef.current.configure({ system: buildSystemPrompt(permContext, extras), extraTools, effort: settings.effort });
|
|
153
|
+
}, [settings.gitContext, settings.effort, activePlugins]);
|
|
154
|
+
// Pick up changes made outside Nou (other editors, git commands).
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!repo)
|
|
157
|
+
return;
|
|
158
|
+
const timer = setInterval(() => {
|
|
159
|
+
if (!busy)
|
|
160
|
+
setGit(gitStatus());
|
|
161
|
+
}, 4000);
|
|
162
|
+
return () => clearInterval(timer);
|
|
163
|
+
}, [repo, busy]);
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (busy)
|
|
166
|
+
return;
|
|
167
|
+
refreshGit();
|
|
168
|
+
if (openFile && diffMode === "off" && !editing)
|
|
169
|
+
setContent(readText(openFile));
|
|
170
|
+
}, [busy]);
|
|
171
|
+
const updateSettings = (patch) => setSettings(saveSettings(patch));
|
|
172
|
+
const switchModel = (next) => {
|
|
173
|
+
const problem = next.provider.unavailable();
|
|
174
|
+
if (problem) {
|
|
175
|
+
setEntries((prev) => [...prev, { kind: "error", text: `${next.provider.label} isn't set up: ${problem}` }]);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
agentRef.current.setModel(next);
|
|
179
|
+
setChoice(next);
|
|
180
|
+
saveModel(next);
|
|
181
|
+
setSettings(loadSettings());
|
|
182
|
+
setEntries((prev) => [...prev, { kind: "info", text: `Now using ${describe(next)}. The conversation carries over.` }]);
|
|
183
|
+
return true;
|
|
184
|
+
};
|
|
185
|
+
const togglePlugin = async (id, enabled) => {
|
|
186
|
+
updateSettings({ plugins: { ...settings.plugins, [id]: enabled } });
|
|
187
|
+
if (!enabled)
|
|
188
|
+
return;
|
|
189
|
+
const entry = plugins.find((p) => p.id === id);
|
|
190
|
+
if (entry && !entry.plugin) {
|
|
191
|
+
const loaded = await loadPlugin(entry);
|
|
192
|
+
setPlugins((prev) => prev.map((p) => (p.id === id ? loaded : p)));
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const answer = (ok) => {
|
|
196
|
+
approval?.resolve(ok);
|
|
197
|
+
setApproval(null);
|
|
198
|
+
};
|
|
199
|
+
const record = openFile ? changes.get(openFile) : undefined;
|
|
200
|
+
const pendingChange = approval?.request.kind === "change" ? approval.request.change : null;
|
|
201
|
+
const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
|
|
202
|
+
const diff = pendingChange ?? (diffMode === "git" ? gitChange : diffMode === "nou" && record ? record.change : null);
|
|
203
|
+
const showEditor = editing && !!openFile && !pendingChange;
|
|
204
|
+
const startEditing = () => {
|
|
205
|
+
if (!openFile)
|
|
206
|
+
return;
|
|
207
|
+
if (draft.current && draft.current.path !== openFile)
|
|
208
|
+
draft.current = null;
|
|
209
|
+
setDiffMode("off");
|
|
210
|
+
setEditing(true);
|
|
211
|
+
setFocus("code");
|
|
212
|
+
};
|
|
213
|
+
const cycleDiff = () => {
|
|
214
|
+
if (!openFile)
|
|
215
|
+
return;
|
|
216
|
+
const order = ["off"];
|
|
217
|
+
if (git.has(openFile))
|
|
218
|
+
order.push("git");
|
|
219
|
+
if (record?.status === "applied")
|
|
220
|
+
order.push("nou");
|
|
221
|
+
if (order.length === 1) {
|
|
222
|
+
setEntries((prev) => [...prev, { kind: "info", text: `${openFile} has no changes vs git HEAD.` }]);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
setEditing(false);
|
|
226
|
+
setDiffMode((m) => order[(order.indexOf(m) + 1) % order.length]);
|
|
227
|
+
};
|
|
228
|
+
useInput((input, key) => {
|
|
229
|
+
if (approval) {
|
|
230
|
+
if (input === "y" || input === "Y")
|
|
231
|
+
answer(true);
|
|
232
|
+
else if (input === "n" || input === "N")
|
|
233
|
+
answer(false);
|
|
234
|
+
else if (key.escape) {
|
|
235
|
+
agentRef.current?.cancel();
|
|
236
|
+
answer(false);
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (key.ctrl && input === "p") {
|
|
241
|
+
setQuickOpen(true);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (showEditor && focus === "code")
|
|
245
|
+
return; // the editor owns the keyboard
|
|
246
|
+
if (key.tab) {
|
|
247
|
+
const step = key.shift ? -1 : 1;
|
|
248
|
+
setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + step + FOCUS_ORDER.length) % FOCUS_ORDER.length]);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (key.escape) {
|
|
252
|
+
if (busy)
|
|
253
|
+
agentRef.current?.cancel();
|
|
254
|
+
else if (diffMode !== "off")
|
|
255
|
+
setDiffMode("off");
|
|
256
|
+
else
|
|
257
|
+
setFocus("chat");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (focus === "chat")
|
|
261
|
+
return; // letters belong to the text box
|
|
262
|
+
if (input === "f")
|
|
263
|
+
setFollow((f) => !f);
|
|
264
|
+
else if (input === "v")
|
|
265
|
+
cycleDiff();
|
|
266
|
+
else if (input === "e")
|
|
267
|
+
startEditing();
|
|
268
|
+
else if (input === "s" || input === ",")
|
|
269
|
+
setView("settings");
|
|
270
|
+
else if (input === "r") {
|
|
271
|
+
refreshGit();
|
|
272
|
+
setTreeVersion((v) => v + 1);
|
|
273
|
+
}
|
|
274
|
+
else if (input === "q")
|
|
275
|
+
exit();
|
|
276
|
+
}, { isActive: view === "main" && !quickOpen });
|
|
277
|
+
/** What the user is looking at, attached to each message so "this file" / "here" make sense. */
|
|
278
|
+
const editorContext = () => {
|
|
279
|
+
if (!openFile)
|
|
280
|
+
return null;
|
|
281
|
+
const unsaved = showEditor && editorState?.dirty;
|
|
282
|
+
const lines = showEditor && editorState ? editorState.lines : content.replace(/\r\n/g, "\n").split("\n");
|
|
283
|
+
let from;
|
|
284
|
+
let to;
|
|
285
|
+
let where;
|
|
286
|
+
if (showEditor && editorState) {
|
|
287
|
+
from = Math.max(1, editorState.line - 15);
|
|
288
|
+
to = Math.min(lines.length, editorState.line + 15);
|
|
289
|
+
where = `cursor on line ${editorState.line}`;
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
from = viewport?.top ?? 1;
|
|
293
|
+
to = Math.min(lines.length, viewport?.bottom ?? 40, from + 80);
|
|
294
|
+
where = `lines ${from}-${to} visible`;
|
|
295
|
+
}
|
|
296
|
+
const snippet = lines.slice(from - 1, to).map((l, i) => `${String(from + i).padStart(4)}| ${l}`).join("\n");
|
|
297
|
+
return `<editor_context>
|
|
298
|
+
The user has ${openFile} open in the editor (${where}${unsaved ? "; they have unsaved edits, shown below" : ""}${git.get(openFile) ? `; git status ${git.get(openFile)}` : ""}).
|
|
299
|
+
${snippet}
|
|
300
|
+
</editor_context>`;
|
|
301
|
+
};
|
|
302
|
+
const contextChip = openFile
|
|
303
|
+
? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ✎ unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
|
|
304
|
+
: null;
|
|
305
|
+
const runCommand = async (input) => {
|
|
306
|
+
const [command, ...rest] = input.slice(1).split(/\s+/);
|
|
307
|
+
const arg = rest.join(" ").trim();
|
|
308
|
+
const info = (text, kind = "info") => setEntries((prev) => [...prev, { kind, text }]);
|
|
309
|
+
switch (command) {
|
|
310
|
+
case "settings":
|
|
311
|
+
setView("settings");
|
|
312
|
+
return;
|
|
313
|
+
case "model": {
|
|
314
|
+
if (!arg) {
|
|
315
|
+
const list = providers.map((p) => {
|
|
316
|
+
const problem = p.unavailable();
|
|
317
|
+
return `${p.id === choice.provider.id ? "▸" : " "} ${p.id.padEnd(10)} ${p.local ? "local " : "cloud "} ${problem ? `(${problem})` : `default ${p.defaultModel}`}`;
|
|
318
|
+
});
|
|
319
|
+
info(`Current: ${describe(choice)}\n${list.join("\n")}\nSwitch with /model <provider[:model]> or open /settings`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
switchModel(resolveModel(arg));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
case "models": {
|
|
326
|
+
const provider = arg ? providers.find((p) => p.id === arg) : choice.provider;
|
|
327
|
+
if (!provider)
|
|
328
|
+
return info(`Unknown provider "${arg}". Try /model to see them.`, "error");
|
|
329
|
+
setBusy(`Listing ${provider.id} models...`);
|
|
330
|
+
try {
|
|
331
|
+
const models = await provider.listModels();
|
|
332
|
+
info(models.length ? `${provider.id} models:\n${models.slice(0, 60).map((m) => ` ${provider.id}:${m}`).join("\n")}` : `${provider.id} has no models.`);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
info(`Couldn't list ${provider.id} models: ${error.message}`, "error");
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
setBusy(null);
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
case "clear":
|
|
343
|
+
agentRef.current.clear();
|
|
344
|
+
setEntries([]);
|
|
345
|
+
return;
|
|
346
|
+
case "help":
|
|
347
|
+
return info(HELP);
|
|
348
|
+
default: {
|
|
349
|
+
for (const { plugin } of activePlugins) {
|
|
350
|
+
const found = plugin.commands?.find((c) => c.name === command);
|
|
351
|
+
if (!found)
|
|
352
|
+
continue;
|
|
353
|
+
try {
|
|
354
|
+
const out = await found.run(arg, { cwd: process.cwd() });
|
|
355
|
+
if (out)
|
|
356
|
+
info(out);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
info(`/${command}: ${error.message}`, "error");
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
return info(`Unknown command /${command}. Type /help.`, "error");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
const send = (text) => {
|
|
368
|
+
if (text.startsWith("/")) {
|
|
369
|
+
void runCommand(text);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
setEntries((prev) => [...prev, { kind: "user", text }]);
|
|
373
|
+
void agentRef.current.send(text, editorContext() ?? undefined);
|
|
374
|
+
};
|
|
375
|
+
const saveFromEditor = (text) => {
|
|
376
|
+
if (!openFile)
|
|
377
|
+
return;
|
|
378
|
+
try {
|
|
379
|
+
writeFileSync(path.resolve(process.cwd(), openFile), text, "utf-8");
|
|
380
|
+
setContent(text);
|
|
381
|
+
draft.current = null;
|
|
382
|
+
setTreeVersion((v) => v + 1);
|
|
383
|
+
refreshGit();
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
setEntries((prev) => [...prev, { kind: "error", text: `Couldn't save ${openFile}: ${error.message}` }]);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
if (view === "settings") {
|
|
390
|
+
return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: plugins, onModel: (next) => switchModel(next), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: () => {
|
|
391
|
+
const found = discoverPlugins();
|
|
392
|
+
setPlugins((prev) => found.map((f) => prev.find((p) => p.id === f.id && p.plugin) ?? f));
|
|
393
|
+
}, onClose: () => setView("main") }));
|
|
394
|
+
}
|
|
395
|
+
const height = Math.max(10, rows - 2);
|
|
396
|
+
const treeWidth = Math.max(24, Math.floor(columns * 0.2));
|
|
397
|
+
const chatWidth = Math.max(34, Math.floor(columns * 0.3));
|
|
398
|
+
const codeWidth = Math.max(20, columns - treeWidth - chatWidth);
|
|
399
|
+
const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
|
|
400
|
+
const mode = approval
|
|
401
|
+
? { label: "APPROVE", color: C.yellow }
|
|
402
|
+
: showEditor ? { label: "EDIT", color: C.green }
|
|
403
|
+
: diff ? { label: "DIFF", color: C.accent2 }
|
|
404
|
+
: busy ? { label: "WORKING", color: C.accent2 }
|
|
405
|
+
: { label: focus.toUpperCase(), color: C.accent };
|
|
406
|
+
const hints = approval
|
|
407
|
+
? "y accept · n reject · Esc stop"
|
|
408
|
+
: showEditor && focus === "code"
|
|
409
|
+
? "Ctrl+S save · Ctrl+Z undo · Esc close editor · Ctrl+P find file"
|
|
410
|
+
: focus === "chat"
|
|
411
|
+
? busy ? "Esc stop · Tab switch pane" : "Enter send · / commands · Ctrl+P find file · Tab switch pane"
|
|
412
|
+
: focus === "tree"
|
|
413
|
+
? "↑↓ move · Enter open · c changes · e edit · Ctrl+P find · s settings · q quit"
|
|
414
|
+
: diff ? "n/N next/prev change · v cycle diff · Esc close diff" : "↑↓ scroll · e edit · v diff · f follow · s settings · q quit";
|
|
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) => {
|
|
416
|
+
open(filePath);
|
|
417
|
+
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, onSave: saveFromEditor, onExit: () => {
|
|
419
|
+
draft.current = null;
|
|
420
|
+
setEditing(false);
|
|
421
|
+
}, onState: (state) => {
|
|
422
|
+
setEditorState(state);
|
|
423
|
+
draft.current = state.dirty
|
|
424
|
+
? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
|
|
425
|
+
: 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 " }), _jsxs(Text, { color: C.dim, children: [" ", hints] })] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
|
|
427
|
+
setQuickOpen(false);
|
|
428
|
+
open(file);
|
|
429
|
+
setReveal(file);
|
|
430
|
+
setFocus("code");
|
|
431
|
+
}, onClose: () => setQuickOpen(false) }))] }));
|
|
432
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
import { wrap } from "./text.js";
|
|
6
|
+
import { C, SPINNER } from "./theme.js";
|
|
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 }) {
|
|
10
|
+
const [input, setInput] = useState("");
|
|
11
|
+
const [scrollBack, setScrollBack] = useState(0);
|
|
12
|
+
const { frame } = useAnimation({ interval: 80, isActive: !!busy });
|
|
13
|
+
const inner = width - 2;
|
|
14
|
+
const lines = useMemo(() => {
|
|
15
|
+
const out = [];
|
|
16
|
+
const all = streaming ? [...entries, { kind: "assistant", text: streaming }] : entries;
|
|
17
|
+
for (const entry of all) {
|
|
18
|
+
if (entry.kind === "user") {
|
|
19
|
+
out.push({ text: "you", color: C.cyan, bold: true, bar: C.cyan });
|
|
20
|
+
for (const line of wrap(entry.text, inner - 3))
|
|
21
|
+
out.push({ text: line, color: C.text, bar: C.cyan });
|
|
22
|
+
out.push({ text: "" });
|
|
23
|
+
}
|
|
24
|
+
else if (entry.kind === "assistant") {
|
|
25
|
+
out.push({ text: "◆ nou", color: C.accent2, bold: true, bar: C.accent2 });
|
|
26
|
+
for (const line of wrap(entry.text, inner - 3)) {
|
|
27
|
+
const isCode = /^\s{2,}|^```|^\s*[{}();]/.test(line);
|
|
28
|
+
out.push({ text: line, ...(isCode ? { color: C.yellow } : {}), bar: C.accent2 });
|
|
29
|
+
}
|
|
30
|
+
out.push({ text: "" });
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const style = entry.kind === "tool"
|
|
34
|
+
? { icon: "⚙", color: C.dim }
|
|
35
|
+
: entry.kind === "error" ? { icon: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
|
|
36
|
+
wrap(entry.text, inner - 4).forEach((line, i) => out.push({ text: `${i === 0 ? ` ${style.icon}` : " "} ${line}`, color: style.color }));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}, [entries, streaming, inner]);
|
|
41
|
+
// New output snaps the view back to the bottom.
|
|
42
|
+
useEffect(() => setScrollBack(0), [entries.length, streaming]);
|
|
43
|
+
const footer = [];
|
|
44
|
+
if (approval?.kind === "command") {
|
|
45
|
+
footer.push({ text: "⚡ Run this command?", color: C.yellow, bold: true });
|
|
46
|
+
for (const line of wrap(`$ ${approval.command}`, inner - 2))
|
|
47
|
+
footer.push({ text: ` ${line}`, color: C.text });
|
|
48
|
+
footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
|
|
49
|
+
}
|
|
50
|
+
else if (approval?.kind === "tool") {
|
|
51
|
+
footer.push({ text: `🔌 Plugin tool: ${approval.name}`, color: C.yellow, bold: true });
|
|
52
|
+
for (const line of wrap(JSON.stringify(approval.args), inner - 2).slice(0, 4))
|
|
53
|
+
footer.push({ text: ` ${line}`, color: C.text });
|
|
54
|
+
footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
|
|
55
|
+
}
|
|
56
|
+
else if (approval?.kind === "change") {
|
|
57
|
+
footer.push({ text: `✎ Review ${approval.change.path} →`, color: C.yellow, bold: true });
|
|
58
|
+
footer.push({ text: " [y] accept [n] reject [Esc] stop", color: C.yellow });
|
|
59
|
+
}
|
|
60
|
+
else if (busy) {
|
|
61
|
+
footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
|
|
62
|
+
}
|
|
63
|
+
const suggestions = input.startsWith("/") && !input.includes(" ")
|
|
64
|
+
? COMMANDS.filter((c) => c.startsWith(input) && c !== input)
|
|
65
|
+
: [];
|
|
66
|
+
const rows = Math.max(1, height - 3 - footer.length - 3);
|
|
67
|
+
const maxBack = Math.max(0, lines.length - rows);
|
|
68
|
+
const back = Math.min(scrollBack, maxBack);
|
|
69
|
+
const visible = lines.slice(Math.max(0, lines.length - rows - back), lines.length - back);
|
|
70
|
+
useInput((_input, key) => {
|
|
71
|
+
if (key.pageUp || (key.upArrow && key.shift))
|
|
72
|
+
setScrollBack((b) => Math.min(maxBack, b + Math.max(1, rows - 2)));
|
|
73
|
+
else if (key.pageDown || (key.downArrow && key.shift))
|
|
74
|
+
setScrollBack((b) => Math.max(0, b - Math.max(1, rows - 2)));
|
|
75
|
+
}, { isActive: focused });
|
|
76
|
+
const submit = (value) => {
|
|
77
|
+
if (!value.trim() || busy || approval)
|
|
78
|
+
return;
|
|
79
|
+
// "/se" + Enter runs /settings when only one command matches.
|
|
80
|
+
const command = !COMMANDS.includes(value.trim()) && suggestions.length === 1 ? suggestions[0] : value.trim();
|
|
81
|
+
setInput("");
|
|
82
|
+
onSubmit(command);
|
|
83
|
+
};
|
|
84
|
+
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
|
+
const empty = entries.length === 0 && !streaming;
|
|
86
|
+
const title = _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back ? _jsxs(Text, { color: C.dim, children: [" \u2191", back, " more"] }) : null] });
|
|
87
|
+
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
|
+
? _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)) }), _jsxs(Box, { height: 1, overflow: "hidden", children: [_jsx(Text, { color: focused ? C.accent2 : C.dim, bold: true, children: "❯ " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !approval, placeholder: busy ? "working… Esc to stop" : "Ask anything · / for commands" })] })] }));
|
|
90
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { artWork } from "../util/index.js";
|
|
5
|
+
import { highlightLines, sideBySide } from "./text.js";
|
|
6
|
+
import { C, fileIcon, gradient } from "./theme.js";
|
|
7
|
+
import { Pane } from "./Pane.js";
|
|
8
|
+
export function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, onViewport }) {
|
|
9
|
+
const [top, setTop] = useState(0);
|
|
10
|
+
const rows = Math.max(1, height - 3);
|
|
11
|
+
const inner = width - 2;
|
|
12
|
+
const lines = useMemo(() => (filePath ? highlightLines(filePath, content) : []), [filePath, content]);
|
|
13
|
+
const diffRows = useMemo(() => (diff ? sideBySide(diff.before, diff.after) : []), [diff]);
|
|
14
|
+
const total = diff ? diffRows.length : lines.length;
|
|
15
|
+
// Jump to the first change whenever a new file or diff is shown.
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const first = diff
|
|
18
|
+
? diffRows.findIndex((r) => r.changed)
|
|
19
|
+
: marked.size ? Math.min(...marked) - 1 : 0;
|
|
20
|
+
setTop(Math.max(0, first - 3));
|
|
21
|
+
}, [filePath, diff, diffRows, marked]);
|
|
22
|
+
const maxTop = Math.max(0, total - rows);
|
|
23
|
+
const start = Math.min(top, maxTop);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (filePath && !diff)
|
|
26
|
+
onViewport(start + 1, Math.min(total, start + rows));
|
|
27
|
+
}, [filePath, diff, start, rows, total]);
|
|
28
|
+
useInput((input, key) => {
|
|
29
|
+
if (key.upArrow || input === "k")
|
|
30
|
+
setTop((t) => Math.max(0, Math.min(t, maxTop) - 1));
|
|
31
|
+
else if (key.downArrow || input === "j")
|
|
32
|
+
setTop((t) => Math.min(maxTop, t + 1));
|
|
33
|
+
else if (key.pageUp || input === "u")
|
|
34
|
+
setTop((t) => Math.max(0, Math.min(t, maxTop) - rows));
|
|
35
|
+
else if (key.pageDown || input === "d" || input === " ")
|
|
36
|
+
setTop((t) => Math.min(maxTop, t + rows));
|
|
37
|
+
else if (input === "g")
|
|
38
|
+
setTop(0);
|
|
39
|
+
else if (input === "G")
|
|
40
|
+
setTop(maxTop);
|
|
41
|
+
else if (diff && (input === "n" || input === "N")) {
|
|
42
|
+
// Jump between change hunks.
|
|
43
|
+
const starts = diffRows.flatMap((r, i) => (r.changed && !diffRows[i - 1]?.changed ? [i] : []));
|
|
44
|
+
const target = input === "n" ? starts.find((s) => s - 3 > start) : [...starts].reverse().find((s) => s - 3 < start);
|
|
45
|
+
if (target !== undefined)
|
|
46
|
+
setTop(Math.max(0, Math.min(maxTop, target - 3)));
|
|
47
|
+
}
|
|
48
|
+
}, { isActive: focused });
|
|
49
|
+
const scroll = total > rows ? ` ${start + 1}-${Math.min(total, start + rows)}/${total}` : "";
|
|
50
|
+
const percent = total > rows ? Math.round(((start + rows) / total) * 100) : 100;
|
|
51
|
+
if (diff) {
|
|
52
|
+
const half = Math.floor((inner - 1) / 2);
|
|
53
|
+
const gutter = String(diffRows.length).length + 1;
|
|
54
|
+
const added = diffRows.filter((r) => r.changed && r.right).length;
|
|
55
|
+
const removed = diffRows.filter((r) => r.changed && r.left).length;
|
|
56
|
+
const cell = (c, side, changed, w) => {
|
|
57
|
+
if (!c)
|
|
58
|
+
return _jsx(Box, { width: w, children: _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "╱".repeat(Math.max(0, w)) }) });
|
|
59
|
+
const color = changed ? (side === "-" ? C.red : C.green) : undefined;
|
|
60
|
+
return (_jsx(Box, { width: w, children: _jsxs(Text, { wrap: "truncate-end", ...(color ? { color } : { color: C.dim }), children: [String(c.n).padStart(gutter - 1), changed ? side : " ", c.text || " "] }) }));
|
|
61
|
+
};
|
|
62
|
+
const { icon, color } = fileIcon(diff.path);
|
|
63
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: "black", children: pending ? (diff.isNew ? " NEW FILE " : " PROPOSED ") : ` ${diffLabel.toUpperCase()} ` }), " ", _jsxs(Text, { color: C.green, children: ["+", added] }), " ", _jsxs(Text, { color: C.red, children: ["-", removed] }), _jsxs(Text, { color: C.dim, children: [pending ? " y accept · n reject" : " n/N next/prev change", scroll] })] }));
|
|
64
|
+
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, ...(pending ? { accent: C.yellow } : {}), children: _jsx(Box, { flexDirection: "column", children: diffRows.slice(start, start + rows).map((row, i) => (_jsxs(Box, { flexDirection: "row", children: [cell(row.left, "-", row.changed, half), _jsx(Text, { color: C.dim, children: "\u2502" }), cell(row.right, "+", row.changed, inner - half - 1)] }, start + i))) }) }));
|
|
65
|
+
}
|
|
66
|
+
if (!filePath) {
|
|
67
|
+
const tips = [
|
|
68
|
+
["Ctrl+P", "find a file"],
|
|
69
|
+
["Tab", "switch panes"],
|
|
70
|
+
["e", "edit the open file"],
|
|
71
|
+
["v", "diff vs git HEAD"],
|
|
72
|
+
["/settings", "models, preferences, plugins"],
|
|
73
|
+
];
|
|
74
|
+
return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! \uD83C\uDF1E Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
|
|
75
|
+
}
|
|
76
|
+
const gutter = String(lines.length).length;
|
|
77
|
+
const { icon, color } = fileIcon(filePath);
|
|
78
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused ? " e edit · v diff" : ""] })] }));
|
|
79
|
+
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, children: _jsx(Box, { flexDirection: "column", children: lines.slice(start, start + rows).map((line, i) => {
|
|
80
|
+
const n = start + i + 1;
|
|
81
|
+
const isMarked = marked.has(n);
|
|
82
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: isMarked ? C.green : C.dim, children: String(n).padStart(gutter) }), _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), line || " "] }, n));
|
|
83
|
+
}) }) }));
|
|
84
|
+
}
|