nou-cli 0.5.3 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,13 +3,24 @@ import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useAnimation, useInput } from "ink";
4
4
  import { statSync } from "fs";
5
5
  import path from "path";
6
- import { closeOpenMarkdown, linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
6
+ import { closeOpenMarkdown, linkAt, linkFileRefs, linkUrls, renderMarkdown, wrap } from "./text.js";
7
7
  import { ChatInput, inputHeight } from "./ChatInput.js";
8
8
  import { copyToClipboard } from "./clipboard.js";
9
- import { C, SPINNER } from "./theme.js";
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";
14
+ /**
15
+ * What the agent did between replies (files read, commands run...) folds into one line, like Claude's
16
+ * "▸ 5 steps": click it (or Ctrl+O for all of them) to see each step. `open` holds the runs shown open.
17
+ */
18
+ function stepLines(steps, open, live, inner) {
19
+ const last = steps[steps.length - 1].text.replace(/\s+/g, " ");
20
+ const summary = `${open ? "▾" : "▸"} ${steps.length} steps${open ? "" : ` · ${live ? "now: " : "last: "}${last}`}`;
21
+ const head = { text: ` ${summary.length > inner - 4 ? `${summary.slice(0, inner - 5)}…` : summary}`, color: open ? C.accent2 : C.dim, lead: " ⚙", leadColor: C.dim, toggle: steps[0] };
22
+ return open ? [head, ...steps.flatMap((e) => cachedLines(e, inner).map((l) => (l.lead === " ⚙" ? { ...l, lead: " " } : l)))] : [head];
23
+ }
13
24
  const BAR = "▌ ";
14
25
  // Whether a path in a message is a file in the project, remembered so each one is checked once.
15
26
  const knownFiles = new Map();
@@ -54,7 +65,7 @@ function entryLines(entry, inner, links = true) {
54
65
  ? { icon: "⚙", color: C.dim }
55
66
  : entry.kind === "error" ? { icon: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
56
67
  wrap(entry.text, inner - 4).forEach((line, i) => {
57
- const spans = links && entry.kind !== "user" ? linkFileRefs([{ text: ` ${line}` }], isProjectFile) : [];
68
+ const spans = links && entry.kind !== "user" ? linkFileRefs(linkUrls([{ text: ` ${line}` }]), isProjectFile) : [];
58
69
  out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
59
70
  });
60
71
  }
@@ -71,17 +82,69 @@ function cachedLines(entry, inner) {
71
82
  laidOut.set(entry, { inner, lines });
72
83
  return lines;
73
84
  }
85
+ /**
86
+ * Where each tab sits in the chat's title row (columns from the pane's inner left edge), and the "+"
87
+ * that opens a new one: shared by the drawing and the clicks.
88
+ */
89
+ export function chatTabZones(tabs) {
90
+ // One tab: just "Chat +", to leave the title row its room.
91
+ if (tabs.length === 1)
92
+ return { zones: [{ id: tabs[0].id, from: 3, to: 7 }], plus: 8, width: 9 };
93
+ let x = 3; // " ◆ "
94
+ const zones = tabs.map((t) => {
95
+ const w = tabText(t).length;
96
+ const zone = { id: t.id, from: x, to: x + w };
97
+ x += w + 1;
98
+ return zone;
99
+ });
100
+ return { zones, plus: x + 1, width: x + 3 };
101
+ }
102
+ const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
74
103
  const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
75
- 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, queued = [] }) {
76
- const [input, setInput] = useState("");
104
+ 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 = [] }) {
105
+ const [input, setInputText] = useState("");
106
+ // Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
107
+ // still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
108
+ const focusedNow = useRef(focused);
109
+ focusedNow.current = focused;
110
+ const [menuRow, setMenuRow] = useState(0);
111
+ /** A new input also puts the command menu back on its first row, in the same render. */
112
+ const setInput = (text) => {
113
+ setInputText(text);
114
+ setMenuRow(0);
115
+ };
77
116
  useEffect(() => {
78
117
  if (prefill)
79
118
  setInput(prefill.text);
80
119
  }, [prefill?.nonce]);
81
120
  const [scrollBack, setScrollBack] = useState(0);
82
- const { frame } = useAnimation({ interval: 80, 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 });
83
124
  const inner = width - 3; // minus the borders and the scrollbar
84
- const history = useMemo(() => entries.flatMap((e) => cachedLines(e, inner)), [entries, inner]);
125
+ const [openSteps, setOpenSteps] = useState(new Set());
126
+ const [allOpen, setAllOpen] = useState(false);
127
+ const history = useMemo(() => {
128
+ const out = [];
129
+ for (let i = 0; i < entries.length;) {
130
+ if (entries[i].kind !== "tool") {
131
+ out.push(...cachedLines(entries[i], inner));
132
+ i++;
133
+ continue;
134
+ }
135
+ // A run of steps: one alone is shown as is, two or more fold into a line.
136
+ let j = i;
137
+ while (j < entries.length && entries[j].kind === "tool")
138
+ j++;
139
+ const steps = entries.slice(i, j);
140
+ if (steps.length === 1)
141
+ out.push(...cachedLines(steps[0], inner));
142
+ else
143
+ out.push(...stepLines(steps, allOpen || openSteps.has(steps[0]), !!busy && j === entries.length, inner));
144
+ i = j;
145
+ }
146
+ return out;
147
+ }, [entries, inner, openSteps, allOpen, !!busy]);
85
148
  const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: closeOpenMarkdown(streaming) }, inner, false)] : history), [history, streaming, inner]);
