nou-cli 0.5.3 → 0.5.6
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 +13 -5
- package/dist/src/cli.js +33 -0
- package/dist/src/config/index.js +3 -0
- package/dist/src/diagnostics/providers.js +23 -2
- package/dist/src/engines/codex.js +28 -1
- package/dist/src/games/index.js +220 -30
- package/dist/src/guide/index.js +100 -0
- package/dist/src/syntax/index.js +17 -17
- package/dist/src/ui/App.js +398 -54
- package/dist/src/ui/Chat.js +103 -19
- package/dist/src/ui/CodeView.js +70 -19
- package/dist/src/ui/Editor.js +44 -7
- package/dist/src/ui/FileTree.js +4 -2
- package/dist/src/ui/GamePanel.js +42 -14
- package/dist/src/ui/Header.js +5 -2
- package/dist/src/ui/ProblemsView.js +4 -2
- package/dist/src/ui/Settings.js +10 -3
- package/dist/src/ui/Terminal.js +5 -2
- package/dist/src/ui/clipboard.js +19 -0
- package/dist/src/ui/commands.js +21 -1
- package/dist/src/ui/focus.js +59 -0
- package/dist/src/ui/redraw.js +16 -0
- package/dist/src/ui/render-options.js +3 -0
- package/dist/src/ui/text.js +76 -3
- package/dist/src/ui/theme.js +2 -0
- package/package.json +1 -1
package/dist/src/ui/App.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
-
import { readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { Box, Text, useApp, useInput, useWindowSize } from "ink";
|
|
6
6
|
import { nouRulesOnly } from "../context/index.js";
|
|
@@ -22,22 +22,25 @@ import { lsp } from "../lsp/index.js";
|
|
|
22
22
|
import { onGrammarLoaded, preload } from "../syntax/index.js";
|
|
23
23
|
import { setCodeTheme } from "../syntax/themes.js";
|
|
24
24
|
import { checkForUpdate, currentVersion } from "../update/index.js";
|
|
25
|
-
import { changedLines, lineCounts, loadFallbackHighlighter } from "./text.js";
|
|
25
|
+
import { changedLines, lineCounts, loadFallbackHighlighter, splitLines } from "./text.js";
|
|
26
26
|
import { findDefinition, wordAt } from "./definition.js";
|
|
27
27
|
import { ProblemsView } from "./ProblemsView.js";
|
|
28
|
+
import { useFocusReporting, useTerminalFocused } from "./focus.js";
|
|
28
29
|
import { useMouse } from "./mouse.js";
|
|
29
30
|
import { C } from "./theme.js";
|
|
30
31
|
import { FileTree } from "./FileTree.js";
|
|
31
32
|
import { CodeView } from "./CodeView.js";
|
|
32
33
|
import { Editor } from "./Editor.js";
|
|
33
|
-
import { SLASH_COMMANDS } from "./commands.js";
|
|
34
|
+
import { SLASH_COMMANDS, runsWhileBusy } from "./commands.js";
|
|
34
35
|
import { WHEEL_STEP } from "./scroll.js";
|
|
35
|
-
import { Chat } from "./Chat.js";
|
|
36
|
-
import { copyToClipboard } from "./clipboard.js";
|
|
36
|
+
import { Chat, chatTabZones } from "./Chat.js";
|
|
37
|
+
import { copyToClipboard, openInBrowser } from "./clipboard.js";
|
|
37
38
|
import { Header } from "./Header.js";
|
|
38
39
|
import { Tabs, layoutTabs } from "./Tabs.js";
|
|
39
|
-
import { GAME_PANEL, GamePanel } from "./GamePanel.js";
|
|
40
|
+
import { GAME_PANEL, GameGuard, GamePanel } from "./GamePanel.js";
|
|
40
41
|
import { Terminal } from "./Terminal.js";
|
|
42
|
+
import { forgetScreen } from "./redraw.js";
|
|
43
|
+
import { showMePrompt, showStepsTool, stepsFromReply } from "../guide/index.js";
|
|
41
44
|
import { Shell } from "../terminal/index.js";
|
|
42
45
|
import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
|
|
43
46
|
import { Settings } from "./Settings.js";
|
|
@@ -124,6 +127,25 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
124
127
|
/** The terminal panel under the code (` or /terminal): its shell lives on while the panel is closed. */
|
|
125
128
|
const [termOpen, setTermOpen] = useState(false);
|
|
126
129
|
const [termVersion, setTermVersion] = useState(0);
|
|
130
|
+
/** Its height while its top border is being dragged (saved when you let go). */
|
|
131
|
+
const [termDrag, setTermDrag] = useState(null);
|
|
132
|
+
/** Bumped to draw every line again after the screen is cleared (/redraw, Ctrl+L). */
|
|
133
|
+
const [, setRepaint] = useState(0);
|
|
134
|
+
/** The Games panel's height while its top border is being dragged (saved when you let go). */
|
|
135
|
+
const [gameSizeDrag, setGameSizeDrag] = useState(null);
|
|
136
|
+
/** The tab strip, kept while nothing in it changes: a new one would redraw the whole code view on every scroll. */
|
|
137
|
+
const tabStripCache = useRef(null);
|
|
138
|
+
/**
|
|
139
|
+
* "Show me how" (/showme): the steps Nou found, and the one you're on. While Nou looks for them,
|
|
140
|
+
* `guideAsk` holds what you asked (and its edits are turned down: you're doing the typing).
|
|
141
|
+
*/
|
|
142
|
+
const [guide, setGuide] = useState(null);
|
|
143
|
+
const guideAsk = useRef(null);
|
|
144
|
+
/** Where the undo history stood when the steps were asked for: an engine that edits anyway is put back. */
|
|
145
|
+
const guideMark = useRef(0);
|
|
146
|
+
/** /resume just listed the conversations: the next message, if it's a number, picks one. */
|
|
147
|
+
const pickingResume = useRef(false);
|
|
148
|
+
const guideFound = useRef(null);
|
|
127
149
|
const shellRef = useRef(null);
|
|
128
150
|
const beforeTerm = useRef("code");
|
|
129
151
|
const [follow, setFollow] = useState(settings.followMode);
|
|
@@ -185,6 +207,9 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
185
207
|
const [plugins, setPlugins] = useState([]);
|
|
186
208
|
const [starters, setStarters] = useState([]);
|
|
187
209
|
const [viewport, setViewport] = useState(null);
|
|
210
|
+
/** Folding in the code view: a click on a row's fold marker, and /fold, /unfold for the whole file. */
|
|
211
|
+
const [foldClick, setFoldClick] = useState(null);
|
|
212
|
+
const [foldAll, setFoldAll] = useState(null);
|
|
188
213
|
// The editor reports every keystroke; the app only re-renders when what it shows changes (line, unsaved, selection).
|
|
189
214
|
const editorStateRef = useRef(null);
|
|
190
215
|
const [editorState, setEditorState] = useState(null);
|
|
@@ -287,34 +312,11 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
287
312
|
openAtRef.current(filePath, line ?? 1, false, to);
|
|
288
313
|
},
|
|
289
314
|
onRead: (filePath, line, to) => followStepRef.current({ file: filePath, ...(line ? { line } : {}), ...(to ? { to } : {}) }),
|
|
290
|
-
onChange: (change, status, turn) =>
|
|
291
|
-
if (status === "applied") {
|
|
292
|
-
timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
|
|
293
|
-
}
|
|
294
|
-
const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
|
|
295
|
-
setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
|
|
296
|
-
setWorking((w) => {
|
|
297
|
-
if (status === "rejected")
|
|
298
|
-
return w.has(change.path) ? new Set([...w].filter((p) => p !== change.path)) : w;
|
|
299
|
-
return w.has(change.path) ? w : new Set(w).add(change.path);
|
|
300
|
-
});
|
|
301
|
-
// Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
|
|
302
|
-
if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
|
|
303
|
-
clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
|
|
304
|
-
setOpenFile(change.path);
|
|
305
|
-
setContent(status === "applied" ? change.after : readText(change.path));
|
|
306
|
-
setDiffMode("off");
|
|
307
|
-
setReveal(change.path);
|
|
308
|
-
}
|
|
309
|
-
if (status === "applied") {
|
|
310
|
-
setTreeVersion((v) => v + 1);
|
|
311
|
-
setGit(gitStatus());
|
|
312
|
-
if (draft.current?.path === change.path) {
|
|
313
|
-
setEntries((prev) => [...prev, { kind: "info", text: `Heads up: you have unsaved edits in ${change.path}; saving them will overwrite Nou's change.` }]);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
},
|
|
315
|
+
onChange: (change, status, turn) => applyChange(change, status, turn, false),
|
|
317
316
|
approve: (request) => {
|
|
317
|
+
// Asked to show how, not to do it: edits are turned down (the model is told to hand back steps).
|
|
318
|
+
if (guideAsk.current && request.kind === "change")
|
|
319
|
+
return Promise.resolve(false);
|
|
318
320
|
const s = settingsRef.current;
|
|
319
321
|
if ((request.kind === "change" && s.autoApproveEdits) || (request.kind !== "change" && s.autoApproveCommands)) {
|
|
320
322
|
return Promise.resolve(true);
|
|
@@ -324,9 +326,154 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
324
326
|
checkChange: (change) => problemsIntroduced(diagnosticsRef.current, change),
|
|
325
327
|
};
|
|
326
328
|
}
|
|
329
|
+
/** A change an engine made or proposes; one from a tab in the background doesn't take over the code view. */
|
|
330
|
+
function applyChange(change, status, turn, background) {
|
|
331
|
+
if (status === "applied") {
|
|
332
|
+
timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
|
|
333
|
+
}
|
|
334
|
+
const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
|
|
335
|
+
setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
|
|
336
|
+
setWorking((w) => {
|
|
337
|
+
if (status === "rejected")
|
|
338
|
+
return w.has(change.path) ? new Set([...w].filter((p) => p !== change.path)) : w;
|
|
339
|
+
return w.has(change.path) ? w : new Set(w).add(change.path);
|
|
340
|
+
});
|
|
341
|
+
// Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
|
|
342
|
+
if (!background && ((status === "pending" && !draftActiveRef.current) || followRef.current)) {
|
|
343
|
+
clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
|
|
344
|
+
setOpenFile(change.path);
|
|
345
|
+
setContent(status === "applied" ? change.after : readText(change.path));
|
|
346
|
+
setDiffMode("off");
|
|
347
|
+
setReveal(change.path);
|
|
348
|
+
}
|
|
349
|
+
if (status === "applied") {
|
|
350
|
+
setTreeVersion((v) => v + 1);
|
|
351
|
+
setGit(gitStatus());
|
|
352
|
+
if (draft.current?.path === change.path) {
|
|
353
|
+
setEntries((prev) => [...prev, { kind: "info", text: `Heads up: you have unsaved edits in ${change.path}; saving them will overwrite Nou's change.` }]);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Conversation tabs: several agents at once. The one on screen uses the state above; the others keep
|
|
358
|
+
// theirs here, and each tab's engine reports through hooks that send it to the right place.
|
|
359
|
+
const activeTab = useRef("1");
|
|
360
|
+
const [activeTabId, setActiveTabId] = useState("1");
|
|
361
|
+
const [chatTabs, setChatTabs] = useState(["1"]);
|
|
362
|
+
const tabStore = useRef(new Map());
|
|
363
|
+
const [, setTabVersion] = useState(0);
|
|
364
|
+
const tabHooks = useRef(new Map());
|
|
365
|
+
/** The tab strip's data, kept while it reads the same, so the chat doesn't redraw on every app render. */
|
|
366
|
+
const chatTabsCache = useRef(null);
|
|
367
|
+
/** The hooks for tab `id`'s engine: on screen, they're the chat's own; in the background, its stored state. */
|
|
368
|
+
function hooksFor(id) {
|
|
369
|
+
const cached = tabHooks.current.get(id);
|
|
370
|
+
if (cached)
|
|
371
|
+
return cached;
|
|
372
|
+
const live = () => hooksRef.current;
|
|
373
|
+
const shown = () => activeTab.current === id;
|
|
374
|
+
const stored = (fn) => {
|
|
375
|
+
const t = tabStore.current.get(id);
|
|
376
|
+
if (!t)
|
|
377
|
+
return;
|
|
378
|
+
fn(t);
|
|
379
|
+
setTabVersion((v) => v + 1);
|
|
380
|
+
};
|
|
381
|
+
const hooks = {
|
|
382
|
+
onEntry: (entry) => (shown() ? live().onEntry(entry) : stored((t) => (t.entries = [...t.entries, entry]))),
|
|
383
|
+
onStream: (text) => (shown() ? live().onStream(text) : void (tabStore.current.get(id) && (tabStore.current.get(id).streaming = text))),
|
|
384
|
+
onBusy: (label) => {
|
|
385
|
+
if (shown())
|
|
386
|
+
return live().onBusy(label);
|
|
387
|
+
stored((t) => {
|
|
388
|
+
t.busy = label;
|
|
389
|
+
if (!label)
|
|
390
|
+
saveTab(t); // it finished in the background: keep it for /resume
|
|
391
|
+
});
|
|
392
|
+
},
|
|
393
|
+
onOpen: (...args) => (shown() ? live().onOpen?.(...args) : undefined),
|
|
394
|
+
onRead: (...args) => (shown() ? live().onRead(...args) : undefined),
|
|
395
|
+
onChange: (change, status, turn) => applyChange(change, status, turn, !shown()),
|
|
396
|
+
approve: (request) => {
|
|
397
|
+
if (shown())
|
|
398
|
+
return live().approve(request);
|
|
399
|
+
const s = settingsRef.current;
|
|
400
|
+
if ((request.kind === "change" && s.autoApproveEdits) || (request.kind !== "change" && s.autoApproveCommands))
|
|
401
|
+
return Promise.resolve(true);
|
|
402
|
+
// It waits for you: its tab says so, and the question is there when you switch to it.
|
|
403
|
+
return new Promise((resolve) => stored((t) => (t.approval = { request, resolve })));
|
|
404
|
+
},
|
|
405
|
+
checkChange: (change) => live().checkChange?.(change) ?? Promise.resolve(null),
|
|
406
|
+
};
|
|
407
|
+
tabHooks.current.set(id, hooks);
|
|
408
|
+
return hooks;
|
|
409
|
+
}
|
|
410
|
+
/** Puts tab `id` on screen, keeping the one there now (and whatever it's doing) in the background. */
|
|
411
|
+
function switchChatTab(id) {
|
|
412
|
+
if (id === activeTab.current || !tabStore.current.has(id))
|
|
413
|
+
return;
|
|
414
|
+
tabStore.current.set(activeTab.current, { entries, streaming: streamed.current.text, busy, approval, queued, conversationId, agent: agentRef.current });
|
|
415
|
+
const t = tabStore.current.get(id);
|
|
416
|
+
tabStore.current.delete(id);
|
|
417
|
+
activeTab.current = id;
|
|
418
|
+
setActiveTabId(id);
|
|
419
|
+
agentRef.current = t.agent;
|
|
420
|
+
setEntries(t.entries);
|
|
421
|
+
showStream(t.streaming);
|
|
422
|
+
setBusy(t.busy);
|
|
423
|
+
setApproval(t.approval);
|
|
424
|
+
setQueued(t.queued);
|
|
425
|
+
setConversationId(t.conversationId);
|
|
426
|
+
setFocus("chat");
|
|
427
|
+
}
|
|
428
|
+
/** A new conversation tab, with the same engine and model, next to the others. */
|
|
429
|
+
function newChatTab() {
|
|
430
|
+
const id = String(Date.now());
|
|
431
|
+
const agent = createEngine(agentRef.current.id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksFor(id));
|
|
432
|
+
agent.configure(engineConfig.current);
|
|
433
|
+
tabStore.current.set(id, { entries: [], streaming: "", busy: null, approval: null, queued: [], conversationId: newSessionId(), agent });
|
|
434
|
+
setChatTabs((tabs) => [...tabs, id]);
|
|
435
|
+
switchChatTab(id);
|
|
436
|
+
}
|
|
437
|
+
/** Closes the conversation tab on screen (stopping its engine); the conversation stays saved for /resume. */
|
|
438
|
+
function closeChatTab() {
|
|
439
|
+
if (chatTabs.length < 2)
|
|
440
|
+
return info("That's the only conversation tab. /clear starts a fresh conversation in it.");
|
|
441
|
+
const id = activeTab.current;
|
|
442
|
+
const at = chatTabs.indexOf(id);
|
|
443
|
+
const next = chatTabs[at + 1] ?? chatTabs[at - 1];
|
|
444
|
+
const agent = agentRef.current;
|
|
445
|
+
approval?.resolve(false);
|
|
446
|
+
switchChatTab(next);
|
|
447
|
+
agent.cancel();
|
|
448
|
+
agent.dispose?.();
|
|
449
|
+
tabStore.current.delete(id);
|
|
450
|
+
tabHooks.current.delete(id);
|
|
451
|
+
setChatTabs((tabs) => tabs.filter((t) => t !== id));
|
|
452
|
+
}
|
|
453
|
+
/** The tabs as the chat's title row shows them: named for what you asked first, marked when working or waiting. */
|
|
454
|
+
function chatTabItems() {
|
|
455
|
+
return chatTabs.map((id, i) => {
|
|
456
|
+
const t = id === activeTab.current ? { entries, busy, approval } : tabStore.current.get(id);
|
|
457
|
+
const first = t?.entries.find((e) => e.kind === "user")?.text.replace(/\s+/g, " ");
|
|
458
|
+
const label = first ? (first.length > 14 ? `${first.slice(0, 13)}…` : first) : `chat ${i + 1}`;
|
|
459
|
+
return { id, label, state: t?.approval ? "waiting" : t?.busy ? "busy" : "idle" };
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
function chatTabsProp() {
|
|
463
|
+
const items = chatTabItems();
|
|
464
|
+
const key = `${activeTabId}|${items.map((t) => `${t.id}:${t.label}:${t.state}`).join("|")}`;
|
|
465
|
+
if (chatTabsCache.current?.key !== key)
|
|
466
|
+
chatTabsCache.current = { key, tabs: { items, active: activeTabId } };
|
|
467
|
+
return chatTabsCache.current.tabs;
|
|
468
|
+
}
|
|
469
|
+
function saveTab(t) {
|
|
470
|
+
const first = t.entries.find((e) => e.kind === "user");
|
|
471
|
+
if (first)
|
|
472
|
+
saveConversation({ id: t.conversationId, title: first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: t.agent.id, entries: t.entries, state: t.agent.snapshot?.() });
|
|
473
|
+
}
|
|
327
474
|
if (!agentRef.current) {
|
|
328
475
|
const saved = loadSettings().engine;
|
|
329
|
-
agentRef.current = createEngine(isEngineId(saved) ? saved : "nou", initialModel, buildSystemPrompt(permContext),
|
|
476
|
+
agentRef.current = createEngine(isEngineId(saved) ? saved : "nou", initialModel, buildSystemPrompt(permContext), hooksFor(activeTab.current));
|
|
330
477
|
}
|
|
331
478
|
useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
|
|
332
479
|
useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
|
|
@@ -398,15 +545,21 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
398
545
|
useEffect(() => {
|
|
399
546
|
const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
|
|
400
547
|
// Other engines read AGENTS.md / CLAUDE.md themselves: they get only the NOU.md rules on top.
|
|
401
|
-
engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, nouRulesOnly(permContext)].filter(Boolean), extraTools: [...extraTools, ...mcpRef.current.tools()], effort: settings.effort };
|
|
548
|
+
engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, nouRulesOnly(permContext)].filter(Boolean), extraTools: [...extraTools, showStepsTool((steps) => (guideFound.current = steps)), ...mcpRef.current.tools()], effort: settings.effort };
|
|
402
549
|
agentRef.current.configure(engineConfig.current);
|
|
403
550
|
}, [settings.gitContext, settings.effort, activePlugins, mcpVersion]);
|
|
404
|
-
// Pick up changes made outside Nou (other editors, git commands).
|
|
551
|
+
// Pick up changes made outside Nou (other editors, git commands). Not while Nou's window is in the
|
|
552
|
+
// background (in a big repo each `git status` costs real CPU); coming back checks right away.
|
|
405
553
|
const polling = useRef(false);
|
|
554
|
+
const wasFocused = useRef(true);
|
|
555
|
+
const windowFocused = useTerminalFocused();
|
|
556
|
+
useFocusReporting(true);
|
|
406
557
|
useEffect(() => {
|
|
407
|
-
|
|
558
|
+
const cameBack = windowFocused && !wasFocused.current;
|
|
559
|
+
wasFocused.current = windowFocused;
|
|
560
|
+
if (!repo || !windowFocused)
|
|
408
561
|
return;
|
|
409
|
-
const
|
|
562
|
+
const poll = () => {
|
|
410
563
|
if (busy || polling.current)
|
|
411
564
|
return;
|
|
412
565
|
polling.current = true;
|
|
@@ -415,9 +568,12 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
415
568
|
// Only redraw when something changed.
|
|
416
569
|
setGit((prev) => (sameStatus(prev, next) ? prev : next));
|
|
417
570
|
});
|
|
418
|
-
}
|
|
571
|
+
};
|
|
572
|
+
if (cameBack)
|
|
573
|
+
poll();
|
|
574
|
+
const timer = setInterval(poll, 4000);
|
|
419
575
|
return () => clearInterval(timer);
|
|
420
|
-
}, [repo, busy]);
|
|
576
|
+
}, [repo, busy, windowFocused]);
|
|
421
577
|
useEffect(() => {
|
|
422
578
|
if (busy)
|
|
423
579
|
return;
|
|
@@ -466,7 +622,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
466
622
|
info(`Installed ${target.label} (${target.package}).`);
|
|
467
623
|
}
|
|
468
624
|
agentRef.current.dispose?.();
|
|
469
|
-
const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext),
|
|
625
|
+
const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksFor(activeTab.current));
|
|
470
626
|
engine.configure(engineConfig.current);
|
|
471
627
|
agentRef.current = engine;
|
|
472
628
|
updateSettings({ engine: id });
|
|
@@ -488,7 +644,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
488
644
|
return info("No earlier conversations in this folder yet: Nou's are saved after each reply, and Claude Code's and Codex's own sessions here show up too.");
|
|
489
645
|
if (!arg) {
|
|
490
646
|
const rows = list.slice(0, 10).map((c, i) => ` ${String(i + 1).padStart(2)}. ${c.title} (${c.messages} message${c.messages === 1 ? "" : "s"} · ${"file" in c ? `${engineInfo(c.engine).label} CLI` : engineInfo(isEngineId(c.engine) ? c.engine : "nou").label} · ${ago(c.updated)})`);
|
|
491
|
-
|
|
647
|
+
pickingResume.current = true; // a bare number, typed next, picks one
|
|
648
|
+
return info(`Earlier conversations here. Type a number to pick one up (or /resume last):\n${rows.join("\n")}`);
|
|
492
649
|
}
|
|
493
650
|
const pick = arg === "last" ? list[0] : list[Number(arg) - 1] ?? list.find((c) => c.id === arg);
|
|
494
651
|
if (!pick)
|
|
@@ -522,7 +679,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
522
679
|
let engine = agentRef.current;
|
|
523
680
|
if (id && id !== engine.id && isInstalled(engineInfo(id))) {
|
|
524
681
|
engine.dispose?.();
|
|
525
|
-
engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext),
|
|
682
|
+
engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksFor(activeTab.current));
|
|
526
683
|
engine.configure(engineConfig.current);
|
|
527
684
|
agentRef.current = engine;
|
|
528
685
|
updateSettings({ engine: id });
|
|
@@ -752,6 +909,18 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
752
909
|
// The Games panel sits under a pane: it takes the clicks that land on it, × on its title closes it,
|
|
753
910
|
// and dragging its title docks it under the pane you let go over.
|
|
754
911
|
const term = layout.current.terminal;
|
|
912
|
+
// Dragging the terminal's top border makes it taller or shorter; its bottom stays put.
|
|
913
|
+
if (term && dragging.current === "terminal") {
|
|
914
|
+
const rows = Math.max(4, term.y + term.height - event.y);
|
|
915
|
+
if (event.kind === "release") {
|
|
916
|
+
dragging.current = null;
|
|
917
|
+
setTermDrag(null);
|
|
918
|
+
updateSettings({ terminalRows: rows });
|
|
919
|
+
}
|
|
920
|
+
else if (event.kind === "drag")
|
|
921
|
+
setTermDrag(rows);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
755
924
|
if (term && event.x >= term.x && event.x < term.x + term.width && event.y >= term.y && event.y < term.y + term.height && !dragging.current) {
|
|
756
925
|
if (event.kind === "wheel")
|
|
757
926
|
return queueWheel("terminal", event.dir, false);
|
|
@@ -759,6 +928,10 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
759
928
|
return;
|
|
760
929
|
if (event.y === term.y + 1 && event.x >= term.x + term.width - 5)
|
|
761
930
|
return toggleTerminal(false); // ×
|
|
931
|
+
if (event.y === term.y) {
|
|
932
|
+
dragging.current = "terminal"; // its top border: drag to resize
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
762
935
|
if (focus !== "terminal")
|
|
763
936
|
beforeTerm.current = focus;
|
|
764
937
|
setFocus("terminal");
|
|
@@ -767,6 +940,18 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
767
940
|
const game = layout.current.game;
|
|
768
941
|
if (gamesOpen && game) {
|
|
769
942
|
const drag = dragging.current;
|
|
943
|
+
// Its top border makes it taller or shorter (its bottom stays put); its title moves it.
|
|
944
|
+
if (drag === "gameSize") {
|
|
945
|
+
const rows = Math.max(GAME_PANEL.minHeight, game.y + game.height - event.y);
|
|
946
|
+
if (event.kind === "release") {
|
|
947
|
+
dragging.current = null;
|
|
948
|
+
setGameSizeDrag(null);
|
|
949
|
+
updateSettings({ gameRows: rows });
|
|
950
|
+
}
|
|
951
|
+
else if (event.kind === "drag")
|
|
952
|
+
setGameSizeDrag(rows);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
770
955
|
if (drag === "game") {
|
|
771
956
|
const { treeWidth, codeWidth } = layout.current;
|
|
772
957
|
const over = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
|
|
@@ -793,7 +978,9 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
793
978
|
if (focus !== "game")
|
|
794
979
|
beforeGame.current = focus;
|
|
795
980
|
setFocus("game");
|
|
796
|
-
if (event.y
|
|
981
|
+
if (event.y === game.y)
|
|
982
|
+
dragging.current = "gameSize";
|
|
983
|
+
else if (event.y === game.y + 1)
|
|
797
984
|
dragging.current = "game";
|
|
798
985
|
return;
|
|
799
986
|
}
|
|
@@ -858,6 +1045,16 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
858
1045
|
scrollPane(pane, event.y);
|
|
859
1046
|
return;
|
|
860
1047
|
}
|
|
1048
|
+
// The chat's title row holds the conversation tabs: a click switches, + opens a new one.
|
|
1049
|
+
if (event.kind === "click" && pane === "chat" && event.y === 2) {
|
|
1050
|
+
const at = event.x - treeWidth - codeWidth - 1;
|
|
1051
|
+
const { zones, plus } = chatTabZones(chatTabItems());
|
|
1052
|
+
if (Math.abs(at - plus) <= 1)
|
|
1053
|
+
return newChatTab();
|
|
1054
|
+
const zone = zones.find((z) => at >= z.from && at < z.to);
|
|
1055
|
+
if (zone)
|
|
1056
|
+
return switchChatTab(zone.id);
|
|
1057
|
+
}
|
|
861
1058
|
// The code view's title row holds the tabs: a click switches, the × closes.
|
|
862
1059
|
if (event.kind === "click" && pane === "code" && event.y === 2 && tabs.length && !diff) {
|
|
863
1060
|
const at = event.x - codeLeft - 1;
|
|
@@ -903,14 +1100,23 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
903
1100
|
const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
|
|
904
1101
|
lastClick.current = double ? null : { x: event.x, y: event.y, time: Date.now() };
|
|
905
1102
|
if (pane === "code" && event.y >= 3 && codePanel === "code") {
|
|
1103
|
+
// As the code view counts them (a last empty line after the final newline isn't shown).
|
|
1104
|
+
const digits = String(splitLines(content).length).length;
|
|
906
1105
|
if (showEditor) {
|
|
907
1106
|
dragging.current = "editor";
|
|
908
1107
|
editorAt(event.x, event.y, double ? "double" : "press");
|
|
909
1108
|
}
|
|
1109
|
+
else if (openFile && !diff && event.x - codeLeft - 1 === digits + 1) {
|
|
1110
|
+
// The fold marker (▾/▸) beside the line number: fold or unfold that block.
|
|
1111
|
+
lastClick.current = null;
|
|
1112
|
+
setFoldClick((f) => ({ row: event.y - 3, nonce: (f?.nonce ?? 0) + 1 }));
|
|
1113
|
+
}
|
|
910
1114
|
else if (double && openFile && !diff && settleDraft(openFile)) {
|
|
911
1115
|
// Double-click in the code: edit right there.
|
|
912
|
-
|
|
913
|
-
|
|
1116
|
+
// The code view's gutter: the line number, a marker and the fold arrow; folds hide lines between rows.
|
|
1117
|
+
const gutter = digits + 2;
|
|
1118
|
+
const line = viewport?.lines?.[event.y - 3] ?? (viewport?.top ?? 1) + event.y - 3;
|
|
1119
|
+
setEditorStart((s) => ({ line, col: Math.max(1, event.x - codeLeft - gutter), nonce: s.nonce + 1 }));
|
|
914
1120
|
setDiffMode("off");
|
|
915
1121
|
setEditing(true);
|
|
916
1122
|
}
|
|
@@ -1161,6 +1367,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1161
1367
|
openAtRef.current = openAt;
|
|
1162
1368
|
/** A file named in the chat (clicked, or shown by Nou): open it there, highlighting the lines it names. */
|
|
1163
1369
|
const openRef = useEvent((ref) => {
|
|
1370
|
+
if (ref.url)
|
|
1371
|
+
return openInBrowser(ref.url);
|
|
1164
1372
|
clearFollow();
|
|
1165
1373
|
const file = path.relative(process.cwd(), path.resolve(ref.path)).replace(/\\/g, "/");
|
|
1166
1374
|
openAt(file, ref.line ?? 1, false, ref.line ? ref.to ?? ref.line : undefined);
|
|
@@ -1234,6 +1442,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1234
1442
|
setFocus("chat");
|
|
1235
1443
|
return;
|
|
1236
1444
|
}
|
|
1445
|
+
if (key.ctrl && input === "l")
|
|
1446
|
+
return redraw(); // (the editor and terminal use Ctrl+L themselves)
|
|
1237
1447
|
if (focus === "chat")
|
|
1238
1448
|
return; // letters belong to the text box
|
|
1239
1449
|
if (input === "?")
|
|
@@ -1536,6 +1746,28 @@ ${snippet}
|
|
|
1536
1746
|
return;
|
|
1537
1747
|
case "terminal":
|
|
1538
1748
|
return toggleTerminal();
|
|
1749
|
+
case "redraw":
|
|
1750
|
+
return redraw();
|
|
1751
|
+
case "fold":
|
|
1752
|
+
case "unfold":
|
|
1753
|
+
if (!openFile)
|
|
1754
|
+
return info("Open a file first: /fold folds every function and block in it, /unfold opens them.", "error");
|
|
1755
|
+
setEditing(false);
|
|
1756
|
+
setFocus("code");
|
|
1757
|
+
return setFoldAll((f) => ({ fold: command === "fold", nonce: (f?.nonce ?? 0) + 1 }));
|
|
1758
|
+
case "new":
|
|
1759
|
+
return newChatTab();
|
|
1760
|
+
case "close":
|
|
1761
|
+
return closeChatTab();
|
|
1762
|
+
case "tab": {
|
|
1763
|
+
const n = Number(arg);
|
|
1764
|
+
if (!n || !chatTabs[n - 1])
|
|
1765
|
+
return info(`Conversation tabs: ${chatTabItems().map((t, i) => `${i + 1}. ${t.label}${t.state !== "idle" ? ` (${t.state === "busy" ? "working" : "waiting on you"})` : ""}`).join(" ")}. /tab <n> switches, /new opens one, /close closes this one.`);
|
|
1766
|
+
return switchChatTab(chatTabs[n - 1]);
|
|
1767
|
+
}
|
|
1768
|
+
case "showme":
|
|
1769
|
+
case "teach":
|
|
1770
|
+
return showMe(rest.join(" "));
|
|
1539
1771
|
case "resume":
|
|
1540
1772
|
return resumeConversation(rest[0] ?? "");
|
|
1541
1773
|
case "plugins":
|
|
@@ -1640,13 +1872,104 @@ ${snippet}
|
|
|
1640
1872
|
setQueued(queued.slice(take));
|
|
1641
1873
|
send(queued.slice(0, take).join("\n\n"));
|
|
1642
1874
|
}, [busy, approval, queued]);
|
|
1875
|
+
// When Nou has finished looking, the guide starts at its first step.
|
|
1876
|
+
useEffect(() => {
|
|
1877
|
+
if (busy || !guideAsk.current)
|
|
1878
|
+
return;
|
|
1879
|
+
const task = guideAsk.current;
|
|
1880
|
+
let steps = guideFound.current;
|
|
1881
|
+
guideAsk.current = null;
|
|
1882
|
+
guideFound.current = null;
|
|
1883
|
+
// Other engines hand their steps back in a ```nou-steps block at the end of the reply: read it, and
|
|
1884
|
+
// show the reply without it.
|
|
1885
|
+
if (!steps?.length) {
|
|
1886
|
+
const reply = [...entries].reverse().find((e) => e.kind === "assistant");
|
|
1887
|
+
const found = reply && stepsFromReply(reply.text);
|
|
1888
|
+
if (found) {
|
|
1889
|
+
steps = found.steps;
|
|
1890
|
+
setEntries((prev) => prev.map((e) => (e === reply ? { ...e, text: found.rest || "Here's how." } : e)));
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
// Asked not to, an engine that edited anyway (Codex can't be stopped before it does) is put back.
|
|
1894
|
+
const edited = timeline.all.slice(guideMark.current).filter((c) => c.source === "nou" && !c.undone);
|
|
1895
|
+
if (edited.length) {
|
|
1896
|
+
undoCheckpoints([...edited].reverse());
|
|
1897
|
+
info("It edited files anyway, so those changes were put back: you're the one typing them.");
|
|
1898
|
+
}
|
|
1899
|
+
if (!steps?.length)
|
|
1900
|
+
return info("Nou didn't come back with steps to show. Try asking more specifically, e.g. /showme add a --verbose flag to the CLI", "error");
|
|
1901
|
+
info(`Here's how, in ${steps.length} step${steps.length > 1 ? "s" : ""}. You type each one in the editor: the dim text ahead of the cursor is what goes next, and Tab fills in the rest of a line. /showme next skips a step, /showme stop ends it.`);
|
|
1902
|
+
setGuide({ steps, at: 0, task });
|
|
1903
|
+
}, [busy]);
|
|
1904
|
+
// Each step: open its file in the editor where the code goes, and say what to type and why.
|
|
1905
|
+
useEffect(() => {
|
|
1906
|
+
if (!guide)
|
|
1907
|
+
return;
|
|
1908
|
+
const step = guide.steps[guide.at];
|
|
1909
|
+
if (!existsSync(step.file)) {
|
|
1910
|
+
// A step that starts a new file: it's created empty, for you to type into.
|
|
1911
|
+
mkdirSync(path.dirname(path.resolve(step.file)), { recursive: true });
|
|
1912
|
+
writeFileSync(step.file, "");
|
|
1913
|
+
setTreeVersion((v) => v + 1);
|
|
1914
|
+
}
|
|
1915
|
+
info(`👉 Step ${guide.at + 1} of ${guide.steps.length}: ${step.file}:${step.line}\n${step.why}\n\n\`\`\`\n${step.code}\n\`\`\``);
|
|
1916
|
+
openAt(step.file, step.line, true);
|
|
1917
|
+
}, [guide?.at, guide?.steps]);
|
|
1918
|
+
const guideStep = guide?.steps[guide.at] ?? null;
|
|
1919
|
+
/** The step's code is all typed: save it, and go on to the next one. */
|
|
1920
|
+
const onGuideDone = useEvent(() => {
|
|
1921
|
+
if (!guide || !guideStep)
|
|
1922
|
+
return;
|
|
1923
|
+
if (draft.current?.path === guideStep.file)
|
|
1924
|
+
saveFromEditor(draft.current.text, guideStep.file);
|
|
1925
|
+
// Later steps in the same file further down move down by the lines just added.
|
|
1926
|
+
const added = guideStep.code.split("\n").length;
|
|
1927
|
+
const steps = guide.steps.map((s, i) => (i > guide.at && s.file === guideStep.file && s.line >= guideStep.line ? { ...s, line: s.line + added } : s));
|
|
1928
|
+
if (guide.at + 1 < steps.length) {
|
|
1929
|
+
info(`✓ Step ${guide.at + 1} done.`);
|
|
1930
|
+
setGuide({ ...guide, steps, at: guide.at + 1 });
|
|
1931
|
+
}
|
|
1932
|
+
else {
|
|
1933
|
+
info(`🎉 That's it: all ${steps.length} step${steps.length > 1 ? "s" : ""} done and saved. Ask Nou to check it over if you like.`);
|
|
1934
|
+
setGuide(null);
|
|
1935
|
+
}
|
|
1936
|
+
});
|
|
1937
|
+
/** /showme <what you want>: Nou finds where the change goes and walks you through typing it. */
|
|
1938
|
+
const showMe = (arg) => {
|
|
1939
|
+
const words = arg.trim();
|
|
1940
|
+
if (words === "stop" || words === "end") {
|
|
1941
|
+
setGuide(null);
|
|
1942
|
+
return info(guide ? "Stopped the guide. What you typed so far stays." : "No guide is running.");
|
|
1943
|
+
}
|
|
1944
|
+
if (words === "next" || words === "skip") {
|
|
1945
|
+
if (!guide)
|
|
1946
|
+
return info("No guide is running.");
|
|
1947
|
+
if (guide.at + 1 >= guide.steps.length) {
|
|
1948
|
+
setGuide(null);
|
|
1949
|
+
return info("That was the last step.");
|
|
1950
|
+
}
|
|
1951
|
+
return setGuide({ ...guide, at: guide.at + 1 });
|
|
1952
|
+
}
|
|
1953
|
+
if (!words) {
|
|
1954
|
+
if (guide)
|
|
1955
|
+
return info(`On step ${guide.at + 1} of ${guide.steps.length}: ${guideStep.file}:${guideStep.line}. /showme next skips it, /showme stop ends it.`);
|
|
1956
|
+
return info("Tell Nou what you want to do and it shows you how, step by step, for you to type: /showme add a --verbose flag to the CLI");
|
|
1957
|
+
}
|
|
1958
|
+
if (busy)
|
|
1959
|
+
return info("Wait for the current reply to finish (or press Esc) first.", "error");
|
|
1960
|
+
setGuide(null);
|
|
1961
|
+
guideAsk.current = words;
|
|
1962
|
+
guideFound.current = null;
|
|
1963
|
+
guideMark.current = timeline.all.length;
|
|
1964
|
+
send(showMePrompt(words, agentRef.current.id === "nou"));
|
|
1965
|
+
};
|
|
1643
1966
|
useEffect(() => {
|
|
1644
1967
|
if (busy)
|
|
1645
1968
|
stopped.current = false;
|
|
1646
1969
|
}, [busy]);
|
|
1647
1970
|
/** From the chat: while Nou works, a message waits its turn, or (`now`) stops Nou and goes right away. */
|
|
1648
1971
|
const submit = (text, now = false) => {
|
|
1649
|
-
if (!busy)
|
|
1972
|
+
if (!busy || (text.startsWith("/") && runsWhileBusy(text)))
|
|
1650
1973
|
return send(text);
|
|
1651
1974
|
if (now) {
|
|
1652
1975
|
stop();
|
|
@@ -1657,6 +1980,11 @@ ${snippet}
|
|
|
1657
1980
|
setQueued((q) => [...q, text]);
|
|
1658
1981
|
};
|
|
1659
1982
|
const send = (text) => {
|
|
1983
|
+
// Right after /resume lists them, a number picks a conversation instead of being sent to the agent.
|
|
1984
|
+
const picking = pickingResume.current;
|
|
1985
|
+
pickingResume.current = false;
|
|
1986
|
+
if (picking && /^\s*\d+\s*$/.test(text))
|
|
1987
|
+
return void resumeConversation(text.trim());
|
|
1660
1988
|
if (text.startsWith("/")) {
|
|
1661
1989
|
void runCommand(text);
|
|
1662
1990
|
return;
|
|
@@ -1700,7 +2028,7 @@ ${snippet}
|
|
|
1700
2028
|
const onTreeHover = useEvent(() => { });
|
|
1701
2029
|
const onJump = useEvent((file, line) => openAt(file, line));
|
|
1702
2030
|
const onCloseProblems = useEvent(() => setCodePanel("code"));
|
|
1703
|
-
const onViewport = useEvent((top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })));
|
|
2031
|
+
const onViewport = useEvent((top, bottom, lines) => setViewport((v) => (v?.top === top && v.bottom === bottom && v.lines?.join() === lines?.join() ? v : { top, bottom, ...(lines ? { lines } : {}) })));
|
|
1704
2032
|
const onSend = useEvent(submit);
|
|
1705
2033
|
const onChatMenu = useEvent((open) => {
|
|
1706
2034
|
chatMenuOpen.current = open;
|
|
@@ -1768,13 +2096,17 @@ ${snippet}
|
|
|
1768
2096
|
const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
|
|
1769
2097
|
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));
|
|
1770
2098
|
const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
|
|
1771
|
-
const
|
|
2099
|
+
const tabKey = tabs.length && openFile ? `${tabs.join("\0")}|${openFile}|${tabsWidth(codeWidth)}|${showEditor && !!editorState?.dirty}` : "";
|
|
2100
|
+
if (tabStripCache.current?.key !== tabKey) {
|
|
2101
|
+
tabStripCache.current = { key: tabKey, strip: tabKey ? _jsx(Tabs, { tabs: tabs, active: openFile, width: tabsWidth(codeWidth), dirty: showEditor && !!editorState?.dirty }) : undefined };
|
|
2102
|
+
}
|
|
2103
|
+
const tabStrip = tabStripCache.current.strip;
|
|
1772
2104
|
// The Games panel takes the bottom of its pane's column: up to 19 rows, and never more than half.
|
|
1773
|
-
const gameHeight = gamesOpen ? Math.
|
|
2105
|
+
const gameHeight = gamesOpen ? Math.max(GAME_PANEL.minHeight, Math.min(gameSizeDrag ?? settings.gameRows ?? GAME_PANEL.height, height - 6)) : 0;
|
|
1774
2106
|
const heights = { tree: height, code: height, chat: height };
|
|
1775
2107
|
heights[gameDock] -= gameHeight;
|
|
1776
2108
|
// The terminal takes the bottom third of the code's column (above the Games panel when that's there too).
|
|
1777
|
-
const termHeight = termOpen ? Math.max(
|
|
2109
|
+
const termHeight = termOpen ? Math.max(4, Math.min(termDrag ?? settings.terminalRows ?? Math.floor(height / 3), heights.code - 4)) : 0;
|
|
1778
2110
|
heights.code -= termHeight;
|
|
1779
2111
|
const dockLeft = gameDock === "tree" ? 0 : gameDock === "code" ? treeWidth : treeWidth + codeWidth;
|
|
1780
2112
|
const dockWidth = gameDock === "tree" ? treeWidth : gameDock === "code" ? codeWidth : chatWidth;
|
|
@@ -1792,6 +2124,11 @@ ${snippet}
|
|
|
1792
2124
|
updateSettings({ mouse: !settings.mouse });
|
|
1793
2125
|
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.");
|
|
1794
2126
|
};
|
|
2127
|
+
/** /redraw, Ctrl+L: draws the whole screen again, in case anything else wrote to the terminal and shifted it. */
|
|
2128
|
+
function redraw() {
|
|
2129
|
+
forgetScreen();
|
|
2130
|
+
setRepaint((n) => n + 1);
|
|
2131
|
+
}
|
|
1795
2132
|
/** Opens the terminal panel (starting its shell the first time) and gives it the keyboard, or closes it. */
|
|
1796
2133
|
function toggleTerminal(open = !termOpen) {
|
|
1797
2134
|
shellRef.current ??= new Shell(() => setTermVersion((v) => v + 1));
|
|
@@ -1804,6 +2141,12 @@ ${snippet}
|
|
|
1804
2141
|
else if (focus === "terminal")
|
|
1805
2142
|
setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
|
|
1806
2143
|
}
|
|
2144
|
+
/** Alt+↑/↓ in the terminal: taller or shorter by a couple of rows. */
|
|
2145
|
+
function resizeTerminal(by) {
|
|
2146
|
+
const now = layout.current.terminal?.height ?? 0;
|
|
2147
|
+
if (now)
|
|
2148
|
+
updateSettings({ terminalRows: Math.max(4, now + by) });
|
|
2149
|
+
}
|
|
1807
2150
|
function leaveTerminal() {
|
|
1808
2151
|
setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
|
|
1809
2152
|
}
|
|
@@ -1819,6 +2162,7 @@ ${snippet}
|
|
|
1819
2162
|
setFocus(beforeGame.current);
|
|
1820
2163
|
};
|
|
1821
2164
|
// Plain functions, not hooks: this runs after the settings screen's early return.
|
|
2165
|
+
const onGameScore = (game, score) => updateSettings({ gameBest: { ...(settings.gameBest ?? {}), [game]: score } });
|
|
1822
2166
|
const leaveGames = () => setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
|
|
1823
2167
|
const gamesSettings = () => {
|
|
1824
2168
|
setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
|
|
@@ -1851,7 +2195,7 @@ ${snippet}
|
|
|
1851
2195
|
{ title: "MCP servers: tools from other programs", keys: "/mcp", run: () => void runCommand("/mcp") },
|
|
1852
2196
|
{ title: "Browser control (Playwright MCP)", keys: "/mcp add browser", run: () => void runCommand("/mcp add browser") },
|
|
1853
2197
|
{ title: "Import MCP servers from Claude Code, Cursor, Codex...", keys: "/mcp import", run: () => void runCommand("/mcp import") },
|
|
1854
|
-
{ title: gamesOpen ? "Close the Games panel" : "Games: Pong, Snake, Breakout", keys: "/games", run: () => toggleGames() },
|
|
2198
|
+
{ title: gamesOpen ? "Close the Games panel" : "Games: Pong, Snake, Breakout, Dodge, Hop, Pairs", keys: "/games", run: () => toggleGames() },
|
|
1855
2199
|
{ title: "Next tab", keys: "]", run: () => cycleTab(1) },
|
|
1856
2200
|
{ title: "Previous tab", keys: "[", run: () => cycleTab(-1) },
|
|
1857
2201
|
{ title: "Close the tab", keys: "x", run: () => closeTab() },
|
|
@@ -1958,9 +2302,9 @@ ${snippet}
|
|
|
1958
2302
|
}
|
|
1959
2303
|
const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
|
|
1960
2304
|
clickable.current = { status: zones, modelFrom: columns - (" ⌂ LOCAL ".length + ` ${modelLabel} `.length + 1) };
|
|
1961
|
-
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, git: git, problemCounts: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, 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, { ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, 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, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest })), _jsx(Chat, { width: chatWidth, height: heights.chat, 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, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, onAnswer: onAnswer, click: chatClick, select: chatSelect, onOpenRef: openRef })] }), _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: followChip }), _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
|
|
2305
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, git: git, problemCounts: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, 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, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, 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, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest, foldClick: foldClick, foldAll: foldAll })), _jsx(Chat, { width: chatWidth, height: heights.chat, 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, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, onAnswer: onAnswer, click: chatClick, select: chatSelect, tabs: chatTabsProp(), onOpenRef: openRef })] }), _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: followChip }), _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
|
|
1962
2306
|
? _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)` : ""] })
|
|
1963
|
-
: 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)))] }) }), layout.current.terminal && shellRef.current && (_jsx(Box, { position: "absolute", marginLeft: layout.current.terminal.x, marginTop: layout.current.terminal.y, children: _jsx(Terminal, { shell: shellRef.current, version: termVersion, width: layout.current.terminal.width, height: layout.current.terminal.height, focused: focus === "terminal", wheel: wheelFor("terminal"), onLeave: leaveTerminal }) })), layout.current.game && (_jsx(Box, { position: "absolute", marginLeft: layout.current.game.x, marginTop: layout.current.game.y, children: _jsx(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, onLeave: leaveGames, onOpenSettings: gamesSettings }) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: paletteActions, initialQuery: quickOpen, onPick: (file) => {
|
|
2307
|
+
: 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)))] }) }), layout.current.terminal && shellRef.current && (_jsx(Box, { position: "absolute", marginLeft: layout.current.terminal.x, marginTop: layout.current.terminal.y, children: _jsx(Terminal, { shell: shellRef.current, version: termVersion, width: layout.current.terminal.width, height: layout.current.terminal.height, focused: focus === "terminal", wheel: wheelFor("terminal"), onLeave: leaveTerminal, onResize: resizeTerminal }) })), layout.current.game && (_jsx(Box, { position: "absolute", marginLeft: layout.current.game.x, marginTop: layout.current.game.y, children: _jsx(GameGuard, { children: _jsx(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, best: settings.gameBest ?? {}, onScore: onGameScore, onLeave: leaveGames, onOpenSettings: gamesSettings }) }) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: paletteActions, initialQuery: quickOpen, onPick: (file) => {
|
|
1964
2308
|
setQuickOpen(null);
|
|
1965
2309
|
clearFollow();
|
|
1966
2310
|
open(file);
|