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.
@@ -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
- if (!repo)
558
+ const cameBack = windowFocused && !wasFocused.current;
559
+ wasFocused.current = windowFocused;
560
+ if (!repo || !windowFocused)
546
561
  return;
547
- const timer = setInterval(() => {
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
- }, 4000);
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
- return info(`Earlier conversations here. /resume <number> picks one up (or /resume last):\n${rows.join("\n")}`);
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
- const gutter = String(content.split("\n").length).length + 1;
1091
- setEditorStart((s) => ({ line: (viewport?.top ?? 1) + event.y - 3, col: Math.max(1, event.x - codeLeft - gutter), nonce: s.nonce + 1 }));
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);
@@ -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
- const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
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);
@@ -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
- const total = diff ? diffRows.length : lines.length;
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) - 1 : 0;
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
- if (current.line < start || current.line >= start + rows)
74
- setTop(Math.max(0, current.line - Math.floor(rows / 3)));
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
- onViewport(start + 1, Math.min(total, start + rows));
95
- }, [filePath, diff, start, rows, total]);
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 + 1;
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 + 1) + left;
105
- let shown = 0;
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 && shown + (raw[col] === "\t" ? 2 : 1) <= want)
108
- shown += raw[col++] === "\t" ? 2 : 1;
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: lines.slice(start, start + rows).map((line, i) => {
194
- const n = start + i + 1;
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
  });
@@ -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 { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!working?.size });
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"] })] })
@@ -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
- export const GamePanel = memo(function GamePanel({ width, height, focused, moving, installed, onLeave, onOpenSettings }) {
27
- const FIELD = { width: Math.max(10, width - 4), height: Math.max(3, height - 6) };
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
- const view = playing ? playing.game.view(playing.state) : null;
34
- const running = !!playing && focused && !paused && !view?.over;
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
- }, playing.game.tickMs);
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)" : "", ": Pong, Snake and Breakout."] })] })) : games.map((g, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(i === row && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: i === row ? C.accent2 : C.dim, children: i === row ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: i === row, children: g.title }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id))) })), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", hint] })] }));
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 };
@@ -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
- const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
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 { frame } = useAnimation({ interval: ANIMATION_MS, isActive: checking.length > 0 });
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 = [];
@@ -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, or ? then \"games\"." }), _jsx(Text, { children: " " }), GAMES.map((g, i) => {
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));