86
149
  // At the bottom, new output scrolls into view. Scrolled up to read, the view stays where it is
87
150
  // (counted from the bottom, so it moves up by however many lines arrived) until you scroll back down.
@@ -135,8 +198,6 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
135
198
  // Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
136
199
  const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
137
200
  const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
138
- const [menuRow, setMenuRow] = useState(0);
139
- useEffect(() => setMenuRow(0), [input]);
140
201
  const showMenu = matches.length > 0;
141
202
  useEffect(() => onMenu?.(showMenu), [showMenu]);
142
203
  const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
@@ -164,6 +225,20 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
164
225
  if (!click)
165
226
  return;
166
227
  const line = visible[click.row];
228
+ const run = line?.toggle;
229
+ if (run) {
230
+ setOpenSteps((open) => {
231
+ const next = new Set(open);
232
+ if (next.has(run) || allOpen)
233
+ next.delete(run);
234
+ else
235
+ next.add(run);
236
+ return next;
237
+ });
238
+ if (allOpen)
239
+ setAllOpen(false);
240
+ return;
241
+ }
167
242
  const ref = line?.spans ? linkAt(line.spans, click.col) : null;
168
243
  if (ref)
169
244
  onOpenRef?.(ref);
@@ -225,13 +300,19 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
225
300
  else if (typed && typed >= " ")
226
301
  setInput(typed); // printable, not a control key
227
302
  }, { isActive: focused && !!approval && !input });
228
- useInput((_input, key) => {
303
+ useInput((input, key) => {
229
304
  if (key.pageUp || (key.upArrow && key.shift))
230
305
  setScrollBack((b) => Math.min(maxBack, Math.min(b, maxBack) + Math.max(1, rows - 2)));
231
306
  else if (key.pageDown || (key.downArrow && key.shift))
232
307
  setScrollBack((b) => Math.max(0, Math.min(b, maxBack) - Math.max(1, rows - 2)));
233
308
  else if (key.ctrl && key.home)
234
309
  setScrollBack(maxBack);
310
+ else if (key.ctrl && input === "o") {
311
+ // Open every run of steps, or fold them all (the ones opened by a click too).
312
+ setAllOpen(!allOpen);
313
+ if (allOpen)
314
+ setOpenSteps(new Set());
315
+ }
235
316
  else if (key.ctrl && key.end)
236
317
  setScrollBack(0);
237
318
  }, { isActive: focused });
@@ -262,19 +343,22 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
262
343
  ? line.spans.map((span, i) => (_jsx(Text, { bold: !!span.bold || !!line.bold, italic: !!span.italic, dimColor: !!span.dim && !span.link, underline: !!span.link, ...(span.link ? { color: C.cyan } : span.code ? { color: C.yellow } : line.color ? { color: line.color } : { color: C.text }), children: span.text }, i)))
263
344
  : _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
264
345
  const empty = entries.length === 0 && !streaming;
265
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
346
+ const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " ", tabs && tabs.items.length > 1 ? (_jsxs(_Fragment, { children: [tabs.items.map((t) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.id === tabs.active ? { backgroundColor: C.panel, color: C.text, bold: true } : { color: t.state === "waiting" ? C.yellow : C.dim }), children: tabText(t) }), " "] }, t.id))), _jsx(Text, { color: C.accent2, bold: true, children: " + " })] })) : _jsxs(_Fragment, { children: ["Chat", tabs ? _jsx(Text, { color: C.accent2, bold: true, children: " +" }) : null] }), flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
266
347
  ? unread
