nou-cli 0.5.0 → 0.5.2
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 +42 -8
- package/dist/index.js +9 -99
- package/dist/src/agent/index.js +169 -33
- package/dist/src/cli.js +115 -0
- package/dist/src/commands/index.js +233 -25
- package/dist/src/config/index.js +14 -0
- package/dist/src/context/index.js +33 -10
- package/dist/src/games/index.js +217 -0
- package/dist/src/mcp/import.js +106 -0
- package/dist/src/mcp/index.js +184 -0
- package/dist/src/providers/anthropic.js +33 -2
- package/dist/src/providers/index.js +13 -0
- package/dist/src/providers/openai.js +6 -2
- package/dist/src/syntax/index.js +10 -20
- package/dist/src/syntax/themes.js +57 -0
- package/dist/src/tools/index.js +86 -2
- package/dist/src/ui/App.js +693 -119
- package/dist/src/ui/Chat.js +84 -21
- package/dist/src/ui/ChatInput.js +4 -2
- package/dist/src/ui/CodeView.js +110 -19
- package/dist/src/ui/Editor.js +33 -10
- package/dist/src/ui/FileTree.js +1 -1
- package/dist/src/ui/GamePanel.js +92 -0
- package/dist/src/ui/Header.js +1 -1
- package/dist/src/ui/QuickOpen.js +51 -12
- package/dist/src/ui/Settings.js +58 -13
- package/dist/src/ui/Tabs.js +55 -0
- package/dist/src/ui/clipboard.js +9 -7
- package/dist/src/ui/commands.js +33 -0
- package/dist/src/ui/definition.js +135 -0
- package/dist/src/ui/fast-width.js +62 -0
- package/dist/src/ui/mouse.js +9 -4
- package/dist/src/ui/text.js +116 -12
- package/dist/src/ui/width-shim.js +7 -0
- package/package.json +2 -1
package/dist/src/ui/Chat.js
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
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 { Box, Text, useAnimation, useInput } from "ink";
|
|
4
|
-
import {
|
|
4
|
+
import { statSync } from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
|
|
5
7
|
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
6
8
|
import { C, SPINNER } from "./theme.js";
|
|
7
9
|
import { Pane } from "./Pane.js";
|
|
10
|
+
import { matchCommands } from "./commands.js";
|
|
8
11
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
9
12
|
const BAR = "▌ ";
|
|
10
|
-
|
|
13
|
+
// Whether a path in a message is a file in the project, remembered so each one is checked once.
|
|
14
|
+
const knownFiles = new Map();
|
|
15
|
+
function isProjectFile(file) {
|
|
16
|
+
let known = knownFiles.get(file);
|
|
17
|
+
if (known === undefined) {
|
|
18
|
+
const full = path.resolve(file);
|
|
19
|
+
const rel = path.relative(process.cwd(), full);
|
|
20
|
+
try {
|
|
21
|
+
known = !rel.startsWith("..") && !path.isAbsolute(rel) && statSync(full).isFile();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
known = false;
|
|
25
|
+
}
|
|
26
|
+
if (knownFiles.size > 5000)
|
|
27
|
+
knownFiles.clear();
|
|
28
|
+
// Only files that exist are remembered: one Nou is about to create becomes a link once it's there.
|
|
29
|
+
if (known)
|
|
30
|
+
knownFiles.set(file, known);
|
|
31
|
+
}
|
|
32
|
+
return known;
|
|
33
|
+
}
|
|
11
34
|
/** Display lines for one message, wrapped to `inner` columns. */
|
|
12
|
-
function entryLines(entry, inner) {
|
|
35
|
+
function entryLines(entry, inner, links = true) {
|
|
13
36
|
const out = [];
|
|
14
37
|
if (entry.kind === "user") {
|
|
15
38
|
out.push({ text: "you", color: C.cyan, bold: true, lead: BAR, leadColor: C.cyan });
|
|
@@ -19,7 +42,7 @@ function entryLines(entry, inner) {
|
|
|
19
42
|
}
|
|
20
43
|
else if (entry.kind === "assistant") {
|
|
21
44
|
out.push({ text: "◆ nou", color: C.accent2, bold: true, lead: BAR, leadColor: C.accent2 });
|
|
22
|
-
for (const line of renderMarkdown(entry.text, inner - 3)) {
|
|
45
|
+
for (const line of renderMarkdown(entry.text, inner - 3, links ? isProjectFile : undefined)) {
|
|
23
46
|
const color = line.kind === "heading" ? C.accent2 : line.kind === "quote" ? C.dim : undefined;
|
|
24
47
|
out.push({ text: "", spans: line.spans, ...(color ? { color } : {}), lead: BAR, leadColor: C.accent2 });
|
|
25
48
|
}
|
|
@@ -29,7 +52,10 @@ function entryLines(entry, inner) {
|
|
|
29
52
|
const style = entry.kind === "tool"
|
|
30
53
|
? { icon: "⚙", color: C.dim }
|
|
31
54
|
: entry.kind === "error" ? { icon: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
|
|
32
|
-
wrap(entry.text, inner - 4).forEach((line, i) =>
|
|
55
|
+
wrap(entry.text, inner - 4).forEach((line, i) => {
|
|
56
|
+
const spans = links && entry.kind !== "user" ? linkFileRefs([{ text: ` ${line}` }], isProjectFile) : [];
|
|
57
|
+
out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
|
|
58
|
+
});
|
|
33
59
|
}
|
|
34
60
|
return out;
|
|
35
61
|
}
|
|
@@ -44,7 +70,7 @@ function cachedLines(entry, inner) {
|
|
|
44
70
|
laidOut.set(entry, { inner, lines });
|
|
45
71
|
return lines;
|
|
46
72
|
}
|
|
47
|
-
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, onSubmit, onAnswer }) {
|
|
73
|
+
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, queued = [] }) {
|
|
48
74
|
const [input, setInput] = useState("");
|
|
49
75
|
useEffect(() => {
|
|
50
76
|
if (prefill)
|
|
@@ -54,7 +80,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
54
80
|
const { frame } = useAnimation({ interval: 80, isActive: !!busy });
|
|
55
81
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
56
82
|
const history = useMemo(() => entries.flatMap((e) => cachedLines(e, inner)), [entries, inner]);
|
|
57
|
-
const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: streaming }, inner)] : history), [history, streaming, inner]);
|
|
83
|
+
const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: streaming }, inner, false)] : history), [history, streaming, inner]);
|
|
58
84
|
// At the bottom, new output scrolls into view. Scrolled up to read, the view stays where it is
|
|
59
85
|
// (counted from the bottom, so it moves up by however many lines arrived) until you scroll back down.
|
|
60
86
|
const lastCount = useRef(lines.length);
|
|
@@ -84,7 +110,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
84
110
|
footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
|
|
85
111
|
}
|
|
86
112
|
else if (approval?.kind === "tool") {
|
|
87
|
-
footer.push({ text: `🔌 Plugin tool: ${approval.name}`, color: C.yellow, bold: true });
|
|
113
|
+
footer.push({ text: `🔌 ${approval.name.startsWith("mcp__") ? `MCP tool ${approval.name.split("__")[1]} → ${approval.name.split("__").slice(2).join("__")}` : `Plugin tool: ${approval.name}`}`, color: C.yellow, bold: true });
|
|
88
114
|
for (const line of wrap(JSON.stringify(approval.args), inner - 2).slice(0, 4))
|
|
89
115
|
footer.push({ text: ` ${line}`, color: C.text });
|
|
90
116
|
footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
|
|
@@ -101,11 +127,23 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
101
127
|
else if (busy) {
|
|
102
128
|
footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
|
|
103
129
|
}
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
130
|
+
for (const text of queued) {
|
|
131
|
+
footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim });
|
|
132
|
+
}
|
|
133
|
+
// Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
|
|
134
|
+
const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
|
|
135
|
+
const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
|
|
136
|
+
const [menuRow, setMenuRow] = useState(0);
|
|
137
|
+
useEffect(() => setMenuRow(0), [input]);
|
|
138
|
+
const showMenu = matches.length > 0;
|
|
139
|
+
useEffect(() => onMenu?.(showMenu), [showMenu]);
|
|
140
|
+
const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
|
|
141
|
+
const menuStart = Math.max(0, Math.min(menuRow - menuRows + 1, matches.length - menuRows));
|
|
142
|
+
const picked = matches[menuRow];
|
|
143
|
+
/** Puts the picked command in the input, with a space when it takes arguments. */
|
|
144
|
+
const complete = (c) => setInput(`/${c.name}${c.args ? " " : ""}`);
|
|
107
145
|
const inputRows = inputHeight(input, inner + 1);
|
|
108
|
-
const rows = Math.max(1, height - 3 - footer.length - 2 - inputRows);
|
|
146
|
+
const rows = Math.max(1, height - 3 - footer.length - 2 - inputRows - (showMenu ? menuRows - 1 : 0));
|
|
109
147
|
const sent = useMemo(() => entries.filter((e) => e.kind === "user").map((e) => e.text), [entries]);
|
|
110
148
|
const maxBack = Math.max(0, lines.length - rows);
|
|
111
149
|
const back = Math.min(scrollBack, maxBack);
|
|
@@ -119,6 +157,15 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
119
157
|
if (scrollTo)
|
|
120
158
|
setScrollBack(maxBack - topFor(scrollTo.fraction, lines.length, rows));
|
|
121
159
|
}, [scrollTo?.nonce]);
|
|
160
|
+
// Clicking a file named in a message opens it in the code view.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!click)
|
|
163
|
+
return;
|
|
164
|
+
const line = visible[click.row];
|
|
165
|
+
const ref = line?.spans ? linkAt(line.spans, click.col) : null;
|
|
166
|
+
if (ref)
|
|
167
|
+
onOpenRef?.(ref);
|
|
168
|
+
}, [click?.nonce]);
|
|
122
169
|
// While something waits for approval, y/n into an empty input answer it; any other key starts a message.
|
|
123
170
|
useInput((typed) => {
|
|
124
171
|
if (/^[yYnN]$/.test(typed))
|
|
@@ -136,16 +183,31 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
136
183
|
else if (key.ctrl && key.end)
|
|
137
184
|
setScrollBack(0);
|
|
138
185
|
}, { isActive: focused });
|
|
139
|
-
|
|
140
|
-
if (
|
|
186
|
+
useInput((_input, key) => {
|
|
187
|
+
if (key.upArrow && !key.shift)
|
|
188
|
+
setMenuRow((r) => (r - 1 + matches.length) % matches.length);
|
|
189
|
+
else if (key.downArrow && !key.shift)
|
|
190
|
+
setMenuRow((r) => (r + 1) % matches.length);
|
|
191
|
+
else if (key.tab && picked)
|
|
192
|
+
complete(picked);
|
|
193
|
+
else if (key.escape)
|
|
194
|
+
setInput("");
|
|
195
|
+
}, { isActive: focused && showMenu });
|
|
196
|
+
const submit = (value, now = false) => {
|
|
197
|
+
if (!value.trim() || approval)
|
|
141
198
|
return;
|
|
142
|
-
|
|
143
|
-
|
|
199
|
+
let text = value.trim();
|
|
200
|
+
if (showMenu && picked) {
|
|
201
|
+
// Enter runs the highlighted command; one that needs an argument is completed instead, to type it.
|
|
202
|
+
if (picked.args?.startsWith("<"))
|
|
203
|
+
return complete(picked);
|
|
204
|
+
text = `/${picked.name}`;
|
|
205
|
+
}
|
|
144
206
|
setInput("");
|
|
145
|
-
onSubmit(
|
|
207
|
+
onSubmit(text, now);
|
|
146
208
|
};
|
|
147
209
|
const render = (line, key, withLead = true) => (_jsxs(Text, { wrap: "truncate-end", children: [withLead && line.lead ? _jsx(Text, { color: line.leadColor ?? C.dim, children: line.lead }) : null, line.spans?.length
|
|
148
|
-
? line.spans.map((span, i) => (_jsx(Text, { bold: !!span.bold || !!line.bold, italic: !!span.italic, dimColor: !!span.dim, ...(span.code ? { color: C.yellow } : line.color ? { color: line.color } : { color: C.text }), children: span.text }, i)))
|
|
210
|
+
? 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)))
|
|
149
211
|
: _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
|
|
150
212
|
const empty = entries.length === 0 && !streaming;
|
|
151
213
|
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
|
|
@@ -153,7 +215,8 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
153
215
|
? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
|
154
216
|
: _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
|
155
217
|
: maxBack > 0 ? _jsx(Text, { color: C.dim, children: " PgUp / wheel to scroll" }) : null] }));
|
|
156
|
-
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) => render(line, firstShown + i, false)) })] })) }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), _jsx(
|
|
157
|
-
|
|
158
|
-
:
|
|
218
|
+
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) => render(line, firstShown + i, false)) })] })) }), _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) => {
|
|
219
|
+
const selected = menuStart + i === menuRow;
|
|
220
|
+
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));
|
|
221
|
+
}) })) : (_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" })] }));
|
|
159
222
|
});
|
package/dist/src/ui/ChatInput.js
CHANGED
|
@@ -35,7 +35,7 @@ function cursorLine(segments, cursor) {
|
|
|
35
35
|
}
|
|
36
36
|
return segments.length - 1;
|
|
37
37
|
}
|
|
38
|
-
export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6 }) {
|
|
38
|
+
export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6, arrowsTaken = false }) {
|
|
39
39
|
const [cursor, setCursor] = useState(value.length);
|
|
40
40
|
const [recall, setRecall] = useState(null);
|
|
41
41
|
const stash = useRef("");
|
|
@@ -75,9 +75,11 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
|
|
|
75
75
|
useInput((input, key) => {
|
|
76
76
|
if (key.tab || key.escape || key.pageUp || key.pageDown || (key.shift && (key.upArrow || key.downArrow)))
|
|
77
77
|
return;
|
|
78
|
+
if (arrowsTaken && (key.upArrow || key.downArrow))
|
|
79
|
+
return;
|
|
78
80
|
if (key.return) {
|
|
79
81
|
setRecall(null);
|
|
80
|
-
onSubmit(value);
|
|
82
|
+
onSubmit(value, key.meta);
|
|
81
83
|
return;
|
|
82
84
|
}
|
|
83
85
|
if (key.backspace)
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { memo, useEffect, useMemo, useState } from "react";
|
|
2
|
+
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, sideBySide, splitLines } from "./text.js";
|
|
6
|
+
import { highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines } 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";
|
|
10
|
+
/** The current match: a yellow background; the others are shown inverted. */
|
|
11
|
+
const CURRENT_MATCH = ["\x1b[43m\x1b[30m", "\x1b[49m\x1b[39m"];
|
|
10
12
|
/** Columns ←/→ and Shift+wheel move sideways. */
|
|
11
13
|
const SIDE_STEP = 8;
|
|
12
|
-
|
|
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 }) {
|
|
13
16
|
const [top, setTop] = useState(0);
|
|
14
|
-
// Columns scrolled sideways
|
|
17
|
+
// Columns scrolled sideways (colors are kept: see sliceAnsi).
|
|
15
18
|
const [left, setLeft] = useState(0);
|
|
16
|
-
|
|
19
|
+
// Ctrl+F: what's being looked for, and which match is current.
|
|
20
|
+
const [find, setFind] = useState(null);
|
|
21
|
+
useEffect(() => onFinding?.(!!find), [!!find]);
|
|
22
|
+
useEffect(() => setFind(null), [filePath]);
|
|
23
|
+
// Only a new request opens it: not the old one seen again when the view comes back (after the editor, settings...).
|
|
24
|
+
const lastFindRequest = useRef(findRequest);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (findRequest && findRequest !== lastFindRequest.current)
|
|
27
|
+
setFind({ query: "", index: 0 });
|
|
28
|
+
lastFindRequest.current = findRequest;
|
|
29
|
+
}, [findRequest]);
|
|
30
|
+
const rows = Math.max(1, height - 3 - (find ? 1 : 0));
|
|
17
31
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
18
32
|
const lines = useMemo(() => (filePath ? highlightLines(filePath, content) : []), [filePath, content, syntaxVersion]);
|
|
19
|
-
const plain = useMemo(() => (left > 0 ? splitLines(content) : []), [content, left > 0]);
|
|
20
33
|
const problemsByLine = useMemo(() => {
|
|
21
34
|
const map = new Map();
|
|
22
35
|
for (const d of problems)
|
|
@@ -39,6 +52,31 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
39
52
|
}, [focusLine]);
|
|
40
53
|
const maxTop = Math.max(0, total - rows);
|
|
41
54
|
const start = Math.min(top, maxTop);
|
|
55
|
+
const shownLines = useMemo(() => (find ? splitLines(content) : []), [content, !!find]);
|
|
56
|
+
const matches = useMemo(() => {
|
|
57
|
+
const out = [];
|
|
58
|
+
const q = find?.query.toLowerCase();
|
|
59
|
+
if (!q)
|
|
60
|
+
return out;
|
|
61
|
+
shownLines.forEach((text, i) => {
|
|
62
|
+
const lower = text.toLowerCase();
|
|
63
|
+
for (let at = lower.indexOf(q); at >= 0 && out.length < 10_000; at = lower.indexOf(q, at + q.length))
|
|
64
|
+
out.push({ line: i, col: at });
|
|
65
|
+
});
|
|
66
|
+
return out;
|
|
67
|
+
}, [shownLines, find?.query]);
|
|
68
|
+
const current = find && matches.length ? matches[((find.index % matches.length) + matches.length) % matches.length] : null;
|
|
69
|
+
// Bring the current match into view (a third of the way down, like a jump), sideways too.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!current)
|
|
72
|
+
return;
|
|
73
|
+
if (current.line < start || current.line >= start + rows)
|
|
74
|
+
setTop(Math.max(0, current.line - Math.floor(rows / 3)));
|
|
75
|
+
const room = inner - String(lines.length).length - 2;
|
|
76
|
+
if (current.col < left || current.col + (find?.query.length ?? 0) > left + room)
|
|
77
|
+
setLeft(Math.max(0, current.col - Math.floor(room / 3)));
|
|
78
|
+
}, [current?.line, current?.col]);
|
|
79
|
+
const found = focusLine?.path && focusLine.path === filePath ? { from: focusLine.line, to: Math.max(focusLine.line, focusLine.to ?? focusLine.line) } : null;
|
|
42
80
|
useEffect(() => {
|
|
43
81
|
if (!wheel)
|
|
44
82
|
return;
|
|
@@ -55,7 +93,42 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
55
93
|
if (filePath && !diff)
|
|
56
94
|
onViewport(start + 1, Math.min(total, start + rows));
|
|
57
95
|
}, [filePath, diff, start, rows, total]);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!defineAt || diff || !filePath)
|
|
98
|
+
return;
|
|
99
|
+
const n = start + defineAt.row + 1;
|
|
100
|
+
const raw = content.replace(/\r\n/g, "\n").split("\n")[n - 1];
|
|
101
|
+
if (raw === undefined)
|
|
102
|
+
return;
|
|
103
|
+
// 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;
|
|
106
|
+
let col = 0;
|
|
107
|
+
while (col < raw.length && shown + (raw[col] === "\t" ? 2 : 1) <= want)
|
|
108
|
+
shown += raw[col++] === "\t" ? 2 : 1;
|
|
109
|
+
onDefine?.(n, col + 1);
|
|
110
|
+
}, [defineAt?.nonce]);
|
|
58
111
|
useInput((input, key) => {
|
|
112
|
+
if (!find)
|
|
113
|
+
return;
|
|
114
|
+
if (key.escape)
|
|
115
|
+
return setFind(null);
|
|
116
|
+
if (key.return || key.downArrow || (key.ctrl && input === "g"))
|
|
117
|
+
return setFind((f) => f && { ...f, index: f.index + 1 });
|
|
118
|
+
if (key.upArrow)
|
|
119
|
+
return setFind((f) => f && { ...f, index: f.index - 1 });
|
|
120
|
+
if (key.backspace || key.delete)
|
|
121
|
+
return setFind((f) => f && { query: f.query.slice(0, -1), index: 0 });
|
|
122
|
+
if (key.ctrl && input === "f")
|
|
123
|
+
return;
|
|
124
|
+
if (input && !key.ctrl && !key.meta && input >= " ")
|
|
125
|
+
setFind((f) => f && { query: f.query + input, index: 0 });
|
|
126
|
+
}, { isActive: focused && !!find });
|
|
127
|
+
useInput((input, key) => {
|
|
128
|
+
if (find)
|
|
129
|
+
return;
|
|
130
|
+
if (key.ctrl && input === "f" && !diff)
|
|
131
|
+
return setFind({ query: "", index: 0 });
|
|
59
132
|
if (key.upArrow || input === "k")
|
|
60
133
|
setTop((t) => Math.max(0, Math.min(t, maxTop) - 1));
|
|
61
134
|
else if (key.downArrow || input === "j")
|
|
@@ -99,11 +172,14 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
99
172
|
}
|
|
100
173
|
if (!filePath) {
|
|
101
174
|
const tips = [
|
|
102
|
-
["
|
|
103
|
-
["
|
|
104
|
-
["
|
|
175
|
+
["?", "everything Nou can do, searchable"],
|
|
176
|
+
["Ctrl+P", "find a file (recent ones first)"],
|
|
177
|
+
["/", "commands, with a menu"],
|
|
178
|
+
["Tab", "switch panes (or click one)"],
|
|
179
|
+
["e", "edit the open file (or double-click it)"],
|
|
105
180
|
["v", "diff vs git HEAD"],
|
|
106
|
-
["
|
|
181
|
+
["drag", "a pane's border to resize it"],
|
|
182
|
+
["s", "settings: models, keys, plugins"],
|
|
107
183
|
];
|
|
108
184
|
return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! \uD83C\uDF1E Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
|
|
109
185
|
}
|
|
@@ -111,13 +187,28 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
111
187
|
const { icon, color } = fileIcon(filePath);
|
|
112
188
|
const errorCount = problems.filter((d) => d.severity === "error").length;
|
|
113
189
|
const warningCount = problems.filter((d) => d.severity === "warning").length;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
190
|
+
// The edit hint only when it fits: a cut-off hint reads as noise.
|
|
191
|
+
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
|
+
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;
|
|
195
|
+
const isFound = !!found && n >= found.from && n <= found.to;
|
|
196
|
+
const isMarked = marked.has(n);
|
|
197
|
+
const here = problemsByLine.get(n);
|
|
198
|
+
const worst = here?.[0];
|
|
199
|
+
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 ? "▎" : " " }), (() => {
|
|
201
|
+
let text = line;
|
|
202
|
+
if (find?.query) {
|
|
203
|
+
for (const m of matches) {
|
|
204
|
+
if (m.line !== n - 1)
|
|
205
|
+
continue;
|
|
206
|
+
const now = m === current;
|
|
207
|
+
text = markAnsi(text, m.col, m.col + find.query.length, ...(now ? CURRENT_MATCH : []));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
text = (left ? sliceAnsi(text, left) : text) || " ";
|
|
211
|
+
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));
|
|
213
|
+
}) }), _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] }));
|
|
123
214
|
});
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
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";
|
|
5
|
-
import { highlightLines } from "./text.js";
|
|
5
|
+
import { highlightLines, sliceAnsi } from "./text.js";
|
|
6
6
|
import { C, fileIcon } from "./theme.js";
|
|
7
7
|
import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
@@ -88,7 +88,7 @@ const LINE_COMMENTS = {
|
|
|
88
88
|
};
|
|
89
89
|
const commentFor = (file) => LINE_COMMENTS[path.extname(file).toLowerCase()] ?? (path.basename(file).toLowerCase() === "dockerfile" ? "#" : null);
|
|
90
90
|
/** 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 }) {
|
|
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 }) {
|
|
92
92
|
const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
|
|
93
93
|
const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
|
|
94
94
|
const comment = useMemo(() => commentFor(filePath), [filePath]);
|
|
@@ -309,10 +309,17 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
309
309
|
view.current.top = topFor(scrollTo.fraction, buf.lines.length, rows);
|
|
310
310
|
rerender((n) => n + 1);
|
|
311
311
|
}, [scrollTo?.nonce]);
|
|
312
|
-
// Click to put the cursor there; drag to select.
|
|
312
|
+
// Click to put the cursor there; double-click to select a word; drag to select.
|
|
313
|
+
const lastMouse = useRef(mouse?.[mouse.length - 1]?.seq ?? 0); // events from before the editor opened are done
|
|
313
314
|
useEffect(() => {
|
|
314
|
-
|
|
315
|
-
|
|
315
|
+
for (const event of mouse ?? []) {
|
|
316
|
+
if (event.seq <= lastMouse.current)
|
|
317
|
+
continue;
|
|
318
|
+
lastMouse.current = event.seq;
|
|
319
|
+
handleMouse(event);
|
|
320
|
+
}
|
|
321
|
+
}, [mouse]);
|
|
322
|
+
const handleMouse = (mouse) => {
|
|
316
323
|
if (mouse.kind === "release") {
|
|
317
324
|
dragFrom.current = null;
|
|
318
325
|
return;
|
|
@@ -322,17 +329,31 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
322
329
|
const col = bufferCol(buf.lines[row], Math.max(0, mouse.x - gutter) + v.left);
|
|
323
330
|
setHover(null);
|
|
324
331
|
setPrompt((p) => (p?.kind === "line" ? null : p));
|
|
332
|
+
if (mouse.kind === "define") {
|
|
333
|
+
dragFrom.current = null;
|
|
334
|
+
setBuf((b) => ({ ...b, row, col, anchor: null }));
|
|
335
|
+
return onDefinition(buf.lines.join("\n"), row + 1, col + 1);
|
|
336
|
+
}
|
|
325
337
|
setBuf((b) => {
|
|
326
338
|
if (mouse.kind === "press") {
|
|
327
339
|
dragFrom.current = { row, col };
|
|
328
340
|
return { ...b, row, col, anchor: null };
|
|
329
341
|
}
|
|
342
|
+
if (mouse.kind === "double") {
|
|
343
|
+
// Double-click selects the word under the pointer.
|
|
344
|
+
const line = b.lines[row];
|
|
345
|
+
const at = isWord(line[col]) ? col : isWord(line[col - 1]) ? col - 1 : -1;
|
|
346
|
+
dragFrom.current = null;
|
|
347
|
+
if (at < 0)
|
|
348
|
+
return { ...b, row, col, anchor: null };
|
|
349
|
+
return { ...b, row, col: wordRight(line, at), anchor: { row, col: wordLeft(line, at + 1) } };
|
|
350
|
+
}
|
|
330
351
|
return { ...b, row, col, anchor: dragFrom.current ?? b.anchor ?? { row: b.row, col: b.col } };
|
|
331
352
|
});
|
|
332
353
|
// Dragging past the top or bottom edge scrolls.
|
|
333
354
|
if (mouse.kind === "drag" && (mouse.y < 0 || mouse.y >= rows))
|
|
334
355
|
reveal.current = true;
|
|
335
|
-
}
|
|
356
|
+
};
|
|
336
357
|
usePaste((text) => {
|
|
337
358
|
if (prompt)
|
|
338
359
|
return setPrompt({ ...prompt, text: prompt.text + text.replace(/\s+/g, " ") });
|
|
@@ -555,9 +576,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
555
576
|
v.top = Math.max(0, Math.min(v.top, maxTop));
|
|
556
577
|
const visible = buf.lines.slice(v.top, v.top + rows);
|
|
557
578
|
const colored = useMemo(
|
|
558
|
-
// Scrolled sideways: every line shifts by the same amount
|
|
559
|
-
|
|
560
|
-
|
|
579
|
+
// Scrolled sideways: every line shifts by the same amount, colors and all.
|
|
580
|
+
() => {
|
|
581
|
+
const lines = highlightLines(filePath, visible.join("\n"));
|
|
582
|
+
return v.left === 0 ? lines : lines.map((l) => sliceAnsi(l, v.left, textWidth));
|
|
583
|
+
}, [filePath, visible.join("\n"), v.left, textWidth, syntaxVersion]);
|
|
561
584
|
const { icon, color } = fileIcon(filePath);
|
|
562
585
|
const byLine = useMemo(() => {
|
|
563
586
|
const map = new Map();
|
|
@@ -578,7 +601,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
578
601
|
const statusColor = confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
|
|
579
602
|
const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
|
|
580
603
|
const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
|
|
581
|
-
return (_jsxs(Pane, { title: _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) => {
|
|
604
|
+
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) => {
|
|
582
605
|
const n = v.top + i;
|
|
583
606
|
const isCursorLine = n === buf.row;
|
|
584
607
|
const problem = byLine.get(n + 1)?.[0];
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -170,7 +170,7 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
|
|
|
170
170
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
171
171
|
const title = changesOnly
|
|
172
172
|
? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
|
|
173
|
-
: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent, children: "\u25A4" }), " Explorer", git.size ?
|
|
173
|
+
: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent, children: "\u25A4" }), " Explorer", git.size ? _jsx(Text, { color: C.dim, children: inner >= 30 ? ` c: changes (${git.size})` : ` ±${git.size}` }) : null] });
|
|
174
174
|
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "row", height: rows, children: [_jsxs(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: [visible.length === 0 && _jsxs(Text, { color: C.dim, children: [" ", changesOnly ? "No changes ✓" : "(empty)"] }), visible.map((node, i) => {
|
|
175
175
|
const selected = start + i === cursor;
|
|
176
176
|
const code = git.get(node.path);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { GAMES, gameById } from "../games/index.js";
|
|
5
|
+
import { C } from "./theme.js";
|
|
6
|
+
/** The panel's size on screen, borders included. */
|
|
7
|
+
export const GAME_PANEL = { width: 40, height: 19 };
|
|
8
|
+
const FIELD = { width: GAME_PANEL.width - 4, height: GAME_PANEL.height - 6 };
|
|
9
|
+
const KEYS = { w: "up", s: "down", a: "left", d: "right", k: "up", j: "down", h: "left", l: "right" };
|
|
10
|
+
/** A row of the playfield, colored per character in runs so it stays a handful of Text nodes. */
|
|
11
|
+
function Row({ line, colors }) {
|
|
12
|
+
const runs = [];
|
|
13
|
+
for (const ch of line) {
|
|
14
|
+
const color = colors[ch];
|
|
15
|
+
const last = runs[runs.length - 1];
|
|
16
|
+
if (last && last.color === color)
|
|
17
|
+
last.text += ch;
|
|
18
|
+
else
|
|
19
|
+
runs.push({ text: ch, color });
|
|
20
|
+
}
|
|
21
|
+
return _jsx(Text, { children: runs.map((r, i) => _jsx(Text, { ...(r.color ? { color: r.color } : {}), children: r.text }, i)) });
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A floating panel for little games. The app places it (it can be dragged by its title bar) and gives it
|
|
25
|
+
* the keyboard while it's focused; a game only runs while it has the keyboard, so it pauses as you leave.
|
|
26
|
+
*/
|
|
27
|
+
export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onLeave, onOpenSettings }) {
|
|
28
|
+
const games = installed.map(gameById).filter((g) => !!g);
|
|
29
|
+
const [row, setRow] = useState(0);
|
|
30
|
+
const [playing, setPlaying] = useState(null);
|
|
31
|
+
const [paused, setPaused] = useState(false);
|
|
32
|
+
const inputs = useRef([]);
|
|
33
|
+
const view = playing ? playing.game.view(playing.state) : null;
|
|
34
|
+
const running = !!playing && focused && !paused && !view?.over;
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!running)
|
|
37
|
+
return;
|
|
38
|
+
const timer = setInterval(() => {
|
|
39
|
+
const pressed = inputs.current;
|
|
40
|
+
inputs.current = [];
|
|
41
|
+
setPlaying((p) => (p ? { ...p, state: p.game.tick(p.state, pressed) } : p));
|
|
42
|
+
}, playing.game.tickMs);
|
|
43
|
+
return () => clearInterval(timer);
|
|
44
|
+
}, [running, playing?.game]);
|
|
45
|
+
const start = (game) => {
|
|
46
|
+
inputs.current = [];
|
|
47
|
+
setPaused(false);
|
|
48
|
+
setPlaying({ game, state: game.create(FIELD.width, FIELD.height) });
|
|
49
|
+
};
|
|
50
|
+
useInput((input, key) => {
|
|
51
|
+
if (key.escape)
|
|
52
|
+
return onLeave();
|
|
53
|
+
if (!playing) {
|
|
54
|
+
if (key.upArrow || input === "k")
|
|
55
|
+
setRow((r) => Math.max(0, r - 1));
|
|
56
|
+
else if (key.downArrow || input === "j")
|
|
57
|
+
setRow((r) => Math.min(games.length - 1, r + 1));
|
|
58
|
+
else if ((key.return || input === " ") && games[row])
|
|
59
|
+
start(games[row]);
|
|
60
|
+
else if (input === "s" && !games.length)
|
|
61
|
+
onOpenSettings();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (input === "q")
|
|
65
|
+
return setPlaying(null);
|
|
66
|
+
if (view?.over) {
|
|
67
|
+
if (key.return || input === " " || input === "r")
|
|
68
|
+
start(playing.game);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (input === " " || input === "p")
|
|
72
|
+
return setPaused((p) => !p);
|
|
73
|
+
const pressed = key.upArrow ? "up" : key.downArrow ? "down" : key.leftArrow ? "left" : key.rightArrow ? "right" : KEYS[input];
|
|
74
|
+
if (pressed)
|
|
75
|
+
inputs.current.push(pressed);
|
|
76
|
+
}, { isActive: focused });
|
|
77
|
+
const color = focused ? C.accent2 : C.dim;
|
|
78
|
+
const inner = GAME_PANEL.width - 2;
|
|
79
|
+
const pad = (s) => (s.length >= inner ? s.slice(0, inner) : s + " ".repeat(inner - s.length));
|
|
80
|
+
const title = playing ? ` ✦ ${playing.game.title}` : " ✦ Games";
|
|
81
|
+
const hint = !focused ? "click to play · drag the title to move" : playing ? (view?.over ? "Enter again · q games · Esc leave" : `${paused ? "PAUSED · " : ""}Space pause · q games · Esc leave`) : "↑↓ choose · Enter play · Esc leave";
|
|
82
|
+
return (_jsxs(Box, { position: "absolute", top: y, left: x, width: GAME_PANEL.width, height: GAME_PANEL.height, flexDirection: "column", borderStyle: "round", borderColor: color, children: [_jsx(Text, { backgroundColor: C.panel, color: color, bold: true, wrap: "truncate-end", children: pad(`${title} ⠿ drag`) }), playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
|
|
83
|
+
// The end-of-game message goes over the middle of the field.
|
|
84
|
+
if (view.over && i === Math.floor(FIELD.height / 2)) {
|
|
85
|
+
const msg = ` ${view.over} `;
|
|
86
|
+
const left = Math.max(0, Math.floor((FIELD.width - msg.length) / 2));
|
|
87
|
+
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);
|
|
88
|
+
}
|
|
89
|
+
return _jsx(Row, { line: line, colors: playing.game.colors }, i);
|
|
90
|
+
}), _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] })] }));
|
|
91
|
+
});
|
|
92
|
+
export { GAMES };
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -8,5 +8,5 @@ const VERSION = `v${currentVersion()}`;
|
|
|
8
8
|
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
|
|
9
9
|
const { frame } = useAnimation({ interval: 120, isActive: !!busy });
|
|
10
10
|
const project = path.basename(process.cwd());
|
|
11
|
-
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }), _jsxs(Text, { children: [update && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] })] }));
|
|
11
|
+
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, flexGrow: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [update && width >= 110 && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] }) })] }));
|
|
12
12
|
});
|