nou-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +140 -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 +5 -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 +268 -29
  34. package/dist/src/ui/Chat.js +11 -4
  35. package/dist/src/ui/ChatInput.js +128 -0
  36. package/dist/src/ui/CodeView.js +21 -5
  37. package/dist/src/ui/Editor.js +90 -21
  38. package/dist/src/ui/FileTree.js +6 -4
  39. package/dist/src/ui/Header.js +2 -2
  40. package/dist/src/ui/ProblemsView.js +71 -0
  41. package/dist/src/ui/Settings.js +30 -15
  42. package/dist/src/ui/text.js +6 -1
  43. package/dist/src/update/index.js +102 -0
  44. package/package.json +9 -4
@@ -8,8 +8,14 @@ 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 } from "../update/index.js";
12
17
  import { changedLines } from "./text.js";
18
+ import { ProblemsView } from "./ProblemsView.js";
13
19
  import { C } from "./theme.js";
14
20
  import { FileTree } from "./FileTree.js";
15
21
  import { CodeView } from "./CodeView.js";
@@ -26,10 +32,15 @@ const HELP = `Commands:
26
32
  /model <provider[:model]> switch, e.g. /model anthropic /model ollama:qwen3:8b
27
33
  /models [provider] list models a provider offers
28
34
  /clear start a fresh conversation
35
+ /undo undo Nou's last change (also: u)
36
+ /rewind undo everything from Nou's last turn
37
+ /history recent changes
29
38
  Keys:
30
39
  Ctrl+P find a file Tab switch pane Esc stop / back
31
40
  e edit open file v diff (git / Nou) f follow mode
32
- c (in files) show only changed files s settings`;
41
+ p problems u undo Nou's edit s settings
42
+ c (in files) show only changed files Ctrl+C twice quit
43
+ In the editor: Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
33
44
  function readText(filePath) {
34
45
  try {
35
46
  return readFileSync(path.resolve(process.cwd(), filePath), "utf-8");
@@ -65,6 +76,20 @@ export function App({ initialModel, permContext }) {
65
76
  const [editorState, setEditorState] = useState(null);
66
77
  // Unsaved editor text survives switching to a diff and back.
67
78
  const draft = useRef(null);
79
+ const [codePanel, setCodePanel] = useState("code");
80
+ const [, setDiagVersion] = useState(0);
81
+ const [syntaxVersion, setSyntaxVersion] = useState(0);
82
+ const [focusLine, setFocusLine] = useState(null);
83
+ const [editorStart, setEditorStart] = useState({ line: 1, nonce: 0 });
84
+ const [pendingNote, setPendingNote] = useState(null);
85
+ const [exitArmed, setExitArmed] = useState(false);
86
+ const [update, setUpdate] = useState(null);
87
+ const diagnosticsRef = useRef(null);
88
+ diagnosticsRef.current ??= new DiagnosticsManager();
89
+ const diagnostics = diagnosticsRef.current;
90
+ const timelineRef = useRef(null);
91
+ timelineRef.current ??= new Timeline();
92
+ const timeline = timelineRef.current;
68
93
  const followRef = useRef(follow);
69
94
  followRef.current = follow;
70
95
  const settingsRef = useRef(settings);
@@ -78,6 +103,9 @@ export function App({ initialModel, permContext }) {
78
103
  setContent(readText(filePath));
79
104
  setDiffMode("off");
80
105
  setEditing(false);
106
+ setCodePanel("code");
107
+ preload(filePath);
108
+ void diagnosticsRef.current.checkFile(filePath);
81
109
  }, []);
82
110
  const agentRef = useRef(null);
83
111
  if (!agentRef.current) {
@@ -91,7 +119,10 @@ export function App({ initialModel, permContext }) {
91
119
  open(filePath);
92
120
  setReveal(filePath);
93
121
  },
94
- onChange: (change, status) => {
122
+ onChange: (change, status, turn) => {
123
+ if (status === "applied") {
124
+ timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
125
+ }
95
126
  const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
96
127
  setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
97
128
  // Diffs awaiting approval are always shown, follow mode or not.
@@ -116,17 +147,36 @@ export function App({ initialModel, permContext }) {
116
147
  }
117
148
  return new Promise((resolve) => setApproval({ request, resolve }));
118
149
  },
150
+ checkChange: async (change) => {
151
+ const introduced = await diagnosticsRef.current.introducedBy(change.path, change.before, change.after);
152
+ return introduced.length ? introduced.slice(0, 15).map((d) => formatDiagnostic(d)).join("\n") : null;
153
+ },
119
154
  });
120
155
  }
156
+ useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
157
+ useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
158
+ useEffect(() => {
159
+ void checkForUpdate().then(setUpdate);
160
+ }, []);
121
161
  // Discover plugins once and load the enabled ones.
122
162
  useEffect(() => {
123
163
  void (async () => {
124
164
  const found = discoverPlugins();
125
- const enabled = loadSettings().plugins;
126
- setPlugins(await Promise.all(found.map((p) => (enabled[p.id] ? loadPlugin(p) : p))));
165
+ const saved = loadSettings();
166
+ setPlugins(await Promise.all(found.map((p) => (isEnabled(p, saved) ? loadPlugin(p) : p))));
127
167
  })();
128
168
  }, []);
129
- const activePlugins = useMemo(() => plugins.filter((p) => settings.plugins[p.id] && p.plugin), [plugins, settings.plugins]);
169
+ const allPlugins = useMemo(() => [...BUILTIN_PLUGINS, ...plugins], [plugins]);
170
+ const activePlugins = useMemo(() => allPlugins.filter((p) => isEnabled(p, settings) && p.plugin), [allPlugins, settings.plugins, settings.trustedPlugins]);
171
+ // Checkers come from enabled plugins (built-in ones included).
172
+ const firstCheck = useRef(true);
173
+ useEffect(() => {
174
+ diagnostics.setProviders(activePlugins.flatMap((p) => p.plugin.diagnostics ?? []));
175
+ if (firstCheck.current) {
176
+ firstCheck.current = false;
177
+ setTimeout(() => void diagnostics.checkProject(), 1500);
178
+ }
179
+ }, [activePlugins]);
130
180
  // Keep the agent's system prompt and tools in sync with settings and plugins.
131
181
  useEffect(() => {
132
182
  const extras = [];
@@ -149,6 +199,24 @@ export function App({ initialModel, permContext }) {
149
199
  });
150
200
  }
151
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
+ });
152
220
  agentRef.current.configure({ system: buildSystemPrompt(permContext, extras), extraTools, effort: settings.effort });
