nou-cli 0.2.1 → 0.4.0

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