nou-cli 0.5.11 → 0.5.12

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 CHANGED
@@ -94,7 +94,7 @@ Every change is reviewed like a code review:
94
94
  | <kbd>Tab</kbd> | switch between files, code and chat |
95
95
  | <kbd>Shift</kbd>+<kbd>Tab</kbd> | ask → auto-edit (edits apply without asking) → auto (commands too); everything stays undoable |
96
96
  | <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file; the files you've opened are listed first |
97
- | <kbd>+</kbd> / <kbd>-</kbd> (in the code) or `/zoom in` / `/zoom out` | make the code panel bigger or smaller, a step at a time: the files and chat give or take the room; <kbd>=</kbd> or `/zoom reset` puts it back (for bigger text, use your terminal's zoom, Cmd/Ctrl and +) |
97
+ | <kbd>Ctrl</kbd>+<kbd>=</kbd> / <kbd>Ctrl</kbd>+<kbd>-</kbd> from anywhere (or <kbd>+</kbd> / <kbd>-</kbd> in the code or files) | zoom the code panel bigger or smaller, a step at a time: the files and chat give or take the room; <kbd>Ctrl</kbd>+<kbd>0</kbd> or <kbd>=</kbd> puts it back. Ctrl+= and Ctrl+- need a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, iTerm2). For bigger text, use your terminal's own zoom |
98
98
  | <kbd>b</kbd> | back to the previous file (press again to flip back) |
99
99
  | <kbd>Ctrl</kbd>+<kbd>F</kbd> (<kbd>Cmd</kbd>+<kbd>F</kbd> on a Mac, in terminals that report Cmd: Ghostty, kitty, WezTerm, iTerm2) | find in the open file, from any pane: matches are highlighted and counted; <kbd>Enter</kbd>/<kbd>↓</kbd> next, <kbd>↑</kbd> previous, <kbd>Esc</kbd> close |
100
100
  | 🗂️ Tabs | every file you open gets a tab over the code: click one to switch, any tab's × to close it, ‹ › for ones that don't fit · <kbd>[</kbd>/<kbd>]</kbd> previous/next · <kbd>x</kbd> or <kbd>Ctrl</kbd>+<kbd>W</kbd> close |
