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.
@@ -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: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
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
- for (const text of queued) {
196
- footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim });
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 (!range)
275
- return;
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
- }, [select?.nonce]);
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
  });
@@ -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 (key.ctrl && input === "f")
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 (key.ctrl && input === "f" && !diff)
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: "black", 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
+ 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: [" \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 : ""] })] }));
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);
@@ -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 (key.ctrl && input === "f")
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 (key.ctrl && input === "f") {
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" : ""} Ctrl+F find · Ctrl+/ comment · Esc done`);
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: "black", children: " \u270E EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
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];
@@ -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 ? "✖" : counts?.warnings ? "▲" : "";
186
- const marker = problemMark || code || (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
187
- const markerColor = counts?.errors ? C.red : counts?.warnings ? C.yellow : code ? GIT_COLORS[code] ?? C.yellow : changed.has(node.path) ? C.green : C.yellow;
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);
@@ -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: "black", bold: true, children: msg }), line.slice(left + msg.length)] }, i);
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] })] }));
@@ -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, SPINNER, gradient, ANIMATION_MS } from "./theme.js";
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
- export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
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
- return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, flexGrow: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [update && width >= 110 && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] }) })] }));
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
  });
@@ -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.dim);
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.dim, wrap: "truncate-end", children: title }), children] }));
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: "✖", color: C.red },
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: ["\u2716 ", 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)" : "" })] }));
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: [" \u2716 ", row.errors] }), row.warnings > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", row.warnings] })] }, `f${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: [" \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}`));
@@ -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
- if (found[cursor])
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: "Go to file\u2026 (> for commands)" }), _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) => {
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;
@@ -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: "Reasoning effort", help: "How hard Claude thinks (low → max). Higher is smarter but slower and pricier." },
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: ["\u2716 ", reason] }) : null, connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
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" || pref.key === "autoSave" || pref.key === "followSpeed"
255
- ? _jsx(Text, { children: (pref.key === "effort" ? EFFORTS : pref.key === "autoSave" ? AUTO_SAVE : FOLLOW_SPEEDS).map((e) => _jsx(Text, { color: e === value ? C.accent2 : C.dim, bold: e === value, children: e === value ? `[${e}]` : ` ${e} ` }, e)) })
256
- : toggle(value, pref.warn)] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", pref.help] }), pref.key === "codeTheme" && selected
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: [" \u2716 ", p.error] })
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
  };
@@ -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
+ }