267
348
  ? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
268
349
  : _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
269
350
  : maxBack > 0 ? _jsx(Text, { color: C.dim, children: " PgUp / wheel to scroll" }) : null] }));
270
- 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: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: visible.map((line, i) => _jsx(Text, { color: line.leadColor ?? C.dim, children: `${line.lead ?? " "}${i < visible.length - 1 ? "\n" : ""}` }, i)) }) }), _jsx(Box, { flexDirection: "column", width: inner - 2, flexShrink: 0, children: visible.map((line, i) => {
271
- const range = selectedIn(firstShown + i);
272
- if (!range)
273
- return render(line, firstShown + i, false);
274
- const t = lineText(line) || " ";
275
- return (_jsxs(Text, { wrap: "truncate-end", color: C.text, children: [t.slice(0, range[0]), _jsx(Text, { inverse: true, children: t.slice(range[0], range[1]) || " " }), t.slice(range[1])] }, firstShown + i));
276
- }) })] })) }), _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) => {
351
+ // The messages, rebuilt only when they or the scroll position change: typing, the context chip and the
352
+ // spinner re-render the chat without touching its (often hundreds of) lines.
353
+ const messages = useMemo(() => (_jsx(_Fragment, { children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: visible.map((line, i) => _jsx(Text, { color: line.leadColor ?? C.dim, children: `${line.lead ?? " "}${i < visible.length - 1 ? "\n" : ""}` }, i)) }) }), _jsx(Box, { flexDirection: "column", width: inner - 2, flexShrink: 0, children: visible.map((line, i) => {
354
+ const range = selectedIn(firstShown + i);
355
+ if (!range)
356
+ return render(line, firstShown + i, false);
357
+ const t = lineText(line) || " ";
358
+ return (_jsxs(Text, { wrap: "truncate-end", color: C.text, children: [t.slice(0, range[0]), _jsx(Text, { inverse: true, children: t.slice(range[0], range[1]) || " " }), t.slice(range[1])] }, firstShown + i));
359
+ }) })] })) })), [lines, firstShown, visible.length, selection, inner, empty]);
360
+ 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) => {
277
361
  const selected = menuStart + i === menuRow;
278
362
  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));
279
- }) })) : (_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: setInput, 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" })] }));
363
+ }) })) : (_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" })] }));
280
364
  });
@@ -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
  });
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import path from "path";
4
4
  import { Box, Text, useInput, usePaste } from "ink";
@@ -8,6 +8,7 @@ import { SEVERITY_STYLE } from "./ProblemsView.js";
8
8
  import { Pane } from "./Pane.js";
9
9
  import { Scrollbar, topFor } from "./scroll.js";
10
10
  import { copyToClipboard, readClipboard } from "./clipboard.js";
11
+ import { guideProgress } from "../guide/index.js";
11
12
  const TAB = " ";
12
13
  const expand = (s) => s.replace(/\t/g, TAB);
13
14
  /** Screen column of buffer column `col` on `line` (tabs take two cells). */
@@ -87,13 +88,24 @@ const LINE_COMMENTS = {
87
88
  ".toml": "#", ".r": "#", ".pl": "#", ".ex": "#", ".exs": "#", ".dockerfile": "#", ".sql": "--", ".lua": "--", ".hs": "--",
88
89
  };
89
90
  const commentFor = (file) => LINE_COMMENTS[path.extname(file).toLowerCase()] ?? (path.basename(file).toLowerCase() === "dockerfile" ? "#" : null);
