nou-cli 0.5.4 → 0.5.10

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.
@@ -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";
@@ -99,8 +100,10 @@ export function chatTabZones(tabs) {
99
100
  return { zones, plus: x + 1, width: x + 3 };
100
101
  }
101
102
  const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
103
+ /** Characters of a word for a double-click: paths and web addresses come whole (src/a.ts, https://x.io/y). */
104
+ const WORD_CHAR = /[\p{L}\p{N}_.\-/@:~#%?=&+]/u;
102
105
  const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
103
- export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [] }) {
106
+ export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [], sendNowFirst = false, onSendNow }) {
104
107
  const [input, setInputText] = useState("");
105
108
  // Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
106
109
  // still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
@@ -117,7 +120,9 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
117
120
  setInput(prefill.text);
118
121
  }, [prefill?.nonce]);
119
122
  const [scrollBack, setScrollBack] = useState(0);
120
- const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
123
+ // Spinners stop while Nou's window is in the background: nobody sees them, and each frame is a redraw.
124
+ const windowFocused = useTerminalFocused();
125
+ const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
121
126
  const inner = width - 3; // minus the borders and the scrollbar
122
127
  const [openSteps, setOpenSteps] = useState(new Set());
123
128
  const [allOpen, setAllOpen] = useState(false);
@@ -189,9 +194,11 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
189
194
  else if (busy) {
190
195
  footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
191
196
  }
192
- for (const text of queued) {
193
- footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim });
194
- }
197
+ // Click what's queued (or /now) to stop Nou and send it right away.
198
+ for (const text of queued)
199
+ footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim, sendNow: true });
200
+ if (queued.length && busy)
201
+ footer.push({ text: " ↳ click to send now, or /now", color: C.accent2, sendNow: true });
195
202
  // Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
196
203
  const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
197
204
  const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
@@ -221,6 +228,12 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
221
228
  useEffect(() => {
222
229
  if (!click)
223
230
  return;
231
+ if (click.row >= rows) {
232
+ // Under the messages: a click on what's queued sends it now.
233
+ if (footer[click.row - rows]?.sendNow)
234
+ onSendNow?.();
235
+ return;
236
+ }
224
237
  const line = visible[click.row];
225
238
  const run = line?.toggle;
226
239
  if (run) {
@@ -249,6 +262,40 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
249
262
  if (!select)
250
263
  return;
251
264
  const at = { line: Math.max(0, Math.min(lines.length - 1, firstShown + select.row)), col: Math.max(0, select.col) };
265
+ if (select.kind === "word" || select.kind === "block") {
266
+ anchor.current = null; // the button coming up doesn't change it
267
+ let range;
268
+ if (select.kind === "word") {
269
+ const chars = [...lineText(lines[at.line] ?? { text: "" })];
270
+ if (!WORD_CHAR.test(chars[at.col] ?? ""))
271
+ return;
272
+ let start = at.col;
273
+ let end = at.col;
274
+ while (start > 0 && WORD_CHAR.test(chars[start - 1]))
275
+ start--;
276
+ while (end < chars.length - 1 && WORD_CHAR.test(chars[end + 1]))
277
+ end++;
278
+ // Punctuation that ends a sentence isn't part of the word.
279
+ while (end > start && /[.,:;?!]/.test(chars[end]))
280
+ end--;
281
+ range = { from: { line: at.line, col: start }, to: { line: at.line, col: end } };
282
+ }
283
+ else {
284
+ const blank = (i) => !lineText(lines[i] ?? { text: "" }).trim();
285
+ if (blank(at.line))
286
+ return;
287
+ let first = at.line;
288
+ let last = at.line;
289
+ while (first > 0 && !blank(first - 1))
290
+ first--;
291
+ while (last < lines.length - 1 && !blank(last + 1))
292
+ last++;
293
+ range = { from: { line: first, col: 0 }, to: { line: last, col: Infinity } };
294
+ }
295
+ setSelection(range);
296
+ copySelection(range);
297
+ return;
298
+ }
252
299
  if (select.kind === "press") {
253
300
  anchor.current = at;
254
301
  setSelection(null);
@@ -268,17 +315,22 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
268
315
  if (select.kind !== "release")
269
316
  return;
270
317
  anchor.current = null;
271
- if (!range)
272
- return;
318
+ if (range)
319
+ copySelection(range);
320
+ }, [select?.nonce]);
321
+ /** Copies the selected text (and says so for a moment). Columns count characters, as the screen shows them. */
322
+ const copySelection = (range) => {
273
323
  const text = lines.slice(range.from.line, range.to.line + 1).map((line, i, all) => {
274
- const t = lineText(line);
275
- return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 ? range.to.col + 1 : undefined).trimEnd();
324
+ const t = [...lineText(line)];
325
+ return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 && Number.isFinite(range.to.col) ? range.to.col + 1 : undefined).join("").trimEnd();
276
326
  }).join("\n");
327
+ if (!text)
328
+ return;
277
329
  void copyToClipboard(text).then((ok) => {
278
330
  setFlash(ok ? `Copied ${text.split("\n").length > 1 ? `${text.split("\n").length} lines` : "it"} ✓` : "Can't copy: press m, then select");
279
331
  setTimeout(() => setFlash(null), 2000);
280
332
  });
281
- }, [select?.nonce]);
333
+ };
282
334
  // Typing or new messages leave the selection be; a new conversation clears it.