@@ -135,7 +135,7 @@ Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]`
135
135
 
136
136
  A small typo in a command still works when it only changes the screen (`/termnial` opens the terminal); for others Nou asks "Did you mean...?" instead of guessing.
137
137
 
138
- New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol), or type `\` and press <kbd>Enter</kbd> in any terminal.
138
+ New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol, which Nou checks for before it starts; also xterm's modifyOtherKeys form), or type `\` and press <kbd>Enter</kbd> in any terminal.
139
139
 
140
140
  Conversations are saved per folder after each reply. `/resume` opens a picker (↑↓ and Enter, type to filter, or type its number) and `/resume 2` (or `nou --continue` for the last one, `nou --resume` for the list) picks one back up with the engine it was on: Nou's own engine restores its messages, Claude Code its session and Codex its thread; pi is handed the conversation so far. Sessions you started in Claude Code's or Codex's own CLI in this folder are listed too (marked "CLI"), and resuming one carries it on in that engine inside Nou.
141
141
 
package/dist/src/cli.js CHANGED
@@ -15,7 +15,7 @@ import { App } from "./ui/App.js";
15
15
  import { installFastOutput } from "./ui/fast-output.js";
16
16
  import { RENDER_OPTIONS } from "./ui/render-options.js";
17
17
  import { setScreenClear } from "./ui/redraw.js";
18
- import { detectBackground } from "./ui/background.js";
18
+ import { detectTerminal } from "./ui/background.js";
19
19
  import { setPalette } from "./ui/theme.js";
20
20
  import { runHost, workspaceFolder } from "./workspaces/host.js";
21
21
  import { pendingWorkspaces } from "./workspaces/client.js";
@@ -96,7 +96,11 @@ async function main() {
96
96
  console.error(`Not a folder, so not opened: ${missing.join(", ")}`);
97
97
  const uiTheme = loadSettings().uiTheme;
98
98
  // Every workspace uses the colors found for this terminal (they can't ask it themselves from the background).
99
- process.env.NOU_PALETTE = uiTheme === "dark" || uiTheme === "light" ? uiTheme : (await detectBackground()) ?? "dark";
99
+ // The host asks the terminal once and tells every workspace (in the background they can't ask it).
100
+ const term = await detectTerminal();
101
+ process.env.NOU_PALETTE = uiTheme === "dark" || uiTheme === "light" ? uiTheme : term.palette ?? "dark";
102
+ if (term.kitty !== null)
103
+ process.env.NOU_KITTY = term.kitty ? "1" : "0";
100
104
  const firstArgs = args.includes("--continue") || args.includes("-c") ? ["--continue"] : [];
101
105
  process.exit(await runHost(folders.length ? folders : [process.cwd()], firstArgs));
102
106
  }
@@ -163,7 +167,13 @@ async function main() {
163
167
  // Colors that are readable on this terminal: a dark or a light set, from the setting or the background.
164
168
  const uiTheme = loadSettings().uiTheme;
165
169
  const fromHost = process.env.NOU_PALETTE;
166
- setPalette(uiTheme === "dark" || uiTheme === "light" ? uiTheme : fromHost === "light" || fromHost === "dark" ? fromHost : (await detectBackground()) ?? "dark");
170
+ const hostKnows = fromHost === "light" || fromHost === "dark";
171
+ const term = hostKnows ? { palette: fromHost, kitty: process.env.NOU_KITTY ? process.env.NOU_KITTY === "1" : null } : await detectTerminal();
172
+ setPalette(uiTheme === "dark" || uiTheme === "light" ? uiTheme : term.palette ?? "dark");
173
+ // Turn the kitty keyboard protocol on when the terminal said it speaks it (Shift/Ctrl/Cmd+Enter, Cmd+F and
174
+ // Ctrl+= need it), off when it answered without it; ask the way Ink does only if it answered nothing.
175
+ if (term.kitty !== null && RENDER_OPTIONS.kittyKeyboard)
176
+ RENDER_OPTIONS.kittyKeyboard = { ...RENDER_OPTIONS.kittyKeyboard, mode: term.kitty ? "enabled" : "disabled" };
167
177
  await installFastOutput();
168
178
  // While Nou is on screen, anything a library prints (console.log, warnings, stderr) goes to a log file:
169
179
  // written into the terminal, it would scroll the screen out from under the mouse.
@@ -1484,6 +1484,12 @@ export function App({ initialModel, permContext, resume }) {
1484
1484
  if (focus !== "chat" && input === "o" && pendingChange)
1485
1485
  return openPending();
1486
1486
  }
1487
+ // Zoom the code panel from anywhere: Ctrl+= / Ctrl+- / Ctrl+0, in terminals that report Ctrl with those
1488
+ // keys (the kitty keyboard protocol Nou asks for). Elsewhere Ctrl+- is the same key as Ctrl+/, so it's
1489
+ // left alone; + and - in the code or the files work in every terminal.
1490
+ if (key.ctrl && (input === "=" || input === "+" || input === "-" || input === "0") && !key.meta) {
1491
+ return zoomCode(input === "0" ? 0 : input === "-" ? -1 : 1);
1492
+ }
1487
1493
  if (key.ctrl && input === "p") {
1488
1494
  setQuickOpen("");
1489
1495
  return;
@@ -1555,7 +1561,7 @@ export function App({ initialModel, permContext, resume }) {
1555
1561
  setPaneWidths(null);
1556
1562
  updateSettings({ paneWidths: null });
1557
1563
  }
1558
- else if ((input === "+" || input === "-") && focus === "code") {
1564
+ else if ((input === "+" || input === "-") && (focus === "code" || focus === "tree")) {
1559
1565
  zoomCode(input === "+" ? 1 : -1);
1560
1566
  }
1561
1567
  else if (input === "v")
@@ -2500,7 +2506,7 @@ ${snippet}
2500
2506
  ? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
2501
2507
  : diff
2502
2508
  ? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
2503
- : [["double-click/e", "edit"], [FIND_KEY, "find"], ["v", "diff"], ...(recent.length > 1 ? [["b", "back"]] : []), ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
2509
+ : [["double-click/e", "edit"], [FIND_KEY, "find"], ["v", "diff"], ...(recent.length > 1 ? [["b", "back"]] : []), ["u", "undo"], ["/", "command"], ["p", "problems"], ["+ -", "zoom"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
2504
2510
  const followChip = follow ? ` ◉ follow${followLag ? ` ·${followLag}` : ""} ` : " ○ follow ";
2505
2511
  const statusUsed = mode.label.length + 2 + autoMode.length + 2 + followChip.length + ` ✗ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
