nou-cli 0.4.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.
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 · **click in the code to edit right there**, 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 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";
@@ -58,10 +59,22 @@ Keys:
58
59
  Ctrl+P find a file Tab switch pane Esc stop / back
59
60
  Shift+Tab ask / auto-edit / auto < > resize pane = reset
60
61
  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
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
62
64
  p problems u undo Nou's edit s settings
63
65
  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`;
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
+ }
65
78
  function readText(filePath) {
66
79
  try {
67
80
  return readFileSync(path.resolve(process.cwd(), filePath), "utf-8");
@@ -117,11 +130,14 @@ export function App({ initialModel, permContext }) {
117
130
  const [plugins, setPlugins] = useState([]);
118
131
  const [starters, setStarters] = useState([]);
119
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);
120
135
  const [editorState, setEditorState] = useState(null);
136
+ const editorProblemsTimer = useRef(null);
121
137
  // Unsaved editor text survives switching to a diff and back.
122
138
  const draft = useRef(null);
123
139
  const [codePanel, setCodePanel] = useState("code");
124
- const [, setDiagVersion] = useState(0);
140
+ const [diagnosticsVersion, setDiagVersion] = useState(0);
125
141
  const [syntaxVersion, setSyntaxVersion] = useState(0);
126
142
  const [focusLine, setFocusLine] = useState(null);
127
143
  const [editorStart, setEditorStart] = useState({ line: 1, nonce: 0 });
@@ -129,6 +145,11 @@ export function App({ initialModel, permContext }) {
129
145
  const [exitArmed, setExitArmed] = useState(false);
130
146
  const [update, setUpdate] = useState(null);
131
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);
132
153
  const [treeClick, setTreeClick] = useState(null);
133
154
  // Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
134
155
  const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
@@ -136,6 +157,7 @@ export function App({ initialModel, permContext }) {
136
157
  const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
137
158
  const paneWidthsRef = useRef(paneWidths);
138
159
  paneWidthsRef.current = paneWidths;
160
+ /** What a mouse drag is moving: a pane border, a pane's scrollbar, or the editor's selection. */
139
161
  const dragging = useRef(null);
140
162
  const pullAbort = useRef(null);
141
163
  const diagnosticsRef = useRef(null);
@@ -404,6 +426,11 @@ export function App({ initialModel, permContext }) {
404
426
  : next === "auto-edit" ? "Auto-edit mode: edits apply without asking; Nou still asks before commands. Review with the diff view, u to undo."
405
427
  : "Auto mode: edits and commands run without asking. Review with the diff view, u to undo. Shift+Tab to change.");
406
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]);
407
434
  const record = openFile ? changes.get(openFile) : undefined;
408
435
  const pendingChange = approval?.request.kind === "change" ? approval.request.change : null;
409
436
  const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
@@ -435,13 +462,6 @@ export function App({ initialModel, permContext }) {
435
462
  cancelled = true;
436
463
  };
437
464
  }, [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
465
  /** Quit, saving the editor's unsaved text first if auto-save is on. */
446
466
  const quit = () => {
447
467
  if (draft.current && settings.autoSave !== "off")
@@ -465,30 +485,79 @@ export function App({ initialModel, permContext }) {
465
485
  setPaneWidths(next);
466
486
  return next;
467
487
  };
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.
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.
470
522
  useMouse((event) => {
471
523
  if (view !== "main" || quickOpen)
472
524
  return;
473
525
  const { treeWidth, codeWidth, paneHeight } = layout.current;
474
- if (event.kind === "drag")
475
- return void (dragging.current && moveBorder(dragging.current, event.x));
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
+ }
476
538
  if (event.kind === "release") {
477
- if (dragging.current)
539
+ if (dragging.current === "tree" || dragging.current === "chat")
478
540
  updateSettings({ paneWidths: paneWidthsRef.current });
541
+ if (dragging.current === "editor")
542
+ editorAt(event.x, event.y, "release");
479
543
  dragging.current = null;
480
544
  return;
481
545
  }
482
546
  if (event.y < 1 || event.y > paneHeight)
483
547
  return; // header or status bar
548
+ const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
549
+ if (event.kind === "wheel")
550
+ return queueWheel(pane, event.dir, event.horizontal);
484
551
  // 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)
552
+ if (Math.abs(event.x - (treeWidth - 0.5)) <= 1)
486
553
  return void (dragging.current = "tree");
487
- if (event.kind === "click" && Math.abs(event.x - (treeWidth + codeWidth - 0.5)) <= 1)
554
+ if (Math.abs(event.x - (treeWidth + codeWidth - 0.5)) <= 1)
488
555
  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 }));
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);
492
561
  return;
493
562
  }
494
563
  if (approval)
@@ -497,11 +566,26 @@ export function App({ initialModel, permContext }) {
497
566
  // Rows start under the pane's top border (y=1) and title (y=2).
498
567
  if (pane === "tree" && event.y >= 3)
499
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
+ }
500
582
  }, settings.mouse);
501
583
  // Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
502
584
  useInput((input, key) => {
503
585
  if (!(key.ctrl && input === "c"))
504
586
  return;
587
+ if (showEditor && focus === "code" && editorStateRef.current?.selection)
588
+ return; // the editor copies it
505
589
  if (busy || approval) {
506
590
  pullAbort.current?.abort();
507
591
  agentRef.current?.cancel();
@@ -713,7 +797,12 @@ export function App({ initialModel, permContext }) {
713
797
  }
714
798
  if (focus === "chat")
715
799
  return; // letters belong to the text box
716
- 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")
717
806
  setFollow((f) => !f);
718
807
  else if (input === "m") {
719
808
  updateSettings({ mouse: !settings.mouse });
@@ -755,6 +844,7 @@ export function App({ initialModel, permContext }) {
755
844
  const editorContext = () => {
756
845
  if (!openFile)
757
846
  return null;
847
+ const editorState = editorStateRef.current;
758
848
  const unsaved = showEditor && editorState?.dirty;
759
849
  const lines = showEditor && editorState ? editorState.lines : content.replace(/\r\n/g, "\n").split("\n");
760
850
  let from;
@@ -982,6 +1072,58 @@ ${snippet}
982
1072
  setEntries((prev) => [...prev, { kind: "error", text: `Couldn't save ${file}: ${error.message}` }]);
