nou-cli 0.3.0 → 0.5.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.
@@ -23,11 +23,16 @@ import { C } from "./theme.js";
23
23
  import { FileTree } from "./FileTree.js";
24
24
  import { CodeView } from "./CodeView.js";
25
25
  import { Editor } from "./Editor.js";
26
+ import { WHEEL_STEP } from "./scroll.js";
26
27
  import { Chat } from "./Chat.js";
28
+ import { copyToClipboard } from "./clipboard.js";
27
29
  import { Header } from "./Header.js";
28
30
  import { QuickOpen, listAllFiles } from "./QuickOpen.js";
29
31
  import { Settings } from "./Settings.js";
30
32
  const FOCUS_ORDER = ["tree", "code", "chat"];
33
+ const MIN_TREE = 14;
34
+ const MIN_CODE = 20;
35
+ const MIN_CHAT = 24;
31
36
  const NO_LINES = new Set();
32
37
  const HELP = `Commands:
33
38
  /settings models, preferences and plugins
@@ -37,6 +42,8 @@ const HELP = `Commands:
37
42
  /connect <provider> enter an API key (and URL), e.g. /connect fireworks /connect litellm
38
43
  /pull <model> download an Ollama model
39
44
  /engine [name] which agent answers: nou, claude-code, codex or pi
45
+ /auto [ask|auto-edit|auto] how much Nou does without asking (also: Shift+Tab)
46
+ /copy [code|file] copy the last reply, its last code block, or the open file
40
47
  /plugins your plugins and the starter ones you can add
41
48
  /plugins add <name> add a starter plugin (copied to ~/.nou/plugins to edit)
42
49
  /plugins new <name> start your own plugin in .nou/plugins/
