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 +2 -2
- package/dist/src/cli.js +13 -3
- package/dist/src/ui/App.js +9 -3
- package/dist/src/ui/Chat.js +16 -3
- package/dist/src/ui/ChatInput.js +28 -5
- package/dist/src/ui/background.js +27 -18
- package/package.json +1 -1
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>
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/src/ui/App.js
CHANGED
|
@@ -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
|
};
|
package/dist/src/ui/Chat.js
CHANGED
|
@@ -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
|
-
|
|
412
|
-
|
|
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
|
});
|
package/dist/src/ui/ChatInput.js
CHANGED
|
@@ -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),
|
|
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),
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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 = (
|
|
17
|
+
const finish = () => {
|
|
20
18
|
clearTimeout(timer);
|
|
21
19
|
stdin.off("data", onData);
|
|
22
20
|
stdin.setRawMode(wasRaw);
|
|
23
21
|
stdin.pause();
|
|
24
|
-
resolve(
|
|
22
|
+
resolve(readTerminalReply(reply, fromEnv));
|
|
25
23
|
};
|
|
26
24
|
const onData = (data) => {
|
|
27
25
|
reply += data.toString("latin1");
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
finish(palette);
|
|
26
|
+
if (DA1.test(reply))
|
|
27
|
+
finish();
|
|
31
28
|
};
|
|
32
|
-
const timer = setTimeout(
|
|
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.
|
|
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": {
|