nou-cli 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -76,12 +76,13 @@ Every change is reviewed like a code review:
76
76
 
77
77
  | Key | Does |
78
78
  | --- | --- |
79
- | 🖱️ Mouse | wheel scrolls the pane under the pointer · click a pane to focus it · click a file to open it · drag a border between panes to resize them (hold <kbd>Shift</kbd> to select text, or press <kbd>m</kbd> to turn the mouse off) |
80
- | <kbd>Shift</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd>, <kbd>PgUp</kbd>/<kbd>PgDn</kbd> | scroll the chat history |
79
+ | 🖱️ Mouse | wheel scrolls the pane under the pointer (<kbd>Shift</kbd>+wheel or a tilt wheel: sideways) · click or drag a scrollbar to jump · click a file to open it · **double-click in the code to edit right there** (in the editor: select a word), drag to select · drag a border between panes to resize them (press <kbd>m</kbd> to turn the mouse off and select text in your terminal) |
80
+ | <kbd>PgUp</kbd>/<kbd>PgDn</kbd>, <kbd>Shift</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd> | scroll the chat; scrolled up, new replies wait below instead of yanking the view (<kbd>Ctrl</kbd>+<kbd>End</kbd> jumps back) |
81
+ | <kbd>/</kbd> | start a command from any pane |
81
82
  | <kbd>Tab</kbd> | switch between files, code and chat |
82
83
  | <kbd>Shift</kbd>+<kbd>Tab</kbd> | ask → auto-edit (edits apply without asking) → auto (commands too); everything stays undoable |
83
84
  | <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file |
84
- | <kbd>e</kbd> | edit the open file (<kbd>Ctrl</kbd>+<kbd>S</kbd> save · <kbd>Ctrl</kbd>+<kbd>Z</kbd>/<kbd>Y</kbd> undo/redo · <kbd>Ctrl</kbd>+<kbd>K</kbd> info · <kbd>Ctrl</kbd>+<kbd>G</kbd> definition · <kbd>Esc</kbd> close) |
85
+ | <kbd>e</kbd> or a double-click | edit the open file (see the editor keys below) |
85
86
  | <kbd>p</kbd> | Problems panel: every error and warning, <kbd>Enter</kbd> jumps to it |
86
87
  | <kbd>u</kbd> | undo Nou's last edit |
87
88
  | <kbd>v</kbd> | diff the open file vs git `HEAD`, Nou's last edit, or this session · <kbd>n</kbd>/<kbd>N</kbd> jump between changes · <kbd>[</kbd>/<kbd>]</kbd> previous/next file in a session diff |
@@ -94,6 +95,20 @@ Every change is reviewed like a code review:
94
95
  | <kbd>Esc</kbd> | stop Nou · close a diff · back to chat |
95
96
  | <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
96
97
 