@@ -50,10 +57,24 @@ const HELP = `Commands:
50
57
  /version which version of Nou this is
51
58
  Keys:
52
59
  Ctrl+P find a file Tab switch pane Esc stop / back
53
- e edit open file v diff (git / Nou / session) f follow mode
60
+ Shift+Tab ask / auto-edit / auto < > resize pane = reset
61
+ m mouse on/off (off: select text to copy; Shift+drag also works in most terminals)
62
+ / start a command from any pane
63
+ e edit open file (or click in the code) v diff (git / Nou / session) f follow mode
54
64
  p problems u undo Nou's edit s settings
55
65
  c (in files) show only changed files Ctrl+C twice quit
56
- In the editor: Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
66
+ Mouse: wheel scrolls (Shift+wheel sideways) · click/drag a scrollbar · click in the code to edit there, drag to select
67
+ In the editor:
68
+ Shift+arrows / mouse drag select · Ctrl+A all · Ctrl+C/X/V copy/cut/paste · Esc clear selection or leave
69
+ Ctrl+←/→ word · Ctrl+Backspace or Ctrl+W delete word · Ctrl+Home/End file start/end
70
+ Ctrl+F find · Ctrl+L go to line · Ctrl+/ comment · Ctrl+D duplicate line · Alt+↑/↓ move line
71
+ Tab/Shift+Tab indent/outdent · Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
72
+ /** A callback that keeps one identity across renders but always runs the latest code, so memoized panes don't re-render for it. */
73
+ function useEvent(fn) {
74
+ const ref = useRef(fn);
75
+ ref.current = fn;
76
+ return useCallback((...args) => ref.current(...args), []);
77
+ }
57
78
  function readText(filePath) {
58
79
  try {
59
80
  return readFileSync(path.resolve(process.cwd(), filePath), "utf-8");
@@ -109,11 +130,14 @@ export function App({ initialModel, permContext }) {
109
130
  const [plugins, setPlugins] = useState([]);
110
131
  const [starters, setStarters] = useState([]);
111
132
  const [viewport, setViewport] = useState(null);
133
+ // The editor reports every keystroke; the app only re-renders when what it shows changes (line, unsaved, selection).
134
+ const editorStateRef = useRef(null);
112
135
  const [editorState, setEditorState] = useState(null);
136
+ const editorProblemsTimer = useRef(null);
113
137
  // Unsaved editor text survives switching to a diff and back.
114
138
  const draft = useRef(null);
115
139
  const [codePanel, setCodePanel] = useState("code");
116
- const [, setDiagVersion] = useState(0);
140
+ const [diagnosticsVersion, setDiagVersion] = useState(0);
117
141
  const [syntaxVersion, setSyntaxVersion] = useState(0);
118
142
  const [focusLine, setFocusLine] = useState(null);
119
143
  const [editorStart, setEditorStart] = useState({ line: 1, nonce: 0 });
@@ -121,9 +145,20 @@ export function App({ initialModel, permContext }) {
121
145
  const [exitArmed, setExitArmed] = useState(false);
122
146
  const [update, setUpdate] = useState(null);
123
147
  const [wheel, setWheel] = useState(null);
148
+ const [scrollTo, setScrollTo] = useState(null);
149
+ const [editorMouse, setEditorMouse] = useState(null);
150
+ /** Wheel events waiting for the next frame, added up per pane. */
151
+ const pendingWheel = useRef({ pane: "chat", lines: 0, columns: 0, timer: null });
152
+ const [chatPrefill, setChatPrefill] = useState(null);
124
153
  const [treeClick, setTreeClick] = useState(null);
125
154
  // Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
126
155
  const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
156
+ /** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
157
+ const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
158
+ const paneWidthsRef = useRef(paneWidths);
159
+ paneWidthsRef.current = paneWidths;
160
+ /** What a mouse drag is moving: a pane border, a pane's scrollbar, or the editor's selection. */
161
+ const dragging = useRef(null);
127
162
  const pullAbort = useRef(null);
128
163
  const diagnosticsRef = useRef(null);
129
164
  diagnosticsRef.current ??= new DiagnosticsManager();
@@ -132,6 +167,8 @@ export function App({ initialModel, permContext }) {
132
167
  timelineRef.current ??= new Timeline(() => setHistoryVersion((v) => v + 1));
133
168
  const timeline = timelineRef.current;
134
169
  const followRef = useRef(follow);
170
+ /** Whether the editor is open, for the agent hooks (which outlive renders). */
171
+ const draftActiveRef = useRef(false);
135
172
  followRef.current = follow;
136
173
  const settingsRef = useRef(settings);
137
174
  settingsRef.current = settings;
@@ -169,8 +206,8 @@ export function App({ initialModel, permContext }) {
169
206
  }
170
207
  const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
171
208
  setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
172
- // Diffs awaiting approval are always shown, follow mode or not.
173
- if (status === "pending" || followRef.current) {
209
+ // Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
210
+ if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
174
211
  setOpenFile(change.path);
175
212
  setContent(status === "applied" ? change.after : readText(change.path));
176
213
  setDiffMode("off");
@@ -280,18 +317,19 @@ export function App({ initialModel, permContext }) {
280
317
  if (!isInstalled(target)) {
281
318
  const controller = new AbortController();
282
319
  pullAbort.current = controller;
283
- info(`Installing ${target.label} (${target.package}) into ~/.nou/engines. Esc to stop.`);
284
- setBusy(`Installing ${target.label}...`);
320
+ 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.`);
321
+ setBusy(`Installing the ${target.label} engine (npm)...`);
285
322
  try {
286
- await installEngine(target, (line) => setBusy(`Installing ${target.label}: ${line.slice(0, 80)}`), controller.signal);
323
+ await installEngine(target, (line) => setBusy(`Installing the ${target.label} engine (npm): ${line.slice(0, 70)}`), controller.signal);
287
324
  }
288
325
  catch (error) {
289
- return info(controller.signal.aborted ? `Stopped installing ${target.label}.` : `Couldn't install ${target.label}: ${error.message}`, "error");
326
+ 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");
290
327
  }
291
328
  finally {
292
329
  pullAbort.current = null;
293
330
  setBusy(null);
294
331
  }
332
+ info(`Installed ${target.label} (${target.package}).`);
295
333
  }
296
334
  agentRef.current.dispose?.();
297
335
  const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
@@ -365,16 +403,45 @@ export function App({ initialModel, permContext }) {
365
403
  approval?.resolve(ok);
366
404
  setApproval(null);
367
405
  };
