nou-cli 0.5.1 → 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 +40 -6
- 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 +675 -112
- 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 +13 -6
- 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 +4 -1
- 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]);
|
|
@@ -329,6 +329,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
329
329
|
const col = bufferCol(buf.lines[row], Math.max(0, mouse.x - gutter) + v.left);
|
|
330
330
|
setHover(null);
|
|
331
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
|
+
}
|
|
332
337
|
setBuf((b) => {
|
|
333
338
|
if (mouse.kind === "press") {
|
|
334
339
|
dragFrom.current = { row, col };
|
|
@@ -571,9 +576,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
571
576
|
v.top = Math.max(0, Math.min(v.top, maxTop));
|
|
572
577
|
const visible = buf.lines.slice(v.top, v.top + rows);
|
|
573
578
|
const colored = useMemo(
|
|
574
|
-
// Scrolled sideways: every line shifts by the same amount
|
|
575
|
-
|
|
576
|
-
|
|
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]);
|
|
577
584
|
const { icon, color } = fileIcon(filePath);
|
|
578
585
|
const byLine = useMemo(() => {
|
|
579
586
|
const map = new Map();
|
|
@@ -594,7 +601,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
594
601
|
const statusColor = confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
|
|
595
602
|
const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
|
|
596
603
|
const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
|
|
597
|
-
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) => {
|
|
598
605
|
const n = v.top + i;
|
|
599
606
|
const isCursorLine = n === buf.row;
|
|
600
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
|
});
|