2506
2512
  const shownHints = [];
@@ -2515,7 +2521,7 @@ ${snippet}
2515
2521
  y: () => answer(true), n: approval ? () => answer(false) : undefined, o: openPending,
2516
2522
  "Shift+Tab": () => cycleAutoMode(), "/": () => typeCommand("/"), "Ctrl+P": () => setQuickOpen(""),
2517
2523
  Tab: () => setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]),
2518
- e: startEditing, "double-click/e": startEditing, b: goBack, [FIND_KEY]: showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
2524
+ "+ -": () => zoomCode(1), e: startEditing, "double-click/e": startEditing, b: goBack, [FIND_KEY]: showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
2519
2525
  s: () => setView("settings"), "/help": () => void runCommand("/help"),
2520
2526
  Esc: busy ? stop : diff && !showEditor ? () => setDiffMode("off") : undefined,
2521
2527
  };
@@ -9,7 +9,7 @@ import { copyToClipboard } from "./clipboard.js";
9
9
  import { C, SPINNER, ANIMATION_MS } from "./theme.js";
10
10
  import { useTerminalFocused } from "./focus.js";
11
11
  import { Pane } from "./Pane.js";
12
- import { matchCommands } from "./commands.js";
12
+ import { SLASH_COMMANDS, matchCommands } from "./commands.js";
13
13
  import { Scrollbar, topFor } from "./scroll.js";
14
14
  /**
15
15
  * What the agent did between replies (files read, commands run...) folds into one line, like Claude's
@@ -203,6 +203,14 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
203
203
  const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
204
204
  const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
205
205
  const showMenu = matches.length > 0;
206
+ // A known command typed at the start of the input ("/zoom in") is drawn in the command color.
207
+ const commandLength = useMemo(() => {
208
+ const name = /^\/(\S+)/.exec(input)?.[1];
209
+ if (!name)
210
+ return 0;
211
+ const known = SLASH_COMMANDS.some((c) => c.name === name) || !!extraCommands?.some((c) => c.name === name);
212
+ return known ? name.length + 1 : 0;
213
+ }, [input, extraCommands]);
206
214
  useEffect(() => onMenu?.(showMenu), [showMenu]);
207
215
  const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
208
216
  const menuStart = Math.max(0, Math.min(menuRow - menuRows + 1, matches.length - menuRows));
@@ -408,6 +416,11 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
408
416
  }) })] })) })), [lines, firstShown, visible.length, selection, inner, empty]);
409
417
  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) => {
410
418
  const selected = menuStart + i === menuRow;
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));
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" })] }));
419
+ // What's typed so far shows in the command color; the rest of the name follows. A plugin's
420
+ // command says so, so Nou's own stand apart.
421
+ const typed = input.slice(1).toLowerCase();
422
+ const typedLen = typed && c.name.startsWith(typed) ? typed.length : 0;
423
+ const fromPlugin = !!extraCommands?.some((e) => e.name === c.name);
424
+ return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsx(Text, { color: C.accent2, bold: true, children: "/" }), _jsx(Text, { color: C.yellow, bold: true, children: c.name.slice(0, typedLen) }), _jsx(Text, { color: C.accent2, bold: selected, children: c.name.slice(typedLen) }), fromPlugin && _jsx(Text, { color: C.cyan, children: " plugin" }), 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));
425
+ }) })) : (_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, command: commandLength, 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" })] }));
413
426
  });
@@ -1,6 +1,6 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
- import { Box, Text, useInput, usePaste } from "ink";
3
+ import { Box, Text, useInput, usePaste, useStdin } from "ink";
4
4
  import { C } from "./theme.js";
5
5
  /** Word-wraps `text` into segments of at most `width` characters, honoring newlines. */