406
+ /** Shows the edit waiting for approval in the code view. */
407
+ const openPending = () => {
408
+ const file = approval?.request.kind === "change" ? approval.request.change.path : null;
409
+ if (!file || !settleDraft(file))
410
+ return;
411
+ setEditing(false);
412
+ open(file);
413
+ setReveal(file);
414
+ setFocus("code");
415
+ };
416
+ /** Ask → Auto-edit (edits apply without asking) → Auto (commands too) → Ask. Undo and diffs work the same in each. */
417
+ const autoMode = settings.autoApproveCommands ? "auto" : settings.autoApproveEdits ? "auto-edit" : "ask";
418
+ const cycleAutoMode = (to) => {
419
+ const next = to ?? (autoMode === "ask" ? "auto-edit" : autoMode === "auto-edit" ? "auto" : "ask");
420
+ updateSettings({ autoApproveEdits: next !== "ask", autoApproveCommands: next === "auto" });
421
+ // Whatever is waiting now falls under the new mode too.
422
+ const kind = approval?.request.kind;
423
+ if (kind && (next === "auto" || (next === "auto-edit" && kind === "change")))
424
+ answer(true);
425
+ info(next === "ask" ? "Ask mode: Nou asks before every edit and command."
426
+ : next === "auto-edit" ? "Auto-edit mode: edits apply without asking; Nou still asks before commands. Review with the diff view, u to undo."
427
+ : "Auto mode: edits and commands run without asking. Review with the diff view, u to undo. Shift+Tab to change.");
428
+ };
429
+ // Derived values handed to the panes keep their identity until they really change, so a pane that
430
+ // didn't change skips re-rendering (memo) instead of redrawing on every keystroke elsewhere.
431
+ const edited = useMemo(() => new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p)), [changes]);
432
+ const problemCounts = useMemo(() => diagnostics.fileCounts(), [diagnosticsVersion]);
433
+ const fileProblems = useMemo(() => (openFile ? diagnostics.get(openFile) : []), [openFile, diagnosticsVersion]);
368
434
  const record = openFile ? changes.get(openFile) : undefined;
369
435
  const pendingChange = approval?.request.kind === "change" ? approval.request.change : null;
370
436
  const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
371
437
  const sessionId = sessionView?.id ?? agentRef.current.session;
372
438
  const sessionFiles = useMemo(() => timeline.sessionChanges(sessionId), [sessionId, historyVersion]);
373
439
  const sessionChange = openFile ? sessionFiles.find((c) => c.path === openFile) ?? null : null;