283
335
  useEffect(() => {
284
336
  if (!entries.length)
@@ -357,5 +409,5 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
357
409
  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: messages }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), showMenu ? (_jsx(Box, { flexDirection: "column", children: matches.slice(menuStart, menuStart + menuRows).map((c, i) => {
358
410
  const selected = menuStart + i === menuRow;
359
411
  return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsxs(Text, { color: selected ? C.accent : C.text, bold: selected, children: ["/", c.name] }), c.args && inner >= 56 ? _jsxs(Text, { color: C.dim, children: [" ", c.args] }) : null /* narrow: the description matters more */, _jsxs(Text, { color: C.dim, children: [" ", c.description] })] }, c.name));
360
- }) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " })), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? "Enter queues · Alt+Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
412
+ }) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " })), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? (sendNowFirst ? "Enter sends now · Alt+Enter queues · Esc stops" : "Enter queues · Alt+Enter sends now · Esc stops") : sendNowFirst ? "Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
361
413
  });
@@ -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")
@@ -167,7 +216,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
167
216
  return (_jsx(Box, { width: w, children: _jsxs(Text, { wrap: "truncate-end", ...(color ? { color } : { color: C.dim }), children: [String(c.n).padStart(gutter - 1), changed ? side : " ", c.text.slice(left) || " "] }) }));
168
217
  };
169
218
  const { icon, color } = fileIcon(diff.path);
170
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: "black", children: pending ? (diff.isNew ? " NEW FILE " : " PROPOSED ") : ` ${diffLabel.toUpperCase()} ` }), " ", _jsxs(Text, { color: C.green, children: ["+", added] }), " ", _jsxs(Text, { color: C.red, children: ["-", removed] }), diffNote ? _jsxs(Text, { color: diffNote.startsWith("✓") ? C.green : C.red, children: [" ", diffNote] }) : null, _jsxs(Text, { color: C.dim, children: [pending ? " y accept · n reject" : " n/N next/prev change", scroll] })] }));
219
+ const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: C.onBadge, children: pending ? (diff.isNew ? " NEW FILE " : " PROPOSED ") : ` ${diffLabel.toUpperCase()} ` }), " ", _jsxs(Text, { color: C.green, children: ["+", added] }), " ", _jsxs(Text, { color: C.red, children: ["-", removed] }), diffNote ? _jsxs(Text, { color: diffNote.startsWith("✓") ? C.green : C.red, children: [" ", diffNote] }) : null, _jsxs(Text, { color: C.dim, children: [pending ? " y accept · n reject" : " n/N next/prev change", scroll] })] }));
171
220
  return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, ...(pending ? { accent: C.yellow } : {}), children: _jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: diffRows.slice(start, start + rows).map((row, i) => (_jsxs(Box, { flexDirection: "row", children: [cell(row.left, "-", row.changed, half), _jsx(Text, { color: C.dim, children: "\u2502" }), cell(row.right, "+", row.changed, inner - half - 1)] }, start + i))) }), _jsx(Scrollbar, { total: total, top: start, rows: rows, focused: focused })] }) }));
172
221
  }
173
222
  if (!filePath) {
@@ -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
  });
@@ -635,7 +635,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
635
635
  const statusColor = guideStatus && !confirmExit && !prompt ? (progress < 0 ? C.red : C.accent2) : confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
636
636
  const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
637
637
  const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
638
- return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: "black", children: " \u270E EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
638
+ return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: C.onBadge, children: " \u270E EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
639
639
  const n = v.top + i;
640
640
  const isCursorLine = n === buf.row;
641
641
  const problem = byLine.get(n + 1)?.[0];
