nou-cli 0.5.3 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/dist/src/cli.js +33 -0
- package/dist/src/config/index.js +3 -0
- package/dist/src/diagnostics/providers.js +23 -2
- package/dist/src/engines/codex.js +28 -1
- package/dist/src/games/index.js +220 -30
- package/dist/src/guide/index.js +100 -0
- package/dist/src/syntax/index.js +17 -17
- package/dist/src/ui/App.js +398 -54
- package/dist/src/ui/Chat.js +103 -19
- package/dist/src/ui/CodeView.js +70 -19
- package/dist/src/ui/Editor.js +44 -7
- package/dist/src/ui/FileTree.js +4 -2
- package/dist/src/ui/GamePanel.js +42 -14
- package/dist/src/ui/Header.js +5 -2
- package/dist/src/ui/ProblemsView.js +4 -2
- package/dist/src/ui/Settings.js +10 -3
- package/dist/src/ui/Terminal.js +5 -2
- package/dist/src/ui/clipboard.js +19 -0
- package/dist/src/ui/commands.js +21 -1
- package/dist/src/ui/focus.js +59 -0
- package/dist/src/ui/redraw.js +16 -0
- package/dist/src/ui/render-options.js +3 -0
- package/dist/src/ui/text.js +76 -3
- package/dist/src/ui/theme.js +2 -0
- package/package.json +1 -1
package/dist/src/ui/Chat.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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((
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
});
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -3,7 +3,7 @@ import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
5
5
|
import { artWork } from "../util/index.js";
|
|
6
|
-
import { highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines } from "./text.js";
|
|
6
|
+
import { foldRanges, highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines, unfoldedLines } from "./text.js";
|
|
7
7
|
import { C, fileIcon, gradient } from "./theme.js";
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
9
9
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
@@ -12,7 +12,7 @@ const CURRENT_MATCH = ["\x1b[43m\x1b[30m", "\x1b[49m\x1b[39m"];
|
|
|
12
12
|
/** Columns ←/→ and Shift+wheel move sideways. */
|
|
13
13
|
const SIDE_STEP = 8;
|
|
14
14
|
const EDIT_HINT = " double-click or e to edit · v diff";
|
|
15
|
-
export const CodeView = memo(function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest }) {
|
|
15
|
+
export const CodeView = memo(function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
|
|
16
16
|
const [top, setTop] = useState(0);
|
|
17
17
|
// Columns scrolled sideways (colors are kept: see sliceAnsi).
|
|
18
18
|
const [left, setLeft] = useState(0);
|
|
@@ -37,18 +37,39 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
37
37
|
return map;
|
|
38
38
|
}, [problems]);
|
|
39
39
|
const diffRows = useMemo(() => (diff ? sideBySide(diff.before, diff.after) : []), [diff]);
|
|
40
|
-
|
|
40
|
+
// Folding: regions from indentation, the ones folded, and the file lines left showing (rows map to these).
|
|
41
|
+
const ranges = useMemo(() => (filePath && !diff ? foldRanges(content) : new Map()), [filePath, content, !!diff]);
|
|
42
|
+
const [folded, setFolded] = useState(new Set());
|
|
43
|
+
useEffect(() => setFolded(new Set()), [filePath]);
|
|
44
|
+
const shown = useMemo(() => unfoldedLines(lines.length, ranges, folded), [lines.length, ranges, folded]);
|
|
45
|
+
/** The row a file line is on (a folded-away line: its fold's row). */
|
|
46
|
+
const rowOf = (line, list = shown) => {
|
|
47
|
+
let row = 0;
|
|
48
|
+
while (row + 1 < list.length && list[row + 1] <= line)
|
|
49
|
+
row++;
|
|
50
|
+
return row;
|
|
51
|
+
};
|
|
52
|
+
/** Opens any fold hiding `line`, and returns the rows with it open. */
|
|
53
|
+
const reveal = (line) => {
|
|
54
|
+
const hiding = [...folded].filter((s) => s < line && (ranges.get(s) ?? 0) >= line);
|
|
55
|
+
if (!hiding.length)
|
|
56
|
+
return shown;
|
|
57
|
+
const next = new Set([...folded].filter((s) => !hiding.includes(s)));
|
|
58
|
+
setFolded(next);
|
|
59
|
+
return unfoldedLines(lines.length, ranges, next);
|
|
60
|
+
};
|
|
61
|
+
const total = diff ? diffRows.length : shown.length;
|
|
41
62
|
// Jump to the first change whenever a new file or diff is shown.
|
|
42
63
|
useEffect(() => {
|
|
43
64
|
const first = diff
|
|
44
65
|
? diffRows.findIndex((r) => r.changed)
|
|
45
|
-
: marked.size ? Math.min(...marked)
|
|
66
|
+
: marked.size ? rowOf(Math.min(...marked)) : 0;
|
|
46
67
|
setTop(Math.max(0, first - 3));
|
|
47
68
|
setLeft(0);
|
|
48
69
|
}, [filePath, diff, diffRows, marked]);
|
|
49
70
|
useEffect(() => {
|
|
50
71
|
if (focusLine && !diff)
|
|
51
|
-
setTop(Math.max(0, focusLine.line - Math.floor(rows / 3)));
|
|
72
|
+
setTop(Math.max(0, rowOf(focusLine.line, reveal(focusLine.line)) + 1 - Math.floor(rows / 3)));
|
|
52
73
|
}, [focusLine]);
|
|
53
74
|
const maxTop = Math.max(0, total - rows);
|
|
54
75
|
const start = Math.min(top, maxTop);
|
|
@@ -70,8 +91,10 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
70
91
|
useEffect(() => {
|
|
71
92
|
if (!current)
|
|
72
93
|
return;
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
const list = reveal(current.line + 1);
|
|
95
|
+
const at = rowOf(current.line + 1, list);
|
|
96
|
+
if (at < start || at >= start + rows)
|
|
97
|
+
setTop(Math.max(0, at - Math.floor(rows / 3)));
|
|
75
98
|
const room = inner - String(lines.length).length - 2;
|
|
76
99
|
if (current.col < left || current.col + (find?.query.length ?? 0) > left + room)
|
|
77
100
|
setLeft(Math.max(0, current.col - Math.floor(room / 3)));
|
|
@@ -90,22 +113,45 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
90
113
|
setTop(topFor(scrollTo.fraction, total, rows));
|
|
91
114
|
}, [scrollTo?.nonce]);
|
|
92
115
|
useEffect(() => {
|
|
93
|
-
if (filePath && !diff)
|
|
94
|
-
|
|
95
|
-
|
|
116
|
+
if (filePath && !diff) {
|
|
117
|
+
const onScreen = shown.slice(start, start + rows);
|
|
118
|
+
onViewport(onScreen[0] ?? 1, onScreen[onScreen.length - 1] ?? 1, onScreen);
|
|
119
|
+
}
|
|
120
|
+
}, [filePath, diff, start, rows, total, shown]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!foldClick || diff)
|
|
123
|
+
return;
|
|
124
|
+
const n = shown[start + foldClick.row];
|
|
125
|
+
if (n === undefined || !ranges.has(n))
|
|
126
|
+
return;
|
|
127
|
+
setFolded((f) => {
|
|
128
|
+
const next = new Set(f);
|
|
129
|
+
if (next.has(n))
|
|
130
|
+
next.delete(n);
|
|
131
|
+
else
|
|
132
|
+
next.add(n);
|
|
133
|
+
return next;
|
|
134
|
+
});
|
|
135
|
+
}, [foldClick?.nonce]);
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (foldAll && !diff)
|
|
138
|
+
setFolded(foldAll.fold ? new Set(ranges.keys()) : new Set());
|
|
139
|
+
}, [foldAll?.nonce]);
|
|
96
140
|
useEffect(() => {
|
|
97
141
|
if (!defineAt || diff || !filePath)
|
|
98
142
|
return;
|
|
99
|
-
const n = start + defineAt.row
|
|
143
|
+
const n = shown[start + defineAt.row];
|
|
144
|
+
if (n === undefined)
|
|
145
|
+
return;
|
|
100
146
|
const raw = content.replace(/\r\n/g, "\n").split("\n")[n - 1];
|
|
101
147
|
if (raw === undefined)
|
|
102
148
|
return;
|
|
103
149
|
// Screen column to the file's own column: tabs are shown as two spaces.
|
|
104
|
-
const want = defineAt.x - (String(lines.length).length +
|
|
105
|
-
let
|
|
150
|
+
const want = defineAt.x - (String(lines.length).length + 2) + left;
|
|
151
|
+
let cells = 0;
|
|
106
152
|
let col = 0;
|
|
107
|
-
while (col < raw.length &&
|
|
108
|
-
|
|
153
|
+
while (col < raw.length && cells + (raw[col] === "\t" ? 2 : 1) <= want)
|
|
154
|
+
cells += raw[col++] === "\t" ? 2 : 1;
|
|
109
155
|
onDefine?.(n, col + 1);
|
|
110
156
|
}, [defineAt?.nonce]);
|
|
111
157
|
useInput((input, key) => {
|
|
@@ -141,6 +187,9 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
141
187
|
setTop(0);
|
|
142
188
|
else if (input === "G" || key.end)
|
|
143
189
|
setTop(maxTop);
|
|
190
|
+
// z: fold everything, or (when it already is) open everything.
|
|
191
|
+
else if (input === "z" && !diff && ranges.size)
|
|
192
|
+
setFolded([...ranges.keys()].every((k) => folded.has(k)) ? new Set() : new Set(ranges.keys()));
|
|
144
193
|
else if (key.leftArrow || input === "h")
|
|
145
194
|
setLeft((l) => Math.max(0, l - SIDE_STEP));
|
|
146
195
|
else if (key.rightArrow || input === "l")
|
|
@@ -190,14 +239,16 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
190
239
|
// The edit hint only when it fits: a cut-off hint reads as noise.
|
|
191
240
|
const titleRoom = tabs ? 0 : width - 4 - (3 + filePath.length + (errorCount ? 5 + String(errorCount).length : 0) + (warningCount ? 5 + String(warningCount).length : 0) + scroll.length + (total > rows ? 7 : 0));
|
|
192
241
|
const title = (_jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath] }), errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused && titleRoom >= EDIT_HINT.length ? EDIT_HINT : ""] })] }));
|
|
193
|
-
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children:
|
|
194
|
-
const
|
|
242
|
+
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: shown.slice(start, start + rows).map((n) => {
|
|
243
|
+
const line = lines[n - 1] ?? "";
|
|
244
|
+
const fold = ranges.get(n);
|
|
245
|
+
const isFolded = fold !== undefined && folded.has(n);
|
|
195
246
|
const isFound = !!found && n >= found.from && n <= found.to;
|
|
196
247
|
const isMarked = marked.has(n);
|
|
197
248
|
const here = problemsByLine.get(n);
|
|
198
249
|
const worst = here?.[0];
|
|
199
250
|
const style = worst ? SEVERITY_STYLE[worst.severity] : null;
|
|
200
|
-
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: style?.color ?? (isMarked ? C.green : C.dim), children: String(n).padStart(gutter) }), style ? _jsx(Text, { color: style.color, children: style.icon }) : isFound ? _jsx(Text, { color: C.cyan, bold: true, children: "\u258C" }) : _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), (() => {
|
|
251
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: style?.color ?? (isMarked ? C.green : C.dim), children: String(n).padStart(gutter) }), style ? _jsx(Text, { color: style.color, children: style.icon }) : isFound ? _jsx(Text, { color: C.cyan, bold: true, children: "\u258C" }) : _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), _jsx(Text, { color: isFolded ? C.accent2 : C.dim, children: fold === undefined ? " " : isFolded ? "▸" : "▾" }), (() => {
|
|
201
252
|
let text = line;
|
|
202
253
|
if (find?.query) {
|
|
203
254
|
for (const m of matches) {
|
|
@@ -209,6 +260,6 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
209
260
|
}
|
|
210
261
|
text = (left ? sliceAnsi(text, left) : text) || " ";
|
|
211
262
|
return isFound ? _jsx(Text, { backgroundColor: C.panel, children: text }) : text;
|
|
212
|
-
})(), worst && style ? _jsxs(Text, { color: style.color, dimColor: true, children: [" ", style.icon, " ", worst.message, here.length > 1 ? ` (+${here.length - 1})` : ""] }) : null] }, n));
|
|
263
|
+
})(), isFolded ? _jsx(Text, { color: C.accent2, backgroundColor: C.panel, children: ` ⋯ ${fold - n} lines ` }) : null, worst && style ? _jsxs(Text, { color: style.color, dimColor: true, children: [" ", style.icon, " ", worst.message, here.length > 1 ? ` (+${here.length - 1})` : ""] }) : null] }, n));
|
|
213
264
|
}) }), _jsx(Scrollbar, { total: total, top: start, rows: rows, focused: focused })] }), find ? (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(Text, { backgroundColor: C.panel, children: find.query }), _jsx(Text, { inverse: true, children: " " }), _jsxs(Text, { color: find.query && !matches.length ? C.red : C.dim, children: [" ", find.query ? (matches.length ? `${matches.indexOf(current) + 1}/${matches.length}${matches.length >= 10_000 ? "+" : ""}` : "no matches") : "type to find", " ", "Enter/\u2193 next \u00B7 \u2191 previous \u00B7 Esc close"] })] })) : null] }));
|
|
214
265
|
});
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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
|
});
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -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
|
|
171
|
+
const windowFocused = useTerminalFocused();
|
|
172
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!working?.size && windowFocused });
|
|
171
173
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
172
174
|
const title = changesOnly
|
|
173
175
|
? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
|
package/dist/src/ui/GamePanel.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { memo, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Component, memo, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { GAMES, gameById } from "../games/index.js";
|
|
5
5
|
import { C } from "./theme.js";
|
|
@@ -23,15 +23,33 @@ function Row({ line, colors }) {
|
|
|
23
23
|
* A panel for little games, docked under a pane (drag its title to another pane; × closes it). The app gives it
|
|
24
24
|
* the keyboard while it's focused; a game only runs while it has the keyboard, so it pauses as you leave.
|
|
25
25
|
*/
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Keeps a game that throws from taking Nou down with it: the panel says so, and you can pick another.
|
|
28
|
+
*/
|
|
29
|
+
export class GameGuard extends Component {
|
|
30
|
+
state = { error: null };
|
|
31
|
+
static getDerivedStateFromError(error) {
|
|
32
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
33
|
+
}
|
|
34
|
+
render() {
|
|
35
|
+
if (!this.state.error)
|
|
36
|
+
return this.props.children;
|
|
37
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: C.red, flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: C.red, children: ["The game stopped: ", this.state.error.slice(0, 80)] }), _jsx(Text, { color: C.dim, children: "Close the panel (\u00D7) and open it again with /games." })] }));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export const GamePanel = memo(function GamePanel({ width, height, focused, moving, installed, best = {}, onScore, onLeave, onOpenSettings }) {
|
|
41
|
+
const FIELD = { width: Math.max(0, width - 4), height: Math.max(0, height - 6) };
|
|
42
|
+
const fits = (g) => FIELD.width >= g.minWidth && FIELD.height >= g.minHeight;
|
|
28
43
|
const games = installed.map(gameById).filter((g) => !!g);
|
|
29
44
|
const [row, setRow] = useState(0);
|
|
30
45
|
const [playing, setPlaying] = useState(null);
|
|
31
46
|
const [paused, setPaused] = useState(false);
|
|
32
47
|
const inputs = useRef([]);
|
|
33
|
-
|
|
34
|
-
const
|
|
48
|
+
// A game that doesn't fit the panel any more (it was made smaller) waits, saying how much room it needs.
|
|
49
|
+
const tooSmall = !!playing && !fits(playing.game);
|
|
50
|
+
const view = playing && !tooSmall ? playing.game.view(playing.state) : null;
|
|
51
|
+
const running = !!playing && !tooSmall && focused && !paused && !view?.over;
|
|
52
|
+
const tickMs = playing ? playing.game.speed?.(playing.state) ?? playing.game.tickMs : 100;
|
|
35
53
|
useEffect(() => {
|
|
36
54
|
if (!running)
|
|
37
55
|
return;
|
|
@@ -39,15 +57,25 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
|
|
|
39
57
|
const pressed = inputs.current;
|
|
40
58
|
inputs.current = [];
|
|
41
59
|
setPlaying((p) => (p ? { ...p, state: p.game.tick(p.state, pressed) } : p));
|
|
42
|
-
},
|
|
60
|
+
}, tickMs);
|
|
43
61
|
return () => clearInterval(timer);
|
|
44
|
-
}, [running, playing?.game]);
|
|
45
|
-
// A different size (moved to another pane, or the window changed) starts the game over at that size
|
|
62
|
+
}, [running, playing?.game, tickMs]);
|
|
63
|
+
// A different size (moved to another pane, or the window changed) starts the game over at that size,
|
|
64
|
+
// once it fits.
|
|
46
65
|
useEffect(() => {
|
|
47
|
-
if (playing)
|
|
66
|
+
if (playing && fits(playing.game))
|
|
48
67
|
setPlaying({ game: playing.game, state: playing.game.create(FIELD.width, FIELD.height) });
|
|
49
68
|
}, [FIELD.width, FIELD.height]);
|
|
69
|
+
// A finished game's score goes into the best scores.
|
|
70
|
+
const final = view?.over ? view.score ?? 0 : null;
|
|
71
|
+
const newBest = final !== null && playing !== null && final > (best[playing.game.id] ?? 0);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (newBest && playing)
|
|
74
|
+
onScore?.(playing.game.id, final);
|
|
75
|
+
}, [newBest]);
|
|
50
76
|
const start = (game) => {
|
|
77
|
+
if (!fits(game))
|
|
78
|
+
return;
|
|
51
79
|
inputs.current = [];
|
|
52
80
|
setPaused(false);
|
|
53
81
|
setPlaying({ game, state: game.create(FIELD.width, FIELD.height) });
|
|
@@ -75,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}
|
|
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)" : "", ":
|
|
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 };
|