nou-cli 0.5.2 → 0.5.4
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 +15 -7
- package/dist/src/agent/index.js +33 -3
- package/dist/src/cli.js +3 -1
- package/dist/src/commands/index.js +1 -1
- package/dist/src/config/index.js +3 -1
- package/dist/src/conversations/external.js +165 -0
- package/dist/src/conversations/index.js +71 -0
- package/dist/src/engines/claude.js +10 -0
- package/dist/src/engines/codex.js +60 -5
- package/dist/src/guide/index.js +100 -0
- package/dist/src/providers/anthropic.js +3 -14
- package/dist/src/syntax/index.js +17 -17
- package/dist/src/terminal/index.js +127 -0
- package/dist/src/ui/App.js +582 -69
- package/dist/src/ui/Chat.js +153 -14
- package/dist/src/ui/ChatInput.js +5 -0
- package/dist/src/ui/CodeView.js +1 -1
- package/dist/src/ui/Editor.js +44 -7
- package/dist/src/ui/FileTree.js +5 -4
- package/dist/src/ui/GamePanel.js +18 -11
- package/dist/src/ui/Header.js +2 -2
- package/dist/src/ui/ProblemsView.js +2 -2
- package/dist/src/ui/Settings.js +2 -2
- package/dist/src/ui/Tabs.js +6 -5
- package/dist/src/ui/Terminal.js +87 -0
- package/dist/src/ui/clipboard.js +19 -0
- package/dist/src/ui/commands.js +20 -0
- package/dist/src/ui/render-options.js +4 -0
- package/dist/src/ui/text.js +47 -3
- package/dist/src/ui/theme.js +2 -0
- package/package.json +1 -1
package/dist/src/ui/Chat.js
CHANGED
|
@@ -3,12 +3,23 @@ import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
4
|
import { statSync } from "fs";
|
|
5
5
|
import path from "path";
|
|
6
|
-
import { linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
|
|
6
|
+
import { closeOpenMarkdown, linkAt, linkFileRefs, linkUrls, renderMarkdown, wrap } from "./text.js";
|
|
7
7
|
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
8
|
-
import {
|
|
8
|
+
import { copyToClipboard } from "./clipboard.js";
|
|
9
|
+
import { C, SPINNER, ANIMATION_MS } from "./theme.js";
|
|
9
10
|
import { Pane } from "./Pane.js";
|
|
10
11
|
import { matchCommands } from "./commands.js";
|
|
11
12
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
13
|
+
/**
|
|
14
|
+
* What the agent did between replies (files read, commands run...) folds into one line, like Claude's
|
|
15
|
+
* "▸ 5 steps": click it (or Ctrl+O for all of them) to see each step. `open` holds the runs shown open.
|
|
16
|
+
*/
|
|
17
|
+
function stepLines(steps, open, live, inner) {
|
|
18
|
+
const last = steps[steps.length - 1].text.replace(/\s+/g, " ");
|
|
19
|
+
const summary = `${open ? "▾" : "▸"} ${steps.length} steps${open ? "" : ` · ${live ? "now: " : "last: "}${last}`}`;
|
|
20
|
+
const head = { text: ` ${summary.length > inner - 4 ? `${summary.slice(0, inner - 5)}…` : summary}`, color: open ? C.accent2 : C.dim, lead: " ⚙", leadColor: C.dim, toggle: steps[0] };
|
|
21
|
+
return open ? [head, ...steps.flatMap((e) => cachedLines(e, inner).map((l) => (l.lead === " ⚙" ? { ...l, lead: " " } : l)))] : [head];
|
|
22
|
+
}
|
|
12
23
|
const BAR = "▌ ";
|
|
13
24
|
// Whether a path in a message is a file in the project, remembered so each one is checked once.
|
|
14
25
|
const knownFiles = new Map();
|
|
@@ -53,7 +64,7 @@ function entryLines(entry, inner, links = true) {
|
|
|
53
64
|
? { icon: "⚙", color: C.dim }
|
|
54
65
|
: entry.kind === "error" ? { icon: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
|
|
55
66
|
wrap(entry.text, inner - 4).forEach((line, i) => {
|
|
56
|
-
const spans = links && entry.kind !== "user" ? linkFileRefs([{ text: ` ${line}` }], isProjectFile) : [];
|
|
67
|
+
const spans = links && entry.kind !== "user" ? linkFileRefs(linkUrls([{ text: ` ${line}` }]), isProjectFile) : [];
|
|
57
68
|
out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
|
|
58
69
|
});
|
|
59
70
|
}
|
|
@@ -70,17 +81,68 @@ function cachedLines(entry, inner) {
|
|
|
70
81
|
laidOut.set(entry, { inner, lines });
|
|
71
82
|
return lines;
|
|
72
83
|
}
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Where each tab sits in the chat's title row (columns from the pane's inner left edge), and the "+"
|
|
86
|
+
* that opens a new one: shared by the drawing and the clicks.
|
|
87
|
+
*/
|
|
88
|
+
export function chatTabZones(tabs) {
|
|
89
|
+
// One tab: just "Chat +", to leave the title row its room.
|
|
90
|
+
if (tabs.length === 1)
|
|
91
|
+
return { zones: [{ id: tabs[0].id, from: 3, to: 7 }], plus: 8, width: 9 };
|
|
92
|
+
let x = 3; // " ◆ "
|
|
93
|
+
const zones = tabs.map((t) => {
|
|
94
|
+
const w = tabText(t).length;
|
|
95
|
+
const zone = { id: t.id, from: x, to: x + w };
|
|
96
|
+
x += w + 1;
|
|
97
|
+
return zone;
|
|
98
|
+
});
|
|
99
|
+
return { zones, plus: x + 1, width: x + 3 };
|
|
100
|
+
}
|
|
101
|
+
const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
|
|
102
|
+
const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
|
|
103
|
+
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [] }) {
|
|
104
|
+
const [input, setInputText] = useState("");
|
|
105
|
+
// Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
|
|
106
|
+
// still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
|
|
107
|
+
const focusedNow = useRef(focused);
|
|
108
|
+
focusedNow.current = focused;
|
|
109
|
+
const [menuRow, setMenuRow] = useState(0);
|
|
110
|
+
/** A new input also puts the command menu back on its first row, in the same render. */
|
|
111
|
+
const setInput = (text) => {
|
|
112
|
+
setInputText(text);
|
|
113
|
+
setMenuRow(0);
|
|
114
|
+
};
|
|
75
115
|
useEffect(() => {
|
|
76
116
|
if (prefill)
|
|
77
117
|
setInput(prefill.text);
|
|
78
118
|
}, [prefill?.nonce]);
|
|
79
119
|
const [scrollBack, setScrollBack] = useState(0);
|
|
80
|
-
const { frame } = useAnimation({ interval:
|
|
120
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
|
|
81
121
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
82
|
-
const
|
|
83
|
-
const
|
|
122
|
+
const [openSteps, setOpenSteps] = useState(new Set());
|
|
123
|
+
const [allOpen, setAllOpen] = useState(false);
|
|
124
|
+
const history = useMemo(() => {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (let i = 0; i < entries.length;) {
|
|
127
|
+
if (entries[i].kind !== "tool") {
|
|
128
|
+
out.push(...cachedLines(entries[i], inner));
|
|
129
|
+
i++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// A run of steps: one alone is shown as is, two or more fold into a line.
|
|
133
|
+
let j = i;
|
|
134
|
+
while (j < entries.length && entries[j].kind === "tool")
|
|
135
|
+
j++;
|
|
136
|
+
const steps = entries.slice(i, j);
|
|
137
|
+
if (steps.length === 1)
|
|
138
|
+
out.push(...cachedLines(steps[0], inner));
|
|
139
|
+
else
|
|
140
|
+
out.push(...stepLines(steps, allOpen || openSteps.has(steps[0]), !!busy && j === entries.length, inner));
|
|
141
|
+
i = j;
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}, [entries, inner, openSteps, allOpen, !!busy]);
|
|
145
|
+
const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: closeOpenMarkdown(streaming) }, inner, false)] : history), [history, streaming, inner]);
|
|
84
146
|
// At the bottom, new output scrolls into view. Scrolled up to read, the view stays where it is
|
|
85
147
|
// (counted from the bottom, so it moves up by however many lines arrived) until you scroll back down.
|
|
86
148
|
const lastCount = useRef(lines.length);
|
|
@@ -133,8 +195,6 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
133
195
|
// Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
|
|
134
196
|
const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
|
|
135
197
|
const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
|
|
136
|
-
const [menuRow, setMenuRow] = useState(0);
|
|
137
|
-
useEffect(() => setMenuRow(0), [input]);
|
|
138
198
|
const showMenu = matches.length > 0;
|
|
139
199
|
useEffect(() => onMenu?.(showMenu), [showMenu]);
|
|
140
200
|
const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
|
|
@@ -162,10 +222,74 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
162
222
|
if (!click)
|
|
163
223
|
return;
|
|
164
224
|
const line = visible[click.row];
|
|
225
|
+
const run = line?.toggle;
|
|
226
|
+
if (run) {
|
|
227
|
+
setOpenSteps((open) => {
|
|
228
|
+
const next = new Set(open);
|
|
229
|
+
if (next.has(run) || allOpen)
|
|
230
|
+
next.delete(run);
|
|
231
|
+
else
|
|
232
|
+
next.add(run);
|
|
233
|
+
return next;
|
|
234
|
+
});
|
|
235
|
+
if (allOpen)
|
|
236
|
+
setAllOpen(false);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
165
239
|
const ref = line?.spans ? linkAt(line.spans, click.col) : null;
|
|
166
240
|
if (ref)
|
|
167
241
|
onOpenRef?.(ref);
|
|
168
242
|
}, [click?.nonce]);
|
|
243
|
+
// Drag over the messages to select them (in lines of the conversation, so it holds while it scrolls);
|
|
244
|
+
// letting go copies them. Dragging past the top or bottom scrolls.
|
|
245
|
+
const [selection, setSelection] = useState(null);
|
|
246
|
+
const [flash, setFlash] = useState(null);
|
|
247
|
+
const anchor = useRef(null);
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (!select)
|
|
250
|
+
return;
|
|
251
|
+
const at = { line: Math.max(0, Math.min(lines.length - 1, firstShown + select.row)), col: Math.max(0, select.col) };
|
|
252
|
+
if (select.kind === "press") {
|
|
253
|
+
anchor.current = at;
|
|
254
|
+
setSelection(null);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (!anchor.current)
|
|
258
|
+
return;
|
|
259
|
+
if (select.kind === "drag") {
|
|
260
|
+
if (select.row < 0)
|
|
261
|
+
setScrollBack((b) => Math.min(maxBack, b + 1));
|
|
262
|
+
else if (select.row >= rows)
|
|
263
|
+
setScrollBack((b) => Math.max(0, b - 1));
|
|
264
|
+
}
|
|
265
|
+
const moved = at.line !== anchor.current.line || at.col !== anchor.current.col;
|
|
266
|
+
const range = moved ? (at.line < anchor.current.line || (at.line === anchor.current.line && at.col < anchor.current.col) ? { from: at, to: anchor.current } : { from: anchor.current, to: at }) : null;
|
|
267
|
+
setSelection(range);
|
|
268
|
+
if (select.kind !== "release")
|
|
269
|
+
return;
|
|
270
|
+
anchor.current = null;
|
|
271
|
+
if (!range)
|
|
272
|
+
return;
|
|
273
|
+
const text = lines.slice(range.from.line, range.to.line + 1).map((line, i, all) => {
|
|
274
|
+
const t = lineText(line);
|
|
275
|
+
return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 ? range.to.col + 1 : undefined).trimEnd();
|
|
276
|
+
}).join("\n");
|
|
277
|
+
void copyToClipboard(text).then((ok) => {
|
|
278
|
+
setFlash(ok ? `Copied ${text.split("\n").length > 1 ? `${text.split("\n").length} lines` : "it"} ✓` : "Can't copy: press m, then select");
|
|
279
|
+
setTimeout(() => setFlash(null), 2000);
|
|
280
|
+
});
|
|
281
|
+
}, [select?.nonce]);
|
|
282
|
+
// Typing or new messages leave the selection be; a new conversation clears it.
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (!entries.length)
|
|
285
|
+
setSelection(null);
|
|
286
|
+
}, [entries.length]);
|
|
287
|
+
/** The selected columns of a line, if any. */
|
|
288
|
+
const selectedIn = (index) => {
|
|
289
|
+
if (!selection || index < selection.from.line || index > selection.to.line)
|
|
290
|
+
return null;
|
|
291
|
+
return [index === selection.from.line ? selection.from.col : 0, index === selection.to.line ? selection.to.col + 1 : Infinity];
|
|
292
|
+
};
|
|
169
293
|
// While something waits for approval, y/n into an empty input answer it; any other key starts a message.
|
|
170
294
|
useInput((typed) => {
|
|
171
295
|
if (/^[yYnN]$/.test(typed))
|
|
@@ -173,13 +297,19 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
173
297
|
else if (typed && typed >= " ")
|
|
174
298
|
setInput(typed); // printable, not a control key
|
|
175
299
|
}, { isActive: focused && !!approval && !input });
|
|
176
|
-
useInput((
|
|
300
|
+
useInput((input, key) => {
|
|
177
301
|
if (key.pageUp || (key.upArrow && key.shift))
|
|
178
302
|
setScrollBack((b) => Math.min(maxBack, Math.min(b, maxBack) + Math.max(1, rows - 2)));
|
|
179
303
|
else if (key.pageDown || (key.downArrow && key.shift))
|
|
180
304
|
setScrollBack((b) => Math.max(0, Math.min(b, maxBack) - Math.max(1, rows - 2)));
|
|
181
305
|
else if (key.ctrl && key.home)
|
|
182
306
|
setScrollBack(maxBack);
|
|
307
|
+
else if (key.ctrl && input === "o") {
|
|
308
|
+
// Open every run of steps, or fold them all (the ones opened by a click too).
|
|
309
|
+
setAllOpen(!allOpen);
|
|
310
|
+
if (allOpen)
|
|
311
|
+
setOpenSteps(new Set());
|
|
312
|
+
}
|
|
183
313
|
else if (key.ctrl && key.end)
|
|
184
314
|
setScrollBack(0);
|
|
185
315
|
}, { isActive: focused });
|
|
@@ -210,13 +340,22 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
210
340
|
? 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)))
|
|
211
341
|
: _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
|
|
212
342
|
const empty = entries.length === 0 && !streaming;
|
|
213
|
-
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
|
|
343
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " ", tabs && tabs.items.length > 1 ? (_jsxs(_Fragment, { children: [tabs.items.map((t) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.id === tabs.active ? { backgroundColor: C.panel, color: C.text, bold: true } : { color: t.state === "waiting" ? C.yellow : C.dim }), children: tabText(t) }), " "] }, t.id))), _jsx(Text, { color: C.accent2, bold: true, children: " + " })] })) : _jsxs(_Fragment, { children: ["Chat", tabs ? _jsx(Text, { color: C.accent2, bold: true, children: " +" }) : null] }), flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
|
|
214
344
|
? unread
|
|
215
345
|
? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
|
216
346
|
: _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
|
217
347
|
: maxBack > 0 ? _jsx(Text, { color: C.dim, children: " PgUp / wheel to scroll" }) : null] }));
|
|
218
|
-
|
|
348
|
+
// The messages, rebuilt only when they or the scroll position change: typing, the context chip and the
|
|
349
|
+
// spinner re-render the chat without touching its (often hundreds of) lines.
|
|
350
|
+
const messages = useMemo(() => (_jsx(_Fragment, { children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: visible.map((line, i) => _jsx(Text, { color: line.leadColor ?? C.dim, children: `${line.lead ?? " "}${i < visible.length - 1 ? "\n" : ""}` }, i)) }) }), _jsx(Box, { flexDirection: "column", width: inner - 2, flexShrink: 0, children: visible.map((line, i) => {
|
|
351
|
+
const range = selectedIn(firstShown + i);
|
|
352
|
+
if (!range)
|
|
353
|
+
return render(line, firstShown + i, false);
|
|
354
|
+
const t = lineText(line) || " ";
|
|
355
|
+
return (_jsxs(Text, { wrap: "truncate-end", color: C.text, children: [t.slice(0, range[0]), _jsx(Text, { inverse: true, children: t.slice(range[0], range[1]) || " " }), t.slice(range[1])] }, firstShown + i));
|
|
356
|
+
}) })] })) })), [lines, firstShown, visible.length, selection, inner, empty]);
|
|
357
|
+
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: messages }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), showMenu ? (_jsx(Box, { flexDirection: "column", children: matches.slice(menuStart, menuStart + menuRows).map((c, i) => {
|
|
219
358
|
const selected = menuStart + i === menuRow;
|
|
220
359
|
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" })] }));
|
|
360
|
+
}) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " })), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? "Enter queues · Alt+Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
|
|
222
361
|
});
|
package/dist/src/ui/ChatInput.js
CHANGED
|
@@ -78,6 +78,11 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
|
|
|
78
78
|
if (arrowsTaken && (key.upArrow || key.downArrow))
|
|
79
79
|
return;
|
|
80
80
|
if (key.return) {
|
|
81
|
+
// Ctrl/Cmd/Shift+Enter add a line (in terminals that report them); so does \ then Enter, everywhere.
|
|
82
|
+
if (key.ctrl || key.super || key.shift)
|
|
83
|
+
return insert("\n");
|
|
84
|
+
if (value[pos - 1] === "\\")
|
|
85
|
+
return set(`${value.slice(0, pos - 1)}\n${value.slice(pos)}`, pos);
|
|
81
86
|
setRecall(null);
|
|
82
87
|
onSubmit(value, key.meta);
|
|
83
88
|
return;
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -181,7 +181,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
181
181
|
["drag", "a pane's border to resize it"],
|
|
182
182
|
["s", "settings: models, keys, plugins"],
|
|
183
183
|
];
|
|
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)))] }) }));
|
|
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, flexShrink: 0, 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)))] }) }));
|
|
185
185
|
}
|
|
186
186
|
const gutter = String(lines.length).length;
|
|
187
187
|
const { icon, color } = fileIcon(filePath);
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { Box, Text, useInput, usePaste } from "ink";
|
|
@@ -8,6 +8,7 @@ import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
9
9
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
10
|
import { copyToClipboard, readClipboard } from "./clipboard.js";
|
|
11
|
+
import { guideProgress } from "../guide/index.js";
|
|
11
12
|
const TAB = " ";
|
|
12
13
|
const expand = (s) => s.replace(/\t/g, TAB);
|
|
13
14
|
/** Screen column of buffer column `col` on `line` (tabs take two cells). */
|
|
@@ -87,13 +88,24 @@ const LINE_COMMENTS = {
|
|
|
87
88
|
".toml": "#", ".r": "#", ".pl": "#", ".ex": "#", ".exs": "#", ".dockerfile": "#", ".sql": "--", ".lua": "--", ".hs": "--",
|
|
88
89
|
};
|
|
89
90
|
const commentFor = (file) => LINE_COMMENTS[path.extname(file).toLowerCase()] ?? (path.basename(file).toLowerCase() === "dockerfile" ? "#" : null);
|
|
91
|
+
/** A line without the cursor or a selection: kept as is while you type on other lines. */
|
|
92
|
+
const PlainRow = memo(function PlainRow({ num, numColor, text }) {
|
|
93
|
+
return _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: numColor, children: num }), text || " "] });
|
|
94
|
+
});
|
|
90
95
|
/** A text editor: arrows and the mouse to move, Shift to select, Ctrl+C/X/V clipboard, Ctrl+S save, Ctrl+Z undo, Esc leave. */
|
|
91
|
-
export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs }) {
|
|
96
|
+
export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs, guide, onGuideDone }) {
|
|
92
97
|
const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
|
|
93
98
|
const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
|
|
94
99
|
const comment = useMemo(() => commentFor(filePath), [filePath]);
|
|
95
100
|
const [buf, setBuf] = useState(() => {
|
|
96
101
|
const lines = initial.replace(/\r\n/g, "\n").split("\n");
|
|
102
|
+
if (guide) {
|
|
103
|
+
// A fresh line for the guided code, indented like its first line.
|
|
104
|
+
const row = Math.max(0, Math.min(lines.length, guide.row));
|
|
105
|
+
const indent = guide.code.match(/^[ \t]*/)[0];
|
|
106
|
+
lines.splice(row, 0, indent);
|
|
107
|
+
return { lines, row, col: indent.length, anchor: null };
|
|
108
|
+
}
|
|
97
109
|
const row = Math.max(0, Math.min(lines.length - 1, (startLine ?? 1) - 1));
|
|
98
110
|
const col = startCol ? Math.min(lines[row].length, startCol - 1) : lines[row].match(/^\s*/)[0].length;
|
|
99
111
|
return { lines, row, col, anchor: null };
|
|
@@ -517,6 +529,12 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
517
529
|
}
|
|
518
530
|
return insert(`\n${indent}${opens ? indentUnit : ""}`);
|
|
519
531
|
}
|
|
532
|
+
if (key.tab && !key.shift && !hasSelection && guide && ghost) {
|
|
533
|
+
// Guided: Tab types the rest of the line for you (and the next line's indentation).
|
|
534
|
+
const end = ghost.indexOf("\n");
|
|
535
|
+
const chunk = end < 0 ? ghost : ghost.slice(0, end + 1) + ghost.slice(end + 1).match(/^[ \t]*/)[0];
|
|
536
|
+
return insert(chunk);
|
|
537
|
+
}
|
|
520
538
|
if (key.tab) {
|
|
521
539
|
// With a selection, or Shift+Tab: indent or outdent whole lines.
|
|
522
540
|
if (key.shift)
|
|
@@ -589,16 +607,32 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
589
607
|
return map;
|
|
590
608
|
}, [problems]);
|
|
591
609
|
const sel = selection(buf);
|
|
610
|
+
// Guided typing: how much of the step's code is typed (from its line's start to the cursor).
|
|
611
|
+
const guideRow = guide ? Math.max(0, Math.min(buf.lines.length - 1, guide.row)) : -1;
|
|
612
|
+
const typed = guide && (buf.row > guideRow || (buf.row === guideRow)) ? [...buf.lines.slice(guideRow, buf.row), buf.lines[buf.row].slice(0, buf.col)].join("\n") : null;
|
|
613
|
+
const progress = guide && typed !== null ? guideProgress(typed, guide.code) : -1;
|
|
614
|
+
const ghost = guide && progress >= 0 ? guide.code.slice(progress) : "";
|
|
615
|
+
const guideDone = useRef(false);
|
|
616
|
+
useEffect(() => {
|
|
617
|
+
if (guide && progress === guide.code.length && !guideDone.current) {
|
|
618
|
+
guideDone.current = true;
|
|
619
|
+
onGuideDone?.();
|
|
620
|
+
}
|
|
621
|
+
}, [progress]);
|
|
592
622
|
const cursorProblem = byLine.get(buf.row + 1)?.[0];
|
|
593
623
|
const selectedCount = sel ? selectedText(buf).length : 0;
|
|
594
|
-
const
|
|
624
|
+
const guideStatus = !guide ? null
|
|
625
|
+
: progress < 0 ? "Not quite: that doesn't match the step's code. Backspace and try again (the dim text shows what goes here)."
|
|
626
|
+
: ghost ? `Type the dim text${ghost.includes("\n") ? ` (${ghost.split("\n").length} lines to go)` : ""} · Tab fills in the rest of the line · Esc stop`
|
|
627
|
+
: "✓ Step done";
|
|
628
|
+
const status = guideStatus && !confirmExit && !prompt ? guideStatus : confirmExit
|
|
595
629
|
? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
|
|
596
630
|
: prompt
|
|
597
631
|
? prompt.kind === "find" ? `Find: ${prompt.text}▏ Enter/↓ next · Shift+Enter/↑ previous · Esc close` : `Go to line: ${prompt.text}▏ Enter go · Esc cancel`
|
|
598
632
|
: flash ?? (cursorProblem
|
|
599
633
|
? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
|
|
600
634
|
: `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""} Ctrl+F find · Ctrl+/ comment · Esc done`);
|
|
601
|
-
const statusColor = confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
|
|
635
|
+
const statusColor = guideStatus && !confirmExit && !prompt ? (progress < 0 ? C.red : C.accent2) : confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
|
|
602
636
|
const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
|
|
603
637
|
const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
|
|
604
638
|
return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: "black", children: " \u270E EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
|
|
@@ -609,7 +643,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
609
643
|
const num = (_jsxs(Text, { color: mark?.color ?? (isCursorLine ? C.green : C.dim), children: [String(n + 1).padStart(gutter - 2), mark ? ` ${mark.icon}` : isCursorLine ? " ▸" : " "] }));
|
|
610
644
|
const inSelection = sel && n >= sel.start.row && n <= sel.end.row;
|
|
611
645
|
if (!isCursorLine && !inSelection) {
|
|
612
|
-
return
|
|
646
|
+
return _jsx(PlainRow, { num: `${String(n + 1).padStart(gutter - 2)}${mark ? ` ${mark.icon}` : " "}`, numColor: mark?.color ?? C.dim, text: colored[i] ?? "" }, n);
|
|
613
647
|
}
|
|
614
648
|
// Lines with the cursor or a selection show plain text, so both can be drawn exactly.
|
|
615
649
|
const shown = expand(line).slice(v.left, v.left + textWidth);
|
|
@@ -626,8 +660,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
626
660
|
else
|
|
627
661
|
cells.push(cell);
|
|
628
662
|
}
|
|
663
|
+
// The code still to type, dim, from the cursor to the end of its line (only past the line's text).
|
|
664
|
+
const ghostLine = isCursorLine && ghost && buf.col >= line.length ? ghost.split("\n")[0] : "";
|
|
665
|
+
const ghostMore = isCursorLine && ghost && buf.col >= line.length ? ghost.split("\n").length - 1 : 0;
|
|
629
666
|
return (_jsxs(Text, { wrap: "truncate-end", children: [num, cells.map((c, j) => c.cursor
|
|
630
|
-
? _jsx(Text, { inverse: true, color: C.green, children: c.text }, j)
|
|
631
|
-
: _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j))] }, n));
|
|
667
|
+
? _jsx(Text, { inverse: true, color: C.green, children: ghostLine ? ghostLine[0] : c.text }, j)
|
|
668
|
+
: _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j)), ghostLine.length > 1 ? _jsx(Text, { color: C.dim, italic: true, children: expand(ghostLine.slice(1)) }) : null, ghostMore ? _jsxs(Text, { color: C.accent2, children: [ghostLine ? " " : "", "\u23CE", ghostMore > 1 ? ` +${ghostMore - 1} line${ghostMore > 2 ? "s" : ""}` : ""] }) : null] }, n));
|
|
632
669
|
}) }), _jsx(Scrollbar, { total: buf.lines.length, top: v.top, rows: rows, focused: focused })] }), hoverLines.length > 0 && (_jsx(Box, { position: "absolute", top: Math.max(1, rows - hoverLines.length - 1), left: gutter + 1, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, width: Math.min(width - gutter - 3, 2 + Math.max(...hoverLines.map((l) => l.length))), children: hoverLines.map((l, i) => _jsx(Text, { color: C.text, wrap: "truncate-end", children: l.padEnd(width) }, i)) })), _jsx(Text, { wrap: "truncate-end", color: statusColor, children: ` ${status}` })] }));
|
|
633
670
|
});
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { readdirSync } from "fs";
|
|
4
4
|
import path from "path";
|
|
5
|
-
import { Box, Text, useInput } from "ink";
|
|
5
|
+
import { Box, Text, useAnimation, useInput } from "ink";
|
|
6
6
|
import { IGNORED } from "../commands/index.js";
|
|
7
|
-
import { C, GIT_COLORS, fileIcon } from "./theme.js";
|
|
7
|
+
import { C, GIT_COLORS, fileIcon, gradient, ANIMATION_MS } from "./theme.js";
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
9
9
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
10
|
function listDir(dir) {
|
|
@@ -31,7 +31,7 @@ function flatten(expanded) {
|
|
|
31
31
|
walk(".", 0);
|
|
32
32
|
return nodes;
|
|
33
33
|
}
|
|
34
|
-
export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
|
|
34
|
+
export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
|
|
35
35
|
const [expanded, setExpanded] = useState(() => new Set(["src"]));
|
|
36
36
|
const [cursor, setCursor] = useState(0);
|
|
37
37
|
// The first row shown. The wheel moves it on its own; the keyboard moves the cursor and the view follows.
|
|
@@ -167,6 +167,7 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
|
|
|
167
167
|
else
|
|
168
168
|
onOpen(node.path);
|
|
169
169
|
}, [click?.nonce]);
|
|
170
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!working?.size });
|
|
170
171
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
171
172
|
const title = changesOnly
|
|
172
173
|
? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
|
|
@@ -187,6 +188,6 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
|
|
|
187
188
|
const room = Math.max(1, inner - marker.length - 1);
|
|
188
189
|
const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
|
|
189
190
|
const iconAt = shown.indexOf(icon);
|
|
190
|
-
return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), _jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) }), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
|
|
191
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), working?.has(node.path) ? (_jsx(Text, { bold: true, inverse: selected && focused, children: gradient(shown.slice(iconAt + icon.length), frame).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) })) : (_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) })), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
|
|
191
192
|
})] }), _jsx(Scrollbar, { total: nodes.length, top: start, rows: rows, focused: focused })] }) }));
|
|
192
193
|
});
|
package/dist/src/ui/GamePanel.js
CHANGED
|
@@ -3,9 +3,8 @@ import { memo, useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { GAMES, gameById } from "../games/index.js";
|
|
5
5
|
import { C } from "./theme.js";
|
|
6
|
-
/** The panel's
|
|
7
|
-
export const GAME_PANEL = {
|
|
8
|
-
const FIELD = { width: GAME_PANEL.width - 4, height: GAME_PANEL.height - 6 };
|
|
6
|
+
/** The panel's tallest height (borders included) when docked under a pane, and the least it needs. */
|
|
7
|
+
export const GAME_PANEL = { height: 19, minWidth: 26, minHeight: 10 };
|
|
9
8
|
const KEYS = { w: "up", s: "down", a: "left", d: "right", k: "up", j: "down", h: "left", l: "right" };
|
|
10
9
|
/** A row of the playfield, colored per character in runs so it stays a handful of Text nodes. */
|
|
11
10
|
function Row({ line, colors }) {
|
|
@@ -21,10 +20,11 @@ function Row({ line, colors }) {
|
|
|
21
20
|
return _jsx(Text, { children: runs.map((r, i) => _jsx(Text, { ...(r.color ? { color: r.color } : {}), children: r.text }, i)) });
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
|
-
* A
|
|
23
|
+
* A panel for little games, docked under a pane (drag its title to another pane; × closes it). The app gives it
|
|
25
24
|
* the keyboard while it's focused; a game only runs while it has the keyboard, so it pauses as you leave.
|
|
26
25
|
*/
|
|
27
|
-
export const GamePanel = memo(function GamePanel({
|
|
26
|
+
export const GamePanel = memo(function GamePanel({ width, height, focused, moving, installed, onLeave, onOpenSettings }) {
|
|
27
|
+
const FIELD = { width: Math.max(10, width - 4), height: Math.max(3, height - 6) };
|
|
28
28
|
const games = installed.map(gameById).filter((g) => !!g);
|
|
29
29
|
const [row, setRow] = useState(0);
|
|
30
30
|
const [playing, setPlaying] = useState(null);
|
|
@@ -42,6 +42,11 @@ export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onL
|
|
|
42
42
|
}, playing.game.tickMs);
|
|
43
43
|
return () => clearInterval(timer);
|
|
44
44
|
}, [running, playing?.game]);
|
|
45
|
+
// A different size (moved to another pane, or the window changed) starts the game over at that size.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (playing)
|
|
48
|
+
setPlaying({ game: playing.game, state: playing.game.create(FIELD.width, FIELD.height) });
|
|
49
|
+
}, [FIELD.width, FIELD.height]);
|
|
45
50
|
const start = (game) => {
|
|
46
51
|
inputs.current = [];
|
|
47
52
|
setPaused(false);
|
|
@@ -74,12 +79,14 @@ export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onL
|
|
|
74
79
|
if (pressed)
|
|
75
80
|
inputs.current.push(pressed);
|
|
76
81
|
}, { isActive: focused });
|
|
77
|
-
const color = focused ? C.accent2 : C.dim;
|
|
78
|
-
const inner =
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
82
|
+
const color = focused || moving ? C.accent2 : C.dim;
|
|
83
|
+
const inner = width - 2;
|
|
84
|
+
const title = moving ? " ✦ Drop it on a pane: files, code or chat" : `${playing ? ` ✦ ${playing.game.title}` : " ✦ Games"} ⠿ drag to move · top edge resizes`;
|
|
85
|
+
// The title row: the name and the drag handle, and × (close) at the right.
|
|
86
|
+
const head = title.length > inner - 3 ? `${title.slice(0, inner - 4)}…` : title.padEnd(inner - 3);
|
|
87
|
+
const hint = !focused ? "click to play" : playing ? (view?.over ? "Enter again · q games · Esc leave" : `${paused ? "PAUSED · " : ""}Space pause · q games · Esc leave`) : "↑↓ choose · Enter play · Esc leave";
|
|
88
|
+
const small = width < GAME_PANEL.minWidth || height < GAME_PANEL.minHeight;
|
|
89
|
+
return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: "round", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { backgroundColor: C.panel, wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: head }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), small ? (_jsx(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: _jsx(Text, { color: C.dim, wrap: "wrap", children: "Too small here: drag me to a bigger pane, or make this one wider." }) })) : null, small ? null : playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
|
|
83
90
|
// The end-of-game message goes over the middle of the field.
|
|
84
91
|
if (view.over && i === Math.floor(FIELD.height / 2)) {
|
|
85
92
|
const msg = ` ${view.over} `;
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -2,11 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { memo } from "react";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { Box, Text, useAnimation } from "ink";
|
|
5
|
-
import { C, SPINNER, gradient } from "./theme.js";
|
|
5
|
+
import { C, SPINNER, gradient, ANIMATION_MS } from "./theme.js";
|
|
6
6
|
import { currentVersion } from "../update/index.js";
|
|
7
7
|
const VERSION = `v${currentVersion()}`;
|
|
8
8
|
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
|
|
9
|
-
const { frame } = useAnimation({ interval:
|
|
9
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
|
|
10
10
|
const project = path.basename(process.cwd());
|
|
11
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
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
|
-
import { C, SPINNER, fileIcon } from "./theme.js";
|
|
4
|
+
import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
|
|
5
5
|
import { Pane } from "./Pane.js";
|
|
6
6
|
export const SEVERITY_STYLE = {
|
|
7
7
|
error: { icon: "✖", color: C.red },
|
|
@@ -11,7 +11,7 @@ export const SEVERITY_STYLE = {
|
|
|
11
11
|
export function ProblemsView({ width, height, focused, problems, checking, wheel, onJump, onClose }) {
|
|
12
12
|
const [cursor, setCursor] = useState(0);
|
|
13
13
|
const [onlyErrors, setOnlyErrors] = useState(false);
|
|
14
|
-
const { frame } = useAnimation({ interval:
|
|
14
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: checking.length > 0 });
|
|
15
15
|
const rows = useMemo(() => {
|
|
16
16
|
const list = onlyErrors ? problems.filter((d) => d.severity === "error") : problems;
|
|
17
17
|
const out = [];
|
package/dist/src/ui/Settings.js
CHANGED
|
@@ -11,7 +11,7 @@ import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
|
|
|
11
11
|
import { currentVersion } from "../update/index.js";
|
|
12
12
|
import { describe, providers, rejectedKey } from "../providers/index.js";
|
|
13
13
|
import { ENGINES, isInstalled } from "../engines/index.js";
|
|
14
|
-
import { C, SPINNER } from "./theme.js";
|
|
14
|
+
import { C, SPINNER, ANIMATION_MS } from "./theme.js";
|
|
15
15
|
/** A few lines of code to show what a code theme looks like. */
|
|
16
16
|
const THEME_PREVIEW = `// Greets someone, politely
|
|
17
17
|
export function greet(name: string, times = 2): string {
|
|
@@ -40,7 +40,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
40
40
|
const [models, setModels] = useState({});
|
|
41
41
|
const [connecting, setConnecting] = useState(connectTo?.connect ? { step: 0, values: {} } : null);
|
|
42
42
|
const [field, setField] = useState(() => (connectTo?.connect ? initialValue(connectTo.connect[0]) : ""));
|
|
43
|
-
const { frame } = useAnimation({ interval:
|
|
43
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS });
|
|
44
44
|
const provider = providers[row] ?? providers[0];
|
|
45
45
|
const listed = models[provider.id];
|
|
46
46
|
// Load the model list for the highlighted provider.
|
package/dist/src/ui/Tabs.js
CHANGED
|
@@ -16,8 +16,8 @@ function labels(tabs) {
|
|
|
16
16
|
* are left out at the ends, marked with ‹ ›.
|
|
17
17
|
*/
|
|
18
18
|
export function layoutTabs(tabs, active, width) {
|
|
19
|
-
const all = labels(tabs).map((label, i) => ({ file: tabs[i], label }));
|
|
20
|
-
const size = (t) => t.label.length +
|
|
19
|
+
const all = labels(tabs).map((label, i) => ({ file: tabs[i], label: label.length > 24 ? `${label.slice(0, 23)}…` : label }));
|
|
20
|
+
const size = (t) => t.label.length + 6 + 1; // " ◆ name × " and "│"
|
|
21
21
|
const at = Math.max(0, all.findIndex((t) => t.file === active));
|
|
22
22
|
let from = at;
|
|
23
23
|
let to = at + 1;
|
|
@@ -39,10 +39,11 @@ export function layoutTabs(tabs, active, width) {
|
|
|
39
39
|
let x = from > 0 ? 1 : 0;
|
|
40
40
|
for (const t of shown) {
|
|
41
41
|
const w = size(t) - 1;
|
|
42
|
-
zones.push({ file: t.file, from: x, to: x + w, close:
|
|
42
|
+
zones.push({ file: t.file, from: x, to: x + w, close: x + w - 2 });
|
|
43
43
|
x += w + 1;
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
// ‹ and › (tabs that don't fit) are clickable too: they go to the previous / next tab.
|
|
46
|
+
return { shown, before: from > 0, after: to < all.length, zones, prev: from > 0 ? 0 : null, next: to < all.length ? x : null };
|
|
46
47
|
}
|
|
47
48
|
/** The open files as tabs, for a pane's title row: the active one highlighted, with × to close it. */
|
|
48
49
|
export function Tabs({ tabs, active, width, dirty }) {
|
|
@@ -50,6 +51,6 @@ export function Tabs({ tabs, active, width, dirty }) {
|
|
|
50
51
|
return (_jsxs(_Fragment, { children: [before ? _jsx(Text, { color: C.dim, children: "\u2039" }) : null, shown.map((t) => {
|
|
51
52
|
const on = t.file === active;
|
|
52
53
|
const { icon, color } = fileIcon(t.file);
|
|
53
|
-
return (_jsxs(Fragment, { children: [_jsxs(Text, { ...(on ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { children: " " }), _jsx(Text, { color: color, children: icon }), _jsxs(Text, { color: on ? C.text : C.dim, bold: on, children: [" ", t.label] }),
|
|
54
|
+
return (_jsxs(Fragment, { children: [_jsxs(Text, { ...(on ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { children: " " }), _jsx(Text, { color: color, children: icon }), _jsxs(Text, { color: on ? C.text : C.dim, bold: on, children: [" ", t.label] }), _jsxs(Text, { color: on && dirty ? C.yellow : on ? C.text : C.dim, children: [" ", on && dirty ? "●" : "×"] }), _jsx(Text, { children: " " })] }), _jsx(Text, { color: C.dim, children: "\u2502" })] }, t.file));
|
|
54
55
|
}), after ? _jsx(Text, { color: C.dim, children: "\u203A" }) : null] }));
|
|
55
56
|
}
|