98
+ In the editor:
99
+
100
+ | Key | Does |
101
+ | --- | --- |
102
+ | <kbd>Shift</kbd>+arrows, <kbd>Shift</kbd>+<kbd>Home</kbd>/<kbd>End</kbd>, mouse drag | select · <kbd>Ctrl</kbd>+<kbd>A</kbd> select all |
103
+ | <kbd>Ctrl</kbd>+<kbd>C</kbd> / <kbd>X</kbd> / <kbd>V</kbd> | copy / cut / paste (the whole line when nothing is selected); your terminal's own paste works too |
104
+ | <kbd>Ctrl</kbd>+<kbd>←</kbd>/<kbd>→</kbd>, <kbd>Ctrl</kbd>+<kbd>Backspace</kbd> or <kbd>Ctrl</kbd>+<kbd>W</kbd> | move by word, delete a word · <kbd>Ctrl</kbd>+<kbd>Home</kbd>/<kbd>End</kbd> start / end of the file |
105
+ | <kbd>Ctrl</kbd>+<kbd>F</kbd> · <kbd>Ctrl</kbd>+<kbd>L</kbd> | find (<kbd>Enter</kbd>/<kbd>↓</kbd> next, <kbd>Shift</kbd>+<kbd>Enter</kbd>/<kbd>↑</kbd> previous) · go to line |
106
+ | <kbd>Ctrl</kbd>+<kbd>/</kbd> · <kbd>Ctrl</kbd>+<kbd>D</kbd> · <kbd>Alt</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd> | comment the line or selection · duplicate it · move it up / down |
107
+ | <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | indent / outdent (whole lines with a selection) · <kbd>Enter</kbd> keeps the indent, one more after `{`, `(`, `[` |
108
+ | <kbd>Ctrl</kbd>+<kbd>S</kbd> · <kbd>Ctrl</kbd>+<kbd>Z</kbd>/<kbd>Y</kbd> | save (auto-save is on by default) · undo / redo |
109
+ | <kbd>Ctrl</kbd>+<kbd>K</kbd> · <kbd>Ctrl</kbd>+<kbd>G</kbd> | hover info · go to definition (with a language server) |
110
+ | <kbd>Esc</kbd> | clear the selection, or leave the editor |
111
+
97
112
  Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/help`
98
113
 
99
114
  ## 🧠 Models
package/dist/index.js CHANGED
@@ -14,6 +14,8 @@ import { borrowToolConfig, ensureLocalModel, pickStartingModel } from "./src/set
14
14
  import { checkForUpdate, currentVersion, installUpdate, relaunch } from "./src/update/index.js";
15
15
  import { lsp } from "./src/lsp/index.js";
16
16
  import { App } from "./src/ui/App.js";
17
+ import { installFastOutput } from "./src/ui/fast-output.js";
18
+ import { RENDER_OPTIONS } from "./src/ui/render-options.js";
17
19
  const HELP = `nou ${currentVersion()}: an AI coding assistant for your terminal
18
20
 
19
21
  Usage:
@@ -92,11 +94,8 @@ async function main() {
92
94
  p.log.info(`Model: ${chalk.yellow(describe(choice))} (change it any time with /model)`);
93
95
  await ensureProjectContext(choice);
94
96
  const permContext = await loadPermanentContext();
95
- // Ctrl+C is handled inside the app: press it twice to quit, so a stray press doesn't lose work.
96
- const app = render(createElement(App, { initialModel: choice, permContext }), {
97
- alternateScreen: true,
98
- exitOnCtrlC: false,
99
- });
97
+ await installFastOutput();
98
+ const app = render(createElement(App, { initialModel: choice, permContext }), RENDER_OPTIONS);
100
99
  await app.waitUntilExit();
101
100
  lsp.stopAll();
102
101
  p.outro(chalk.cyan("Bye bye!"));
package/dist/src/env.js CHANGED
@@ -7,6 +7,9 @@ if (major < 22 || (major === 22 && minor < 12)) {
7
7
  console.error(`Nou needs Node.js 22.12 or newer (you have ${process.versions.node}). Get it from https://nodejs.org`);
8
8
  process.exit(1);
9
9
  }
10
+ // React and Ink pick their build from NODE_ENV when they load: without this every render runs React's
11
+ // development build, which is several times slower and checks every update.
12
+ process.env.NODE_ENV ??= "production";
10
13
  // Load the project's .env (API keys, base URLs). Providers read their settings when they load,
11
14
  // so loading .env later would be too late for them.
12
15
  if (existsSync(".env")) {
@@ -23,6 +23,7 @@ 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";
27
28
  import { copyToClipboard } from "./clipboard.js";
28
29
  import { Header } from "./Header.js";
@@ -33,6 +34,7 @@ const MIN_TREE = 14;
33
34
  const MIN_CODE = 20;
34
35
  const MIN_CHAT = 24;
35
36
  const NO_LINES = new Set();
37
+ const DOUBLE_CLICK_MS = 500;
36
38
  const HELP = `Commands:
37
39
  /settings models, preferences and plugins
38
40
  /model show providers and the current model
@@ -58,10 +60,22 @@ Keys:
58
60
  Ctrl+P find a file Tab switch pane Esc stop / back
59
61
  Shift+Tab ask / auto-edit / auto < > resize pane = reset
60
62
  m mouse on/off (off: select text to copy; Shift+drag also works in most terminals)
61
- e edit open file v diff (git / Nou / session) f follow mode
63
+ / start a command from any pane
64
+ e edit open file (or double-click in the code) v diff (git / Nou / session) f follow mode
62
65
  p problems u undo Nou's edit s settings
63
66
  c (in files) show only changed files Ctrl+C twice quit
64
- In the editor: Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
67
+ Mouse: wheel scrolls (Shift+wheel sideways) · click/drag a scrollbar · double-click in the code to edit there (in the editor: select a word), drag to select
68
+ In the editor:
69
+ Shift+arrows / mouse drag select · Ctrl+A all · Ctrl+C/X/V copy/cut/paste · Esc clear selection or leave
70
+ Ctrl+←/→ word · Ctrl+Backspace or Ctrl+W delete word · Ctrl+Home/End file start/end
71
+ Ctrl+F find · Ctrl+L go to line · Ctrl+/ comment · Ctrl+D duplicate line · Alt+↑/↓ move line
72
+ Tab/Shift+Tab indent/outdent · Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
73
+ /** A callback that keeps one identity across renders but always runs the latest code, so memoized panes don't re-render for it. */
74
+ function useEvent(fn) {
75
+ const ref = useRef(fn);
76
+ ref.current = fn;
77
+ return useCallback((...args) => ref.current(...args), []);
78
+ }
65
79
  function readText(filePath) {
66
80
  try {
67
81
  return readFileSync(path.resolve(process.cwd(), filePath), "utf-8");
@@ -117,11 +131,14 @@ export function App({ initialModel, permContext }) {
117
131
  const [plugins, setPlugins] = useState([]);
118
132
  const [starters, setStarters] = useState([]);
119
133
  const [viewport, setViewport] = useState(null);
134
+ // The editor reports every keystroke; the app only re-renders when what it shows changes (line, unsaved, selection).
135
+ const editorStateRef = useRef(null);
120
136
  const [editorState, setEditorState] = useState(null);
137
+ const editorProblemsTimer = useRef(null);
121
138
  // Unsaved editor text survives switching to a diff and back.
122
139
  const draft = useRef(null);
123
140
  const [codePanel, setCodePanel] = useState("code");
124
- const [, setDiagVersion] = useState(0);
141
+ const [diagnosticsVersion, setDiagVersion] = useState(0);
125
142
  const [syntaxVersion, setSyntaxVersion] = useState(0);
126
143
  const [focusLine, setFocusLine] = useState(null);
127
144
  const [editorStart, setEditorStart] = useState({ line: 1, nonce: 0 });
@@ -129,6 +146,11 @@ export function App({ initialModel, permContext }) {
129
146
  const [exitArmed, setExitArmed] = useState(false);
130
147
  const [update, setUpdate] = useState(null);
131
148
  const [wheel, setWheel] = useState(null);
149
+ const [scrollTo, setScrollTo] = useState(null);
150
+ const [editorMouse, setEditorMouse] = useState([]);
151
+ /** Wheel events waiting for the next frame, added up per pane. */
152
+ const pendingWheel = useRef({ pane: "chat", lines: 0, columns: 0, timer: null });
153
+ const [chatPrefill, setChatPrefill] = useState(null);
132
154
  const [treeClick, setTreeClick] = useState(null);
133
155
  // Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
134
156
  const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
@@ -136,6 +158,8 @@ export function App({ initialModel, permContext }) {
136
158
  const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
137
159
  const paneWidthsRef = useRef(paneWidths);
138
160
  paneWidthsRef.current = paneWidths;
161
+ /** What a mouse drag is moving: a pane border, a pane's scrollbar, or the editor's selection. */
162
+ const lastClick = useRef(null);
139
163
  const dragging = useRef(null);
140
164
  const pullAbort = useRef(null);
141
165
  const diagnosticsRef = useRef(null);
@@ -404,6 +428,11 @@ export function App({ initialModel, permContext }) {
404
428
  : next === "auto-edit" ? "Auto-edit mode: edits apply without asking; Nou still asks before commands. Review with the diff view, u to undo."
405
429
  : "Auto mode: edits and commands run without asking. Review with the diff view, u to undo. Shift+Tab to change.");
406
430
  };
431
+ // Derived values handed to the panes keep their identity until they really change, so a pane that
432
+ // didn't change skips re-rendering (memo) instead of redrawing on every keystroke elsewhere.
433
+ const edited = useMemo(() => new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p)), [changes]);
434
+ const problemCounts = useMemo(() => diagnostics.fileCounts(), [diagnosticsVersion]);
435
+ const fileProblems = useMemo(() => (openFile ? diagnostics.get(openFile) : []), [openFile, diagnosticsVersion]);
407
436
  const record = openFile ? changes.get(openFile) : undefined;
408
437
  const pendingChange = approval?.request.kind === "change" ? approval.request.change : null;
409
438
  const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
@@ -435,13 +464,6 @@ export function App({ initialModel, permContext }) {
435
464
  cancelled = true;
436
465
  };
437
466
  }, [pendingChange]);
438
- // Live problems while you type (debounced).
439
- useEffect(() => {
440
- if (!showEditor || !openFile || !editorState)
441
- return;
442
- const timer = setTimeout(() => void diagnostics.checkFile(openFile, editorState.lines.join("\n")), 700);
443
- return () => clearTimeout(timer);
444
- }, [editorState?.lines]);
445
467
  /** Quit, saving the editor's unsaved text first if auto-save is on. */
446
468
  const quit = () => {
447
469
  if (draft.current && settings.autoSave !== "off")
@@ -465,30 +487,84 @@ export function App({ initialModel, permContext }) {
465
487
  setPaneWidths(next);
466
488
  return next;
467
489
  };
468
- // Mouse: the wheel scrolls whatever pane is under the pointer; a click focuses that pane
469
- // (and in the file tree, opens the clicked file). Dragging a border between panes resizes them.
490
+ /** Adds a wheel event to its pane's total; the panes get one update per frame however many events arrive. */
491
+ const queueWheel = (pane, dir, horizontal) => {
492
+ const q = pendingWheel.current;
493
+ if (q.timer && q.pane !== pane)
494
+ flushWheel();
495
+ q.pane = pane;
496
+ if (horizontal)
497
+ q.columns += dir * WHEEL_STEP;
498
+ else
499
+ q.lines += dir * WHEEL_STEP;
500
+ q.timer ??= setTimeout(flushWheel, 16);
501
+ };
502
+ const flushWheel = () => {
503
+ const q = pendingWheel.current;
504
+ if (q.timer)
505
+ clearTimeout(q.timer);
506
+ q.timer = null;
507
+ if (!q.lines && !q.columns)
508
+ return;
509
+ const { pane, lines, columns } = q;
510
+ q.lines = 0;
511
+ q.columns = 0;
512
+ setWheel((w) => ({ pane, lines, columns, nonce: (w?.nonce ?? 0) + 1 }));
513
+ };
514
+ /** Scrolls a pane to where its scrollbar was clicked or dragged (rows start at y=3, under the border and title). */
515
+ const scrollPane = (pane, y) => {
516
+ const { paneHeight } = layout.current;
517
+ const fraction = (y - 3) / Math.max(1, paneHeight - 4);
518
+ setScrollTo((s) => ({ pane, fraction, nonce: (s?.nonce ?? 0) + 1 }));
519
+ };
520
+ // Mouse: the wheel scrolls whatever pane is under the pointer (Shift+wheel sideways); a click focuses
521
+ // that pane, opens the clicked file in the tree, and puts the cursor where you clicked in the code
522
+ // (starting the editor). Drag to select in the editor, drag a scrollbar to scroll, drag the border
523
+ // between panes to resize them.
470
524
  useMouse((event) => {
471
525
  if (view !== "main" || quickOpen)
472
526
  return;
473
527
  const { treeWidth, codeWidth, paneHeight } = layout.current;
474
- if (event.kind === "drag")
475
- return void (dragging.current && moveBorder(dragging.current, event.x));
528
+ const codeLeft = treeWidth;
529
+ const editorAt = (x, y, kind) => setEditorMouse((m) => [...m.slice(-15), { x: x - codeLeft - 1, y: y - 3, kind, seq: (m[m.length - 1]?.seq ?? 0) + 1 }]);
530
+ if (event.kind === "drag") {
531
+ lastClick.current = null; // a press that became a drag doesn't start a double-click
532
+ const drag = dragging.current;
533
+ if (drag === "tree" || drag === "chat")
534
+ moveBorder(drag, event.x);
535
+ else if (drag === "editor")
536
+ editorAt(event.x, event.y, "drag");
537
+ else if (drag)
538
+ scrollPane(drag.scroll, event.y);
539
+ return;
540
+ }
476
541
  if (event.kind === "release") {
477
- if (dragging.current)
478
- updateSettings({ paneWidths: paneWidthsRef.current });
542
+ // The release says where the button let go, so a drag lands there even in a terminal that
543
+ // doesn't report the movement in between.
544
+ if (dragging.current === "tree" || dragging.current === "chat")
545
+ updateSettings({ paneWidths: moveBorder(dragging.current, event.x) });
546
+ else if (dragging.current && typeof dragging.current === "object")
547
+ scrollPane(dragging.current.scroll, event.y);
548
+ if (dragging.current === "editor")
549
+ editorAt(event.x, event.y, "release");
479
550
  dragging.current = null;
480
551
  return;
481
552
  }
482
553
  if (event.y < 1 || event.y > paneHeight)
483
554
  return; // header or status bar
555
+ const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
556
+ if (event.kind === "wheel")
557
+ return queueWheel(pane, event.dir, event.horizontal);
484
558
  // Each border is two columns: the left pane's edge and the right pane's.
485
- if (event.kind === "click" && Math.abs(event.x - (treeWidth - 0.5)) <= 1)
559
+ if (Math.abs(event.x - (treeWidth - 0.5)) <= 1)
486
560
  return void (dragging.current = "tree");
487
- if (event.kind === "click" && Math.abs(event.x - (treeWidth + codeWidth - 0.5)) <= 1)
561
+ if (Math.abs(event.x - (treeWidth + codeWidth - 0.5)) <= 1)
488
562
  return void (dragging.current = "chat");
489
- const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
490
- if (event.kind === "wheel") {
491
- setWheel((w) => ({ pane, dir: event.dir, nonce: (w?.nonce ?? 0) + 1 }));
563
+ // Each pane's scrollbar is the column inside its right border.
564
+ const scrollbarX = pane === "tree" ? treeWidth - 2 : pane === "code" ? treeWidth + codeWidth - 2 : columns - 2;
565
+ if (event.x === scrollbarX && event.y >= 3) {
566
+ dragging.current = { scroll: pane };
567
+ scrollPane(pane, event.y);
492
568
  return;
493
569
  }
494
570
  if (approval)
@@ -497,11 +573,30 @@ export function App({ initialModel, permContext }) {
497
573
  // Rows start under the pane's top border (y=1) and title (y=2).
498
574
  if (pane === "tree" && event.y >= 3)
499
575
  setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
576
+ // Two clicks in the same spot within half a second are a double-click.
577
+ const last = lastClick.current;
578
+ const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
579
+ lastClick.current = double ? null : { x: event.x, y: event.y, time: Date.now() };
580
+ if (pane === "code" && event.y >= 3 && codePanel === "code") {
581
+ if (showEditor) {
582
+ dragging.current = "editor";
583
+ editorAt(event.x, event.y, double ? "double" : "press");
584
+ }
585
+ else if (double && openFile && !diff && settleDraft(openFile)) {
586
+ // Double-click in the code: edit right there.
587
+ const gutter = String(content.split("\n").length).length + 1;
588
+ setEditorStart((s) => ({ line: (viewport?.top ?? 1) + event.y - 3, col: Math.max(1, event.x - codeLeft - gutter), nonce: s.nonce + 1 }));
589
+ setDiffMode("off");
590
+ setEditing(true);
591
+ }
592
+ }
500
593
  }, settings.mouse);
501
594
  // Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
502
595
  useInput((input, key) => {
503
596
  if (!(key.ctrl && input === "c"))
504
597
  return;
598
+ if (showEditor && focus === "code" && editorStateRef.current?.selection)
599
+ return; // the editor copies it
505
600
  if (busy || approval) {
506
601
  pullAbort.current?.abort();
507
602
  agentRef.current?.cancel();
@@ -713,7 +808,12 @@ export function App({ initialModel, permContext }) {
713
808
  }
714
809
  if (focus === "chat")
715
810
  return; // letters belong to the text box
716
- if (input === "f")
811
+ if (input === "/") {
812
+ // Commands work from anywhere: / jumps to the chat's command box.
813
+ setFocus("chat");
814
+ setChatPrefill((p) => ({ text: "/", nonce: (p?.nonce ?? 0) + 1 }));
815
+ }
816
+ else if (input === "f")
717
817
  setFollow((f) => !f);
718
818
  else if (input === "m") {
719
819
  updateSettings({ mouse: !settings.mouse });
@@ -755,6 +855,7 @@ export function App({ initialModel, permContext }) {
755
855
  const editorContext = () => {
756
856
  if (!openFile)
757
857
  return null;
858
+ const editorState = editorStateRef.current;
758
859
  const unsaved = showEditor && editorState?.dirty;
759
860
  const lines = showEditor && editorState ? editorState.lines : content.replace(/\r\n/g, "\n").split("\n");
760
861
  let from;
@@ -982,6 +1083,58 @@ ${snippet}
982
1083
  setEntries((prev) => [...prev, { kind: "error", text: `Couldn't save ${file}: ${error.message}` }]);
983
1084
  }
984
1085
  }
