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 +18 -3
- package/dist/index.js +4 -5
- package/dist/src/env.js +3 -0
- package/dist/src/ui/App.js +182 -51
- package/dist/src/ui/Chat.js +92 -45
- package/dist/src/ui/ChatInput.js +11 -2
- package/dist/src/ui/CodeView.js +41 -22
- package/dist/src/ui/Editor.js +487 -99
- package/dist/src/ui/FileTree.js +64 -37
- package/dist/src/ui/Header.js +3 -2
- package/dist/src/ui/ProblemsView.js +2 -2
- package/dist/src/ui/clipboard.js +30 -0
- package/dist/src/ui/fast-output.js +250 -0
- package/dist/src/ui/mouse.js +9 -4
- package/dist/src/ui/render-options.js +11 -0
- package/dist/src/ui/scroll.js +30 -0
- package/package.json +3 -2
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
|
|
80
|
-
| <kbd>
|
|
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 (
|
|
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
|
-
|
|
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")) {
|
package/dist/src/ui/App.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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
|
-
|
|
478
|
-
|
|
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 (
|
|
559
|
+
if (Math.abs(event.x - (treeWidth - 0.5)) <= 1)
|
|
486
560
|
return void (dragging.current = "tree");
|
|
487
|
-
if (
|
|
561
|
+
if (Math.abs(event.x - (treeWidth + codeWidth - 0.5)) <= 1)
|
|
488
562
|
return void (dragging.current = "chat");
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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 === "
|
|
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
|
|
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
|
-
?
|
|
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:
|
|
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);
|