91
+ /** A line without the cursor or a selection: kept as is while you type on other lines. */
92
+ const PlainRow = memo(function PlainRow({ num, numColor, text }) {
93
+ return _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: numColor, children: num }), text || " "] });
94
+ });
90
95
  /** A text editor: arrows and the mouse to move, Shift to select, Ctrl+C/X/V clipboard, Ctrl+S save, Ctrl+Z undo, Esc leave. */
91
- export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs }) {
96
+ export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs, guide, onGuideDone }) {
92
97
  const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
93
98
  const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
94
99
  const comment = useMemo(() => commentFor(filePath), [filePath]);
95
100
  const [buf, setBuf] = useState(() => {
96
101
  const lines = initial.replace(/\r\n/g, "\n").split("\n");
102
+ if (guide) {
103
+ // A fresh line for the guided code, indented like its first line.
104
+ const row = Math.max(0, Math.min(lines.length, guide.row));
105
+ const indent = guide.code.match(/^[ \t]*/)[0];
106
+ lines.splice(row, 0, indent);
107
+ return { lines, row, col: indent.length, anchor: null };
108
+ }
97
109
  const row = Math.max(0, Math.min(lines.length - 1, (startLine ?? 1) - 1));
98
110
  const col = startCol ? Math.min(lines[row].length, startCol - 1) : lines[row].match(/^\s*/)[0].length;
99
111
  return { lines, row, col, anchor: null };
@@ -517,6 +529,12 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
517
529
  }
518
530
  return insert(`\n${indent}${opens ? indentUnit : ""}`);
519
531
  }
532
+ if (key.tab && !key.shift && !hasSelection && guide && ghost) {
533
+ // Guided: Tab types the rest of the line for you (and the next line's indentation).
534
+ const end = ghost.indexOf("\n");
535
+ const chunk = end < 0 ? ghost : ghost.slice(0, end + 1) + ghost.slice(end + 1).match(/^[ \t]*/)[0];
536
+ return insert(chunk);
537
+ }
520
538
  if (key.tab) {
521
539
  // With a selection, or Shift+Tab: indent or outdent whole lines.
522
540
  if (key.shift)
@@ -589,16 +607,32 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
589
607
  return map;
590
608
  }, [problems]);
591
609
  const sel = selection(buf);
610
+ // Guided typing: how much of the step's code is typed (from its line's start to the cursor).
611
+ const guideRow = guide ? Math.max(0, Math.min(buf.lines.length - 1, guide.row)) : -1;
612
+ const typed = guide && (buf.row > guideRow || (buf.row === guideRow)) ? [...buf.lines.slice(guideRow, buf.row), buf.lines[buf.row].slice(0, buf.col)].join("\n") : null;
613
+ const progress = guide && typed !== null ? guideProgress(typed, guide.code) : -1;
614
+ const ghost = guide && progress >= 0 ? guide.code.slice(progress) : "";
615
+ const guideDone = useRef(false);
616
+ useEffect(() => {
617
+ if (guide && progress === guide.code.length && !guideDone.current) {
618
+ guideDone.current = true;
619
+ onGuideDone?.();
620
+ }
621
+ }, [progress]);
592
622
  const cursorProblem = byLine.get(buf.row + 1)?.[0];
593
623
  const selectedCount = sel ? selectedText(buf).length : 0;
594
- const status = confirmExit
624
+ const guideStatus = !guide ? null
625
+ : progress < 0 ? "Not quite: that doesn't match the step's code. Backspace and try again (the dim text shows what goes here)."
626
+ : ghost ? `Type the dim text${ghost.includes("\n") ? ` (${ghost.split("\n").length} lines to go)` : ""} · Tab fills in the rest of the line · Esc stop`
627
+ : "✓ Step done";
628
+ const status = guideStatus && !confirmExit && !prompt ? guideStatus : confirmExit
595
629
  ? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
596
630
  : prompt
597
631
  ? prompt.kind === "find" ? `Find: ${prompt.text}▏ Enter/↓ next · Shift+Enter/↑ previous · Esc close` : `Go to line: ${prompt.text}▏ Enter go · Esc cancel`
