nou-cli 0.5.4 → 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 +7 -2
- package/dist/src/cli.js +33 -0
- package/dist/src/config/index.js +1 -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/ui/App.js +64 -14
- package/dist/src/ui/Chat.js +4 -1
- package/dist/src/ui/CodeView.js +70 -19
- package/dist/src/ui/FileTree.js +3 -1
- package/dist/src/ui/GamePanel.js +41 -13
- package/dist/src/ui/Header.js +4 -1
- package/dist/src/ui/ProblemsView.js +3 -1
- package/dist/src/ui/Settings.js +9 -2
- package/dist/src/ui/commands.js +5 -2
- 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 +45 -0
- package/package.json +1 -1
package/dist/src/ui/App.js
CHANGED
|
@@ -22,9 +22,10 @@ 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";
|
|
@@ -36,8 +37,9 @@ import { Chat, chatTabZones } from "./Chat.js";
|
|
|
36
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";
|
|
41
43
|
import { showMePrompt, showStepsTool, stepsFromReply } from "../guide/index.js";
|
|
42
44
|
import { Shell } from "../terminal/index.js";
|
|
43
45
|
import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
|
|
@@ -127,6 +129,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
127
129
|
const [termVersion, setTermVersion] = useState(0);
|
|
128
130
|
/** Its height while its top border is being dragged (saved when you let go). */
|
|
129
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);
|
|
130
134
|
/** The Games panel's height while its top border is being dragged (saved when you let go). */
|
|
131
135
|
const [gameSizeDrag, setGameSizeDrag] = useState(null);
|
|
132
136
|
/** The tab strip, kept while nothing in it changes: a new one would redraw the whole code view on every scroll. */
|
|
@@ -139,6 +143,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
139
143
|
const guideAsk = useRef(null);
|
|
140
144
|
/** Where the undo history stood when the steps were asked for: an engine that edits anyway is put back. */
|
|
141
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);
|
|
142
148
|
const guideFound = useRef(null);
|
|
143
149
|
const shellRef = useRef(null);
|
|
144
150
|
const beforeTerm = useRef("code");
|
|
@@ -201,6 +207,9 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
201
207
|
const [plugins, setPlugins] = useState([]);
|
|
202
208
|
const [starters, setStarters] = useState([]);
|
|
203
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);
|
|
204
213
|
// The editor reports every keystroke; the app only re-renders when what it shows changes (line, unsaved, selection).
|
|
205
214
|
const editorStateRef = useRef(null);
|
|
206
215
|
const [editorState, setEditorState] = useState(null);
|
|
@@ -539,12 +548,18 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
539
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 };
|
|
540
549
|
agentRef.current.configure(engineConfig.current);
|
|
541
550
|
}, [settings.gitContext, settings.effort, activePlugins, mcpVersion]);
|
|
542
|
-
// 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.
|
|
543
553
|
const polling = useRef(false);
|
|
554
|
+
const wasFocused = useRef(true);
|
|
555
|
+
const windowFocused = useTerminalFocused();
|
|
556
|
+
useFocusReporting(true);
|
|
544
557
|
useEffect(() => {
|
|
545
|
-
|
|
558
|
+
const cameBack = windowFocused && !wasFocused.current;
|
|
559
|
+
wasFocused.current = windowFocused;
|
|
560
|
+
if (!repo || !windowFocused)
|
|
546
561
|
return;
|
|
547
|
-
const
|
|
562
|
+
const poll = () => {
|
|
548
563
|
if (busy || polling.current)
|
|
549
564
|
return;
|
|
550
565
|
polling.current = true;
|
|
@@ -553,9 +568,12 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
553
568
|
// Only redraw when something changed.
|
|
554
569
|
setGit((prev) => (sameStatus(prev, next) ? prev : next));
|
|
555
570
|
});
|
|
556
|
-
}
|
|
571
|
+
};
|
|
572
|
+
if (cameBack)
|
|
573
|
+
poll();
|
|
574
|
+
const timer = setInterval(poll, 4000);
|
|
557
575
|
return () => clearInterval(timer);
|
|
558
|
-
}, [repo, busy]);
|
|
576
|
+
}, [repo, busy, windowFocused]);
|
|
559
577
|
useEffect(() => {
|
|
560
578
|
if (busy)
|
|
561
579
|
return;
|
|
@@ -626,7 +644,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
626
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.");
|
|
627
645
|
if (!arg) {
|
|
628
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)})`);
|
|
629
|
-
|
|
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")}`);
|
|
630
649
|
}
|
|
631
650
|
const pick = arg === "last" ? list[0] : list[Number(arg) - 1] ?? list.find((c) => c.id === arg);
|
|
632
651
|
if (!pick)
|
|
@@ -1081,14 +1100,23 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1081
1100
|
const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
|
|
1082
1101
|
lastClick.current = double ? null : { x: event.x, y: event.y, time: Date.now() };
|
|
1083
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;
|
|
1084
1105
|
if (showEditor) {
|
|
1085
1106
|
dragging.current = "editor";
|
|
1086
1107
|
editorAt(event.x, event.y, double ? "double" : "press");
|
|
1087
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
|
+
}
|
|
1088
1114
|
else if (double && openFile && !diff && settleDraft(openFile)) {
|
|
1089
1115
|
// Double-click in the code: edit right there.
|
|
1090
|
-
|
|
1091
|
-
|
|
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 }));
|
|
1092
1120
|
setDiffMode("off");
|
|
1093
1121
|
setEditing(true);
|
|
1094
1122
|
}
|
|
@@ -1414,6 +1442,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1414
1442
|
setFocus("chat");
|
|
1415
1443
|
return;
|
|
1416
1444
|
}
|
|
1445
|
+
if (key.ctrl && input === "l")
|
|
1446
|
+
return redraw(); // (the editor and terminal use Ctrl+L themselves)
|
|
1417
1447
|
if (focus === "chat")
|
|
1418
1448
|
return; // letters belong to the text box
|
|
1419
1449
|
if (input === "?")
|
|
@@ -1716,6 +1746,15 @@ ${snippet}
|
|
|
1716
1746
|
return;
|
|
1717
1747
|
case "terminal":
|
|
1718
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 }));
|
|
1719
1758
|
case "new":
|
|
1720
1759
|
return newChatTab();
|
|
1721
1760
|
case "close":
|
|
@@ -1941,6 +1980,11 @@ ${snippet}
|
|
|
1941
1980
|
setQueued((q) => [...q, text]);
|
|
1942
1981
|
};
|
|
1943
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());
|
|
1944
1988
|
if (text.startsWith("/")) {
|
|
1945
1989
|
void runCommand(text);
|
|
1946
1990
|
return;
|
|
@@ -1984,7 +2028,7 @@ ${snippet}
|
|
|
1984
2028
|
const onTreeHover = useEvent(() => { });
|
|
1985
2029
|
const onJump = useEvent((file, line) => openAt(file, line));
|
|
1986
2030
|
const onCloseProblems = useEvent(() => setCodePanel("code"));
|
|
1987
|
-
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 } : {}) })));
|
|
1988
2032
|
const onSend = useEvent(submit);
|
|
1989
2033
|
const onChatMenu = useEvent((open) => {
|
|
1990
2034
|
chatMenuOpen.current = open;
|
|
@@ -2080,6 +2124,11 @@ ${snippet}
|
|
|
2080
2124
|
updateSettings({ mouse: !settings.mouse });
|
|
2081
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.");
|
|
2082
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
|
+
}
|
|
2083
2132
|
/** Opens the terminal panel (starting its shell the first time) and gives it the keyboard, or closes it. */
|
|
2084
2133
|
function toggleTerminal(open = !termOpen) {
|
|
2085
2134
|
shellRef.current ??= new Shell(() => setTermVersion((v) => v + 1));
|
|
@@ -2113,6 +2162,7 @@ ${snippet}
|
|
|
2113
2162
|
setFocus(beforeGame.current);
|
|
2114
2163
|
};
|
|
2115
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 } });
|
|
2116
2166
|
const leaveGames = () => setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
|
|
2117
2167
|
const gamesSettings = () => {
|
|
2118
2168
|
setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
|
|
@@ -2145,7 +2195,7 @@ ${snippet}
|
|
|
2145
2195
|
{ title: "MCP servers: tools from other programs", keys: "/mcp", run: () => void runCommand("/mcp") },
|
|
2146
2196
|
{ title: "Browser control (Playwright MCP)", keys: "/mcp add browser", run: () => void runCommand("/mcp add browser") },
|
|
2147
2197
|
{ title: "Import MCP servers from Claude Code, Cursor, Codex...", keys: "/mcp import", run: () => void runCommand("/mcp import") },
|
|
2148
|
-
{ 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() },
|
|
2149
2199
|
{ title: "Next tab", keys: "]", run: () => cycleTab(1) },
|
|
2150
2200
|
{ title: "Previous tab", keys: "[", run: () => cycleTab(-1) },
|
|
2151
2201
|
{ title: "Close the tab", keys: "x", run: () => closeTab() },
|
|
@@ -2252,9 +2302,9 @@ ${snippet}
|
|
|
2252
2302
|
}
|
|
2253
2303
|
const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
|
|
2254
2304
|
clickable.current = { status: zones, modelFrom: columns - (" ⌂ LOCAL ".length + ` ${modelLabel} `.length + 1) };
|
|
2255
|
-
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 })), _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
|
|
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
|
|
2256
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)` : ""] })
|
|
2257
|
-
: 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(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) => {
|
|
2258
2308
|
setQuickOpen(null);
|
|
2259
2309
|
clearFollow();
|
|
2260
2310
|
open(file);
|
package/dist/src/ui/Chat.js
CHANGED
|
@@ -7,6 +7,7 @@ import { closeOpenMarkdown, linkAt, linkFileRefs, linkUrls, renderMarkdown, wrap
|
|
|
7
7
|
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
8
8
|
import { copyToClipboard } from "./clipboard.js";
|
|
9
9
|
import { C, SPINNER, ANIMATION_MS } from "./theme.js";
|
|
10
|
+
import { useTerminalFocused } from "./focus.js";
|
|
10
11
|
import { Pane } from "./Pane.js";
|
|
11
12
|
import { matchCommands } from "./commands.js";
|
|
12
13
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
@@ -117,7 +118,9 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
117
118
|
setInput(prefill.text);
|
|
118
119
|
}, [prefill?.nonce]);
|
|
119
120
|
const [scrollBack, setScrollBack] = useState(0);
|
|
120
|
-
|
|
121
|
+
// Spinners stop while Nou's window is in the background: nobody sees them, and each frame is a redraw.
|
|
122
|
+
const windowFocused = useTerminalFocused();
|
|
123
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
|
|
121
124
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
122
125
|
const [openSteps, setOpenSteps] = useState(new Set());
|
|
123
126
|
const [allOpen, setAllOpen] = useState(false);
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -3,7 +3,7 @@ import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
5
5
|
import { artWork } from "../util/index.js";
|
|
6
|
-
import { highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines } from "./text.js";
|
|
6
|
+
import { foldRanges, highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines, unfoldedLines } from "./text.js";
|
|
7
7
|
import { C, fileIcon, gradient } from "./theme.js";
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
9
9
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
@@ -12,7 +12,7 @@ const CURRENT_MATCH = ["\x1b[43m\x1b[30m", "\x1b[49m\x1b[39m"];
|
|
|
12
12
|
/** Columns ←/→ and Shift+wheel move sideways. */
|
|
13
13
|
const SIDE_STEP = 8;
|
|
14
14
|
const EDIT_HINT = " double-click or e to edit · v diff";
|
|
15
|
-
export const CodeView = memo(function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest }) {
|
|
15
|
+
export const CodeView = memo(function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
|
|
16
16
|
const [top, setTop] = useState(0);
|
|
17
17
|
// Columns scrolled sideways (colors are kept: see sliceAnsi).
|
|
18
18
|
const [left, setLeft] = useState(0);
|
|
@@ -37,18 +37,39 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
37
37
|
return map;
|
|
38
38
|
}, [problems]);
|
|
39
39
|
const diffRows = useMemo(() => (diff ? sideBySide(diff.before, diff.after) : []), [diff]);
|
|
40
|
-
|
|
40
|
+
// Folding: regions from indentation, the ones folded, and the file lines left showing (rows map to these).
|
|
41
|
+
const ranges = useMemo(() => (filePath && !diff ? foldRanges(content) : new Map()), [filePath, content, !!diff]);
|
|
42
|
+
const [folded, setFolded] = useState(new Set());
|
|
43
|
+
useEffect(() => setFolded(new Set()), [filePath]);
|
|
44
|
+
const shown = useMemo(() => unfoldedLines(lines.length, ranges, folded), [lines.length, ranges, folded]);
|
|
45
|
+
/** The row a file line is on (a folded-away line: its fold's row). */
|
|
46
|
+
const rowOf = (line, list = shown) => {
|
|
47
|
+
let row = 0;
|
|
48
|
+
while (row + 1 < list.length && list[row + 1] <= line)
|
|
49
|
+
row++;
|
|
50
|
+
return row;
|
|
51
|
+
};
|
|
52
|
+
/** Opens any fold hiding `line`, and returns the rows with it open. */
|
|
53
|
+
const reveal = (line) => {
|
|
54
|
+
const hiding = [...folded].filter((s) => s < line && (ranges.get(s) ?? 0) >= line);
|
|
55
|
+
if (!hiding.length)
|
|
56
|
+
return shown;
|
|
57
|
+
const next = new Set([...folded].filter((s) => !hiding.includes(s)));
|
|
58
|
+
setFolded(next);
|
|
59
|
+
return unfoldedLines(lines.length, ranges, next);
|
|
60
|
+
};
|
|
61
|
+
const total = diff ? diffRows.length : shown.length;
|
|
41
62
|
// Jump to the first change whenever a new file or diff is shown.
|
|
42
63
|
useEffect(() => {
|
|
43
64
|
const first = diff
|
|
44
65
|
? diffRows.findIndex((r) => r.changed)
|
|
45
|
-
: marked.size ? Math.min(...marked)
|
|
66
|
+
: marked.size ? rowOf(Math.min(...marked)) : 0;
|
|
46
67
|
setTop(Math.max(0, first - 3));
|
|
47
68
|
setLeft(0);
|
|
48
69
|
}, [filePath, diff, diffRows, marked]);
|
|
49
70
|
useEffect(() => {
|
|
50
71
|
if (focusLine && !diff)
|
|
51
|
-
setTop(Math.max(0, focusLine.line - Math.floor(rows / 3)));
|
|
72
|
+
setTop(Math.max(0, rowOf(focusLine.line, reveal(focusLine.line)) + 1 - Math.floor(rows / 3)));
|
|
52
73
|
}, [focusLine]);
|
|
53
74
|
const maxTop = Math.max(0, total - rows);
|
|
54
75
|
const start = Math.min(top, maxTop);
|
|
@@ -70,8 +91,10 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
70
91
|
useEffect(() => {
|
|
71
92
|
if (!current)
|
|
72
93
|
return;
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
const list = reveal(current.line + 1);
|
|
95
|
+
const at = rowOf(current.line + 1, list);
|
|
96
|
+
if (at < start || at >= start + rows)
|
|
97
|
+
setTop(Math.max(0, at - Math.floor(rows / 3)));
|
|
75
98
|
const room = inner - String(lines.length).length - 2;
|
|
76
99
|
if (current.col < left || current.col + (find?.query.length ?? 0) > left + room)
|
|
77
100
|
setLeft(Math.max(0, current.col - Math.floor(room / 3)));
|
|
@@ -90,22 +113,45 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
90
113
|
setTop(topFor(scrollTo.fraction, total, rows));
|
|
91
114
|
}, [scrollTo?.nonce]);
|
|
92
115
|
useEffect(() => {
|
|
93
|
-
if (filePath && !diff)
|
|
94
|
-
|
|
95
|
-
|
|
116
|
+
if (filePath && !diff) {
|
|
117
|
+
const onScreen = shown.slice(start, start + rows);
|
|
118
|
+
onViewport(onScreen[0] ?? 1, onScreen[onScreen.length - 1] ?? 1, onScreen);
|
|
119
|
+
}
|
|
120
|
+
}, [filePath, diff, start, rows, total, shown]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!foldClick || diff)
|
|
123
|
+
return;
|
|
124
|
+
const n = shown[start + foldClick.row];
|
|
125
|
+
if (n === undefined || !ranges.has(n))
|
|
126
|
+
return;
|
|
127
|
+
setFolded((f) => {
|
|
128
|
+
const next = new Set(f);
|
|
129
|
+
if (next.has(n))
|
|
130
|
+
next.delete(n);
|
|
131
|
+
else
|
|
132
|
+
next.add(n);
|
|
133
|
+
return next;
|
|
134
|
+
});
|
|
135
|
+
}, [foldClick?.nonce]);
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (foldAll && !diff)
|
|
138
|
+
setFolded(foldAll.fold ? new Set(ranges.keys()) : new Set());
|
|
139
|
+
}, [foldAll?.nonce]);
|
|
96
140
|
useEffect(() => {
|
|
97
141
|
if (!defineAt || diff || !filePath)
|
|
98
142
|
return;
|
|
99
|
-
const n = start + defineAt.row
|
|
143
|
+
const n = shown[start + defineAt.row];
|
|
144
|
+
if (n === undefined)
|
|
145
|
+
return;
|
|
100
146
|
const raw = content.replace(/\r\n/g, "\n").split("\n")[n - 1];
|
|
101
147
|
if (raw === undefined)
|
|
102
148
|
return;
|
|
103
149
|
// Screen column to the file's own column: tabs are shown as two spaces.
|
|
104
|
-
const want = defineAt.x - (String(lines.length).length +
|
|
105
|
-
let
|
|
150
|
+
const want = defineAt.x - (String(lines.length).length + 2) + left;
|
|
151
|
+
let cells = 0;
|
|
106
152
|
let col = 0;
|
|
107
|
-
while (col < raw.length &&
|
|
108
|
-
|
|
153
|
+
while (col < raw.length && cells + (raw[col] === "\t" ? 2 : 1) <= want)
|
|
154
|
+
cells += raw[col++] === "\t" ? 2 : 1;
|
|
109
155
|
onDefine?.(n, col + 1);
|
|
110
156
|
}, [defineAt?.nonce]);
|
|
111
157
|
useInput((input, key) => {
|
|
@@ -141,6 +187,9 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
141
187
|
setTop(0);
|
|
142
188
|
else if (input === "G" || key.end)
|
|
143
189
|
setTop(maxTop);
|
|
190
|
+
// z: fold everything, or (when it already is) open everything.
|
|
191
|
+
else if (input === "z" && !diff && ranges.size)
|
|
192
|
+
setFolded([...ranges.keys()].every((k) => folded.has(k)) ? new Set() : new Set(ranges.keys()));
|
|
144
193
|
else if (key.leftArrow || input === "h")
|
|
145
194
|
setLeft((l) => Math.max(0, l - SIDE_STEP));
|
|
146
195
|
else if (key.rightArrow || input === "l")
|
|
@@ -190,14 +239,16 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
190
239
|
// The edit hint only when it fits: a cut-off hint reads as noise.
|
|
191
240
|
const titleRoom = tabs ? 0 : width - 4 - (3 + filePath.length + (errorCount ? 5 + String(errorCount).length : 0) + (warningCount ? 5 + String(warningCount).length : 0) + scroll.length + (total > rows ? 7 : 0));
|
|
192
241
|
const title = (_jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath] }), errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused && titleRoom >= EDIT_HINT.length ? EDIT_HINT : ""] })] }));
|
|
193
|
-
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children:
|
|
194
|
-
const
|
|
242
|
+
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: shown.slice(start, start + rows).map((n) => {
|
|
243
|
+
const line = lines[n - 1] ?? "";
|
|
244
|
+
const fold = ranges.get(n);
|
|
245
|
+
const isFolded = fold !== undefined && folded.has(n);
|
|
195
246
|
const isFound = !!found && n >= found.from && n <= found.to;
|
|
196
247
|
const isMarked = marked.has(n);
|
|
197
248
|
const here = problemsByLine.get(n);
|
|
198
249
|
const worst = here?.[0];
|
|
199
250
|
const style = worst ? SEVERITY_STYLE[worst.severity] : null;
|
|
200
|
-
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: style?.color ?? (isMarked ? C.green : C.dim), children: String(n).padStart(gutter) }), style ? _jsx(Text, { color: style.color, children: style.icon }) : isFound ? _jsx(Text, { color: C.cyan, bold: true, children: "\u258C" }) : _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), (() => {
|
|
251
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: style?.color ?? (isMarked ? C.green : C.dim), children: String(n).padStart(gutter) }), style ? _jsx(Text, { color: style.color, children: style.icon }) : isFound ? _jsx(Text, { color: C.cyan, bold: true, children: "\u258C" }) : _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), _jsx(Text, { color: isFolded ? C.accent2 : C.dim, children: fold === undefined ? " " : isFolded ? "▸" : "▾" }), (() => {
|
|
201
252
|
let text = line;
|
|
202
253
|
if (find?.query) {
|
|
203
254
|
for (const m of matches) {
|
|
@@ -209,6 +260,6 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
209
260
|
}
|
|
210
261
|
text = (left ? sliceAnsi(text, left) : text) || " ";
|
|
211
262
|
return isFound ? _jsx(Text, { backgroundColor: C.panel, children: text }) : text;
|
|
212
|
-
})(), worst && style ? _jsxs(Text, { color: style.color, dimColor: true, children: [" ", style.icon, " ", worst.message, here.length > 1 ? ` (+${here.length - 1})` : ""] }) : null] }, n));
|
|
263
|
+
})(), isFolded ? _jsx(Text, { color: C.accent2, backgroundColor: C.panel, children: ` ⋯ ${fold - n} lines ` }) : null, worst && style ? _jsxs(Text, { color: style.color, dimColor: true, children: [" ", style.icon, " ", worst.message, here.length > 1 ? ` (+${here.length - 1})` : ""] }) : null] }, n));
|
|
213
264
|
}) }), _jsx(Scrollbar, { total: total, top: start, rows: rows, focused: focused })] }), find ? (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(Text, { backgroundColor: C.panel, children: find.query }), _jsx(Text, { inverse: true, children: " " }), _jsxs(Text, { color: find.query && !matches.length ? C.red : C.dim, children: [" ", find.query ? (matches.length ? `${matches.indexOf(current) + 1}/${matches.length}${matches.length >= 10_000 ? "+" : ""}` : "no matches") : "type to find", " ", "Enter/\u2193 next \u00B7 \u2191 previous \u00B7 Esc close"] })] })) : null] }));
|
|
214
265
|
});
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from "path";
|
|
|
5
5
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
6
6
|
import { IGNORED } from "../commands/index.js";
|
|
7
7
|
import { C, GIT_COLORS, fileIcon, gradient, ANIMATION_MS } from "./theme.js";
|
|
8
|
+
import { useTerminalFocused } from "./focus.js";
|
|
8
9
|
import { Pane } from "./Pane.js";
|
|
9
10
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
11
|
function listDir(dir) {
|
|
@@ -167,7 +168,8 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
|
|
|
167
168
|
else
|
|
168
169
|
onOpen(node.path);
|
|
169
170
|
}, [click?.nonce]);
|
|
170
|
-
const
|
|
171
|
+
const windowFocused = useTerminalFocused();
|
|
172
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!working?.size && windowFocused });
|
|
171
173
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
172
174
|
const title = changesOnly
|
|
173
175
|
? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
|
package/dist/src/ui/GamePanel.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { memo, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Component, memo, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { GAMES, gameById } from "../games/index.js";
|
|
5
5
|
import { C } from "./theme.js";
|
|
@@ -23,15 +23,33 @@ function Row({ line, colors }) {
|
|
|
23
23
|
* A panel for little games, docked under a pane (drag its title to another pane; × closes it). The app gives it
|
|
24
24
|
* the keyboard while it's focused; a game only runs while it has the keyboard, so it pauses as you leave.
|
|
25
25
|
*/
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Keeps a game that throws from taking Nou down with it: the panel says so, and you can pick another.
|
|
28
|
+
*/
|
|
29
|
+
export class GameGuard extends Component {
|
|
30
|
+
state = { error: null };
|
|
31
|
+
static getDerivedStateFromError(error) {
|
|
32
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
33
|
+
}
|
|
34
|
+
render() {
|
|
35
|
+
if (!this.state.error)
|
|
36
|
+
return this.props.children;
|
|
37
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: C.red, flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: C.red, children: ["The game stopped: ", this.state.error.slice(0, 80)] }), _jsx(Text, { color: C.dim, children: "Close the panel (\u00D7) and open it again with /games." })] }));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export const GamePanel = memo(function GamePanel({ width, height, focused, moving, installed, best = {}, onScore, onLeave, onOpenSettings }) {
|
|
41
|
+
const FIELD = { width: Math.max(0, width - 4), height: Math.max(0, height - 6) };
|
|
42
|
+
const fits = (g) => FIELD.width >= g.minWidth && FIELD.height >= g.minHeight;
|
|
28
43
|
const games = installed.map(gameById).filter((g) => !!g);
|
|
29
44
|
const [row, setRow] = useState(0);
|
|
30
45
|
const [playing, setPlaying] = useState(null);
|
|
31
46
|
const [paused, setPaused] = useState(false);
|
|
32
47
|
const inputs = useRef([]);
|
|
33
|
-
|
|
34
|
-
const
|
|
48
|
+
// A game that doesn't fit the panel any more (it was made smaller) waits, saying how much room it needs.
|
|
49
|
+
const tooSmall = !!playing && !fits(playing.game);
|
|
50
|
+
const view = playing && !tooSmall ? playing.game.view(playing.state) : null;
|
|
51
|
+
const running = !!playing && !tooSmall && focused && !paused && !view?.over;
|
|
52
|
+
const tickMs = playing ? playing.game.speed?.(playing.state) ?? playing.game.tickMs : 100;
|
|
35
53
|
useEffect(() => {
|
|
36
54
|
if (!running)
|
|
37
55
|
return;
|
|
@@ -39,15 +57,25 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
|
|
|
39
57
|
const pressed = inputs.current;
|
|
40
58
|
inputs.current = [];
|
|
41
59
|
setPlaying((p) => (p ? { ...p, state: p.game.tick(p.state, pressed) } : p));
|
|
42
|
-
},
|
|
60
|
+
}, tickMs);
|
|
43
61
|
return () => clearInterval(timer);
|
|
44
|
-
}, [running, playing?.game]);
|
|
45
|
-
// A different size (moved to another pane, or the window changed) starts the game over at that size
|
|
62
|
+
}, [running, playing?.game, tickMs]);
|
|
63
|
+
// A different size (moved to another pane, or the window changed) starts the game over at that size,
|
|
64
|
+
// once it fits.
|
|
46
65
|
useEffect(() => {
|
|
47
|
-
if (playing)
|
|
66
|
+
if (playing && fits(playing.game))
|
|
48
67
|
setPlaying({ game: playing.game, state: playing.game.create(FIELD.width, FIELD.height) });
|
|
49
68
|
}, [FIELD.width, FIELD.height]);
|
|
69
|
+
// A finished game's score goes into the best scores.
|
|
70
|
+
const final = view?.over ? view.score ?? 0 : null;
|
|
71
|
+
const newBest = final !== null && playing !== null && final > (best[playing.game.id] ?? 0);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (newBest && playing)
|
|
74
|
+
onScore?.(playing.game.id, final);
|
|
75
|
+
}, [newBest]);
|
|
50
76
|
const start = (game) => {
|
|
77
|
+
if (!fits(game))
|
|
78
|
+
return;
|
|
51
79
|
inputs.current = [];
|
|
52
80
|
setPaused(false);
|
|
53
81
|
setPlaying({ game, state: game.create(FIELD.width, FIELD.height) });
|
|
@@ -75,7 +103,7 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
|
|
|
75
103
|
}
|
|
76
104
|
if (input === " " || input === "p")
|
|
77
105
|
return setPaused((p) => !p);
|
|
78
|
-
const pressed = key.upArrow ? "up" : key.downArrow ? "down" : key.leftArrow ? "left" : key.rightArrow ? "right" : KEYS[input];
|
|
106
|
+
const pressed = key.return ? "action" : key.upArrow ? "up" : key.downArrow ? "down" : key.leftArrow ? "left" : key.rightArrow ? "right" : KEYS[input];
|
|
79
107
|
if (pressed)
|
|
80
108
|
inputs.current.push(pressed);
|
|
81
109
|
}, { isActive: focused });
|
|
@@ -86,14 +114,14 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
|
|
|
86
114
|
const head = title.length > inner - 3 ? `${title.slice(0, inner - 4)}…` : title.padEnd(inner - 3);
|
|
87
115
|
const hint = !focused ? "click to play" : playing ? (view?.over ? "Enter again · q games · Esc leave" : `${paused ? "PAUSED · " : ""}Space pause · q games · Esc leave`) : "↑↓ choose · Enter play · Esc leave";
|
|
88
116
|
const small = width < GAME_PANEL.minWidth || height < GAME_PANEL.minHeight;
|
|
89
|
-
return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: "round", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { backgroundColor: C.panel, wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: head }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), small ? (_jsx(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: _jsx(Text, { color: C.dim, wrap: "wrap", children: "Too small here: drag me to a bigger pane, or make this one wider." }) })) : null, small ? null : playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
|
|
117
|
+
return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: "round", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { backgroundColor: C.panel, wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: head }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), small ? (_jsx(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: _jsx(Text, { color: C.dim, wrap: "wrap", children: "Too small here: drag me to a bigger pane, or make this one wider." }) })) : null, small ? null : playing && tooSmall ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [_jsxs(Text, { color: C.yellow, wrap: "wrap", children: [playing.game.title, " needs more room: a field of ", playing.game.minWidth, "\u00D7", playing.game.minHeight, ", and this one is ", FIELD.width, "\u00D7", FIELD.height, "."] }), _jsx(Text, { color: C.dim, wrap: "wrap", children: "Drag the top edge up, move the panel to a wider pane, or q for another game." })] })) : playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
|
|
90
118
|
// The end-of-game message goes over the middle of the field.
|
|
91
119
|
if (view.over && i === Math.floor(FIELD.height / 2)) {
|
|
92
|
-
const msg = ` ${view.over} `;
|
|
120
|
+
const msg = ` ${view.over}${newBest ? " New best!" : ""} `;
|
|
93
121
|
const left = Math.max(0, Math.floor((FIELD.width - msg.length) / 2));
|
|
94
122
|
return _jsxs(Text, { children: [line.slice(0, left), _jsx(Text, { backgroundColor: C.yellow, color: "black", bold: true, children: msg }), line.slice(left + msg.length)] }, i);
|
|
95
123
|
}
|
|
96
124
|
return _jsx(Row, { line: line, colors: playing.game.colors }, i);
|
|
97
|
-
}), _jsx(Text, { color: C.dim, children: "─".repeat(FIELD.width) }), _jsx(Text, { color: C.text, wrap: "truncate-end", children: view.status })] })) : (_jsx(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: games.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.text, children: "No games yet." }), _jsxs(Text, { color: C.dim, wrap: "wrap", children: ["Add some in settings \u2192 Games", focused ? " (press s)" : "", ":
|
|
125
|
+
}), _jsx(Text, { color: C.dim, children: "─".repeat(FIELD.width) }), _jsx(Text, { color: C.text, wrap: "truncate-end", children: view.status })] })) : (_jsx(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: games.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.text, children: "No games yet." }), _jsxs(Text, { color: C.dim, wrap: "wrap", children: ["Add some in settings \u2192 Games", focused ? " (press s)" : "", ": ", GAMES.map((g) => g.title).join(", "), "."] })] })) : (_jsxs(_Fragment, { children: [games.map((g, i) => (_jsxs(Text, { wrap: "truncate-end", ...(i === row && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: i === row ? C.accent2 : C.dim, children: i === row ? "▸ " : " " }), _jsx(Text, { color: fits(g) ? C.text : C.dim, bold: i === row, children: g.title }), best[g.id] ? _jsxs(Text, { color: C.yellow, children: [" best ", best[g.id]] }) : null, fits(g) ? null : _jsx(Text, { color: C.dim, children: " (needs more room)" })] }, g.id))), games[row] ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: C.dim, wrap: "wrap", children: games[row].blurb }), _jsx(Text, { color: C.accent2, wrap: "truncate-end", children: games[row].controls })] })) : null] })) })), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", hint] })] }));
|
|
98
126
|
});
|
|
99
127
|
export { GAMES };
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -3,10 +3,13 @@ import { memo } from "react";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { Box, Text, useAnimation } from "ink";
|
|
5
5
|
import { C, SPINNER, gradient, ANIMATION_MS } from "./theme.js";
|
|
6
|
+
import { useTerminalFocused } from "./focus.js";
|
|
6
7
|
import { currentVersion } from "../update/index.js";
|
|
7
8
|
const VERSION = `v${currentVersion()}`;
|
|
8
9
|
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
|
|
9
|
-
|
|
10
|
+
// The spinner stops while Nou's window is in the background: nobody sees it, and each frame is a redraw.
|
|
11
|
+
const windowFocused = useTerminalFocused();
|
|
12
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
|
|
10
13
|
const project = path.basename(process.cwd());
|
|
11
14
|
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, flexGrow: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [update && width >= 110 && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] }) })] }));
|
|
12
15
|
});
|
|
@@ -2,6 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
4
|
import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
|
|
5
|
+
import { useTerminalFocused } from "./focus.js";
|
|
5
6
|
import { Pane } from "./Pane.js";
|
|
6
7
|
export const SEVERITY_STYLE = {
|
|
7
8
|
error: { icon: "✖", color: C.red },
|
|
@@ -11,7 +12,8 @@ export const SEVERITY_STYLE = {
|
|
|
11
12
|
export function ProblemsView({ width, height, focused, problems, checking, wheel, onJump, onClose }) {
|
|
12
13
|
const [cursor, setCursor] = useState(0);
|
|
13
14
|
const [onlyErrors, setOnlyErrors] = useState(false);
|
|
14
|
-
const
|
|
15
|
+
const windowFocused = useTerminalFocused();
|
|
16
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: checking.length > 0 && windowFocused });
|
|
15
17
|
const rows = useMemo(() => {
|
|
16
18
|
const list = onlyErrors ? problems.filter((d) => d.severity === "error") : problems;
|
|
17
19
|
const out = [];
|
package/dist/src/ui/Settings.js
CHANGED
|
@@ -40,9 +40,11 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
40
40
|
const [models, setModels] = useState({});
|
|
41
41
|
const [connecting, setConnecting] = useState(connectTo?.connect ? { step: 0, values: {} } : null);
|
|
42
42
|
const [field, setField] = useState(() => (connectTo?.connect ? initialValue(connectTo.connect[0]) : ""));
|
|
43
|
-
const { frame } = useAnimation({ interval: ANIMATION_MS });
|
|
44
43
|
const provider = providers[row] ?? providers[0];
|
|
45
44
|
const listed = models[provider.id];
|
|
45
|
+
// Only "loading models…" animates; a timer running the whole time settings is open would redraw it 10
|
|
46
|
+
// times a second for nothing.
|
|
47
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: listed === "loading" });
|
|
46
48
|
// Load the model list for the highlighted provider.
|
|
47
49
|
useEffect(() => {
|
|
48
50
|
if (tab !== "Model" || connecting || provider.unavailable() || models[provider.id])
|
|
@@ -190,6 +192,11 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
190
192
|
used += cost(--first);
|
|
191
193
|
return { first, last };
|
|
192
194
|
})();
|
|
195
|
+
const gameWindow = (() => {
|
|
196
|
+
const fit = Math.max(1, Math.floor((boxHeight - 4) / 3));
|
|
197
|
+
const first = Math.max(0, Math.min((tab === "Games" ? row : 0) - fit + 1, GAMES.length - fit));
|
|
198
|
+
return { first, last: first + fit };
|
|
199
|
+
})();
|
|
193
200
|
const toggle = (on, warn) => (_jsx(Text, { color: on ? (warn ? C.orange : C.green) : C.dim, bold: true, children: on ? "● ON " : "○ OFF" }));
|
|
194
201
|
const body = () => {
|
|
195
202
|
if (tab === "Model") {
|
|
@@ -252,7 +259,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
252
259
|
}), prefWindow.last < PREFS.length ? _jsxs(Text, { color: C.dim, children: [" \u2193 ", PREFS.length - prefWindow.last, " more"] }) : null] }));
|
|
253
260
|
}
|
|
254
261
|
if (tab === "Games") {
|
|
255
|
-
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Little games for a break while Nou works. Added ones show in the Games panel: /games
|
|
262
|
+
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [_jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Little games for a break while Nou works. Added ones show in the Games panel: /games." }), _jsx(Text, { children: " " }), GAMES.map((g, i) => ({ g, i })).slice(gameWindow.first, gameWindow.last).map(({ g, i }) => {
|
|
256
263
|
const selected = i === row;
|
|
257
264
|
const added = settings.games.includes(g.id);
|
|
258
265
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), added ? _jsx(Text, { color: C.green, bold: true, children: "\u2713 ADDED" }) : _jsx(Text, { color: C.accent2, bold: true, children: "\u2B07 GET " }), _jsxs(Text, { color: C.text, bold: true, children: [" ", g.title] }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id));
|