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