6
6
  export function layout(text, width) {
@@ -35,7 +35,7 @@ function cursorLine(segments, cursor) {
35
35
  }
36
36
  return segments.length - 1;
37
37
  }
38
- export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6, arrowsTaken = false }) {
38
+ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6, arrowsTaken = false, command = 0 }) {
39
39
  const [cursor, setCursor] = useState(value.length);
40
40
  const [recall, setRecall] = useState(null);
41
41
  const stash = useRef("");
@@ -57,6 +57,24 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
57
57
  setCursor(Math.max(0, Math.min(next.length, nextCursor)));
58
58
  };
59
59
  const insert = (text) => set(value.slice(0, pos) + text + value.slice(pos), pos + text.length);
60
+ // Shift/Ctrl/Cmd+Enter as xterm's modifyOtherKeys sends them (ESC [27;<mods>;13~, from terminals set up that
61
+ // way): Ink drops these, so they're caught here and add a line, like the kitty protocol's form does.
62
+ const { stdin } = useStdin();
63
+ const insertLine = useRef(() => { });
64
+ insertLine.current = () => insert("\n");
65
+ useEffect(() => {
66
+ if (!focus || !stdin)
67
+ return;
68
+ const onData = (data) => {
69
+ // eslint-disable-next-line no-control-regex -- the sequence starts with ESC
70
+ if (/\x1b\[27;([2-9]|1[0-6]);13~/.test(String(data)))
71
+ insertLine.current();
72
+ };
73
+ stdin.on("data", onData);
74
+ return () => {
75
+ stdin.off("data", onData);
76
+ };
77
+ }, [focus, stdin]);
60
78
  const recallHistory = (step) => {
61
79
  if (!history.length)
62
80
  return false;
@@ -131,11 +149,16 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
131
149
  return (_jsx(Box, { flexDirection: "column", children: shown.map((s, i) => {
132
150
  const text = value.slice(s.start, s.end).replace(/\n/g, "");
133
151
  const isCursor = focus && first + i === line;
152
+ // A known command at the start ("/zoom in") in the command color, the rest as typed.
153
+ const paint = (from, to) => {
154
+ const cmdEnd = Math.max(from, Math.min(to, command - s.start));
155
+ return (_jsxs(_Fragment, { children: [cmdEnd > from && _jsx(Text, { color: C.accent2, bold: true, children: text.slice(from, cmdEnd) }), _jsx(Text, { color: C.text, children: text.slice(cmdEnd, to) })] }));
156
+ };
134
157
  if (!isCursor) {
135
- return _jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text || " " })] }, first + i);
158
+ return _jsxs(Text, { children: [prompt(i), text ? paint(0, text.length) : " "] }, first + i);
136
159
  }
137
160
  const at = pos - s.start;
138
- return (_jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text.slice(0, at) }), _jsx(Text, { inverse: true, children: text[at] ?? " " }), _jsx(Text, { color: C.text, children: text.slice(at + 1) })] }, first + i));
161
+ return (_jsxs(Text, { children: [prompt(i), paint(0, at), _jsx(Text, { inverse: true, children: text[at] ?? " " }), paint(at + 1, text.length)] }, first + i));
139
162
  }) }));
140
163
  }
141
164
  /** How many rows the input will take, so the chat can size its history above it. */
