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 +18 -3
- package/dist/index.js +4 -5
- package/dist/src/env.js +3 -0
- package/dist/src/ui/App.js +170 -50
- 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 +471 -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 +4 -1
- 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 · **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 (
|
|
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
|
-
|
|
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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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 (
|
|
552
|
+
if (Math.abs(event.x - (treeWidth - 0.5)) <= 1)
|
|
486
553
|
return void (dragging.current = "tree");
|
|
487
|
-
if (
|
|
554
|
+
if (Math.abs(event.x - (treeWidth + codeWidth - 0.5)) <= 1)
|
|
488
555
|
return void (dragging.current = "chat");
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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 === "
|
|
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
|
|
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
|
-
?
|
|
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:
|
|
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);
|