374
- const diff = pendingChange ?? (diffMode === "git" ? gitChange
440
+ const diff = (pendingChange && pendingChange.path === openFile ? pendingChange : null) ?? (diffMode === "git" ? gitChange
375
441
  : diffMode === "nou" && record ? record.change
376
442
  : diffMode === "session" ? sessionChange : null);
377
- const showEditor = editing && !!openFile && !pendingChange;
443
+ const showEditor = editing && !!openFile && !(pendingChange && pendingChange.path === openFile);
444
+ draftActiveRef.current = showEditor;
378
445
  // Check Nou's proposed edit before you approve it: does it add problems?
379
446
  useEffect(() => {
380
447
  setPendingNote(null);
@@ -395,13 +462,6 @@ export function App({ initialModel, permContext }) {
395
462
  cancelled = true;
396
463
  };
397
464
  }, [pendingChange]);
398
- // Live problems while you type (debounced).
399
- useEffect(() => {
400
- if (!showEditor || !openFile || !editorState)
401
- return;
402
- const timer = setTimeout(() => void diagnostics.checkFile(openFile, editorState.lines.join("\n")), 700);
403
- return () => clearTimeout(timer);
404
- }, [editorState?.lines]);
405
465
  /** Quit, saving the editor's unsaved text first if auto-save is on. */
406
466
  const quit = () => {
407
467
  if (draft.current && settings.autoSave !== "off")
@@ -415,17 +475,89 @@ export function App({ initialModel, permContext }) {
415
475
  setExitArmed(true);
416
476
  setTimeout(() => setExitArmed(false), 2000);
417
477
  };
418
- // Mouse: the wheel scrolls whatever pane is under the pointer; a click focuses that pane
419
- // (and in the file tree, opens the clicked file).
478
+ /** Moves a pane border to column `x` and keeps every pane usable. */
479
+ const moveBorder = (border, x) => {
480
+ const { treeWidth, codeWidth } = layout.current;
481
+ const chatWidth = columns - treeWidth - codeWidth;
482
+ const tree = border === "tree" ? Math.min(Math.max(x + 1, MIN_TREE), columns - chatWidth - MIN_CODE) : treeWidth;
483
+ const chat = border === "chat" ? Math.min(Math.max(columns - x, MIN_CHAT), columns - treeWidth - MIN_CODE) : chatWidth;
484
+ const next = { tree: tree / columns, chat: chat / columns };
485
+ setPaneWidths(next);
486
+ return next;
487
+ };
488
+ /** Adds a wheel event to its pane's total; the panes get one update per frame however many events arrive. */
489
+ const queueWheel = (pane, dir, horizontal) => {
490
+ const q = pendingWheel.current;
491
+ if (q.timer && q.pane !== pane)
492
+ flushWheel();
493
+ q.pane = pane;
494
+ if (horizontal)
495
+ q.columns += dir * WHEEL_STEP;
496
+ else
497
+ q.lines += dir * WHEEL_STEP;
498
+ q.timer ??= setTimeout(flushWheel, 16);
499
+ };
500
+ const flushWheel = () => {
501
+ const q = pendingWheel.current;
502
+ if (q.timer)
503
+ clearTimeout(q.timer);
504
+ q.timer = null;
505
+ if (!q.lines && !q.columns)
506
+ return;
507
+ const { pane, lines, columns } = q;
508
+ q.lines = 0;
509
+ q.columns = 0;
510
+ setWheel((w) => ({ pane, lines, columns, nonce: (w?.nonce ?? 0) + 1 }));
511
+ };
512
+ /** Scrolls a pane to where its scrollbar was clicked or dragged (rows start at y=3, under the border and title). */
513
+ const scrollPane = (pane, y) => {
514
+ const { paneHeight } = layout.current;
515
+ const fraction = (y - 3) / Math.max(1, paneHeight - 4);
516
+ setScrollTo((s) => ({ pane, fraction, nonce: (s?.nonce ?? 0) + 1 }));
517
+ };
518
+ // Mouse: the wheel scrolls whatever pane is under the pointer (Shift+wheel sideways); a click focuses
519
+ // that pane, opens the clicked file in the tree, and puts the cursor where you clicked in the code
520
+ // (starting the editor). Drag to select in the editor, drag a scrollbar to scroll, drag the border
521
+ // between panes to resize them.
420
522
  useMouse((event) => {
421
523
  if (view !== "main" || quickOpen)
422
524
  return;
423
525
  const { treeWidth, codeWidth, paneHeight } = layout.current;
526
+ const codeLeft = treeWidth;
527
+ const editorAt = (x, y, kind) => setEditorMouse((m) => ({ x: x - codeLeft - 1, y: y - 3, kind, nonce: (m?.nonce ?? 0) + 1 }));
528
+ if (event.kind === "drag") {
529
+ const drag = dragging.current;
530
+ if (drag === "tree" || drag === "chat")
531
+ moveBorder(drag, event.x);
532
+ else if (drag === "editor")
533
+ editorAt(event.x, event.y, "drag");
534
+ else if (drag)
535
+ scrollPane(drag.scroll, event.y);
536
+ return;
537
+ }
538
+ if (event.kind === "release") {
539
+ if (dragging.current === "tree" || dragging.current === "chat")
540
+ updateSettings({ paneWidths: paneWidthsRef.current });
541
+ if (dragging.current === "editor")
542
+ editorAt(event.x, event.y, "release");
543
+ dragging.current = null;
544
+ return;
545
+ }
424
546
  if (event.y < 1 || event.y > paneHeight)
425
547
  return; // header or status bar
426
548
  const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
427
- if (event.kind === "wheel") {
428
- setWheel((w) => ({ pane, dir: event.dir, nonce: (w?.nonce ?? 0) + 1 }));
549
+ if (event.kind === "wheel")
550
+ return queueWheel(pane, event.dir, event.horizontal);
551
+ // Each border is two columns: the left pane's edge and the right pane's.
552
+ if (Math.abs(event.x - (treeWidth - 0.5)) <= 1)
553
+ return void (dragging.current = "tree");
554
+ if (Math.abs(event.x - (treeWidth + codeWidth - 0.5)) <= 1)
555
+ return void (dragging.current = "chat");
556
+ // Each pane's scrollbar is the column inside its right border.
557
+ const scrollbarX = pane === "tree" ? treeWidth - 2 : pane === "code" ? treeWidth + codeWidth - 2 : columns - 2;
558
+ if (event.x === scrollbarX && event.y >= 3) {
559
+ dragging.current = { scroll: pane };
560
+ scrollPane(pane, event.y);
429
561
  return;
430
562
  }
431
563
  if (approval)
@@ -434,11 +566,26 @@ export function App({ initialModel, permContext }) {
434
566
  // Rows start under the pane's top border (y=1) and title (y=2).
435
567
  if (pane === "tree" && event.y >= 3)
436
568
  setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
569
+ if (pane === "code" && event.y >= 3 && codePanel === "code") {
570
+ if (showEditor) {
571
+ dragging.current = "editor";
572
+ editorAt(event.x, event.y, "press");
573
+ }
574
+ else if (openFile && !diff && settleDraft(openFile)) {
575
+ // Click in the code: edit right there.
576
+ const gutter = String(content.split("\n").length).length + 1;
577
+ setEditorStart((s) => ({ line: (viewport?.top ?? 1) + event.y - 3, col: Math.max(1, event.x - codeLeft - gutter), nonce: s.nonce + 1 }));
578
+ setDiffMode("off");
579
+ setEditing(true);
580
+ }
581
+ }
437
582
  }, settings.mouse);
438
583
  // Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
439
584
  useInput((input, key) => {
440
585
  if (!(key.ctrl && input === "c"))
441
586
  return;
587
+ if (showEditor && focus === "code" && editorStateRef.current?.selection)
588
+ return; // the editor copies it
442
589
  if (busy || approval) {
443
590
  pullAbort.current?.abort();
444
591
  agentRef.current?.cancel();
@@ -611,16 +758,17 @@ export function App({ initialModel, permContext }) {
611
758
  setDiffMode((m) => order[(order.indexOf(m) + 1) % order.length]);
612
759
  };
613
760
  useInput((input, key) => {
614
- if (approval) {
615
- if (input === "y" || input === "Y")
616
- answer(true);
617
- else if (input === "n" || input === "N")
618
- answer(false);
619
- else if (key.escape) {
761
+ // A waiting approval doesn't freeze anything: y/n answer it from the tree or code view (the chat
762
+ // handles them itself when its input is empty), Esc stops the turn, and every other key works as usual.
763
+ if (approval && !(showEditor && focus === "code")) {
764
+ if (key.escape) {
620
765
  agentRef.current?.cancel();
621
- answer(false);
766
+ return answer(false);
622
767
  }
623
- return;
768
+ if (focus !== "chat" && /^[yYnN]$/.test(input))
769
+ return answer(/[yY]/.test(input));
770
+ if (focus !== "chat" && input === "o" && pendingChange)
771
+ return openPending();
624
772
  }
625
773
  if (key.ctrl && input === "p") {
626
774
  setQuickOpen(true);
@@ -628,9 +776,10 @@ export function App({ initialModel, permContext }) {
628
776
  }
629
777
  if (showEditor && focus === "code")
630
778
  return; // the editor owns the keyboard
779
+ if (key.tab && key.shift)
780
+ return cycleAutoMode();
631
781
  if (key.tab) {
632
- const step = key.shift ? -1 : 1;
633
- setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + step + FOCUS_ORDER.length) % FOCUS_ORDER.length]);
782
+ setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]);
634
783
  return;
635
784
  }
636
785
  if (key.escape) {
@@ -648,8 +797,17 @@ export function App({ initialModel, permContext }) {
648
797
  }
649
798
  if (focus === "chat")
650
799
  return; // letters belong to the text box
651
- if (input === "f")
800
+ if (input === "/") {
801
+ // Commands work from anywhere: / jumps to the chat's command box.
802
+ setFocus("chat");
803
+ setChatPrefill((p) => ({ text: "/", nonce: (p?.nonce ?? 0) + 1 }));
804
+ }
805
+ else if (input === "f")
652
806
  setFollow((f) => !f);
807
+ else if (input === "m") {
808
+ updateSettings({ mouse: !settings.mouse });
809
+ 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.");
810
+ }
653
811
  else if (input === "p") {
654
812
  setCodePanel((p) => (p === "problems" ? "code" : "problems"));
655
813
  setEditing(false);
@@ -657,6 +815,16 @@ export function App({ initialModel, permContext }) {
657
815
  }
658
816
  else if (input === "u")
659
817
  undoLast();
818
+ else if (input === "<" || input === ">") {
819
+ // Moves the focused pane's inner border: the tree's right edge, or the code view's edge with the chat.
820
+ const { treeWidth, codeWidth } = layout.current;
821
+ const step = input === ">" ? 2 : -2;
822
+ updateSettings({ paneWidths: moveBorder(focus === "tree" ? "tree" : "chat", (focus === "tree" ? treeWidth - 1 : treeWidth + codeWidth) + step) });
823
+ }
824
+ else if (input === "=") {
825
+ setPaneWidths(null);
826
+ updateSettings({ paneWidths: null });
827
+ }
660
828
  else if (input === "v")
661
829
  cycleDiff();
662
830
  else if ((input === "]" || input === "[") && diffMode === "session")
@@ -676,6 +844,7 @@ export function App({ initialModel, permContext }) {
676
844
  const editorContext = () => {
677
845
  if (!openFile)
678
846
  return null;
847
+ const editorState = editorStateRef.current;
679
848
  const unsaved = showEditor && editorState?.dirty;
680
849
  const lines = showEditor && editorState ? editorState.lines : content.replace(/\r\n/g, "\n").split("\n");
681
850
  let from;
@@ -774,13 +943,31 @@ ${snippet}
774
943
  }
775
944
  return chooseModel(resolveModel(arg));
776
945
  }
946
+ case "copy": {
947
+ const reply = [...entries].reverse().find((e) => e.kind === "assistant")?.text;
948
+ const blocks = reply ? [...reply.matchAll(/```[^\n]*\n([\s\S]*?)```/g)].map((m) => m[1]) : [];
949
+ const what = arg === "file" ? (openFile ? readText(openFile) : null) : arg === "code" ? blocks[blocks.length - 1] ?? null : reply ?? null;
950
+ if (arg && arg !== "file" && arg !== "code")
951
+ return info("Usage: /copy (the last reply) · /copy code (its last code block) · /copy file (the open file)", "error");
952
+ if (!what)
953
+ return info(arg === "file" ? "No file is open." : arg === "code" ? "The last reply has no code block." : "Nothing to copy yet.", "error");
954
+ const ok = await copyToClipboard(what);
955
+ 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");
956
+ }
957
+ case "auto": {
958
+ if (!arg)
959
+ return cycleAutoMode();
960
+ if (arg === "ask" || arg === "auto-edit" || arg === "auto")
961
+ return cycleAutoMode(arg);
962
+ return info("Usage: /auto [ask|auto-edit|auto]", "error");
963
+ }
777
964
  case "engine": {
778
965
  if (arg && isEngineId(arg))
779
966
  return switchEngine(arg);
780
967
  if (arg)
781
968
  return info(`Unknown engine ${arg}. Choose one of: ${ENGINES.map((e) => e.id).join(", ")}`, "error");
782
969
  const current = agentRef.current.id;
783
- const lines = ENGINES.map((e) => ` ${e.id === current ? "●" : "○"} ${e.id.padEnd(12)} ${e.description}${isInstalled(e) ? "" : " (installs on first use)"}`);
970
+ const lines = ENGINES.map((e) => ` ${e.id === current ? "●" : "○"} ${e.id.padEnd(12)} ${e.description}${isInstalled(e) ? "" : ` (not installed: switching runs npm install ${e.package})`}`);
784
971
  return info(`Engines (/engine <name> to switch, or /settings → Engine):\n${lines.join("\n")}`);
785
972
  }
786
973
  case "pull": {
@@ -885,6 +1072,58 @@ ${snippet}
885
1072
  setEntries((prev) => [...prev, { kind: "error", text: `Couldn't save ${file}: ${error.message}` }]);
886
1073
  }
887
1074
  }
1075
+ // Stable callbacks for the memoized panes.
1076
+ const onTreeOpen = useEvent((filePath) => {
1077
+ open(filePath);
1078
+ setFocus("code");
1079
+ });
1080
+ const onTreeHover = useEvent(() => { });
1081
+ const onJump = useEvent((file, line) => openAt(file, line));
1082
+ const onCloseProblems = useEvent(() => setCodePanel("code"));
1083
+ const onViewport = useEvent((top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })));
1084
+ const onSend = useEvent(send);
1085
+ const onAnswer = useEvent(answer);
1086
+ const onEditorHover = useEvent((text, line, col) => (lspEnabled && openFile ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)));
1087
+ const onEditorDefinition = useEvent((text, line, col) => {
1088
+ if (!openFile)
1089
+ return;
1090
+ if (!lspEnabled)
1091
+ return info("Go to definition needs a language server (see /settings → Plugins).", "error");
1092
+ void lsp.definition(openFile, text, line, col).then((target) => {
1093
+ if (!target)
1094
+ return info("No definition found here.");
1095
+ // Keep unsaved text when jumping within the same file.
1096
+ if (target.file === openFile && draft.current)
1097
+ draft.current = { ...draft.current, text };
1098
+ openAt(target.file, target.line, true);
1099
+ });
1100
+ });
1101
+ const onEditorSave = useEvent((text) => saveFromEditor(text));
1102
+ const onEditorExit = useEvent(() => {
1103
+ draft.current = null;
1104
+ editorStateRef.current = null;
1105
+ setEditing(false);
1106
+ });
1107
+ const onEditorState = useEvent((state) => {
1108
+ if (!openFile)
1109
+ return;
1110
+ const prev = editorStateRef.current;
1111
+ editorStateRef.current = state;
1112
+ if (prev?.line !== state.line || prev?.dirty !== state.dirty || prev?.selection !== state.selection) {
1113
+ setEditorState({ line: state.line, dirty: state.dirty, selection: state.selection });
1114
+ }
1115
+ draft.current = state.dirty
1116
+ ? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
1117
+ : null;
1118
+ // Live problems while you type (debounced).
1119
+ if (prev && prev.lines !== state.lines) {
1120
+ if (editorProblemsTimer.current)
1121
+ clearTimeout(editorProblemsTimer.current);
1122
+ const file = openFile;
1123
+ editorProblemsTimer.current = setTimeout(() => void diagnostics.checkFile(file, state.lines.join("\n")), 700);
1124
+ }
1125
+ });
1126
+ const lspEnabled = activePlugins.some((p) => p.id === "lsp");
888
1127
  if (view === "settings") {
889
1128
  return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, ...(connectTo ? { connectTo } : {}), onModel: (next) => void chooseModel(next), onEngine: (id) => void switchEngine(id), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: rescanPlugins, onClose: () => {
890
1129
  setConnectTo(undefined);
@@ -892,56 +1131,47 @@ ${snippet}
892
1131
  } }));
893
1132
  }
894
1133
  const height = Math.max(10, rows - 2);
895
- const treeWidth = Math.max(24, Math.floor(columns * 0.2));
896
- const chatWidth = Math.max(34, Math.floor(columns * 0.3));
897
- const codeWidth = Math.max(20, columns - treeWidth - chatWidth);
1134
+ const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
1135
+ 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));
1136
+ const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
898
1137
  layout.current = { treeWidth, codeWidth, paneHeight: height };
