nou-cli 0.1.0 → 0.2.1

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