153
221
  }, [settings.gitContext, settings.effort, activePlugins]);
154
222
  // Pick up changes made outside Nou (other editors, git commands).
@@ -183,11 +251,13 @@ export function App({ initialModel, permContext }) {
183
251
  return true;
184
252
  };
185
253
  const togglePlugin = async (id, enabled) => {
186
- updateSettings({ plugins: { ...settings.plugins, [id]: enabled } });
254
+ const entry = allPlugins.find((p) => p.id === id);
255
+ if (!entry)
256
+ return;
257
+ updateSettings(trustPatch(entry, enabled, settings));
187
258
  if (!enabled)
188
259
  return;
189
- const entry = plugins.find((p) => p.id === id);
190
- if (entry && !entry.plugin) {
260
+ if (!entry.plugin) {
191
261
  const loaded = await loadPlugin(entry);
192
262
  setPlugins((prev) => prev.map((p) => (p.id === id ? loaded : p)));
193
263
  }
@@ -201,12 +271,141 @@ export function App({ initialModel, permContext }) {
201
271
  const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
202
272
  const diff = pendingChange ?? (diffMode === "git" ? gitChange : diffMode === "nou" && record ? record.change : null);
203
273
  const showEditor = editing && !!openFile && !pendingChange;
274
+ // Check Nou's proposed edit before you approve it: does it add problems?
275
+ useEffect(() => {
276
+ setPendingNote(null);
277
+ if (!pendingChange)
278
+ return;
279
+ let cancelled = false;
280
+ void (async () => {
281
+ const before = await diagnostics.checkText(pendingChange.path, pendingChange.before);
282
+ const after = await diagnostics.checkText(pendingChange.path, pendingChange.after);
283
+ if (cancelled)
284
+ return;
285
+ const added = newProblems(before, after);
286
+ setPendingNote(added.length
287
+ ? { short: `⚠ +${added.length} problem${added.length === 1 ? "" : "s"}`, detail: added.slice(0, 3).map((d) => formatDiagnostic(d)).join("\n") }
288
+ : { short: "✓ no new problems", detail: null });
289
+ })();
290
+ return () => {
291
+ cancelled = true;
292
+ };
293
+ }, [pendingChange]);
294
+ // Live problems while you type (debounced).
295
+ useEffect(() => {
296
+ if (!showEditor || !openFile || !editorState)
297
+ return;
298
+ const timer = setTimeout(() => void diagnostics.checkFile(openFile, editorState.lines.join("\n")), 700);
299
+ return () => clearTimeout(timer);
300
+ }, [editorState?.lines]);
301
+ /** Quit, saving the editor's unsaved text first if auto-save is on. */
302
+ const quit = () => {
303
+ if (draft.current && settings.autoSave !== "off")
304
+ saveFromEditor(draft.current.text, draft.current.path);
305
+ exit();
306
+ };
307
+ /** Quitting always takes two presses (Ctrl+C or q) within 2 seconds, so a stray key never loses work. */
308
+ const requestQuit = () => {
309
+ if (exitArmed)
310
+ return quit();
311
+ setExitArmed(true);
312
+ setTimeout(() => setExitArmed(false), 2000);
313
+ };
314
+ // Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
315
+ useInput((input, key) => {
316
+ if (!(key.ctrl && input === "c"))
317
+ return;
318
+ if (busy || approval) {
319
+ agentRef.current?.cancel();
320
+ approval?.resolve(false);
321
+ setApproval(null);
322
+ return;
323
+ }
324
+ requestQuit();
325
+ });
326
+ const info = (text, kind = "info") => setEntries((prev) => [...prev, { kind, text }]);
327
+ /** After a file changes outside the normal flow (undo), refresh everything that shows it. */
328
+ const reload = (file) => {
329
+ if (file === openFile)
330
+ setContent(readText(file));
331
+ setTreeVersion((v) => v + 1);
332
+ refreshGit();
333
+ void diagnostics.checkFile(file);
334
+ };
335
+ const undoCheckpoints = (list) => {
336
+ const done = [];
337
+ for (const cp of list) {
338
+ const result = timeline.undo(cp);
339
+ if (result.ok) {
340
+ done.push(cp.path);
341
+ info(`↶ Undid Nou's change to ${cp.path}${result.merged ? " (kept your later edits)" : ""}`);
342
+ reload(cp.path);
343
+ }
344
+ else {
345
+ info(`Couldn't undo ${cp.path}: ${result.reason}. Press v on the file to see the diff.`, "error");
346
+ }
347
+ }
348
+ if (done.length)
349
+ agentRef.current.note(`The user undid your changes to ${done.join(", ")}; those files are back to how they were before your edits.`);
350
+ };
351
+ const undoLast = () => {
352
+ const last = timeline.lastNouChange();
353
+ if (!last)
354
+ return info("Nothing of Nou's to undo.");
355
+ undoCheckpoints([last]);
356
+ };
357
+ const rewind = () => {
358
+ const turn = timeline.lastTurn();
359
+ if (!turn.length)
360
+ return info("Nothing of Nou's to rewind.");
361
+ undoCheckpoints(turn);
362
+ };
363
+ const showHistory = () => {
364
+ const items = timeline.all.slice(-15).reverse();
365
+ if (!items.length)
366
+ return info("No changes recorded yet.");
367
+ info(`Recent changes (newest first):\n${items.map((c) => {
368
+ const time = new Date(c.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
369
+ return ` ${time} ${c.source === "nou" ? "Nou" : "you"} ${c.before === null ? "created " : ""}${c.path}${c.undone ? " (undone)" : ""}`;
370
+ }).join("\n")}\nUndo with /undo (last) or /rewind (whole last turn).`);
371
+ };
372
+ /**
373
+ * Before editing another file: an unsaved draft of a different file is saved (auto-save on) or kept and
374
+ * the switch refused (auto-save off). Never silently dropped. Returns whether it's OK to continue.
375
+ */
376
+ const settleDraft = (target) => {
377
+ if (!draft.current || draft.current.path === target)
378
+ return true;
379
+ if (settings.autoSave !== "off") {
380
+ saveFromEditor(draft.current.text, draft.current.path);
381
+ return true;
382
+ }
383
+ info(`You have unsaved edits in ${draft.current.path}. Open it and save (Ctrl+S) first, or turn on auto-save in settings.`, "error");
384
+ return false;
385
+ };
386
+ /** Opens a file at a line, in the editor if `edit` (used by the Problems list and go-to-definition). */
387
+ const openAt = (file, line, edit = false) => {
388
+ if (!settleDraft(file))
389
+ return;
390
+ if (file !== openFile)
391
+ open(file);
392
+ setReveal(file);
393
+ setCodePanel("code");
394
+ if (edit) {
395
+ setEditorStart((s) => ({ line, nonce: s.nonce + 1 }));
396
+ setEditing(true);
397
+ setFocus("code");
398
+ }
399
+ else {
400
+ setFocusLine((f) => ({ line, nonce: (f?.nonce ?? 0) + 1 }));
401
+ }
402
+ };
204
403
  const startEditing = () => {
205
- if (!openFile)
404
+ if (!openFile || !settleDraft(openFile))
206
405
  return;
207
- if (draft.current && draft.current.path !== openFile)
208
- draft.current = null;
209
406
  setDiffMode("off");
407
+ setCodePanel("code");
408
+ setEditorStart((s) => ({ line: viewport?.top ?? 1, nonce: s.nonce + 1 }));
210
409
  setEditing(true);
211
410
  setFocus("code");
212
411
  };
@@ -261,6 +460,13 @@ export function App({ initialModel, permContext }) {
261
460
  return; // letters belong to the text box
262
461
  if (input === "f")
263
462
  setFollow((f) => !f);
463
+ else if (input === "p") {
464
+ setCodePanel((p) => (p === "problems" ? "code" : "problems"));
465
+ setEditing(false);
466
+ setFocus("code");
467
+ }
468
+ else if (input === "u")
469
+ undoLast();
264
470
  else if (input === "v")
265
471
  cycleDiff();
266
472
  else if (input === "e")
@@ -272,8 +478,8 @@ export function App({ initialModel, permContext }) {
272
478
  setTreeVersion((v) => v + 1);
273
479
  }
274
480
  else if (input === "q")
275
- exit();
276
- }, { isActive: view === "main" && !quickOpen });
481
+ requestQuit();
482
+ }, { isActive: view === "main" && !quickOpen && !(codePanel === "problems" && focus === "code") });
277
483
  /** What the user is looking at, attached to each message so "this file" / "here" make sense. */