1086
+ // Stable callbacks for the memoized panes.
1087
+ const onTreeOpen = useEvent((filePath) => {
1088
+ open(filePath);
1089
+ setFocus("code");
1090
+ });
1091
+ const onTreeHover = useEvent(() => { });
1092
+ const onJump = useEvent((file, line) => openAt(file, line));
1093
+ const onCloseProblems = useEvent(() => setCodePanel("code"));
1094
+ const onViewport = useEvent((top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })));
1095
+ const onSend = useEvent(send);
1096
+ const onAnswer = useEvent(answer);
1097
+ const onEditorHover = useEvent((text, line, col) => (lspEnabled && openFile ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)));
1098
+ const onEditorDefinition = useEvent((text, line, col) => {
1099
+ if (!openFile)
1100
+ return;
1101
+ if (!lspEnabled)
1102
+ return info("Go to definition needs a language server (see /settings → Plugins).", "error");
1103
+ void lsp.definition(openFile, text, line, col).then((target) => {
1104
+ if (!target)
1105
+ return info("No definition found here.");
1106
+ // Keep unsaved text when jumping within the same file.
1107
+ if (target.file === openFile && draft.current)
1108
+ draft.current = { ...draft.current, text };
1109
+ openAt(target.file, target.line, true);
1110
+ });
1111
+ });
1112
+ const onEditorSave = useEvent((text) => saveFromEditor(text));
1113
+ const onEditorExit = useEvent(() => {
1114
+ draft.current = null;
1115
+ editorStateRef.current = null;
1116
+ setEditing(false);
1117
+ });
1118
+ const onEditorState = useEvent((state) => {
1119
+ if (!openFile)
1120
+ return;
1121
+ const prev = editorStateRef.current;
1122
+ editorStateRef.current = state;
1123
+ if (prev?.line !== state.line || prev?.dirty !== state.dirty || prev?.selection !== state.selection) {
1124
+ setEditorState({ line: state.line, dirty: state.dirty, selection: state.selection });
1125
+ }
1126
+ draft.current = state.dirty
1127
+ ? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
1128
+ : null;
1129
+ // Live problems while you type (debounced).
1130
+ if (prev && prev.lines !== state.lines) {
1131
+ if (editorProblemsTimer.current)
1132
+ clearTimeout(editorProblemsTimer.current);
1133
+ const file = openFile;
1134
+ editorProblemsTimer.current = setTimeout(() => void diagnostics.checkFile(file, state.lines.join("\n")), 700);
1135
+ }
1136
+ });
1137
+ const lspEnabled = activePlugins.some((p) => p.id === "lsp");
985
1138
  if (view === "settings") {
986
1139
  return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, ...(connectTo ? { connectTo } : {}), onModel: (next) => void chooseModel(next), onEngine: (id) => void switchEngine(id), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: rescanPlugins, onClose: () => {
987
1140
  setConnectTo(undefined);
@@ -994,11 +1147,9 @@ ${snippet}
994
1147
  const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
995
1148
  layout.current = { treeWidth, codeWidth, paneHeight: height };
996
1149
  const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
997
- const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
998
- const fileProblems = openFile ? diagnostics.get(openFile) : [];
1150
+ const scrollFor = (pane) => (scrollTo?.pane === pane ? scrollTo : null);
999
1151
  const totals = diagnostics.counts();
1000
1152
  const checking = diagnostics.busy;
1001
- const lspEnabled = activePlugins.some((p) => p.id === "lsp");
1002
1153
  const mode = approval
1003
1154
  ? { label: "APPROVE", color: C.yellow }
1004
1155
  : showEditor ? { label: "EDIT", color: C.green }
@@ -1009,16 +1160,18 @@ ${snippet}
1009
1160
  const hints = approval && !(showEditor && focus === "code")
1010
1161
  ? [["y", "accept"], ["n", "reject"], ...(pendingChange && diff !== pendingChange ? [["o", "show the edit"]] : []), ["Esc", "stop"], ["Shift+Tab", "auto mode"]]
1011
1162
  : showEditor && focus === "code"
1012
- ? [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Ctrl+Y", "redo"], ["Ctrl+K", "info"], ["Ctrl+G", "definition"]]
1163
+ ? editorState?.selection
1164
+ ? [["Ctrl+C", "copy"], ["Ctrl+X", "cut"], ["Tab", "indent"], ["Ctrl+/", "comment"], ["Esc", "deselect"]]
1165
+ : [["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"]]
1013
1166
  : focus === "chat"
1014
1167
  ? busy
1015
1168
  ? [["Esc", "stop"], ["Shift+↑↓", "scroll"], ["Tab", "next pane"]]
1016
1169
  : [["Enter", "send"], ["/", "commands"], ["Tab", "next pane"], ["Ctrl+P", "find file"], ["Shift+↑↓", "scroll"]]
1017
1170
  : focus === "tree"
1018
- ? [["Enter", "open"], ["e", "edit"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
1171
+ ? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
1019
1172
  : diff
1020
1173
  ? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
1021
- : [["e", "edit"], ["v", "diff"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
1174
+ : [["double-click/e", "edit"], ["v", "diff"], ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
1022
1175
  const statusUsed = mode.label.length + 2 + autoMode.length + 2 + 10 + ` ✖ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
1023
1176
  const shownHints = [];
1024
1177
  for (let used = statusUsed, i = 0; i < hints.length; i++) {
@@ -1027,29 +1180,7 @@ ${snippet}
1027
1180
  break;
1028
1181
  shownHints.push(hints[i]);
1029
1182
  }
1030
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), wheel: wheelFor("tree"), click: treeClick, onOpen: (filePath) => {
1031
- open(filePath);
1032
- setFocus("code");
1033
- }, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
1034
- if (!lspEnabled)
1035
- return info("Go to definition needs a language server (see /settings → Plugins).", "error");
1036
- void lsp.definition(openFile, text, line, col).then((target) => {
1037
- if (!target)
1038
- return info("No definition found here.");
1039
- // Keep unsaved text when jumping within the same file.
1040
- if (target.file === openFile && draft.current)
1041
- draft.current = { ...draft.current, text };
1042
- openAt(target.file, target.line, true);
1043
- });
1044
- }, onSave: (text) => saveFromEditor(text), onExit: () => {
1045
- draft.current = null;
1046
- setEditing(false);
1047
- }, onState: (state) => {
1048
- setEditorState(state);
1049
- draft.current = state.dirty
1050
- ? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
1051
- : null;
1052
- } }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), onSubmit: send, onAnswer: answer })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
1183
+ 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
1053
1184
  ? _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)` : ""] })
1054
1185
  : 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) => {
1055
1186
  setQuickOpen(false);