@@ -1,42 +1,51 @@
1
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.
2
+ * Asks the terminal, once, before Nou draws anything:
3
+ * - its background color (OSC 11), so the colors are readable on it (COLORFGBG answers that without asking);
4
+ * - whether it speaks the kitty keyboard protocol (CSI ? u), which tells Shift/Ctrl/Cmd+Enter, Cmd+F and
5
+ * Ctrl+= apart from plain keys. Ink can ask too, but only after the first frame is drawn, and with a big
6
+ * project the answer lands after its 200 ms timeout, so the protocol never came on (Shift+Enter sent).
7
+ * Then DA1 (CSI c), which every terminal answers, and in order: once its answer is in, so are the others.
8
8
  */
9
- export async function detectBackground(timeoutMs = 150) {
9
+ export async function detectTerminal(timeoutMs = 400) {
10
10
  const fromEnv = paletteFromColorFgBg(process.env.COLORFGBG);
11
- if (fromEnv)
12
- return fromEnv;
13
11
  const stdin = process.stdin;
14
12
  if (!stdin.isTTY || !process.stdout.isTTY || typeof stdin.setRawMode !== "function")
15
- return null;
13
+ return { palette: fromEnv, kitty: null };
16
14
  return new Promise((resolve) => {
17
15
  let reply = "";
18
16
  const wasRaw = stdin.isRaw;
19
- const finish = (result) => {
17
+ const finish = () => {
20
18
  clearTimeout(timer);
21
19
  stdin.off("data", onData);
22
20
  stdin.setRawMode(wasRaw);
23
21
  stdin.pause();
24
- resolve(result);
22
+ resolve(readTerminalReply(reply, fromEnv));
25
23
  };
26
24
  const onData = (data) => {
27
25
  reply += data.toString("latin1");
28
- const palette = paletteFromOsc11(reply);
29
- if (palette)
30
- finish(palette);
26
+ if (DA1.test(reply))
27
+ finish();
31
28
  };
32
- const timer = setTimeout(() => finish(null), timeoutMs);
29
+ const timer = setTimeout(finish, timeoutMs);
33
30
  stdin.setRawMode(true);
34
31
  stdin.resume();
35
32
  stdin.on("data", onData);
36
- // ST-terminated (ESC \): the form most terminals answer in kind.
37
- process.stdout.write("\x1b]11;?\x1b\\");
33
+ // OSC 11 is ST-terminated (ESC \): the form most terminals answer in kind.
34
+ process.stdout.write(`${fromEnv ? "" : "\x1b]11;?\x1b\\"}\x1b[?u\x1b[c`);
38
35
  });
39
36
  }
37
+ // eslint-disable-next-line no-control-regex -- terminal answers start with ESC
38
+ const KITTY = /\x1b\[\?\d+u/;
39
+ // eslint-disable-next-line no-control-regex -- terminal answers start with ESC
40
+ const DA1 = /\x1b\[\?[\d;]*c/;
41
+ /** What the terminal's answers say (see detectTerminal); `fromEnv` is the background COLORFGBG gave, if any. */
42
+ export function readTerminalReply(reply, fromEnv = null) {
43
+ return { palette: fromEnv ?? paletteFromOsc11(reply), kitty: KITTY.test(reply) ? true : DA1.test(reply) ? false : null };
44
+ }
45
+ /** Just the background (see detectTerminal). */
46
+ export async function detectBackground(timeoutMs = 400) {
47
+ return (await detectTerminal(timeoutMs)).palette;
48
+ }
40
49
  /** "15;0" (light text on black) → dark; "0;15" → light. The last number is the background's color index. */
41
50
  export function paletteFromColorFgBg(value) {
42
51
  const bg = Number(value?.split(";").at(-1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "description": "Nou: an IDE-style AI coding assistant for your terminal. Local (Ollama, LM Studio) and cloud (Claude, OpenAI, Gemini...) models, live diffs, git-aware, built-in editor.",
5
5
  "type": "module",
6
6
  "bin": {