nou-cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +12 -0
- package/README.md +42 -6
- package/dist/index.js +22 -2
- package/dist/src/agent/harness.js +54 -0
- package/dist/src/agent/index.js +159 -15
- package/dist/src/commands/index.js +54 -5
- package/dist/src/config/credentials.js +48 -0
- package/dist/src/config/index.js +3 -0
- package/dist/src/config/origins.js +14 -0
- package/dist/src/context/index.js +1 -1
- package/dist/src/engines/claude.js +147 -0
- package/dist/src/engines/codex.js +131 -0
- package/dist/src/engines/external.js +152 -0
- package/dist/src/engines/index.js +127 -0
- package/dist/src/engines/pi.js +161 -0
- package/dist/src/engines/types.js +1 -0
- package/dist/src/env.js +5 -1
- package/dist/src/git/index.js +14 -4
- package/dist/src/history/index.js +53 -1
- package/dist/src/plugins/index.js +109 -21
- package/dist/src/providers/anthropic.js +16 -1
- package/dist/src/providers/ollama.js +58 -1
- package/dist/src/providers/openai.js +22 -5
- package/dist/src/search/index.js +183 -0
- package/dist/src/setup/index.js +240 -0
- package/dist/src/tools/index.js +72 -2
- package/dist/src/ui/App.js +351 -72
- package/dist/src/ui/Chat.js +9 -3
- package/dist/src/ui/CodeView.js +5 -1
- package/dist/src/ui/Editor.js +5 -1
- package/dist/src/ui/FileTree.js +18 -1
- package/dist/src/ui/Header.js +3 -1
- package/dist/src/ui/ProblemsView.js +5 -1
- package/dist/src/ui/Settings.js +93 -14
- package/dist/src/ui/mouse.js +52 -0
- package/dist/src/ui/text.js +12 -0
- package/package.json +5 -3
- package/plugins/fetch-url.mjs +82 -0
- package/plugins/project-rules.mjs +43 -0
- package/plugins/run-tests.mjs +89 -0
- package/plugins/todos.mjs +81 -0
package/dist/src/ui/App.js
CHANGED
|
@@ -3,19 +3,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { readFileSync, writeFileSync } from "fs";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { Box, Text, useApp, useInput, useWindowSize } from "ink";
|
|
6
|
-
import {
|
|
6
|
+
import { buildSystemPrompt } from "../agent/index.js";
|
|
7
|
+
import { ENGINES, createEngine, engineInfo, installEngine, isEngineId, isInstalled } from "../engines/index.js";
|
|
8
|
+
import { harness, problemsIntroduced } from "../agent/harness.js";
|
|
7
9
|
import { describe, providers, resolveModel, saveModel } from "../providers/index.js";
|
|
10
|
+
import { sameModel } from "../providers/ollama.js";
|
|
8
11
|
import { loadSettings, saveSettings } from "../config/index.js";
|
|
9
|
-
import { branch as gitBranch, diffAgainstHead, isRepo, status as gitStatus,
|
|
10
|
-
import {
|
|
11
|
-
import { BUILTIN_PLUGINS, discoverPlugins, isEnabled, loadPlugin, trustPatch } from "../plugins/index.js";
|
|
12
|
+
import { branch as gitBranch, diffAgainstHead, isRepo, sameStatus, status as gitStatus, statusAsync as gitStatusAsync } from "../git/index.js";
|
|
13
|
+
import { BUILTIN_PLUGINS, addStarter, discoverPlugins, isEnabled, loadPlugin, scaffoldPlugin, starterPlugins, trustPatch, trustState } from "../plugins/index.js";
|
|
12
14
|
import { DiagnosticsManager, formatDiagnostic, newProblems } from "../diagnostics/index.js";
|
|
13
15
|
import { Timeline } from "../history/index.js";
|
|
14
16
|
import { lsp } from "../lsp/index.js";
|
|
15
17
|
import { onGrammarLoaded, preload } from "../syntax/index.js";
|
|
16
|
-
import { checkForUpdate } from "../update/index.js";
|
|
17
|
-
import { changedLines } from "./text.js";
|
|
18
|
+
import { checkForUpdate, currentVersion } from "../update/index.js";
|
|
19
|
+
import { changedLines, lineCounts } from "./text.js";
|
|
18
20
|
import { ProblemsView } from "./ProblemsView.js";
|
|
21
|
+
import { useMouse } from "./mouse.js";
|
|
19
22
|
import { C } from "./theme.js";
|
|
20
23
|
import { FileTree } from "./FileTree.js";
|
|
21
24
|
import { CodeView } from "./CodeView.js";
|
|
@@ -31,13 +34,23 @@ const HELP = `Commands:
|
|
|
31
34
|
/model show providers and the current model
|
|
32
35
|
/model <provider[:model]> switch, e.g. /model anthropic /model ollama:qwen3:8b
|
|
33
36
|
/models [provider] list models a provider offers
|
|
37
|
+
/connect <provider> enter an API key (and URL), e.g. /connect fireworks /connect litellm
|
|
38
|
+
/pull <model> download an Ollama model
|
|
39
|
+
/engine [name] which agent answers: nou, claude-code, codex or pi
|
|
40
|
+
/plugins your plugins and the starter ones you can add
|
|
41
|
+
/plugins add <name> add a starter plugin (copied to ~/.nou/plugins to edit)
|
|
42
|
+
/plugins new <name> start your own plugin in .nou/plugins/
|
|
34
43
|
/clear start a fresh conversation
|
|
44
|
+
/compact summarize the conversation so far, to free up context
|
|
35
45
|
/undo undo Nou's last change (also: u)
|
|
36
46
|
/rewind undo everything from Nou's last turn
|
|
37
47
|
/history recent changes
|
|
48
|
+
/diff [file] everything changed this session ([ ] step through files)
|
|
49
|
+
/sessions earlier sessions; /diff <n> shows one of them
|
|
50
|
+
/version which version of Nou this is
|
|
38
51
|
Keys:
|
|
39
52
|
Ctrl+P find a file Tab switch pane Esc stop / back
|
|
40
|
-
e edit open file v diff (git / Nou) f follow mode
|
|
53
|
+
e edit open file v diff (git / Nou / session) f follow mode
|
|
41
54
|
p problems u undo Nou's edit s settings
|
|
42
55
|
c (in files) show only changed files Ctrl+C twice quit
|
|
43
56
|
In the editor: Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
|
|
@@ -54,11 +67,30 @@ export function App({ initialModel, permContext }) {
|
|
|
54
67
|
const { columns, rows } = useWindowSize();
|
|
55
68
|
const [settings, setSettings] = useState(loadSettings);
|
|
56
69
|
const [view, setView] = useState("main");
|
|
70
|
+
const [connectTo, setConnectTo] = useState();
|
|
57
71
|
const [quickOpen, setQuickOpen] = useState(false);
|
|
58
72
|
const [focus, setFocus] = useState("chat");
|
|
59
73
|
const [follow, setFollow] = useState(settings.followMode);
|
|
60
74
|
const [entries, setEntries] = useState([]);
|
|
61
75
|
const [streaming, setStreaming] = useState("");
|
|
76
|
+
// Streamed text arrives a token at a time; redrawing the whole screen for each one is wasted work.
|
|
77
|
+
const streamed = useRef({ text: "", timer: null });
|
|
78
|
+
const showStream = (text) => {
|
|
79
|
+
const s = streamed.current;
|
|
80
|
+
s.text = text;
|
|
81
|
+
if (!text) {
|
|
82
|
+
if (s.timer)
|
|
83
|
+
clearTimeout(s.timer);
|
|
84
|
+
s.timer = null;
|
|
85
|
+
setStreaming("");
|
|
86
|
+
}
|
|
87
|
+
else if (!s.timer) {
|
|
88
|
+
s.timer = setTimeout(() => {
|
|
89
|
+
s.timer = null;
|
|
90
|
+
setStreaming(s.text);
|
|
91
|
+
}, 33);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
62
94
|
const [busy, setBusy] = useState(null);
|
|
63
95
|
const [choice, setChoice] = useState(initialModel);
|
|
64
96
|
const [approval, setApproval] = useState(null);
|
|
@@ -69,9 +101,13 @@ export function App({ initialModel, permContext }) {
|
|
|
69
101
|
// The last change Nou made per file, so we can flag edited lines and re-open the diff.
|
|
70
102
|
const [changes, setChanges] = useState(new Map());
|
|
71
103
|
const [diffMode, setDiffMode] = useState("off");
|
|
104
|
+
// Which session the "session" diff shows (null: this one), and a counter that bumps when history changes.
|
|
105
|
+
const [sessionView, setSessionView] = useState(null);
|
|
106
|
+
const [historyVersion, setHistoryVersion] = useState(0);
|
|
72
107
|
const [editing, setEditing] = useState(false);
|
|
73
108
|
const [git, setGit] = useState(() => gitStatus());
|
|
74
109
|
const [plugins, setPlugins] = useState([]);
|
|
110
|
+
const [starters, setStarters] = useState([]);
|
|
75
111
|
const [viewport, setViewport] = useState(null);
|
|
76
112
|
const [editorState, setEditorState] = useState(null);
|
|
77
113
|
// Unsaved editor text survives switching to a diff and back.
|
|
@@ -84,11 +120,16 @@ export function App({ initialModel, permContext }) {
|
|
|
84
120
|
const [pendingNote, setPendingNote] = useState(null);
|
|
85
121
|
const [exitArmed, setExitArmed] = useState(false);
|
|
86
122
|
const [update, setUpdate] = useState(null);
|
|
123
|
+
const [wheel, setWheel] = useState(null);
|
|
124
|
+
const [treeClick, setTreeClick] = useState(null);
|
|
125
|
+
// Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
|
|
126
|
+
const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
|
|
127
|
+
const pullAbort = useRef(null);
|
|
87
128
|
const diagnosticsRef = useRef(null);
|
|
88
129
|
diagnosticsRef.current ??= new DiagnosticsManager();
|
|
89
130
|
const diagnostics = diagnosticsRef.current;
|
|
90
131
|
const timelineRef = useRef(null);
|
|
91
|
-
timelineRef.current ??= new Timeline();
|
|
132
|
+
timelineRef.current ??= new Timeline(() => setHistoryVersion((v) => v + 1));
|
|
92
133
|
const timeline = timelineRef.current;
|
|
93
134
|
const followRef = useRef(follow);
|
|
94
135
|
followRef.current = follow;
|
|
@@ -108,10 +149,13 @@ export function App({ initialModel, permContext }) {
|
|
|
108
149
|
void diagnosticsRef.current.checkFile(filePath);
|
|
109
150
|
}, []);
|
|
110
151
|
const agentRef = useRef(null);
|
|
111
|
-
|
|
112
|
-
|
|
152
|
+
const hooksRef = useRef(null);
|
|
153
|
+
/** What the engine was last configured with, to configure a new one the same way. */
|
|
154
|
+
const engineConfig = useRef({});
|
|
155
|
+
if (!hooksRef.current) {
|
|
156
|
+
hooksRef.current = {
|
|
113
157
|
onEntry: (entry) => setEntries((prev) => [...prev, entry]),
|
|
114
|
-
onStream:
|
|
158
|
+
onStream: showStream,
|
|
115
159
|
onBusy: setBusy,
|
|
116
160
|
onRead: (filePath) => {
|
|
117
161
|
if (!followRef.current)
|
|
@@ -147,11 +191,12 @@ export function App({ initialModel, permContext }) {
|
|
|
147
191
|
}
|
|
148
192
|
return new Promise((resolve) => setApproval({ request, resolve }));
|
|
149
193
|
},
|
|
150
|
-
checkChange:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
194
|
+
checkChange: (change) => problemsIntroduced(diagnosticsRef.current, change),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (!agentRef.current) {
|
|
198
|
+
const saved = loadSettings().engine;
|
|
199
|
+
agentRef.current = createEngine(isEngineId(saved) ? saved : "nou", initialModel, buildSystemPrompt(permContext), hooksRef.current);
|
|
155
200
|
}
|
|
156
201
|
useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
|
|
157
202
|
useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
|
|
@@ -164,9 +209,11 @@ export function App({ initialModel, permContext }) {
|
|
|
164
209
|
const found = discoverPlugins();
|
|
165
210
|
const saved = loadSettings();
|
|
166
211
|
setPlugins(await Promise.all(found.map((p) => (isEnabled(p, saved) ? loadPlugin(p) : p))));
|
|
212
|
+
setStarters(await Promise.all(starterPlugins().map(loadPlugin)));
|
|
167
213
|
})();
|
|
168
214
|
}, []);
|
|
169
|
-
|
|
215
|
+
// Starter plugins are listed until you add them (or have your own plugin with the same name).
|
|
216
|
+
const allPlugins = useMemo(() => [...BUILTIN_PLUGINS, ...plugins, ...starters.filter((s) => !plugins.some((p) => p.id === s.id))], [plugins, starters]);
|
|
170
217
|
const activePlugins = useMemo(() => allPlugins.filter((p) => isEnabled(p, settings) && p.plugin), [allPlugins, settings.plugins, settings.trustedPlugins]);
|
|
171
218
|
// Checkers come from enabled plugins (built-in ones included).
|
|
172
219
|
const firstCheck = useRef(true);
|
|
@@ -179,53 +226,24 @@ export function App({ initialModel, permContext }) {
|
|
|
179
226
|
}, [activePlugins]);
|
|
180
227
|
// Keep the agent's system prompt and tools in sync with settings and plugins.
|
|
181
228
|
useEffect(() => {
|
|
182
|
-
const extras =
|
|
183
|
-
|
|
184
|
-
|
|
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 });
|
|
229
|
+
const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
|
|
230
|
+
engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, permContext].filter(Boolean), extraTools, effort: settings.effort };
|
|
231
|
+
agentRef.current.configure(engineConfig.current);
|
|
221
232
|
}, [settings.gitContext, settings.effort, activePlugins]);
|
|
222
233
|
// Pick up changes made outside Nou (other editors, git commands).
|
|
234
|
+
const polling = useRef(false);
|
|
223
235
|
useEffect(() => {
|
|
224
236
|
if (!repo)
|
|
225
237
|
return;
|
|
226
238
|
const timer = setInterval(() => {
|
|
227
|
-
if (
|
|
228
|
-
|
|
239
|
+
if (busy || polling.current)
|
|
240
|
+
return;
|
|
241
|
+
polling.current = true;
|
|
242
|
+
void gitStatusAsync().then((next) => {
|
|
243
|
+
polling.current = false;
|
|
244
|
+
// Only redraw when something changed.
|
|
245
|
+
setGit((prev) => (sameStatus(prev, next) ? prev : next));
|
|
246
|
+
});
|
|
229
247
|
}, 4000);
|
|
230
248
|
return () => clearInterval(timer);
|
|
231
249
|
}, [repo, busy]);
|
|
@@ -247,13 +265,94 @@ export function App({ initialModel, permContext }) {
|
|
|
247
265
|
setChoice(next);
|
|
248
266
|
saveModel(next);
|
|
249
267
|
setSettings(loadSettings());
|
|
250
|
-
|
|
268
|
+
const engine = agentRef.current;
|
|
269
|
+
const carries = engine.id === "nou" || engine.id === "claude-code";
|
|
270
|
+
setEntries((prev) => [...prev, { kind: "info", text: `Now using ${engine.id === "nou" ? describe(next) : `${engineInfo(engine.id).label} with ${engineInfo(engine.id).model(next)}`}. ${carries ? "The conversation carries over." : "This starts a new conversation."}` }]);
|
|
251
271
|
return true;
|
|
252
272
|
};
|
|
273
|
+
/** Switches the agent engine, installing it first if needed (Esc stops the install). Starts a new conversation. */
|
|
274
|
+
const switchEngine = async (id) => {
|
|
275
|
+
if (busy)
|
|
276
|
+
return info("Wait for the current reply to finish (or press Esc) before switching engines.", "error");
|
|
277
|
+
const target = engineInfo(id);
|
|
278
|
+
if (agentRef.current.id === id)
|
|
279
|
+
return info(`Already using ${target.label}.`);
|
|
280
|
+
if (!isInstalled(target)) {
|
|
281
|
+
const controller = new AbortController();
|
|
282
|
+
pullAbort.current = controller;
|
|
283
|
+
info(`Installing ${target.label} (${target.package}) into ~/.nou/engines. Esc to stop.`);
|
|
284
|
+
setBusy(`Installing ${target.label}...`);
|
|
285
|
+
try {
|
|
286
|
+
await installEngine(target, (line) => setBusy(`Installing ${target.label}: ${line.slice(0, 80)}`), controller.signal);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
return info(controller.signal.aborted ? `Stopped installing ${target.label}.` : `Couldn't install ${target.label}: ${error.message}`, "error");
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
pullAbort.current = null;
|
|
293
|
+
setBusy(null);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
agentRef.current.dispose?.();
|
|
297
|
+
const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
|
|
298
|
+
engine.configure(engineConfig.current);
|
|
299
|
+
agentRef.current = engine;
|
|
300
|
+
updateSettings({ engine: id });
|
|
301
|
+
info(`Now using the ${target.label} engine (model: ${target.model(choice)}). This starts a new conversation.${target.approvals === "after" ? " It edits without asking first; review its changes in the diff view and press u to undo." : ""}`);
|
|
302
|
+
};
|
|
303
|
+
/** Downloads an Ollama model, showing progress in the status line. Esc stops it. */
|
|
304
|
+
const pullModel = async (model) => {
|
|
305
|
+
const ollama = providers.find((p) => p.id === "ollama");
|
|
306
|
+
const controller = new AbortController();
|
|
307
|
+
pullAbort.current = controller;
|
|
308
|
+
setBusy(`Downloading ${model}...`);
|
|
309
|
+
try {
|
|
310
|
+
await ollama.pull(model, (status, percent) => setBusy(`Downloading ${model}: ${percent === null ? status : `${percent}%`}`), controller.signal);
|
|
311
|
+
info(`Downloaded ${model}.`);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
info(controller.signal.aborted ? `Stopped downloading ${model}.` : `Couldn't download ${model}: ${error.message}`, "error");
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
pullAbort.current = null;
|
|
320
|
+
setBusy(null);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
/** Switches model, downloading it first if it's an Ollama model that isn't installed yet. */
|
|
324
|
+
const chooseModel = async (next) => {
|
|
325
|
+
if (next.provider.pull) {
|
|
326
|
+
const installed = await next.provider.listModels().catch(() => null);
|
|
327
|
+
if (installed && !installed.some((m) => sameModel(m, next.model))) {
|
|
328
|
+
info(`${next.model} isn't downloaded yet; downloading it now (Esc to stop).`);
|
|
329
|
+
if (!(await pullModel(next.model)))
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
switchModel(next);
|
|
334
|
+
};
|
|
335
|
+
/** Copies a starter plugin into ~/.nou/plugins/, switches it on and loads it. */
|
|
336
|
+
const addStarterPlugin = async (starter) => {
|
|
337
|
+
const added = addStarter(starter);
|
|
338
|
+
updateSettings(trustPatch(added, true, settings));
|
|
339
|
+
const loaded = await loadPlugin(added);
|
|
340
|
+
setPlugins((prev) => [...prev.filter((p) => p.id !== added.id), loaded]);
|
|
341
|
+
return loaded;
|
|
342
|
+
};
|
|
343
|
+
const rescanPlugins = () => {
|
|
344
|
+
const found = discoverPlugins();
|
|
345
|
+
setPlugins((prev) => found.map((f) => prev.find((p) => p.id === f.id && p.plugin) ?? f));
|
|
346
|
+
};
|
|
253
347
|
const togglePlugin = async (id, enabled) => {
|
|
254
348
|
const entry = allPlugins.find((p) => p.id === id);
|
|
255
349
|
if (!entry)
|
|
256
350
|
return;
|
|
351
|
+
if (entry.scope === "starter") {
|
|
352
|
+
if (enabled)
|
|
353
|
+
await addStarterPlugin(entry);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
257
356
|
updateSettings(trustPatch(entry, enabled, settings));
|
|
258
357
|
if (!enabled)
|
|
259
358
|
return;
|
|
@@ -269,7 +368,12 @@ export function App({ initialModel, permContext }) {
|
|
|
269
368
|
const record = openFile ? changes.get(openFile) : undefined;
|
|
270
369
|
const pendingChange = approval?.request.kind === "change" ? approval.request.change : null;
|
|
271
370
|
const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
|
|
272
|
-
const
|
|
371
|
+
const sessionId = sessionView?.id ?? agentRef.current.session;
|
|
372
|
+
const sessionFiles = useMemo(() => timeline.sessionChanges(sessionId), [sessionId, historyVersion]);
|
|
373
|
+
const sessionChange = openFile ? sessionFiles.find((c) => c.path === openFile) ?? null : null;
|
|
374
|
+
const diff = pendingChange ?? (diffMode === "git" ? gitChange
|
|
375
|
+
: diffMode === "nou" && record ? record.change
|
|
376
|
+
: diffMode === "session" ? sessionChange : null);
|
|
273
377
|
const showEditor = editing && !!openFile && !pendingChange;
|
|
274
378
|
// Check Nou's proposed edit before you approve it: does it add problems?
|
|
275
379
|
useEffect(() => {
|
|
@@ -311,11 +415,32 @@ export function App({ initialModel, permContext }) {
|
|
|
311
415
|
setExitArmed(true);
|
|
312
416
|
setTimeout(() => setExitArmed(false), 2000);
|
|
313
417
|
};
|
|
418
|
+
// Mouse: the wheel scrolls whatever pane is under the pointer; a click focuses that pane
|
|
419
|
+
// (and in the file tree, opens the clicked file).
|
|
420
|
+
useMouse((event) => {
|
|
421
|
+
if (view !== "main" || quickOpen)
|
|
422
|
+
return;
|
|
423
|
+
const { treeWidth, codeWidth, paneHeight } = layout.current;
|
|
424
|
+
if (event.y < 1 || event.y > paneHeight)
|
|
425
|
+
return; // header or status bar
|
|
426
|
+
const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
|
|
427
|
+
if (event.kind === "wheel") {
|
|
428
|
+
setWheel((w) => ({ pane, dir: event.dir, nonce: (w?.nonce ?? 0) + 1 }));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (approval)
|
|
432
|
+
return;
|
|
433
|
+
setFocus(pane);
|
|
434
|
+
// Rows start under the pane's top border (y=1) and title (y=2).
|
|
435
|
+
if (pane === "tree" && event.y >= 3)
|
|
436
|
+
setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
|
|
437
|
+
}, settings.mouse);
|
|
314
438
|
// Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
|
|
315
439
|
useInput((input, key) => {
|
|
316
440
|
if (!(key.ctrl && input === "c"))
|
|
317
441
|
return;
|
|
318
442
|
if (busy || approval) {
|
|
443
|
+
pullAbort.current?.abort();
|
|
319
444
|
agentRef.current?.cancel();
|
|
320
445
|
approval?.resolve(false);
|
|
321
446
|
setApproval(null);
|
|
@@ -369,6 +494,65 @@ export function App({ initialModel, permContext }) {
|
|
|
369
494
|
return ` ${time} ${c.source === "nou" ? "Nou" : "you"} ${c.before === null ? "created " : ""}${c.path}${c.undone ? " (undone)" : ""}`;
|
|
370
495
|
}).join("\n")}\nUndo with /undo (last) or /rewind (whole last turn).`);
|
|
371
496
|
};
|
|
497
|
+
/** Opens a file showing its net change over the session being viewed. */
|
|
498
|
+
const openSessionDiff = (file) => {
|
|
499
|
+
if (!settleDraft(file))
|
|
500
|
+
return;
|
|
501
|
+
open(file);
|
|
502
|
+
setReveal(file);
|
|
503
|
+
setDiffMode("session");
|
|
504
|
+
setFocus("code");
|
|
505
|
+
};
|
|
506
|
+
/** [ and ]: previous / next file in the session diff. */
|
|
507
|
+
const stepSessionFile = (step) => {
|
|
508
|
+
if (!sessionFiles.length)
|
|
509
|
+
return;
|
|
510
|
+
const at = sessionFiles.findIndex((c) => c.path === openFile);
|
|
511
|
+
const next = at < 0 ? 0 : (at + step + sessionFiles.length) % sessionFiles.length;
|
|
512
|
+
openSessionDiff(sessionFiles[next].path);
|
|
513
|
+
};
|
|
514
|
+
const sessionLabel = (s) => new Date(s.start).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
515
|
+
const showSessions = () => {
|
|
516
|
+
const list = timeline.sessions().slice(0, 15);
|
|
517
|
+
if (!list.length)
|
|
518
|
+
return info("No sessions with changes yet.");
|
|
519
|
+
info(`Sessions with changes (newest first):\n${list.map((s, i) => {
|
|
520
|
+
const current = s.id === agentRef.current.session ? " (this session)" : "";
|
|
521
|
+
return ` ${String(i + 1).padStart(2)}. ${sessionLabel(s)} ${s.files.length} file${s.files.length === 1 ? "" : "s"}${current}`;
|
|
522
|
+
}).join("\n")}\nShow one with /diff <number>.`);
|
|
523
|
+
};
|
|
524
|
+
/** /diff: this session; /diff <n>: the nth session from /sessions; /diff <file>: one file of the session being viewed. */
|
|
525
|
+
const showSessionDiff = (arg) => {
|
|
526
|
+
const current = agentRef.current.session;
|
|
527
|
+
let id = current;
|
|
528
|
+
let label = "this session";
|
|
529
|
+
let file = null;
|
|
530
|
+
if (/^\d+$/.test(arg)) {
|
|
531
|
+
const picked = timeline.sessions()[Number(arg) - 1];
|
|
532
|
+
if (!picked)
|
|
533
|
+
return info(`There's no session ${arg}. See /sessions.`, "error");
|
|
534
|
+
id = picked.id;
|
|
535
|
+
if (id !== current)
|
|
536
|
+
label = `session ${sessionLabel(picked)}`;
|
|
537
|
+
}
|
|
538
|
+
else if (arg) {
|
|
539
|
+
file = arg.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
540
|
+
if (sessionView)
|
|
541
|
+
({ id, label } = sessionView);
|
|
542
|
+
}
|
|
543
|
+
const files = timeline.sessionChanges(id);
|
|
544
|
+
setSessionView(id === current ? null : { id, label });
|
|
545
|
+
if (!files.length)
|
|
546
|
+
return info(`No changes in ${label}.`);
|
|
547
|
+
if (file && !files.some((c) => c.path === file))
|
|
548
|
+
return info(`${file} didn't change in ${label}.`, "error");
|
|
549
|
+
const lines = files.map((c) => {
|
|
550
|
+
const { added, removed } = lineCounts(c.before, c.after);
|
|
551
|
+
return ` ${c.isNew ? "A" : "M"} ${c.path} +${added} -${removed}`;
|
|
552
|
+
});
|
|
553
|
+
info(`Changes in ${label} (yours and Nou's):\n${lines.join("\n")}\nShowing ${file ?? files[0].path}. [ ] previous/next file · v cycle diffs · Esc close.`);
|
|
554
|
+
openSessionDiff(file ?? files[0].path);
|
|
555
|
+
};
|
|
372
556
|
/**
|
|
373
557
|
* Before editing another file: an unsaved draft of a different file is saved (auto-save on) or kept and
|
|
374
558
|
* the switch refused (auto-save off). Never silently dropped. Returns whether it's OK to continue.
|
|
@@ -417,6 +601,8 @@ export function App({ initialModel, permContext }) {
|
|
|
417
601
|
order.push("git");
|
|
418
602
|
if (record?.status === "applied")
|
|
419
603
|
order.push("nou");
|
|
604
|
+
if (sessionChange)
|
|
605
|
+
order.push("session");
|
|
420
606
|
if (order.length === 1) {
|
|
421
607
|
setEntries((prev) => [...prev, { kind: "info", text: `${openFile} has no changes vs git HEAD.` }]);
|
|
422
608
|
return;
|
|
@@ -448,10 +634,14 @@ export function App({ initialModel, permContext }) {
|
|
|
448
634
|
return;
|
|
449
635
|
}
|
|
450
636
|
if (key.escape) {
|
|
451
|
-
if (busy)
|
|
637
|
+
if (busy) {
|
|
638
|
+
pullAbort.current?.abort();
|
|
452
639
|
agentRef.current?.cancel();
|
|
453
|
-
|
|
640
|
+
}
|
|
641
|
+
else if (diffMode !== "off") {
|
|
454
642
|
setDiffMode("off");
|
|
643
|
+
setSessionView(null);
|
|
644
|
+
}
|
|
455
645
|
else
|
|
456
646
|
setFocus("chat");
|
|
457
647
|
return;
|
|
@@ -469,6 +659,8 @@ export function App({ initialModel, permContext }) {
|
|
|
469
659
|
undoLast();
|
|
470
660
|
else if (input === "v")
|
|
471
661
|
cycleDiff();
|
|
662
|
+
else if ((input === "]" || input === "[") && diffMode === "session")
|
|
663
|
+
stepSessionFile(input === "]" ? 1 : -1);
|
|
472
664
|
else if (input === "e")
|
|
473
665
|
startEditing();
|
|
474
666
|
else if (input === "s" || input === ",")
|
|
@@ -508,6 +700,36 @@ ${snippet}
|
|
|
508
700
|
const contextChip = openFile
|
|
509
701
|
? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ✎ unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
|
|
510
702
|
: null;
|
|
703
|
+
const pluginsCommand = async ([action, name]) => {
|
|
704
|
+
if (action === "add") {
|
|
705
|
+
const starter = starters.find((s) => s.id === name);
|
|
706
|
+
if (!starter)
|
|
707
|
+
return info(`No starter plugin "${name ?? ""}". Starters: ${starters.map((s) => s.id).join(", ")}`, "error");
|
|
708
|
+
if (plugins.some((p) => p.id === starter.id))
|
|
709
|
+
return info(`You already have a plugin called ${starter.id}; see /settings → Plugins.`, "error");
|
|
710
|
+
const added = await addStarterPlugin(starter);
|
|
711
|
+
return added.error
|
|
712
|
+
? info(`Added ${added.file}, but it failed to load: ${added.error}`, "error")
|
|
713
|
+
: info(`Added and switched on ${added.plugin.name} (${added.file}). Edit that file to make it your own.`);
|
|
714
|
+
}
|
|
715
|
+
if (action === "new") {
|
|
716
|
+
try {
|
|
717
|
+
const file = scaffoldPlugin(name ?? "");
|
|
718
|
+
rescanPlugins();
|
|
719
|
+
return info(`Created ${path.relative(process.cwd(), file)}. Edit it, then switch it on in /settings → Plugins.`);
|
|
720
|
+
}
|
|
721
|
+
catch (error) {
|
|
722
|
+
return info(`/plugins new: ${error.message}`, "error");
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (action)
|
|
726
|
+
return info("Usage: /plugins, /plugins add <name>, /plugins new <name>", "error");
|
|
727
|
+
const mark = { on: "●", off: "○", changed: "⚠" };
|
|
728
|
+
const lines = allPlugins.map((p) => p.scope === "starter"
|
|
729
|
+
? ` + ${p.id.padEnd(16)} ${p.plugin?.description ?? ""}`
|
|
730
|
+
: ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✖ ${p.error}` : ""}`);
|
|
731
|
+
return info(`Plugins (● on, ○ off, + starter you can add with /plugins add <name>):\n${lines.join("\n")}\nWrite your own: /plugins new <name>`);
|
|
732
|
+
};
|
|
511
733
|
const runCommand = async (input) => {
|
|
512
734
|
const [command, ...rest] = input.slice(1).split(/\s+/);
|
|
513
735
|
const arg = rest.join(" ").trim();
|
|
@@ -518,13 +740,29 @@ ${snippet}
|
|
|
518
740
|
return rewind();
|
|
519
741
|
case "history":
|
|
520
742
|
return showHistory();
|
|
743
|
+
case "diff":
|
|
744
|
+
return showSessionDiff(arg);
|
|
745
|
+
case "sessions":
|
|
746
|
+
return showSessions();
|
|
521
747
|
case "problems":
|
|
522
748
|
setCodePanel("problems");
|
|
523
749
|
setFocus("code");
|
|
524
750
|
return;
|
|
525
751
|
case "settings":
|
|
752
|
+
setConnectTo(undefined);
|
|
526
753
|
setView("settings");
|
|
527
754
|
return;
|
|
755
|
+
case "connect": {
|
|
756
|
+
const provider = providers.find((p) => p.id === arg.toLowerCase());
|
|
757
|
+
const choices = providers.filter((p) => p.connect).map((p) => p.id).join(", ");
|
|
758
|
+
if (!provider)
|
|
759
|
+
return info(arg ? `Unknown provider "${arg}". Choose one of: ${choices}` : `Usage: /connect <provider>, one of: ${choices}`, "error");
|
|
760
|
+
if (!provider.connect)
|
|
761
|
+
return info(`${provider.label} needs nothing to connect.`);
|
|
762
|
+
setConnectTo(provider);
|
|
763
|
+
setView("settings");
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
528
766
|
case "model": {
|
|
529
767
|
if (!arg) {
|
|
530
768
|
const list = providers.map((p) => {
|
|
@@ -534,7 +772,23 @@ ${snippet}
|
|
|
534
772
|
info(`Current: ${describe(choice)}\n${list.join("\n")}\nSwitch with /model <provider[:model]> or open /settings`);
|
|
535
773
|
return;
|
|
536
774
|
}
|
|
537
|
-
|
|
775
|
+
return chooseModel(resolveModel(arg));
|
|
776
|
+
}
|
|
777
|
+
case "engine": {
|
|
778
|
+
if (arg && isEngineId(arg))
|
|
779
|
+
return switchEngine(arg);
|
|
780
|
+
if (arg)
|
|
781
|
+
return info(`Unknown engine ${arg}. Choose one of: ${ENGINES.map((e) => e.id).join(", ")}`, "error");
|
|
782
|
+
const current = agentRef.current.id;
|
|
783
|
+
const lines = ENGINES.map((e) => ` ${e.id === current ? "●" : "○"} ${e.id.padEnd(12)} ${e.description}${isInstalled(e) ? "" : " (installs on first use)"}`);
|
|
784
|
+
return info(`Engines (/engine <name> to switch, or /settings → Engine):\n${lines.join("\n")}`);
|
|
785
|
+
}
|
|
786
|
+
case "pull": {
|
|
787
|
+
if (!arg)
|
|
788
|
+
return info("Usage: /pull <model>, e.g. /pull qwen3:8b. Browse models at https://ollama.com/library", "error");
|
|
789
|
+
const model = arg.replace(/^ollama:/, "");
|
|
790
|
+
if (await pullModel(model))
|
|
791
|
+
info(`Use it with /model ollama:${model}`);
|
|
538
792
|
return;
|
|
539
793
|
}
|
|
540
794
|
case "models": {
|
|
@@ -554,10 +808,33 @@ ${snippet}
|
|
|
554
808
|
}
|
|
555
809
|
return;
|
|
556
810
|
}
|
|
811
|
+
case "compact": {
|
|
812
|
+
const engine = agentRef.current;
|
|
813
|
+
if (!engine.compact)
|
|
814
|
+
return info(`${engineInfo(engine.id).label} manages its own context.`);
|
|
815
|
+
if (busy)
|
|
816
|
+
return info("Wait for the current reply to finish first.", "error");
|
|
817
|
+
setBusy("Summarizing the conversation...");
|
|
818
|
+
try {
|
|
819
|
+
if (!(await engine.compact(true)))
|
|
820
|
+
info("Nothing to compact yet.");
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
info(`Couldn't compact: ${error.message}`, "error");
|
|
824
|
+
}
|
|
825
|
+
finally {
|
|
826
|
+
setBusy(null);
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
557
830
|
case "clear":
|
|
558
831
|
agentRef.current.clear();
|
|
559
832
|
setEntries([]);
|
|
560
833
|
return;
|
|
834
|
+
case "plugins":
|
|
835
|
+
return pluginsCommand(rest);
|
|
836
|
+
case "version":
|
|
837
|
+
return info(`nou v${currentVersion()}${update ? ` · v${update} is available, run \`nou update\`` : " · up to date"}`);
|
|
561
838
|
case "help":
|
|
562
839
|
return info(HELP);
|
|
563
840
|
default: {
|
|
@@ -609,15 +886,17 @@ ${snippet}
|
|
|
609
886
|
}
|
|
610
887
|
}
|
|
611
888
|
if (view === "settings") {
|
|
612
|
-
return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, onModel: (next) =>
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}
|
|
889
|
+
return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, ...(connectTo ? { connectTo } : {}), onModel: (next) => void chooseModel(next), onEngine: (id) => void switchEngine(id), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: rescanPlugins, onClose: () => {
|
|
890
|
+
setConnectTo(undefined);
|
|
891
|
+
setView("main");
|
|
892
|
+
} }));
|
|
616
893
|
}
|
|
617
894
|
const height = Math.max(10, rows - 2);
|
|
618
895
|
const treeWidth = Math.max(24, Math.floor(columns * 0.2));
|
|
619
896
|
const chatWidth = Math.max(34, Math.floor(columns * 0.3));
|
|
620
897
|
const codeWidth = Math.max(20, columns - treeWidth - chatWidth);
|
|
898
|
+
layout.current = { treeWidth, codeWidth, paneHeight: height };
|
|
899
|
+
const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
|
|
621
900
|
const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
|
|
622
901
|
const fileProblems = openFile ? diagnostics.get(openFile) : [];
|
|
623
902
|
const totals = diagnostics.counts();
|
|
@@ -634,14 +913,14 @@ ${snippet}
|
|
|
634
913
|
: showEditor && focus === "code"
|
|
635
914
|
? `Ctrl+S save${settings.autoSave === "off" ? "" : " (auto-save on)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close`
|
|
636
915
|
: focus === "chat"
|
|
637
|
-
? busy ? "Esc stop · Tab switch pane" : "Enter send · / commands · Ctrl+P find file · Tab switch pane"
|
|
916
|
+
? busy ? "Esc stop · Shift+↑↓ scroll chat · Tab switch pane" : "Enter send · Shift+↑↓ / PgUp scroll · / commands · Ctrl+P find file · Tab switch pane"
|
|
638
917
|
: focus === "tree"
|
|
639
918
|
? "↑↓ move · Enter open · c changes · e edit · p problems · u undo · Ctrl+P find · s settings"
|
|
640
|
-
: diff ?
|
|
641
|
-
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: describe(choice)
|
|
919
|
+
: diff ? `n/N next/prev change${diffMode === "session" ? " · [ ] prev/next file" : ""} · v cycle diff · Esc close diff` : "↑↓ scroll · e edit · v diff · p problems · u undo · s settings";
|
|
920
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), wheel: wheelFor("tree"), click: treeClick, onOpen: (filePath) => {
|
|
642
921
|
open(filePath);
|
|
643
922
|
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) => {
|
|
923
|
+
}, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code" && !approval, problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
|
|
645
924
|
if (!lspEnabled)
|
|
646
925
|
return info("Go to definition needs a language server (see /settings → Plugins).", "error");
|
|
647
926
|
void lsp.definition(openFile, text, line, col).then((target) => {
|
|
@@ -660,7 +939,7 @@ ${snippet}
|
|
|
660
939
|
draft.current = state.dirty
|
|
661
940
|
? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
|
|
662
941
|
: 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
|
|
942
|
+
} }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code" && !approval, filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), onSubmit: send })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
|
|
664
943
|
? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
|
|
665
944
|
: _jsxs(Text, { color: C.dim, children: [" ", hints] })] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
|
|
666
945
|
setQuickOpen(false);
|