278
484
  const editorContext = () => {
279
485
  if (!openFile)
@@ -305,8 +511,17 @@ ${snippet}
305
511
  const runCommand = async (input) => {
306
512
  const [command, ...rest] = input.slice(1).split(/\s+/);
307
513
  const arg = rest.join(" ").trim();
308
- const info = (text, kind = "info") => setEntries((prev) => [...prev, { kind, text }]);
309
514
  switch (command) {
515
+ case "undo":
516
+ return undoLast();
517
+ case "rewind":
518
+ return rewind();
519
+ case "history":
520
+ return showHistory();
521
+ case "problems":
522
+ setCodePanel("problems");
523
+ setFocus("code");
524
+ return;
310
525
  case "settings":
311
526
  setView("settings");
312
527
  return;
@@ -372,22 +587,29 @@ ${snippet}
372
587
  setEntries((prev) => [...prev, { kind: "user", text }]);
373
588
  void agentRef.current.send(text, editorContext() ?? undefined);
374
589
  };
375
- const saveFromEditor = (text) => {
376
- if (!openFile)
590
+ function saveFromEditor(text, file = openFile) {
591
+ if (!file)
377
592
  return;
378
593
  try {
379
- writeFileSync(path.resolve(process.cwd(), openFile), text, "utf-8");
380
- setContent(text);
381
- draft.current = null;
594
+ const full = path.resolve(process.cwd(), file);
595
+ const before = readText(file);
596
+ writeFileSync(full, text, "utf-8");
597
+ timeline.record({ path: file, before, after: text, source: "you", turn: agentRef.current.turnId });
598
+ if (file === openFile)
599
+ setContent(text);
600
+ if (draft.current?.path === file)
601
+ draft.current = null;
382
602
  setTreeVersion((v) => v + 1);
383
603
  refreshGit();
604
+ void diagnostics.checkFile(file);
605
+ diagnostics.scheduleProject();
384
606
  }
385
607
  catch (error) {
386
- setEntries((prev) => [...prev, { kind: "error", text: `Couldn't save ${openFile}: ${error.message}` }]);
608
+ setEntries((prev) => [...prev, { kind: "error", text: `Couldn't save ${file}: ${error.message}` }]);
387
609
  }
388
- };
610
+ }
389
611
  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: () => {
612
+ return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, onModel: (next) => switchModel(next), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: () => {
391
613
  const found = discoverPlugins();
392
614
  setPlugins((prev) => found.map((f) => prev.find((p) => p.id === f.id && p.plugin) ?? f));
393
615
  }, onClose: () => setView("main") }));