598
632
  : flash ?? (cursorProblem
599
633
  ? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
600
634
  : `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""} Ctrl+F find · Ctrl+/ comment · Esc done`);
601
- const statusColor = confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
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;
602
636
  const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
603
637
  const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
604
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) => {
@@ -609,7 +643,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
609
643
  const num = (_jsxs(Text, { color: mark?.color ?? (isCursorLine ? C.green : C.dim), children: [String(n + 1).padStart(gutter - 2), mark ? ` ${mark.icon}` : isCursorLine ? " ▸" : " "] }));
610
644
  const inSelection = sel && n >= sel.start.row && n <= sel.end.row;
611
645
  if (!isCursorLine && !inSelection) {
612
- return _jsxs(Text, { wrap: "truncate-end", children: [num, colored[i] || " "] }, n);
646
+ return _jsx(PlainRow, { num: `${String(n + 1).padStart(gutter - 2)}${mark ? ` ${mark.icon}` : " "}`, numColor: mark?.color ?? C.dim, text: colored[i] ?? "" }, n);
613
647
  }
614
648
  // Lines with the cursor or a selection show plain text, so both can be drawn exactly.
615
649
  const shown = expand(line).slice(v.left, v.left + textWidth);
@@ -626,8 +660,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
626
660
  else
627
661
  cells.push(cell);
628
662
  }
663
+ // The code still to type, dim, from the cursor to the end of its line (only past the line's text).
664
+ const ghostLine = isCursorLine && ghost && buf.col >= line.length ? ghost.split("\n")[0] : "";
665
+ const ghostMore = isCursorLine && ghost && buf.col >= line.length ? ghost.split("\n").length - 1 : 0;
629
666
  return (_jsxs(Text, { wrap: "truncate-end", children: [num, cells.map((c, j) => c.cursor
630
- ? _jsx(Text, { inverse: true, color: C.green, children: c.text }, j)
631
- : _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j))] }, n));
667
+ ? _jsx(Text, { inverse: true, color: C.green, children: ghostLine ? ghostLine[0] : c.text }, j)
668
+ : _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j)), ghostLine.length > 1 ? _jsx(Text, { color: C.dim, italic: true, children: expand(ghostLine.slice(1)) }) : null, ghostMore ? _jsxs(Text, { color: C.accent2, children: [ghostLine ? " " : "", "\u23CE", ghostMore > 1 ? ` +${ghostMore - 1} line${ghostMore > 2 ? "s" : ""}` : ""] }) : null] }, n));
632
669
  }) }), _jsx(Scrollbar, { total: buf.lines.length, top: v.top, rows: rows, focused: focused })] }), hoverLines.length > 0 && (_jsx(Box, { position: "absolute", top: Math.max(1, rows - hoverLines.length - 1), left: gutter + 1, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, width: Math.min(width - gutter - 3, 2 + Math.max(...hoverLines.map((l) => l.length))), children: hoverLines.map((l, i) => _jsx(Text, { color: C.text, wrap: "truncate-end", children: l.padEnd(width) }, i)) })), _jsx(Text, { wrap: "truncate-end", color: statusColor, children: ` ${status}` })] }));
633
670
  });
@@ -4,7 +4,8 @@ import { readdirSync } from "fs";
4
4
  import path from "path";
5
5
  import { Box, Text, useAnimation, useInput } from "ink";
6
6
  import { IGNORED } from "../commands/index.js";
7
- import { C, GIT_COLORS, fileIcon, gradient } from "./theme.js";
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: 120, 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,25 +103,25 @@ 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 });
82
110
  const color = focused || moving ? C.accent2 : C.dim;
83
111
  const inner = width - 2;
84
- const title = moving ? " ✦ Drop it on a pane: files, code or chat" : playing ? ` ✦ ${playing.game.title} ⠿ drag to move` : " ✦ Games ⠿ drag to move";
112
+ const title = moving ? " ✦ Drop it on a pane: files, code or chat" : `${playing ? ` ✦ ${playing.game.title}` : " ✦ Games"} ⠿ drag to move · top edge resizes`;
85
113
  // The title row: the name and the drag handle, and × (close) at the right.
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 };