899
1138
  const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
900
- const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
901
- const fileProblems = openFile ? diagnostics.get(openFile) : [];
1139
+ const scrollFor = (pane) => (scrollTo?.pane === pane ? scrollTo : null);
902
1140
  const totals = diagnostics.counts();
903
1141
  const checking = diagnostics.busy;
904
- const lspEnabled = activePlugins.some((p) => p.id === "lsp");
905
1142
  const mode = approval
906
1143
  ? { label: "APPROVE", color: C.yellow }
907
1144
  : showEditor ? { label: "EDIT", color: C.green }
908
1145
  : diff ? { label: "DIFF", color: C.accent2 }
909
1146
  : busy ? { label: "WORKING", color: C.accent2 }
910
1147
  : { label: focus.toUpperCase(), color: C.accent };
911
- const hints = approval
912
- ? "y accept · n reject · Esc stop"
1148
+ // Shortcuts for the status bar, most useful first; the ones that don't fit are left out (all of them are in /help).
1149
+ const hints = approval && !(showEditor && focus === "code")
1150
+ ? [["y", "accept"], ["n", "reject"], ...(pendingChange && diff !== pendingChange ? [["o", "show the edit"]] : []), ["Esc", "stop"], ["Shift+Tab", "auto mode"]]
913
1151
  : showEditor && focus === "code"