@@ -397,6 +619,10 @@ ${snippet}
397
619
  const chatWidth = Math.max(34, Math.floor(columns * 0.3));
398
620
  const codeWidth = Math.max(20, columns - treeWidth - chatWidth);
399
621
  const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
622
+ const fileProblems = openFile ? diagnostics.get(openFile) : [];
623
+ const totals = diagnostics.counts();
624
+ const checking = diagnostics.busy;
625
+ const lspEnabled = activePlugins.some((p) => p.id === "lsp");
400
626
  const mode = approval
401
627
  ? { label: "APPROVE", color: C.yellow }
402
628
  : showEditor ? { label: "EDIT", color: C.green }
@@ -406,16 +632,27 @@ ${snippet}
406
632
  const hints = approval
407
633
  ? "y accept · n reject · Esc stop"
408
634
  : showEditor && focus === "code"
409
- ? "Ctrl+S save · Ctrl+Z undo · Esc close editor · Ctrl+P find file"
635
+ ? `Ctrl+S save${settings.autoSave === "off" ? "" : " (auto-save on)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close`
410
636
  : focus === "chat"
411
637
  ? busy ? "Esc stop · Tab switch pane" : "Enter send · / commands · Ctrl+P find file · Tab switch pane"
412
638
  : 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) => {
639
+ ? "↑↓ move · Enter open · c changes · e edit · p problems · u undo · Ctrl+P find · s settings"
640
+ : diff ? "n/N next/prev change · v cycle diff · Esc close diff" : "↑↓ scroll · e edit · v diff · p problems · u undo · s settings";
641
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: describe(choice), local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), onOpen: (filePath) => {
416
642
  open(filePath);
417
643
  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: () => {
644
+ }, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code" && !approval, problems: diagnostics.all(), checking: checking, onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
645
+ if (!lspEnabled)
646
+ return info("Go to definition needs a language server (see /settings → Plugins).", "error");
647
+ void lsp.definition(openFile, text, line, col).then((target) => {
648
+ if (!target)
649
+ return info("No definition found here.");
650
+ // Keep unsaved text when jumping within the same file.
651
+ if (target.file === openFile && draft.current)
652
+ draft.current = { ...draft.current, text };
653
+ openAt(target.file, target.line, true);
654
+ });
655
+ }, onSave: (text) => saveFromEditor(text), onExit: () => {
419
656
  draft.current = null;
420
657
  setEditing(false);
421
658
  }, onState: (state) => {
@@ -423,7 +660,9 @@ ${snippet}
423
660
  draft.current = state.dirty
424
661
  ? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
425
662
  : 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) => {
663
+ } }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code" && !approval, filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : "Nou's edit", pending: !!pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, onSubmit: send })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
664
+ ? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
665
+ : _jsxs(Text, { color: C.dim, children: [" ", hints] })] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
427
666
  setQuickOpen(false);