@@ -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
- 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);
122
+ return _jsxs(Text, { children: [line.slice(0, left), _jsx(Text, { backgroundColor: C.yellow, color: C.onBadge, 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 };
@@ -2,11 +2,39 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { memo } from "react";
3
3
  import path from "path";
4
4
  import { Box, Text, useAnimation } from "ink";
5
- import { C, SPINNER, gradient, ANIMATION_MS } from "./theme.js";
5
+ import { C, 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
- export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
9
- const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
9
+ /** What comes before the project (or the workspace tabs): " ◆ NOU v1.2.3 │ ". */
10
+ const LEAD = ` ◆ NOU ${VERSION} │ `;
11
+ const tabLabel = (t, i) => ` ${i + 1} ${t.name}${t.busy ? " ●" : ""} `;
12
+ /**
13
+ * Where each workspace tab, and the + that opens one, sits in the header row: for clicks. With one workspace
14
+ * (not hosted) there's just the project's name and the +.
15
+ */
16
+ export function headerZones(tabs) {
17
+ const zones = [];
18
+ let x = LEAD.length;
19
+ if (!tabs.length) {
20
+ x += path.basename(process.cwd()).length + 1;
21
+ }
22
+ else {
23
+ tabs.forEach((t, i) => {
24
+ const w = tabLabel(t, i).length;
25
+ zones.push({ from: x, to: x + w, tab: i });
26
+ x += w + 1;
27
+ });
28
+ }
29
+ zones.push({ from: x, to: x + 3, tab: "new" });
30
+ return zones;
31
+ }
32
+ export const Header = memo(function Header({ width, branch, changes, model, local, busy, update, tabs = [] }) {
33
+ // The spinner stops while Nou's window is in the background: nobody sees it, and each frame is a redraw.
34
+ const windowFocused = useTerminalFocused();
35
+ const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
10
36
  const project = path.basename(process.cwd());
11
- 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} ` }), " "] }) })] }));
37
+ 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 " }), tabs.length
38
+ ? tabs.map((t, i) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: t.active ? C.accent : C.text, bold: t.active, children: tabLabel(t, i) }), " "] }, t.id)))
39
+ : _jsxs(Text, { color: C.text, children: [project, " "] }), _jsx(Text, { color: C.accent2, children: " + " }), 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] })] }))] }) }), _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: C.onBadge, bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] }) })] }));
12
40
  });
@@ -3,6 +3,6 @@ import { Box, Text } from "ink";
3
3
  import { C } from "./theme.js";
4
4
  /** A bordered panel with a title row. Content gets `width - 2` columns and `height - 3` rows. */
5
5
  export function Pane({ title, width, height, focused, accent, children }) {
6
- const color = accent ?? (focused ? C.accent : C.dim);
7
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, borderStyle: focused || accent ? "round" : "single", borderColor: color, overflow: "hidden", children: [_jsx(Text, { bold: true, color: focused || accent ? color : C.dim, wrap: "truncate-end", children: title }), children] }));
6
+ const color = accent ?? (focused ? C.accent : C.border);
7
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, borderStyle: focused || accent ? "round" : "single", borderColor: color, overflow: "hidden", children: [_jsx(Text, { bold: true, color: focused || accent ? color : C.text, wrap: "truncate-end", children: title }), children] }));
8
8
  }
@@ -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 = [];
@@ -58,7 +58,7 @@ export function score(file, query) {
58
58
  return { score: total - file.length * 0.05, hits };
59
59
  }
60
60
  /** Ctrl+P: find a file. Start with ">" (or press ?) for every action Nou has, with its shortcut. */
61
- export function QuickOpen({ width, top, files, recent = [], actions, initialQuery = "", onPick, onRun, onClose }) {
61
+ export function QuickOpen({ width, top, files, recent = [], actions, initialQuery = "", placeholder = "Go to file… (> for commands)", onPick, onRun, onClose }) {
62
62
  const [query, setQuery] = useState(initialQuery);
63
63
  const [cursor, setCursor] = useState(0);
64
64
  const boxWidth = Math.min(80, Math.max(40, width - 10));
@@ -107,12 +107,17 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
107
107
  setCursor(0);
108
108
  }, onSubmit: () => {
109
109
  if (commandMode) {
110
- if (found[cursor])
110
+ // A number picks that row of a numbered list (/resume's: "1. ...", "2. ...").
111
+ const n = /^\s*(\d+)\s*$/.exec(query.slice(1))?.[1];
112
+ const numbered = n ? actions.find((a) => a.title.startsWith(`${n}. `)) : undefined;
113
+ if (numbered)
114
+ onRun(numbered);
115
+ else if (found[cursor])
111
116
  onRun(found[cursor].action);
112
117
  }
113
118
  else if (results[cursor])
114
119
  onPick(results[cursor].file);
115
- }, placeholder: "Go to file\u2026 (> for commands)" }), _jsx(Text, { children: " ".repeat(Math.max(0, inner - 4 - Math.max(query.length, 29))) })] }), _jsx(Text, { color: C.dim, children: "─".repeat(inner) }), commandMode && found.length === 0 && _jsx(Text, { color: C.dim, children: pad(" No matching commands") }), commandMode && found.slice(first, first + rowsShown).map((r, i) => {
120
+ }, placeholder: placeholder }), _jsx(Text, { children: " ".repeat(Math.max(0, inner - 4 - Math.max(query.length, 29))) })] }), _jsx(Text, { color: C.dim, children: "─".repeat(inner) }), commandMode && found.length === 0 && _jsx(Text, { color: C.dim, children: pad(" No matching commands") }), commandMode && found.slice(first, first + rowsShown).map((r, i) => {
116
121
  const selected = first + i === cursor;
117
122
  const keys = r.action.keys ?? "";
118
123
  const room = inner - 3 - keys.length - 1;