nou-cli 0.5.6 → 0.5.11
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 +14 -7
- package/dist/src/agent/index.js +1 -0
- package/dist/src/cli.js +36 -0
- package/dist/src/config/index.js +19 -0
- package/dist/src/conversations/external.js +35 -13
- package/dist/src/guide/index.js +75 -0
- package/dist/src/ui/App.js +282 -39
- package/dist/src/ui/Chat.js +60 -11
- package/dist/src/ui/CodeView.js +7 -6
- package/dist/src/ui/Editor.js +5 -4
- package/dist/src/ui/FileTree.js +6 -5
- package/dist/src/ui/GamePanel.js +1 -1
- package/dist/src/ui/Header.js +39 -3
- package/dist/src/ui/Pane.js +2 -2
- package/dist/src/ui/ProblemsView.js +3 -3
- package/dist/src/ui/QuickOpen.js +8 -3
- package/dist/src/ui/Settings.js +14 -7
- package/dist/src/ui/Terminal.js +7 -1
- package/dist/src/ui/background.js +58 -0
- package/dist/src/ui/commands.js +46 -6
- package/dist/src/ui/keys.js +8 -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
|
@@ -63,7 +63,7 @@ function entryLines(entry, inner, links = true) {
|
|
|
63
63
|
else {
|
|
64
64
|
const style = entry.kind === "tool"
|
|
65
65
|
? { icon: "⚙", color: C.dim }
|
|
66
|
-
: entry.kind === "error" ? { icon: "
|
|
66
|
+
: entry.kind === "error" ? { icon: "✗", color: C.red } : { icon: "ℹ", color: C.accent };
|
|
67
67
|
wrap(entry.text, inner - 4).forEach((line, i) => {
|
|
68
68
|
const spans = links && entry.kind !== "user" ? linkFileRefs(linkUrls([{ text: ` ${line}` }]), isProjectFile) : [];
|
|
69
69
|
out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
|
|
@@ -100,8 +100,10 @@ export function chatTabZones(tabs) {
|
|
|
100
100
|
return { zones, plus: x + 1, width: x + 3 };
|
|
101
101
|
}
|
|
102
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;
|
|
103
105
|
const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
|
|
104
|
-
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [] }) {
|
|
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 }) {
|
|
105
107
|
const [input, setInputText] = useState("");
|
|
106
108
|
// Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
|
|
107
109
|
// still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
|
|
@@ -192,9 +194,11 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
192
194
|
else if (busy) {
|
|
193
195
|
footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
|
|
194
196
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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 });
|
|
198
202
|
// Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
|
|
199
203
|
const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
|
|
200
204
|
const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
|
|
@@ -224,6 +228,12 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
224
228
|
useEffect(() => {
|
|
225
229
|
if (!click)
|
|
226
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
|
+
}
|
|
227
237
|
const line = visible[click.row];
|
|
228
238
|
const run = line?.toggle;
|
|
229
239
|
if (run) {
|
|
@@ -252,6 +262,40 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
252
262
|
if (!select)
|
|
253
263
|
return;
|
|
254
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
|
+
}
|
|
255
299
|
if (select.kind === "press") {
|
|
256
300
|
anchor.current = at;
|
|
257
301
|
setSelection(null);
|
|
@@ -271,17 +315,22 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
271
315
|
if (select.kind !== "release")
|
|
272
316
|
return;
|
|
273
317
|
anchor.current = null;
|
|
274
|
-
if (
|
|
275
|
-
|
|
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) => {
|
|
276
323
|
const text = lines.slice(range.from.line, range.to.line + 1).map((line, i, all) => {
|
|
277
|
-
const t = lineText(line);
|
|
278
|
-
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();
|
|
279
326
|
}).join("\n");
|
|
327
|
+
if (!text)
|
|
328
|
+
return;
|
|
280
329
|
void copyToClipboard(text).then((ok) => {
|
|
281
330
|
setFlash(ok ? `Copied ${text.split("\n").length > 1 ? `${text.split("\n").length} lines` : "it"} ✓` : "Can't copy: press m, then select");
|
|
282
331
|
setTimeout(() => setFlash(null), 2000);
|
|
283
332
|
});
|
|
284
|
-
}
|
|
333
|
+
};
|
|
285
334
|
// Typing or new messages leave the selection be; a new conversation clears it.
|
|
286
335
|
useEffect(() => {
|
|
287
336
|
if (!entries.length)
|
|
@@ -360,5 +409,5 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
360
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) => {
|
|
361
410
|
const selected = menuStart + i === menuRow;
|
|
362
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));
|
|
363
|
-
}) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " })), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? "Enter queues · Alt+Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
|
|
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" })] }));
|
|
364
413
|
});
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -5,6 +5,7 @@ import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
|
5
5
|
import { artWork } from "../util/index.js";
|
|
6
6
|
import { foldRanges, highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines, unfoldedLines } from "./text.js";
|
|
7
7
|
import { C, fileIcon, gradient } from "./theme.js";
|
|
8
|
+
import { isFind } from "./keys.js";
|
|
8
9
|
import { Pane } from "./Pane.js";
|
|
9
10
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
11
|
/** The current match: a yellow background; the others are shown inverted. */
|
|
@@ -12,7 +13,7 @@ const CURRENT_MATCH = ["\x1b[43m\x1b[30m", "\x1b[49m\x1b[39m"];
|
|
|
12
13
|
/** Columns ←/→ and Shift+wheel move sideways. */
|
|
13
14
|
const SIDE_STEP = 8;
|
|
14
15
|
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, foldClick, foldAll }) {
|
|
16
|
+
export const CodeView = memo(function CodeView({ showing, width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
|
|
16
17
|
const [top, setTop] = useState(0);
|
|
17
18
|
// Columns scrolled sideways (colors are kept: see sliceAnsi).
|
|
18
19
|
const [left, setLeft] = useState(0);
|
|
@@ -165,7 +166,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
165
166
|
return setFind((f) => f && { ...f, index: f.index - 1 });
|
|
166
167
|
if (key.backspace || key.delete)
|
|
167
168
|
return setFind((f) => f && { query: f.query.slice(0, -1), index: 0 });
|
|
168
|
-
if (
|
|
169
|
+
if (isFind(input, key))
|
|
169
170
|
return;
|
|
170
171
|
if (input && !key.ctrl && !key.meta && input >= " ")
|
|
171
172
|
setFind((f) => f && { query: f.query + input, index: 0 });
|
|
@@ -173,7 +174,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
173
174
|
useInput((input, key) => {
|
|
174
175
|
if (find)
|
|
175
176
|
return;
|
|
176
|
-
if (
|
|
177
|
+
if (isFind(input, key) && !diff)
|
|
177
178
|
return setFind({ query: "", index: 0 });
|
|
178
179
|
if (key.upArrow || input === "k")
|
|
179
180
|
setTop((t) => Math.max(0, Math.min(t, maxTop) - 1));
|
|
@@ -216,7 +217,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
216
217
|
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) || " "] }) }));
|
|
217
218
|
};
|
|
218
219
|
const { icon, color } = fileIcon(diff.path);
|
|
219
|
-
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color:
|
|
220
|
+
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] })] }));
|
|
220
221
|
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 })] }) }));
|
|
221
222
|
}
|
|
222
223
|
if (!filePath) {
|
|
@@ -238,8 +239,8 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
238
239
|
const warningCount = problems.filter((d) => d.severity === "warning").length;
|
|
239
240
|
// The edit hint only when it fits: a cut-off hint reads as noise.
|
|
240
241
|
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));
|
|
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: [" \
|
|
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) => {
|
|
242
|
+
const title = (_jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath] }), showing && _jsxs(_Fragment, { children: [" ", _jsx(Text, { backgroundColor: C.accent2, color: C.onBadge, bold: true, children: ` ◉ SHOWING ${showing} ` })] }), errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2717 ", 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 : ""] })] }));
|
|
243
|
+
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, ...(showing ? { accent: C.accent2 } : {}), 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
244
|
const line = lines[n - 1] ?? "";
|
|
244
245
|
const fold = ranges.get(n);
|
|
245
246
|
const isFolded = fold !== undefined && folded.has(n);
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "path";
|
|
|
4
4
|
import { Box, Text, useInput, usePaste } from "ink";
|
|
5
5
|
import { highlightLines, sliceAnsi } from "./text.js";
|
|
6
6
|
import { C, fileIcon } from "./theme.js";
|
|
7
|
+
import { FIND_KEY, isFind } from "./keys.js";
|
|
7
8
|
import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
8
9
|
import { Pane } from "./Pane.js";
|
|
9
10
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
@@ -401,7 +402,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
401
402
|
return find(prompt.text, key.upArrow);
|
|
402
403
|
if (key.backspace || key.delete)
|
|
403
404
|
return setPrompt({ ...prompt, text: prompt.text.slice(0, -1) });
|
|
404
|
-
if (
|
|
405
|
+
if (isFind(input, key))
|
|
405
406
|
return find(prompt.text);
|
|
406
407
|
if (input && !key.ctrl && !key.meta && (prompt.kind === "find" || /^\d+$/.test(input))) {
|
|
407
408
|
const text = prompt.text + input;
|
|
@@ -433,7 +434,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
433
434
|
return step(undo, redo);
|
|
434
435
|
if (key.ctrl && input === "y")
|
|
435
436
|
return step(redo, undo);
|
|
436
|
-
if (
|
|
437
|
+
if (isFind(input, key)) {
|
|
437
438
|
const word = selectedText(buf);
|
|
438
439
|
return setPrompt({ kind: "find", text: word.includes("\n") ? "" : word });
|
|
439
440
|
}
|
|
@@ -631,11 +632,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
631
632
|
? prompt.kind === "find" ? `Find: ${prompt.text}▏ Enter/↓ next · Shift+Enter/↑ previous · Esc close` : `Go to line: ${prompt.text}▏ Enter go · Esc cancel`
|
|
632
633
|
: flash ?? (cursorProblem
|
|
633
634
|
? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
|
|
634
|
-
: `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""}
|
|
635
|
+
: `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""} ${FIND_KEY} find · Ctrl+/ comment · Esc done`);
|
|
635
636
|
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
637
|
const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
|
|
637
638
|
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:
|
|
639
|
+
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
640
|
const n = v.top + i;
|
|
640
641
|
const isCursorLine = n === buf.row;
|
|
641
642
|
const problem = byLine.get(n + 1)?.[0];
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -32,7 +32,7 @@ function flatten(expanded) {
|
|
|
32
32
|
walk(".", 0);
|
|
33
33
|
return nodes;
|
|
34
34
|
}
|
|
35
|
-
export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
|
|
35
|
+
export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, showing, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
|
|
36
36
|
const [expanded, setExpanded] = useState(() => new Set(["src"]));
|
|
37
37
|
const [cursor, setCursor] = useState(0);
|
|
38
38
|
// The first row shown. The wheel moves it on its own; the keyboard moves the cursor and the view follows.
|
|
@@ -182,11 +182,12 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
|
|
|
182
182
|
? { icon: expanded.has(node.path) ? "▾" : "▸", color: C.accent }
|
|
183
183
|
: fileIcon(node.path);
|
|
184
184
|
const counts = problemCounts.get(node.path);
|
|
185
|
-
const problemMark = counts?.errors ? "
|
|
186
|
-
const
|
|
187
|
-
const
|
|
185
|
+
const problemMark = counts?.errors ? "✗" : counts?.warnings ? "▲" : "";
|
|
186
|
+
const stops = showing?.get(node.path);
|
|
187
|
+
const marker = stops ? `◉${stops.join(",")}` : problemMark || code || (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
|
|
188
|
+
const markerColor = stops ? C.accent2 : counts?.errors ? C.red : counts?.warnings ? C.yellow : code ? GIT_COLORS[code] ?? C.yellow : changed.has(node.path) ? C.green : C.yellow;
|
|
188
189
|
const label = `${" ".repeat(node.depth)}${icon} ${node.name}`;
|
|
189
|
-
const nameColor = isOpen ? C.cyan : counts?.errors ? C.red : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
|
|
190
|
+
const nameColor = stops ? C.accent2 : isOpen ? C.cyan : counts?.errors ? C.red : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
|
|
190
191
|
const room = Math.max(1, inner - marker.length - 1);
|
|
191
192
|
const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
|
|
192
193
|
const iconAt = shown.indexOf(icon);
|
package/dist/src/ui/GamePanel.js
CHANGED
|
@@ -119,7 +119,7 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
|
|
|
119
119
|
if (view.over && i === Math.floor(FIELD.height / 2)) {
|
|
120
120
|
const msg = ` ${view.over}${newBest ? " New best!" : ""} `;
|
|
121
121
|
const left = Math.max(0, Math.floor((FIELD.width - msg.length) / 2));
|
|
122
|
-
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);
|
|
123
123
|
}
|
|
124
124
|
return _jsx(Row, { line: line, colors: playing.game.colors }, i);
|
|
125
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] })] }));
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -2,14 +2,50 @@ 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
6
|
import { useTerminalFocused } from "./focus.js";
|
|
7
7
|
import { currentVersion } from "../update/index.js";
|
|
8
8
|
const VERSION = `v${currentVersion()}`;
|
|
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
|
+
/**
|
|
33
|
+
* The header's right side: the update notice (when there's room), where the model runs and which. Plain
|
|
34
|
+
* characters only: symbols that are also emoji (☁, ⬆) are drawn two columns wide by some terminals.
|
|
35
|
+
*/
|
|
36
|
+
const rightSide = (width, model, local, update) => ({
|
|
37
|
+
notice: update && width >= 110 ? `↑ v${update} available · run \`nou update\` ` : "",
|
|
38
|
+
badge: ` ${local ? "LOCAL" : "CLOUD"} `,
|
|
39
|
+
model: ` ${model} `,
|
|
40
|
+
});
|
|
41
|
+
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update, tabs = [] }) {
|
|
10
42
|
// The spinner stops while Nou's window is in the background: nobody sees it, and each frame is a redraw.
|
|
11
43
|
const windowFocused = useTerminalFocused();
|
|
12
44
|
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
|
|
13
45
|
const project = path.basename(process.cwd());
|
|
14
|
-
|
|
46
|
+
const right = rightSide(width, model, local, update);
|
|
47
|
+
const rightWidth = right.notice.length + right.badge.length + right.model.length + 1;
|
|
48
|
+
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { width: Math.max(0, width - rightWidth - 4), flexShrink: 0, 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
|
|
49
|
+
? 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)))
|
|
50
|
+
: _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: [right.notice && _jsx(Text, { color: C.green, bold: true, children: right.notice }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: C.onBadge, bold: true, children: right.badge }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: right.model }), " "] }) })] }));
|
|
15
51
|
});
|
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
|
}
|
|
@@ -5,7 +5,7 @@ import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
|
|
|
5
5
|
import { useTerminalFocused } from "./focus.js";
|
|
6
6
|
import { Pane } from "./Pane.js";
|
|
7
7
|
export const SEVERITY_STYLE = {
|
|
8
|
-
error: { icon: "
|
|
8
|
+
error: { icon: "✗", color: C.red },
|
|
9
9
|
warning: { icon: "▲", color: C.yellow },
|
|
10
10
|
info: { icon: "ℹ", color: C.accent },
|
|
11
11
|
};
|
|
@@ -63,13 +63,13 @@ export function ProblemsView({ width, height, focused, problems, checking, wheel
|
|
|
63
63
|
const errors = problems.filter((d) => d.severity === "error").length;
|
|
64
64
|
const warnings = problems.filter((d) => d.severity === "warning").length;
|
|
65
65
|
const start = Math.max(0, Math.min(cursor - Math.floor(visibleRows / 2), rows.length - visibleRows));
|
|
66
|
-
const title = (_jsxs(_Fragment, { children: [" ", _jsxs(Text, { color: C.red, children: ["\
|
|
66
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsxs(Text, { color: C.red, children: ["\u2717 ", errors] }), " ", _jsxs(Text, { color: C.yellow, children: ["\u25B2 ", warnings] }), " Problems", checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " checking ", checking.join(", ")] }), _jsx(Text, { color: C.dim, children: onlyErrors ? " (errors only)" : "" })] }));
|
|
67
67
|
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, ...(focused ? { accent: C.red } : {}), children: [_jsxs(Box, { flexDirection: "column", height: visibleRows, children: [rows.length === 0 && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.green, children: checking.length ? "Checking…" : "✓ No problems found" }), _jsx(Text, { color: C.dim, children: "Checked by the syntax parser, plus ESLint, TypeScript, Ruff and language servers when your project has them." })] })), rows.slice(start, start + visibleRows).map((row, i) => {
|
|
68
68
|
const selected = focused && start + i === cursor;
|
|
69
69
|
const bg = selected ? { backgroundColor: C.panel } : {};
|
|
70
70
|
if (row.kind === "file") {
|
|
71
71
|
const { icon, color } = fileIcon(row.file);
|
|
72
|
-
return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [_jsx(Text, { color: color, children: icon }), " ", _jsx(Text, { bold: true, color: C.text, children: row.file }), row.errors > 0 && _jsxs(Text, { color: C.red, children: [" \
|
|
72
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [_jsx(Text, { color: color, children: icon }), " ", _jsx(Text, { bold: true, color: C.text, children: row.file }), row.errors > 0 && _jsxs(Text, { color: C.red, children: [" \u2717 ", row.errors] }), row.warnings > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", row.warnings] })] }, `f${row.file}`));
|
|
73
73
|
}
|
|
74
74
|
const style = SEVERITY_STYLE[row.d.severity];
|
|
75
75
|
return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [" ", _jsx(Text, { color: style.color, children: style.icon }), _jsxs(Text, { color: C.dim, children: [" ", String(row.d.line).padStart(4), ":", String(row.d.col).padEnd(3), " "] }), _jsx(Text, { ...(selected ? { color: C.text } : {}), children: row.d.message }), _jsxs(Text, { color: C.dim, children: [" ", row.d.source, row.d.code ? ` ${row.d.code}` : ""] })] }, `${row.d.file}:${row.d.line}:${row.d.col}:${i}`));
|
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;
|
package/dist/src/ui/Settings.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
|
-
import { AUTO_SAVE, CODE_THEMES, EFFORTS, FOLLOW_SPEEDS } from "../config/index.js";
|
|
5
|
+
import { AUTO_SAVE, CODE_THEMES, UI_THEMES, EFFORT_LABELS, EFFORTS, FOLLOW_SPEEDS } from "../config/index.js";
|
|
6
6
|
import { highlightLines } from "./text.js";
|
|
7
7
|
import { GAMES } from "../games/index.js";
|
|
8
8
|
import { saveCredentials } from "../config/credentials.js";
|
|
@@ -19,13 +19,15 @@ export function greet(name: string, times = 2): string {
|
|
|
19
19
|
}`;
|
|
20
20
|
const TABS = ["Model", "Engine", "Preferences", "Plugins", "Games"];
|
|
21
21
|
const PREFS = [
|
|
22
|
-
{ key: "effort", label: "
|
|
22
|
+
{ key: "effort", label: "Speed vs smarts", help: "How hard the model thinks before it answers (Claude, Codex, pi and Nou's engine with Claude)." },
|
|
23
23
|
{ key: "followSpeed", label: "Follow mode speed", help: "How long each file Nou reads stays on screen in follow mode (f): slow, normal, fast, or as it happens." },
|
|
24
|
+
{ key: "uiTheme", label: "Nou's colors", help: "For a dark or a light terminal; auto asks the terminal which it is. Applies when Nou next starts." },
|
|
24
25
|
{ key: "codeTheme", label: "Code colors", help: "The theme for code. ←/→ to try them; github-light is for light terminals." },
|
|
25
26
|
{ key: "autoSave", label: "Auto-save", help: "delay: a second after you stop typing · focus: when you leave the editor · off: only Ctrl+S." },
|
|
26
27
|
{ key: "mouse", label: "Mouse", help: "Wheel scrolls the pane under the pointer; click to focus or open files. Hold Shift to select text." },
|
|
27
28
|
{ key: "followMode", label: "Follow mode on start", help: "The code view jumps to files Nou reads and edits." },
|
|
28
29
|
{ key: "reuseToolConfig", label: "Use Claude Code / Codex keys", help: "Use the API keys from Claude Code's or Codex's settings when you haven't set one. Applies on restart." },
|
|
30
|
+
{ key: "sendNowWhileBusy", label: "Send while Nou works", help: "On: Enter stops Nou and sends your message now (Alt+Enter queues it). Off: Enter queues it for when Nou finishes." },
|
|
29
31
|
{ key: "gitContext", label: "Git context", help: "Give the model your branch, changes and commits, plus git tools." },
|
|
30
32
|
{ key: "autoApproveEdits", label: "Auto-approve edits", help: "Apply file edits without asking (diffs are still shown).", warn: true },
|
|
31
33
|
{ key: "autoApproveCommands", label: "Auto-approve commands", help: "Run shell commands without asking. Only turn on if you trust the model.", warn: true },
|
|
@@ -159,6 +161,9 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
159
161
|
else if (pref.key === "codeTheme") {
|
|
160
162
|
onChange({ codeTheme: CODE_THEMES[(CODE_THEMES.indexOf(settings.codeTheme) + step + CODE_THEMES.length) % CODE_THEMES.length] });
|
|
161
163
|
}
|
|
164
|
+
else if (pref.key === "uiTheme") {
|
|
165
|
+
onChange({ uiTheme: UI_THEMES[(UI_THEMES.indexOf(settings.uiTheme) + step + UI_THEMES.length) % UI_THEMES.length] });
|
|
166
|
+
}
|
|
162
167
|
else if (pref.key === "autoSave") {
|
|
163
168
|
onChange({ autoSave: AUTO_SAVE[(AUTO_SAVE.indexOf(settings.autoSave) + step + AUTO_SAVE.length) % AUTO_SAVE.length] });
|
|
164
169
|
}
|
|
@@ -208,7 +213,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
208
213
|
const selected = i === row;
|
|
209
214
|
const current = p.id === choice.provider.id;
|
|
210
215
|
return (_jsxs(Text, { wrap: "truncate-end", ...(selected && column === "providers" ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? " ● " : " " }), _jsx(Text, { color: problem ? C.dim : C.text, bold: selected, children: p.label.padEnd(labelWidth).slice(0, labelWidth) }), _jsx(Text, { color: p.local ? C.green : C.accent2, children: p.local ? " local" : " cloud" }), problem ? _jsx(Text, { color: C.dim, children: " \uD83D\uDD12" }) : null] }, p.id));
|
|
211
|
-
})] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: column === "models" ? C.accent : C.dim, paddingX: 1, children: [_jsxs(Text, { color: C.dim, bold: true, children: ["MODELS \u00B7 ", provider.label] }), reason ? _jsxs(Text, { color: C.red, wrap: "wrap", children: ["\
|
|
216
|
+
})] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: column === "models" ? C.accent : C.dim, paddingX: 1, children: [_jsxs(Text, { color: C.dim, bold: true, children: ["MODELS \u00B7 ", provider.label] }), reason ? _jsxs(Text, { color: C.red, wrap: "wrap", children: ["\u2717 ", reason] }) : null, connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
|
|
212
217
|
const entered = connecting.values[f.env];
|
|
213
218
|
const shown = (v) => (f.secret ? "•".repeat(Math.min(v.length, 12)) : v);
|
|
214
219
|
if (i < connecting.step) {
|
|
@@ -251,9 +256,11 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
251
256
|
const value = settings[pref.key];
|
|
252
257
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: selected, children: pref.label.padEnd(26) }), pref.key === "codeTheme"
|
|
253
258
|
? _jsxs(Text, { children: [_jsx(Text, { color: C.dim, children: "\u2039 " }), _jsx(Text, { color: C.accent2, bold: true, children: String(value) }), _jsxs(Text, { color: C.dim, children: [" \u203A ", CODE_THEMES.indexOf(value) + 1, "/", CODE_THEMES.length] })] })
|
|
254
|
-
: pref.key === "effort"
|
|
255
|
-
? _jsx(Text, { children:
|
|
256
|
-
:
|
|
259
|
+
: pref.key === "effort"
|
|
260
|
+
? _jsx(Text, { children: EFFORTS.map((e) => _jsx(Text, { color: e === value ? C.accent2 : C.dim, bold: e === value, children: e === value ? `[${EFFORT_LABELS[e].name}]` : ` ${EFFORT_LABELS[e].name} ` }, e)) })
|
|
261
|
+
: pref.key === "autoSave" || pref.key === "followSpeed" || pref.key === "uiTheme"
|
|
262
|
+
? _jsx(Text, { children: (pref.key === "autoSave" ? AUTO_SAVE : pref.key === "uiTheme" ? UI_THEMES : FOLLOW_SPEEDS).map((e) => _jsx(Text, { color: e === value ? C.accent2 : C.dim, bold: e === value, children: e === value ? `[${e}]` : ` ${e} ` }, e)) })
|
|
263
|
+
: toggle(value, pref.warn)] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", pref.key === "effort" ? `${EFFORT_LABELS[settings.effort].tradeoff}` : pref.help] }), pref.key === "codeTheme" && selected
|
|
257
264
|
? highlightLines("preview.ts", THEME_PREVIEW).map((line, n) => _jsxs(Text, { wrap: "truncate-end", children: [" ", line] }, n))
|
|
258
265
|
: null] }, pref.key));
|
|
259
266
|
}), prefWindow.last < PREFS.length ? _jsxs(Text, { color: C.dim, children: [" \u2193 ", PREFS.length - prefWindow.last, " more"] }) : null] }));
|
|
@@ -279,7 +286,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
279
286
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), p.scope === "starter" ? _jsx(Text, { color: C.accent2, bold: true, children: "+ ADD" }) : toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope === "starter" ? "starter, optional" : p.scope, parts.length ? ` · ${parts.join(", ")}` : ""] })] }), state === "changed"
|
|
280
287
|
? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u26A0 Changed since you enabled it. Review the code, then press Enter to enable again." })
|
|
281
288
|
: p.error
|
|
282
|
-
? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \
|
|
289
|
+
? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \u2717 ", p.error] })
|
|
283
290
|
: _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", p.plugin?.description ?? (enabled ? "" : "Enable to load it.")] })] }, p.id));
|
|
284
291
|
}), _jsx(Text, { children: " " }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Starters: Enter adds one to ", PLUGIN_DIRS.find((d) => d.scope === "user").dir, ", where you can edit it."] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Write your own: /plugins new <name>, or drop a .js/.mjs file into ", PLUGIN_DIRS.map((d) => d.dir).join(" or "), ", then press r."] })] }));
|
|
285
292
|
};
|
package/dist/src/ui/Terminal.js
CHANGED
|
@@ -8,7 +8,7 @@ const COLORS = { out: C.text, command: C.accent, info: C.dim, error: C.red };
|
|
|
8
8
|
* what you type goes to it; Ctrl+C stops it. ↑↓ go through what you ran, Ctrl+L clears, Esc leaves.
|
|
9
9
|
* Drag its top border (or Alt+↑/↓) to make it taller or shorter.
|
|
10
10
|
*/
|
|
11
|
-
export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave, onResize }) {
|
|
11
|
+
export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave, onClose, onResize }) {
|
|
12
12
|
const [input, setInput] = useState("");
|
|
13
13
|
const [history, setHistory] = useState([]);
|
|
14
14
|
const [back, setBack] = useState(-1);
|
|
@@ -48,6 +48,12 @@ export const Terminal = memo(function Terminal({ shell, version, width, height,
|
|
|
48
48
|
if (key.ctrl && typed === "l")
|
|
49
49
|
return shell.clear();
|
|
50
50
|
if (key.return) {
|
|
51
|
+
// Each command runs in its own shell, so `exit` would end only that one and nothing would seem to
|
|
52
|
+
// happen: at the prompt it closes the terminal instead.
|
|
53
|
+
if (!shell.running && /^\s*(exit|logout)(\s+\d+)?\s*$/.test(input)) {
|
|
54
|
+
setInput("");
|
|
55
|
+
return onClose();
|
|
56
|
+
}
|
|
51
57
|
if (!shell.running && input.trim())
|
|
52
58
|
setHistory((h) => [...h.filter((c) => c !== input.trim()), input.trim()].slice(-100));
|
|
53
59
|
if (shell.running)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the terminal's background is light or dark, so Nou can pick colors that are readable on it.
|
|
3
|
+
*
|
|
4
|
+
* COLORFGBG (set by rxvt, Konsole and others: "foreground;background" as color numbers) answers at once.
|
|
5
|
+
* Otherwise Nou asks the terminal for its background color (OSC 11, answered by iTerm2, Terminal.app,
|
|
6
|
+
* kitty, Ghostty, WezTerm, Alacritty, foot, GNOME's and VS Code's terminals and recent Windows Terminal).
|
|
7
|
+
* A terminal that doesn't answer within `timeoutMs` gets null, and Nou keeps its dark colors.
|
|
8
|
+
*/
|
|
9
|
+
export async function detectBackground(timeoutMs = 150) {
|
|
10
|
+
const fromEnv = paletteFromColorFgBg(process.env.COLORFGBG);
|
|
11
|
+
if (fromEnv)
|
|
12
|
+
return fromEnv;
|
|
13
|
+
const stdin = process.stdin;
|
|
14
|
+
if (!stdin.isTTY || !process.stdout.isTTY || typeof stdin.setRawMode !== "function")
|
|
15
|
+
return null;
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
let reply = "";
|
|
18
|
+
const wasRaw = stdin.isRaw;
|
|
19
|
+
const finish = (result) => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
stdin.off("data", onData);
|
|
22
|
+
stdin.setRawMode(wasRaw);
|
|
23
|
+
stdin.pause();
|
|
24
|
+
resolve(result);
|
|
25
|
+
};
|
|
26
|
+
const onData = (data) => {
|
|
27
|
+
reply += data.toString("latin1");
|
|
28
|
+
const palette = paletteFromOsc11(reply);
|
|
29
|
+
if (palette)
|
|
30
|
+
finish(palette);
|
|
31
|
+
};
|
|
32
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
33
|
+
stdin.setRawMode(true);
|
|
34
|
+
stdin.resume();
|
|
35
|
+
stdin.on("data", onData);
|
|
36
|
+
// ST-terminated (ESC \): the form most terminals answer in kind.
|
|
37
|
+
process.stdout.write("\x1b]11;?\x1b\\");
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** "15;0" (light text on black) → dark; "0;15" → light. The last number is the background's color index. */
|
|
41
|
+
export function paletteFromColorFgBg(value) {
|
|
42
|
+
const bg = Number(value?.split(";").at(-1));
|
|
43
|
+
if (!value || !Number.isInteger(bg))
|
|
44
|
+
return null;
|
|
45
|
+
// 7 (light gray) and 9-15 (bright colors, white) are light backgrounds; 0-6 and 8 are dark.
|
|
46
|
+
return bg === 7 || (bg >= 9 && bg <= 15) ? "light" : "dark";
|
|
47
|
+
}
|
|
48
|
+
/** The terminal's answer to OSC 11: ESC ] 11 ; rgb:RRRR/GGGG/BBBB (1-4 hex digits a channel), then BEL or ESC \. */
|
|
49
|
+
export function paletteFromOsc11(reply) {
|
|
50
|
+
// eslint-disable-next-line no-control-regex -- the reply starts with ESC
|
|
51
|
+
const m = reply.match(/\x1b\]11;rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i);
|
|
52
|
+
if (!m)
|
|
53
|
+
return null;
|
|
54
|
+
const channel = (hex) => parseInt(hex, 16) / (16 ** hex.length - 1);
|
|
55
|
+
const [r, g, b] = [channel(m[1]), channel(m[2]), channel(m[3])];
|
|
56
|
+
// Perceived brightness (Rec. 709 weights); above half is a light background.
|
|
57
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.5 ? "light" : "dark";
|
|
58
|
+
}
|