428
667
  open(file);
429
668
  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
8
  export const COMMANDS = ["/model", "/models", "/settings", "/clear", "/help"];
9
- export function Chat({ width, height, focused, entries, streaming, busy, approval, contextChip, onSubmit }) {
9
+ export function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, 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,7 +68,9 @@ 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);
@@ -86,5 +93,5 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
86
93
  const title = _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back ? _jsxs(Text, { color: C.dim, children: [" \u2191", back, " more"] }) : null] });
87
94
  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
95
  ? _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" })] })] }));
96
+ : 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
97
  }
@@ -0,0 +1,128 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import { Box, Text, useInput, usePaste } from "ink";
4
+ import { C } from "./theme.js";
5
+ /** Word-wraps `text` into segments of at most `width` characters, honoring newlines. */
6
+ export function layout(text, width) {
7
+ const w = Math.max(1, width);
8
+ const segments = [];
9
+ let pos = 0;
10
+ while (pos <= text.length) {
11
+ const newline = text.indexOf("\n", pos);
12
+ const lineEnd = newline === -1 ? text.length : newline;
13
+ if (lineEnd - pos <= w) {
14
+ segments.push({ start: pos, end: lineEnd });
15
+ if (newline === -1)
16
+ break;
17
+ pos = newline + 1;
18
+ continue;
19
+ }
20
+ // Break after the last space that fits, or hard-break a long word.
21
+ const space = text.lastIndexOf(" ", pos + w - 1);
22
+ const end = space >= pos ? space + 1 : pos + w;
23
+ segments.push({ start: pos, end });
24
+ pos = end;
25
+ }
26
+ return segments;
27
+ }
28
+ /** The segment the cursor sits in; a cursor at a wrap boundary belongs to the next line. */
29
+ function cursorLine(segments, cursor) {
30
+ for (let i = 0; i < segments.length; i++) {
31
+ const s = segments[i];
32
+ const next = segments[i + 1];
33
+ if (cursor >= s.start && (cursor < s.end || (cursor === s.end && (!next || next.start > s.end))))
34
+ return i;
35
+ }
36
+ return segments.length - 1;
37
+ }
38
+ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6 }) {
39
+ const [cursor, setCursor] = useState(value.length);
40
+ const [recall, setRecall] = useState(null);
41
+ const stash = useRef("");
42
+ const textWidth = Math.max(4, width - 2);
43
+ const pos = Math.min(cursor, value.length);
44
+ const segments = layout(value, textWidth);
45
+ const line = cursorLine(segments, pos);
46
+ const set = (next, nextCursor) => {
47
+ onChange(next);
48
+ setCursor(Math.max(0, Math.min(next.length, nextCursor)));
49
+ };
50
+ const insert = (text) => set(value.slice(0, pos) + text + value.slice(pos), pos + text.length);
51
+ const recallHistory = (step) => {
52
+ if (!history.length)
53
+ return false;
54
+ const current = recall ?? history.length;
55
+ const next = current + step;
56
+ if (next < 0 || next > history.length)
57
+ return false;
58
+ if (recall === null)
59
+ stash.current = value;
60
+ const text = next === history.length ? stash.current : history[next];
61
+ setRecall(next === history.length ? null : next);
62
+ set(text, text.length);
63
+ return true;
64
+ };
65
+ usePaste((text) => insert(text.replace(/\r\n?/g, "\n")), { isActive: focus });
66
+ useInput((input, key) => {
67
+ if (key.tab || key.escape || key.pageUp || key.pageDown || (key.shift && (key.upArrow || key.downArrow)))
68
+ return;
69
+ if (key.return) {
70
+ setRecall(null);
71
+ onSubmit(value);
72
+ return;
73
+ }
74
+ if (key.backspace)
75
+ return pos > 0 && set(value.slice(0, pos - 1) + value.slice(pos), pos - 1);
76
+ if (key.delete)
77
+ return pos < value.length && set(value.slice(0, pos) + value.slice(pos + 1), pos);
78
+ if (key.leftArrow)
79
+ return setCursor(key.ctrl || key.meta ? value.lastIndexOf(" ", pos - 2) + 1 : pos - 1 < 0 ? 0 : pos - 1);
80
+ if (key.rightArrow) {
81
+ if (key.ctrl || key.meta) {
82
+ const next = value.indexOf(" ", pos + 1);
83
+ return setCursor(next === -1 ? value.length : next);
84
+ }
85
+ return setCursor(Math.min(value.length, pos + 1));
86
+ }
87
+ if (key.home || (key.ctrl && input === "a"))
88
+ return setCursor(segments[line].start);
89
+ if (key.end || (key.ctrl && input === "e"))
90
+ return setCursor(segments[line].end);
91
+ if (key.ctrl && input === "u")
92
+ return set("", 0);
93
+ if (key.upArrow || key.downArrow) {
94
+ const target = line + (key.upArrow ? -1 : 1);
95
+ if (target >= 0 && target < segments.length) {
96
+ // Move between wrapped lines, keeping the column.
97
+ const col = pos - segments[line].start;
98
+ const s = segments[target];
99
+ return setCursor(Math.min(s.start + col, s.end));
100
+ }
101
+ recallHistory(key.upArrow ? -1 : 1);
102
+ return;
103
+ }
104
+ if (key.ctrl || key.meta || !input)
105
+ return;
106
+ insert(input.replace(/\r\n?/g, "\n"));
107
+ }, { isActive: focus });
108
+ // Show a window of lines around the cursor once the message gets long.
109
+ const first = Math.max(0, Math.min(line - maxLines + 1, segments.length - maxLines));
110
+ const shown = segments.slice(first, first + maxLines);
111
+ const prompt = (i) => (_jsx(Text, { color: focus ? C.accent2 : C.dim, bold: true, children: first + i === 0 ? "❯ " : first > 0 && i === 0 ? "↑ " : " " }));
112
+ if (!value) {
113
+ return (_jsxs(Box, { children: [prompt(0), focus ? _jsx(Text, { inverse: true, children: " " }) : null, _jsx(Text, { color: C.dim, wrap: "truncate-end", children: placeholder })] }));
114
+ }
115
+ return (_jsx(Box, { flexDirection: "column", children: shown.map((s, i) => {
116
+ const text = value.slice(s.start, s.end).replace(/\n/g, "");
117
+ const isCursor = focus && first + i === line;
118
+ if (!isCursor) {
119
+ return _jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text || " " })] }, first + i);
120
+ }
121
+ const at = pos - s.start;
122
+ return (_jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text.slice(0, at) }), _jsx(Text, { inverse: true, children: text[at] ?? " " }), _jsx(Text, { color: C.text, children: text.slice(at + 1) })] }, first + i));
123
+ }) }));
124
+ }
125
+ /** How many rows the input will take, so the chat can size its history above it. */
126
+ export function inputHeight(value, width, maxLines = 6) {
127
+ return value ? Math.min(maxLines, layout(value, Math.max(4, width - 2)).length) : 1;
128
+ }