nou-cli 0.5.4 → 0.5.10
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 +18 -7
- package/dist/src/agent/index.js +1 -0
- package/dist/src/cli.js +69 -0
- package/dist/src/config/index.js +20 -0
- package/dist/src/conversations/external.js +35 -13
- package/dist/src/diagnostics/providers.js +23 -2
- package/dist/src/engines/codex.js +28 -1
- package/dist/src/games/index.js +220 -30
- package/dist/src/guide/index.js +75 -0
- package/dist/src/ui/App.js +281 -36
- package/dist/src/ui/Chat.js +63 -11
- package/dist/src/ui/CodeView.js +71 -20
- package/dist/src/ui/Editor.js +1 -1
- package/dist/src/ui/FileTree.js +3 -1
- package/dist/src/ui/GamePanel.js +42 -14
- package/dist/src/ui/Header.js +32 -4
- package/dist/src/ui/Pane.js +2 -2
- package/dist/src/ui/ProblemsView.js +3 -1
- package/dist/src/ui/QuickOpen.js +8 -3
- package/dist/src/ui/Settings.js +21 -7
- package/dist/src/ui/Terminal.js +7 -1
- package/dist/src/ui/background.js +58 -0
- package/dist/src/ui/commands.js +49 -7
- package/dist/src/ui/focus.js +59 -0
- package/dist/src/ui/redraw.js +16 -0
- package/dist/src/ui/render-options.js +3 -0
- package/dist/src/ui/text.js +45 -0
- package/dist/src/ui/theme.js +59 -13
- package/dist/src/workspaces/client.js +69 -0
- package/dist/src/workspaces/host.js +144 -0
- package/package.json +2 -1
package/dist/src/ui/Chat.js
CHANGED
|
@@ -7,6 +7,7 @@ import { closeOpenMarkdown, linkAt, linkFileRefs, linkUrls, renderMarkdown, wrap
|
|
|
7
7
|
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
8
8
|
import { copyToClipboard } from "./clipboard.js";
|
|
9
9
|
import { C, SPINNER, ANIMATION_MS } from "./theme.js";
|
|
10
|
+
import { useTerminalFocused } from "./focus.js";
|
|
10
11
|
import { Pane } from "./Pane.js";
|
|
11
12
|
import { matchCommands } from "./commands.js";
|
|
12
13
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
@@ -99,8 +100,10 @@ export function chatTabZones(tabs) {
|
|
|
99
100
|
return { zones, plus: x + 1, width: x + 3 };
|
|
100
101
|
}
|
|
101
102
|
const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
|
|
103
|
+
/** Characters of a word for a double-click: paths and web addresses come whole (src/a.ts, https://x.io/y). */
|
|
104
|
+
const WORD_CHAR = /[\p{L}\p{N}_.\-/@:~#%?=&+]/u;
|
|
102
105
|
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 = [] }) {
|
|
106
|
+
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 = [], sendNowFirst = false, onSendNow }) {
|
|
104
107
|
const [input, setInputText] = useState("");
|
|
105
108
|
// Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
|
|
106
109
|
// still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
|
|
@@ -117,7 +120,9 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
117
120
|
setInput(prefill.text);
|
|
118
121
|
}, [prefill?.nonce]);
|
|
119
122
|
const [scrollBack, setScrollBack] = useState(0);
|
|
120
|
-
|
|
123
|
+
// Spinners stop while Nou's window is in the background: nobody sees them, and each frame is a redraw.
|
|
124
|
+
const windowFocused = useTerminalFocused();
|
|
125
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
|
|
121
126
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
122
127
|
const [openSteps, setOpenSteps] = useState(new Set());
|
|
123
128
|
const [allOpen, setAllOpen] = useState(false);
|
|
@@ -189,9 +194,11 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
189
194
|
else if (busy) {
|
|
190
195
|
footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
|
|
191
196
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
197
|
+
// Click what's queued (or /now) to stop Nou and send it right away.
|
|
198
|
+
for (const text of queued)
|
|
199
|
+
footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim, sendNow: true });
|
|
200
|
+
if (queued.length && busy)
|
|
201
|
+
footer.push({ text: " ↳ click to send now, or /now", color: C.accent2, sendNow: true });
|
|
195
202
|
// Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
|
|
196
203
|
const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
|
|
197
204
|
const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
|
|
@@ -221,6 +228,12 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
221
228
|
useEffect(() => {
|
|
222
229
|
if (!click)
|
|
223
230
|
return;
|
|
231
|
+
if (click.row >= rows) {
|
|
232
|
+
// Under the messages: a click on what's queued sends it now.
|
|
233
|
+
if (footer[click.row - rows]?.sendNow)
|
|
234
|
+
onSendNow?.();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
224
237
|
const line = visible[click.row];
|
|
225
238
|
const run = line?.toggle;
|
|
226
239
|
if (run) {
|
|
@@ -249,6 +262,40 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
249
262
|
if (!select)
|
|
250
263
|
return;
|
|
251
264
|
const at = { line: Math.max(0, Math.min(lines.length - 1, firstShown + select.row)), col: Math.max(0, select.col) };
|
|
265
|
+
if (select.kind === "word" || select.kind === "block") {
|
|
266
|
+
anchor.current = null; // the button coming up doesn't change it
|
|
267
|
+
let range;
|
|
268
|
+
if (select.kind === "word") {
|
|
269
|
+
const chars = [...lineText(lines[at.line] ?? { text: "" })];
|
|
270
|
+
if (!WORD_CHAR.test(chars[at.col] ?? ""))
|
|
271
|
+
return;
|
|
272
|
+
let start = at.col;
|
|
273
|
+
let end = at.col;
|
|
274
|
+
while (start > 0 && WORD_CHAR.test(chars[start - 1]))
|
|
275
|
+
start--;
|
|
276
|
+
while (end < chars.length - 1 && WORD_CHAR.test(chars[end + 1]))
|
|
277
|
+
end++;
|
|
278
|
+
// Punctuation that ends a sentence isn't part of the word.
|
|
279
|
+
while (end > start && /[.,:;?!]/.test(chars[end]))
|
|
280
|
+
end--;
|
|
281
|
+
range = { from: { line: at.line, col: start }, to: { line: at.line, col: end } };
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
const blank = (i) => !lineText(lines[i] ?? { text: "" }).trim();
|
|
285
|
+
if (blank(at.line))
|
|
286
|
+
return;
|
|
287
|
+
let first = at.line;
|
|
288
|
+
let last = at.line;
|
|
289
|
+
while (first > 0 && !blank(first - 1))
|
|
290
|
+
first--;
|
|
291
|
+
while (last < lines.length - 1 && !blank(last + 1))
|
|
292
|
+
last++;
|
|
293
|
+
range = { from: { line: first, col: 0 }, to: { line: last, col: Infinity } };
|
|
294
|
+
}
|
|
295
|
+
setSelection(range);
|
|
296
|
+
copySelection(range);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
252
299
|
if (select.kind === "press") {
|
|
253
300
|
anchor.current = at;
|
|
254
301
|
setSelection(null);
|
|
@@ -268,17 +315,22 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
268
315
|
if (select.kind !== "release")
|
|
269
316
|
return;
|
|
270
317
|
anchor.current = null;
|
|
271
|
-
if (
|
|
272
|
-
|
|
318
|
+
if (range)
|
|
319
|
+
copySelection(range);
|
|
320
|
+
}, [select?.nonce]);
|
|
321
|
+
/** Copies the selected text (and says so for a moment). Columns count characters, as the screen shows them. */
|
|
322
|
+
const copySelection = (range) => {
|
|
273
323
|
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();
|
|
324
|
+
const t = [...lineText(line)];
|
|
325
|
+
return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 && Number.isFinite(range.to.col) ? range.to.col + 1 : undefined).join("").trimEnd();
|
|
276
326
|
}).join("\n");
|
|
327
|
+
if (!text)
|
|
328
|
+
return;
|
|
277
329
|
void copyToClipboard(text).then((ok) => {
|
|
278
330
|
setFlash(ok ? `Copied ${text.split("\n").length > 1 ? `${text.split("\n").length} lines` : "it"} ✓` : "Can't copy: press m, then select");
|
|
279
331
|
setTimeout(() => setFlash(null), 2000);
|
|
280
332
|
});
|
|
281
|
-
}
|
|
333
|
+
};
|
|
282
334
|
// Typing or new messages leave the selection be; a new conversation clears it.
|
|
283
335
|
useEffect(() => {
|
|
284
336
|
if (!entries.length)
|
|
@@ -357,5 +409,5 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
357
409
|
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) => {
|
|
358
410
|
const selected = menuStart + i === menuRow;
|
|
359
411
|
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));
|
|
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" })] }));
|
|
412
|
+
}) })) : (_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 ? (sendNowFirst ? "Enter sends now · Alt+Enter queues · Esc stops" : "Enter queues · Alt+Enter sends now · Esc stops") : sendNowFirst ? "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" })] }));
|
|
361
413
|
});
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -3,7 +3,7 @@ import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
5
5
|
import { artWork } from "../util/index.js";
|
|
6
|
-
import { highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines } from "./text.js";
|
|
6
|
+
import { foldRanges, highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines, unfoldedLines } from "./text.js";
|
|
7
7
|
import { C, fileIcon, gradient } from "./theme.js";
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
9
9
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
@@ -12,7 +12,7 @@ const CURRENT_MATCH = ["\x1b[43m\x1b[30m", "\x1b[49m\x1b[39m"];
|
|
|
12
12
|
/** Columns ←/→ and Shift+wheel move sideways. */
|
|
13
13
|
const SIDE_STEP = 8;
|
|
14
14
|
const EDIT_HINT = " double-click or e to edit · v diff";
|
|
15
|
-
export const CodeView = memo(function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest }) {
|
|
15
|
+
export const CodeView = memo(function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
|
|
16
16
|
const [top, setTop] = useState(0);
|
|
17
17
|
// Columns scrolled sideways (colors are kept: see sliceAnsi).
|
|
18
18
|
const [left, setLeft] = useState(0);
|
|
@@ -37,18 +37,39 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
37
37
|
return map;
|
|
38
38
|
}, [problems]);
|
|
39
39
|
const diffRows = useMemo(() => (diff ? sideBySide(diff.before, diff.after) : []), [diff]);
|
|
40
|
-
|
|
40
|
+
// Folding: regions from indentation, the ones folded, and the file lines left showing (rows map to these).
|
|
41
|
+
const ranges = useMemo(() => (filePath && !diff ? foldRanges(content) : new Map()), [filePath, content, !!diff]);
|
|
42
|
+
const [folded, setFolded] = useState(new Set());
|
|
43
|
+
useEffect(() => setFolded(new Set()), [filePath]);
|
|
44
|
+
const shown = useMemo(() => unfoldedLines(lines.length, ranges, folded), [lines.length, ranges, folded]);
|
|
45
|
+
/** The row a file line is on (a folded-away line: its fold's row). */
|
|
46
|
+
const rowOf = (line, list = shown) => {
|
|
47
|
+
let row = 0;
|
|
48
|
+
while (row + 1 < list.length && list[row + 1] <= line)
|
|
49
|
+
row++;
|
|
50
|
+
return row;
|
|
51
|
+
};
|
|
52
|
+
/** Opens any fold hiding `line`, and returns the rows with it open. */
|
|
53
|
+
const reveal = (line) => {
|
|
54
|
+
const hiding = [...folded].filter((s) => s < line && (ranges.get(s) ?? 0) >= line);
|
|
55
|
+
if (!hiding.length)
|
|
56
|
+
return shown;
|
|
57
|
+
const next = new Set([...folded].filter((s) => !hiding.includes(s)));
|
|
58
|
+
setFolded(next);
|
|
59
|
+
return unfoldedLines(lines.length, ranges, next);
|
|
60
|
+
};
|
|
61
|
+
const total = diff ? diffRows.length : shown.length;
|
|
41
62
|
// Jump to the first change whenever a new file or diff is shown.
|
|
42
63
|
useEffect(() => {
|
|
43
64
|
const first = diff
|
|
44
65
|
? diffRows.findIndex((r) => r.changed)
|
|
45
|
-
: marked.size ? Math.min(...marked)
|
|
66
|
+
: marked.size ? rowOf(Math.min(...marked)) : 0;
|
|
46
67
|
setTop(Math.max(0, first - 3));
|
|
47
68
|
setLeft(0);
|
|
48
69
|
}, [filePath, diff, diffRows, marked]);
|
|
49
70
|
useEffect(() => {
|
|
50
71
|
if (focusLine && !diff)
|
|
51
|
-
setTop(Math.max(0, focusLine.line - Math.floor(rows / 3)));
|
|
72
|
+
setTop(Math.max(0, rowOf(focusLine.line, reveal(focusLine.line)) + 1 - Math.floor(rows / 3)));
|
|
52
73
|
}, [focusLine]);
|
|
53
74
|
const maxTop = Math.max(0, total - rows);
|
|
54
75
|
const start = Math.min(top, maxTop);
|
|
@@ -70,8 +91,10 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
70
91
|
useEffect(() => {
|
|
71
92
|
if (!current)
|
|
72
93
|
return;
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
const list = reveal(current.line + 1);
|
|
95
|
+
const at = rowOf(current.line + 1, list);
|
|
96
|
+
if (at < start || at >= start + rows)
|
|
97
|
+
setTop(Math.max(0, at - Math.floor(rows / 3)));
|
|
75
98
|
const room = inner - String(lines.length).length - 2;
|
|
76
99
|
if (current.col < left || current.col + (find?.query.length ?? 0) > left + room)
|
|
77
100
|
setLeft(Math.max(0, current.col - Math.floor(room / 3)));
|
|
@@ -90,22 +113,45 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
90
113
|
setTop(topFor(scrollTo.fraction, total, rows));
|
|
91
114
|
}, [scrollTo?.nonce]);
|
|
92
115
|
useEffect(() => {
|
|
93
|
-
if (filePath && !diff)
|
|
94
|
-
|
|
95
|
-
|
|
116
|
+
if (filePath && !diff) {
|
|
117
|
+
const onScreen = shown.slice(start, start + rows);
|
|
118
|
+
onViewport(onScreen[0] ?? 1, onScreen[onScreen.length - 1] ?? 1, onScreen);
|
|
119
|
+
}
|
|
120
|
+
}, [filePath, diff, start, rows, total, shown]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!foldClick || diff)
|
|
123
|
+
return;
|
|
124
|
+
const n = shown[start + foldClick.row];
|
|
125
|
+
if (n === undefined || !ranges.has(n))
|
|
126
|
+
return;
|
|
127
|
+
setFolded((f) => {
|
|
128
|
+
const next = new Set(f);
|
|
129
|
+
if (next.has(n))
|
|
130
|
+
next.delete(n);
|
|
131
|
+
else
|
|
132
|
+
next.add(n);
|
|
133
|
+
return next;
|
|
134
|
+
});
|
|
135
|
+
}, [foldClick?.nonce]);
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (foldAll && !diff)
|
|
138
|
+
setFolded(foldAll.fold ? new Set(ranges.keys()) : new Set());
|
|
139
|
+
}, [foldAll?.nonce]);
|
|
96
140
|
useEffect(() => {
|
|
97
141
|
if (!defineAt || diff || !filePath)
|
|
98
142
|
return;
|
|
99
|
-
const n = start + defineAt.row
|
|
143
|
+
const n = shown[start + defineAt.row];
|
|
144
|
+
if (n === undefined)
|
|
145
|
+
return;
|
|
100
146
|
const raw = content.replace(/\r\n/g, "\n").split("\n")[n - 1];
|
|
101
147
|
if (raw === undefined)
|
|
102
148
|
return;
|
|
103
149
|
// Screen column to the file's own column: tabs are shown as two spaces.
|
|
104
|
-
const want = defineAt.x - (String(lines.length).length +
|
|
105
|
-
let
|
|
150
|
+
const want = defineAt.x - (String(lines.length).length + 2) + left;
|
|
151
|
+
let cells = 0;
|
|
106
152
|
let col = 0;
|
|
107
|
-
while (col < raw.length &&
|
|
108
|
-
|
|
153
|
+
while (col < raw.length && cells + (raw[col] === "\t" ? 2 : 1) <= want)
|
|
154
|
+
cells += raw[col++] === "\t" ? 2 : 1;
|
|
109
155
|
onDefine?.(n, col + 1);
|
|
110
156
|
}, [defineAt?.nonce]);
|
|
111
157
|
useInput((input, key) => {
|
|
@@ -141,6 +187,9 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
141
187
|
setTop(0);
|
|
142
188
|
else if (input === "G" || key.end)
|
|
143
189
|
setTop(maxTop);
|
|
190
|
+
// z: fold everything, or (when it already is) open everything.
|
|
191
|
+
else if (input === "z" && !diff && ranges.size)
|
|
192
|
+
setFolded([...ranges.keys()].every((k) => folded.has(k)) ? new Set() : new Set(ranges.keys()));
|
|
144
193
|
else if (key.leftArrow || input === "h")
|
|
145
194
|
setLeft((l) => Math.max(0, l - SIDE_STEP));
|
|
146
195
|
else if (key.rightArrow || input === "l")
|
|
@@ -167,7 +216,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
167
216
|
return (_jsx(Box, { width: w, children: _jsxs(Text, { wrap: "truncate-end", ...(color ? { color } : { color: C.dim }), children: [String(c.n).padStart(gutter - 1), changed ? side : " ", c.text.slice(left) || " "] }) }));
|
|
168
217
|
};
|
|
169
218
|
const { icon, color } = fileIcon(diff.path);
|
|
170
|
-
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color:
|
|
219
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: C.onBadge, children: pending ? (diff.isNew ? " NEW FILE " : " PROPOSED ") : ` ${diffLabel.toUpperCase()} ` }), " ", _jsxs(Text, { color: C.green, children: ["+", added] }), " ", _jsxs(Text, { color: C.red, children: ["-", removed] }), diffNote ? _jsxs(Text, { color: diffNote.startsWith("✓") ? C.green : C.red, children: [" ", diffNote] }) : null, _jsxs(Text, { color: C.dim, children: [pending ? " y accept · n reject" : " n/N next/prev change", scroll] })] }));
|
|
171
220
|
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, ...(pending ? { accent: C.yellow } : {}), children: _jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: diffRows.slice(start, start + rows).map((row, i) => (_jsxs(Box, { flexDirection: "row", children: [cell(row.left, "-", row.changed, half), _jsx(Text, { color: C.dim, children: "\u2502" }), cell(row.right, "+", row.changed, inner - half - 1)] }, start + i))) }), _jsx(Scrollbar, { total: total, top: start, rows: rows, focused: focused })] }) }));
|
|
172
221
|
}
|
|
173
222
|
if (!filePath) {
|
|
@@ -190,14 +239,16 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
190
239
|
// The edit hint only when it fits: a cut-off hint reads as noise.
|
|
191
240
|
const titleRoom = tabs ? 0 : width - 4 - (3 + filePath.length + (errorCount ? 5 + String(errorCount).length : 0) + (warningCount ? 5 + String(warningCount).length : 0) + scroll.length + (total > rows ? 7 : 0));
|
|
192
241
|
const title = (_jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath] }), errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused && titleRoom >= EDIT_HINT.length ? EDIT_HINT : ""] })] }));
|
|
193
|
-
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children:
|
|
194
|
-
const
|
|
242
|
+
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: shown.slice(start, start + rows).map((n) => {
|
|
243
|
+
const line = lines[n - 1] ?? "";
|
|
244
|
+
const fold = ranges.get(n);
|
|
245
|
+
const isFolded = fold !== undefined && folded.has(n);
|
|
195
246
|
const isFound = !!found && n >= found.from && n <= found.to;
|
|
196
247
|
const isMarked = marked.has(n);
|
|
197
248
|
const here = problemsByLine.get(n);
|
|
198
249
|
const worst = here?.[0];
|
|
199
250
|
const style = worst ? SEVERITY_STYLE[worst.severity] : null;
|
|
200
|
-
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: style?.color ?? (isMarked ? C.green : C.dim), children: String(n).padStart(gutter) }), style ? _jsx(Text, { color: style.color, children: style.icon }) : isFound ? _jsx(Text, { color: C.cyan, bold: true, children: "\u258C" }) : _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), (() => {
|
|
251
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: style?.color ?? (isMarked ? C.green : C.dim), children: String(n).padStart(gutter) }), style ? _jsx(Text, { color: style.color, children: style.icon }) : isFound ? _jsx(Text, { color: C.cyan, bold: true, children: "\u258C" }) : _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), _jsx(Text, { color: isFolded ? C.accent2 : C.dim, children: fold === undefined ? " " : isFolded ? "▸" : "▾" }), (() => {
|
|
201
252
|
let text = line;
|
|
202
253
|
if (find?.query) {
|
|
203
254
|
for (const m of matches) {
|
|
@@ -209,6 +260,6 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
209
260
|
}
|
|
210
261
|
text = (left ? sliceAnsi(text, left) : text) || " ";
|
|
211
262
|
return isFound ? _jsx(Text, { backgroundColor: C.panel, children: text }) : text;
|
|
212
|
-
})(), worst && style ? _jsxs(Text, { color: style.color, dimColor: true, children: [" ", style.icon, " ", worst.message, here.length > 1 ? ` (+${here.length - 1})` : ""] }) : null] }, n));
|
|
263
|
+
})(), isFolded ? _jsx(Text, { color: C.accent2, backgroundColor: C.panel, children: ` ⋯ ${fold - n} lines ` }) : null, worst && style ? _jsxs(Text, { color: style.color, dimColor: true, children: [" ", style.icon, " ", worst.message, here.length > 1 ? ` (+${here.length - 1})` : ""] }) : null] }, n));
|
|
213
264
|
}) }), _jsx(Scrollbar, { total: total, top: start, rows: rows, focused: focused })] }), find ? (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(Text, { backgroundColor: C.panel, children: find.query }), _jsx(Text, { inverse: true, children: " " }), _jsxs(Text, { color: find.query && !matches.length ? C.red : C.dim, children: [" ", find.query ? (matches.length ? `${matches.indexOf(current) + 1}/${matches.length}${matches.length >= 10_000 ? "+" : ""}` : "no matches") : "type to find", " ", "Enter/\u2193 next \u00B7 \u2191 previous \u00B7 Esc close"] })] })) : null] }));
|
|
214
265
|
});
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -635,7 +635,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
635
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;
|
|
636
636
|
const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
|
|
637
637
|
const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
|
|
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:
|
|
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: C.onBadge, 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) => {
|
|
639
639
|
const n = v.top + i;
|
|
640
640
|
const isCursorLine = n === buf.row;
|
|
641
641
|
const problem = byLine.get(n + 1)?.[0];
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from "path";
|
|
|
5
5
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
6
6
|
import { IGNORED } from "../commands/index.js";
|
|
7
7
|
import { C, GIT_COLORS, fileIcon, gradient, ANIMATION_MS } from "./theme.js";
|
|
8
|
+
import { useTerminalFocused } from "./focus.js";
|
|
8
9
|
import { Pane } from "./Pane.js";
|
|
9
10
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
11
|
function listDir(dir) {
|
|
@@ -167,7 +168,8 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
|
|
|
167
168
|
else
|
|
168
169
|
onOpen(node.path);
|
|
169
170
|
}, [click?.nonce]);
|
|
170
|
-
const
|
|
171
|
+
const windowFocused = useTerminalFocused();
|
|
172
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!working?.size && windowFocused });
|
|
171
173
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
172
174
|
const title = changesOnly
|
|
173
175
|
? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
|
package/dist/src/ui/GamePanel.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { memo, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Component, memo, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { GAMES, gameById } from "../games/index.js";
|
|
5
5
|
import { C } from "./theme.js";
|
|
@@ -23,15 +23,33 @@ function Row({ line, colors }) {
|
|
|
23
23
|
* A panel for little games, docked under a pane (drag its title to another pane; × closes it). The app gives it
|
|
24
24
|
* the keyboard while it's focused; a game only runs while it has the keyboard, so it pauses as you leave.
|
|
25
25
|
*/
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Keeps a game that throws from taking Nou down with it: the panel says so, and you can pick another.
|
|
28
|
+
*/
|
|
29
|
+
export class GameGuard extends Component {
|
|
30
|
+
state = { error: null };
|
|
31
|
+
static getDerivedStateFromError(error) {
|
|
32
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
33
|
+
}
|
|
34
|
+
render() {
|
|
35
|
+
if (!this.state.error)
|
|
36
|
+
return this.props.children;
|
|
37
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: C.red, flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: C.red, children: ["The game stopped: ", this.state.error.slice(0, 80)] }), _jsx(Text, { color: C.dim, children: "Close the panel (\u00D7) and open it again with /games." })] }));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export const GamePanel = memo(function GamePanel({ width, height, focused, moving, installed, best = {}, onScore, onLeave, onOpenSettings }) {
|
|
41
|
+
const FIELD = { width: Math.max(0, width - 4), height: Math.max(0, height - 6) };
|
|
42
|
+
const fits = (g) => FIELD.width >= g.minWidth && FIELD.height >= g.minHeight;
|
|
28
43
|
const games = installed.map(gameById).filter((g) => !!g);
|
|
29
44
|
const [row, setRow] = useState(0);
|
|
30
45
|
const [playing, setPlaying] = useState(null);
|
|
31
46
|
const [paused, setPaused] = useState(false);
|
|
32
47
|
const inputs = useRef([]);
|
|
33
|
-
|
|
34
|
-
const
|
|
48
|
+
// A game that doesn't fit the panel any more (it was made smaller) waits, saying how much room it needs.
|
|
49
|
+
const tooSmall = !!playing && !fits(playing.game);
|
|
50
|
+
const view = playing && !tooSmall ? playing.game.view(playing.state) : null;
|
|
51
|
+
const running = !!playing && !tooSmall && focused && !paused && !view?.over;
|
|
52
|
+
const tickMs = playing ? playing.game.speed?.(playing.state) ?? playing.game.tickMs : 100;
|
|
35
53
|
useEffect(() => {
|
|
36
54
|
if (!running)
|
|
37
55
|
return;
|
|
@@ -39,15 +57,25 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
|
|
|
39
57
|
const pressed = inputs.current;
|
|
40
58
|
inputs.current = [];
|
|
41
59
|
setPlaying((p) => (p ? { ...p, state: p.game.tick(p.state, pressed) } : p));
|
|
42
|
-
},
|
|
60
|
+
}, tickMs);
|
|
43
61
|
return () => clearInterval(timer);
|
|
44
|
-
}, [running, playing?.game]);
|
|
45
|
-
// A different size (moved to another pane, or the window changed) starts the game over at that size
|
|
62
|
+
}, [running, playing?.game, tickMs]);
|
|
63
|
+
// A different size (moved to another pane, or the window changed) starts the game over at that size,
|
|
64
|
+
// once it fits.
|
|
46
65
|
useEffect(() => {
|
|
47
|
-
if (playing)
|
|
66
|
+
if (playing && fits(playing.game))
|
|
48
67
|
setPlaying({ game: playing.game, state: playing.game.create(FIELD.width, FIELD.height) });
|
|
49
68
|
}, [FIELD.width, FIELD.height]);
|
|
69
|
+
// A finished game's score goes into the best scores.
|
|
70
|
+
const final = view?.over ? view.score ?? 0 : null;
|
|
71
|
+
const newBest = final !== null && playing !== null && final > (best[playing.game.id] ?? 0);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (newBest && playing)
|
|
74
|
+
onScore?.(playing.game.id, final);
|
|
75
|
+
}, [newBest]);
|
|
50
76
|
const start = (game) => {
|
|
77
|
+
if (!fits(game))
|
|
78
|
+
return;
|
|
51
79
|
inputs.current = [];
|
|
52
80
|
setPaused(false);
|
|
53
81
|
setPlaying({ game, state: game.create(FIELD.width, FIELD.height) });
|
|
@@ -75,7 +103,7 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
|
|
|
75
103
|
}
|
|
76
104
|
if (input === " " || input === "p")
|
|
77
105
|
return setPaused((p) => !p);
|
|
78
|
-
const pressed = key.upArrow ? "up" : key.downArrow ? "down" : key.leftArrow ? "left" : key.rightArrow ? "right" : KEYS[input];
|
|
106
|
+
const pressed = key.return ? "action" : key.upArrow ? "up" : key.downArrow ? "down" : key.leftArrow ? "left" : key.rightArrow ? "right" : KEYS[input];
|
|
79
107
|
if (pressed)
|
|
80
108
|
inputs.current.push(pressed);
|
|
81
109
|
}, { isActive: focused });
|
|
@@ -86,14 +114,14 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
|
|
|
86
114
|
const head = title.length > inner - 3 ? `${title.slice(0, inner - 4)}…` : title.padEnd(inner - 3);
|
|
87
115
|
const hint = !focused ? "click to play" : playing ? (view?.over ? "Enter again · q games · Esc leave" : `${paused ? "PAUSED · " : ""}Space pause · q games · Esc leave`) : "↑↓ choose · Enter play · Esc leave";
|
|
88
116
|
const small = width < GAME_PANEL.minWidth || height < GAME_PANEL.minHeight;
|
|
89
|
-
return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: "round", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { backgroundColor: C.panel, wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: head }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), small ? (_jsx(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: _jsx(Text, { color: C.dim, wrap: "wrap", children: "Too small here: drag me to a bigger pane, or make this one wider." }) })) : null, small ? null : playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
|
|
117
|
+
return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: "round", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { backgroundColor: C.panel, wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: head }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), small ? (_jsx(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: _jsx(Text, { color: C.dim, wrap: "wrap", children: "Too small here: drag me to a bigger pane, or make this one wider." }) })) : null, small ? null : playing && tooSmall ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [_jsxs(Text, { color: C.yellow, wrap: "wrap", children: [playing.game.title, " needs more room: a field of ", playing.game.minWidth, "\u00D7", playing.game.minHeight, ", and this one is ", FIELD.width, "\u00D7", FIELD.height, "."] }), _jsx(Text, { color: C.dim, wrap: "wrap", children: "Drag the top edge up, move the panel to a wider pane, or q for another game." })] })) : playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
|
|
90
118
|
// The end-of-game message goes over the middle of the field.
|
|
91
119
|
if (view.over && i === Math.floor(FIELD.height / 2)) {
|
|
92
|
-
const msg = ` ${view.over} `;
|
|
120
|
+
const msg = ` ${view.over}${newBest ? " New best!" : ""} `;
|
|
93
121
|
const left = Math.max(0, Math.floor((FIELD.width - msg.length) / 2));
|
|
94
|
-
return _jsxs(Text, { children: [line.slice(0, left), _jsx(Text, { backgroundColor: C.yellow, color:
|
|
122
|
+
return _jsxs(Text, { children: [line.slice(0, left), _jsx(Text, { backgroundColor: C.yellow, color: C.onBadge, bold: true, children: msg }), line.slice(left + msg.length)] }, i);
|
|
95
123
|
}
|
|
96
124
|
return _jsx(Row, { line: line, colors: playing.game.colors }, i);
|
|
97
|
-
}), _jsx(Text, { color: C.dim, children: "─".repeat(FIELD.width) }), _jsx(Text, { color: C.text, wrap: "truncate-end", children: view.status })] })) : (_jsx(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: games.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.text, children: "No games yet." }), _jsxs(Text, { color: C.dim, wrap: "wrap", children: ["Add some in settings \u2192 Games", focused ? " (press s)" : "", ":
|
|
125
|
+
}), _jsx(Text, { color: C.dim, children: "─".repeat(FIELD.width) }), _jsx(Text, { color: C.text, wrap: "truncate-end", children: view.status })] })) : (_jsx(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: games.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.text, children: "No games yet." }), _jsxs(Text, { color: C.dim, wrap: "wrap", children: ["Add some in settings \u2192 Games", focused ? " (press s)" : "", ": ", GAMES.map((g) => g.title).join(", "), "."] })] })) : (_jsxs(_Fragment, { children: [games.map((g, i) => (_jsxs(Text, { wrap: "truncate-end", ...(i === row && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: i === row ? C.accent2 : C.dim, children: i === row ? "▸ " : " " }), _jsx(Text, { color: fits(g) ? C.text : C.dim, bold: i === row, children: g.title }), best[g.id] ? _jsxs(Text, { color: C.yellow, children: [" best ", best[g.id]] }) : null, fits(g) ? null : _jsx(Text, { color: C.dim, children: " (needs more room)" })] }, g.id))), games[row] ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: C.dim, wrap: "wrap", children: games[row].blurb }), _jsx(Text, { color: C.accent2, wrap: "truncate-end", children: games[row].controls })] })) : null] })) })), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", hint] })] }));
|
|
98
126
|
});
|
|
99
127
|
export { GAMES };
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -2,11 +2,39 @@ 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,
|
|
5
|
+
import { C, gradient, ANIMATION_MS } from "./theme.js";
|
|
6
|
+
import { useTerminalFocused } from "./focus.js";
|
|
6
7
|
import { currentVersion } from "../update/index.js";
|
|
7
8
|
const VERSION = `v${currentVersion()}`;
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/** What comes before the project (or the workspace tabs): " ◆ NOU v1.2.3 │ ". */
|
|
10
|
+
const LEAD = ` ◆ NOU ${VERSION} │ `;
|
|
11
|
+
const tabLabel = (t, i) => ` ${i + 1} ${t.name}${t.busy ? " ●" : ""} `;
|
|
12
|
+
/**
|
|
13
|
+
* Where each workspace tab, and the + that opens one, sits in the header row: for clicks. With one workspace
|
|
14
|
+
* (not hosted) there's just the project's name and the +.
|
|
15
|
+
*/
|
|
16
|
+
export function headerZones(tabs) {
|
|
17
|
+
const zones = [];
|
|
18
|
+
let x = LEAD.length;
|
|
19
|
+
if (!tabs.length) {
|
|
20
|
+
x += path.basename(process.cwd()).length + 1;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
tabs.forEach((t, i) => {
|
|
24
|
+
const w = tabLabel(t, i).length;
|
|
25
|
+
zones.push({ from: x, to: x + w, tab: i });
|
|
26
|
+
x += w + 1;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
zones.push({ from: x, to: x + 3, tab: "new" });
|
|
30
|
+
return zones;
|
|
31
|
+
}
|
|
32
|
+
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update, tabs = [] }) {
|
|
33
|
+
// The spinner stops while Nou's window is in the background: nobody sees it, and each frame is a redraw.
|
|
34
|
+
const windowFocused = useTerminalFocused();
|
|
35
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
|
|
10
36
|
const project = path.basename(process.cwd());
|
|
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 " }),
|
|
37
|
+
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 " }), tabs.length
|
|
38
|
+
? tabs.map((t, i) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: t.active ? C.accent : C.text, bold: t.active, children: tabLabel(t, i) }), " "] }, t.id)))
|
|
39
|
+
: _jsxs(Text, { color: C.text, children: [project, " "] }), _jsx(Text, { color: C.accent2, children: " + " }), 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] })] }))] }) }), _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: C.onBadge, bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] }) })] }));
|
|
12
40
|
});
|
package/dist/src/ui/Pane.js
CHANGED
|
@@ -3,6 +3,6 @@ import { Box, Text } from "ink";
|
|
|
3
3
|
import { C } from "./theme.js";
|
|
4
4
|
/** A bordered panel with a title row. Content gets `width - 2` columns and `height - 3` rows. */
|
|
5
5
|
export function Pane({ title, width, height, focused, accent, children }) {
|
|
6
|
-
const color = accent ?? (focused ? C.accent : C.
|
|
7
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, borderStyle: focused || accent ? "round" : "single", borderColor: color, overflow: "hidden", children: [_jsx(Text, { bold: true, color: focused || accent ? color : C.
|
|
6
|
+
const color = accent ?? (focused ? C.accent : C.border);
|
|
7
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, borderStyle: focused || accent ? "round" : "single", borderColor: color, overflow: "hidden", children: [_jsx(Text, { bold: true, color: focused || accent ? color : C.text, wrap: "truncate-end", children: title }), children] }));
|
|
8
8
|
}
|
|
@@ -2,6 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
4
|
import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
|
|
5
|
+
import { useTerminalFocused } from "./focus.js";
|
|
5
6
|
import { Pane } from "./Pane.js";
|
|
6
7
|
export const SEVERITY_STYLE = {
|
|
7
8
|
error: { icon: "✖", color: C.red },
|
|
@@ -11,7 +12,8 @@ export const SEVERITY_STYLE = {
|
|
|
11
12
|
export function ProblemsView({ width, height, focused, problems, checking, wheel, onJump, onClose }) {
|
|
12
13
|
const [cursor, setCursor] = useState(0);
|
|
13
14
|
const [onlyErrors, setOnlyErrors] = useState(false);
|
|
14
|
-
const
|
|
15
|
+
const windowFocused = useTerminalFocused();
|
|
16
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: checking.length > 0 && windowFocused });
|
|
15
17
|
const rows = useMemo(() => {
|
|
16
18
|
const list = onlyErrors ? problems.filter((d) => d.severity === "error") : problems;
|
|
17
19
|
const out = [];
|
package/dist/src/ui/QuickOpen.js
CHANGED
|
@@ -58,7 +58,7 @@ export function score(file, query) {
|
|
|
58
58
|
return { score: total - file.length * 0.05, hits };
|
|
59
59
|
}
|
|
60
60
|
/** Ctrl+P: find a file. Start with ">" (or press ?) for every action Nou has, with its shortcut. */
|
|
61
|
-
export function QuickOpen({ width, top, files, recent = [], actions, initialQuery = "", onPick, onRun, onClose }) {
|
|
61
|
+
export function QuickOpen({ width, top, files, recent = [], actions, initialQuery = "", placeholder = "Go to file… (> for commands)", onPick, onRun, onClose }) {
|
|
62
62
|
const [query, setQuery] = useState(initialQuery);
|
|
63
63
|
const [cursor, setCursor] = useState(0);
|
|
64
64
|
const boxWidth = Math.min(80, Math.max(40, width - 10));
|
|
@@ -107,12 +107,17 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
|
|
|
107
107
|
setCursor(0);
|
|
108
108
|
}, onSubmit: () => {
|
|
109
109
|
if (commandMode) {
|
|
110
|
-
|
|
110
|
+
// A number picks that row of a numbered list (/resume's: "1. ...", "2. ...").
|
|
111
|
+
const n = /^\s*(\d+)\s*$/.exec(query.slice(1))?.[1];
|
|
112
|
+
const numbered = n ? actions.find((a) => a.title.startsWith(`${n}. `)) : undefined;
|
|
113
|
+
if (numbered)
|
|
114
|
+
onRun(numbered);
|
|
115
|
+
else if (found[cursor])
|
|
111
116
|
onRun(found[cursor].action);
|
|
112
117
|
}
|
|
113
118
|
else if (results[cursor])
|
|
114
119
|
onPick(results[cursor].file);
|
|
115
|
-
}, placeholder:
|
|
120
|
+
}, placeholder: placeholder }), _jsx(Text, { children: " ".repeat(Math.max(0, inner - 4 - Math.max(query.length, 29))) })] }), _jsx(Text, { color: C.dim, children: "─".repeat(inner) }), commandMode && found.length === 0 && _jsx(Text, { color: C.dim, children: pad(" No matching commands") }), commandMode && found.slice(first, first + rowsShown).map((r, i) => {
|
|
116
121
|
const selected = first + i === cursor;
|
|
117
122
|
const keys = r.action.keys ?? "";
|
|
118
123
|
const room = inner - 3 - keys.length - 1;
|