983
1073
  }
984
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");
985
1127
  if (view === "settings") {
986
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: () => {
987
1129
  setConnectTo(undefined);
@@ -994,11 +1136,9 @@ ${snippet}
994
1136
  const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
995
1137
  layout.current = { treeWidth, codeWidth, paneHeight: height };
996
1138
  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) : [];
1139
+ const scrollFor = (pane) => (scrollTo?.pane === pane ? scrollTo : null);
999
1140
  const totals = diagnostics.counts();
1000
1141
  const checking = diagnostics.busy;
1001
- const lspEnabled = activePlugins.some((p) => p.id === "lsp");
1002
1142
  const mode = approval
1003
1143
  ? { label: "APPROVE", color: C.yellow }
1004
1144
  : showEditor ? { label: "EDIT", color: C.green }
@@ -1009,16 +1149,18 @@ ${snippet}
1009
1149
  const hints = approval && !(showEditor && focus === "code")
1010
1150
  ? [["y", "accept"], ["n", "reject"], ...(pendingChange && diff !== pendingChange ? [["o", "show the edit"]] : []), ["Esc", "stop"], ["Shift+Tab", "auto mode"]]
1011
1151
  : 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"]]
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"]]
1013
1155
  : focus === "chat"
1014
1156
  ? busy
1015
1157
  ? [["Esc", "stop"], ["Shift+↑↓", "scroll"], ["Tab", "next pane"]]
1016
1158
  : [["Enter", "send"], ["/", "commands"], ["Tab", "next pane"], ["Ctrl+P", "find file"], ["Shift+↑↓", "scroll"]]
1017
1159
  : focus === "tree"
1018
- ? [["Enter", "open"], ["e", "edit"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
1160
+ ? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
1019
1161
  : diff
1020
1162
  ? [["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"]];
1163
+ : [["click/e", "edit"], ["v", "diff"], ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
1022
1164
  const statusUsed = mode.label.length + 2 + autoMode.length + 2 + 10 + ` ✖ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
1023
1165
  const shownHints = [];
1024
1166
  for (let used = statusUsed, i = 0; i < hints.length; i++) {
@@ -1027,29 +1169,7 @@ ${snippet}
1027
1169
  break;
1028
1170
  shownHints.push(hints[i]);
1029
1171
  }
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
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
1053
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)` : ""] })
1054
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) => {
1055
1175
  setQuickOpen(false);