914
- ? `Ctrl+S save${settings.autoSave === "off" ? "" : " (auto-save on)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close`
1152
+ ? editorState?.selection
1153
+ ? [["Ctrl+C", "copy"], ["Ctrl+X", "cut"], ["Tab", "indent"], ["Ctrl+/", "comment"], ["Esc", "deselect"]]
1154
+ : [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Shift+→", "select"], ["Ctrl+F", "find"], ["Ctrl+/", "comment"], ["/help", "all keys"]]
915
1155
  : focus === "chat"
916
- ? busy ? "Esc stop · Shift+↑↓ scroll chat · Tab switch pane" : "Enter send · Shift+↑↓ / PgUp scroll · / commands · Ctrl+P find file · Tab switch pane"
1156
+ ? busy
1157
+ ? [["Esc", "stop"], ["Shift+↑↓", "scroll"], ["Tab", "next pane"]]
1158
+ : [["Enter", "send"], ["/", "commands"], ["Tab", "next pane"], ["Ctrl+P", "find file"], ["Shift+↑↓", "scroll"]]
917
1159
  : focus === "tree"
918
- ? "↑↓ move · Enter open · c changes · e edit · p problems · u undo · Ctrl+P find · s settings"
919
- : diff ? `n/N next/prev change${diffMode === "session" ? " · [ ] prev/next file" : ""} · v cycle diff · Esc close diff` : "↑↓ scroll · e edit · v diff · p problems · u undo · s settings";
920
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), wheel: wheelFor("tree"), click: treeClick, onOpen: (filePath) => {
921
- open(filePath);
922
- setFocus("code");
923
- }, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code" && !approval, problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
924
- if (!lspEnabled)
925
- return info("Go to definition needs a language server (see /settings → Plugins).", "error");
926
- void lsp.definition(openFile, text, line, col).then((target) => {
927
- if (!target)
928
- return info("No definition found here.");
929
- // Keep unsaved text when jumping within the same file.
930
- if (target.file === openFile && draft.current)
931
- draft.current = { ...draft.current, text };
932
- openAt(target.file, target.line, true);
933
- });
934
- }, onSave: (text) => saveFromEditor(text), onExit: () => {
935
- draft.current = null;
936
- setEditing(false);
937
- }, onState: (state) => {
938
- setEditorState(state);
939
- draft.current = state.dirty
940
- ? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
941
- : null;
942
- } }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code" && !approval, filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), onSubmit: send })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
1160
+ ? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
1161
+ : diff
1162
+ ? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
1163
+ : [["click/e", "edit"], ["v", "diff"], ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
1164
+ const statusUsed = mode.label.length + 2 + autoMode.length + 2 + 10 + ` ✖ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
1165
+ const shownHints = [];
1166
+ for (let used = statusUsed, i = 0; i < hints.length; i++) {
1167
+ used += hints[i][0].length + hints[i][1].length + 4;
1168
+ if (used > columns)
1169
+ break;
1170
+ shownHints.push(hints[i]);
1171
+ }
1172
+ 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: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : 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, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState }, `${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"), scrollTo: scrollFor("code"), onViewport: onViewport })), _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"), scrollTo: scrollFor("chat"), prefill: chatPrefill, onSubmit: onSend, onAnswer: onAnswer })] }), _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
943
1173
  ? _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)` : ""] })
944
- : _jsxs(Text, { color: C.dim, children: [" ", hints] })] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
1174
+ : 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) => {
945
1175
  setQuickOpen(false);
946
1176
  open(file);
947
1177
  setReveal(file);