nou-cli 0.5.6 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,14 +2,39 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { memo } from "react";
3
3
  import path from "path";
4
4
  import { Box, Text, useAnimation } from "ink";
5
- import { C, 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
+ export const Header = memo(function Header({ width, branch, changes, model, local, busy, update, tabs = [] }) {
10
33
  // The spinner stops while Nou's window is in the background: nobody sees it, and each frame is a redraw.
11
34
  const windowFocused = useTerminalFocused();
12
35
  const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
13
36
  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} ` }), " "] }) })] }));
37
+ return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, flexGrow: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), tabs.length
38
+ ? tabs.map((t, i) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: t.active ? C.accent : C.text, bold: t.active, children: tabLabel(t, i) }), " "] }, t.id)))
39
+ : _jsxs(Text, { color: C.text, children: [project, " "] }), _jsx(Text, { color: C.accent2, children: " + " }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] }))] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [update && width >= 110 && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: C.onBadge, bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] }) })] }));
15
40
  });
@@ -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
  }
@@ -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
  }
@@ -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] }));
@@ -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
+ }
@@ -2,19 +2,58 @@
2
2
  * Commands that only change what's on screen (or handle a busy Nou themselves): they run right away
3
3
  * while Nou works instead of waiting in the queue behind it.
4
4
  */
5
- const INSTANT = new Set(["fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
5
+ const INSTANT = new Set(["now", "fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
6
6
  /** Whether a typed command runs right away while Nou works (see INSTANT); `/showme` counts only to look or step. */
7
+ /**
8
+ * The command a mistyped name means: the same letters in another order ("/termnial"), or one letter off
9
+ * ("/termial", "/terminall"). Only when exactly one command fits, so a typo never runs the wrong one.
10
+ */
11
+ export function closestCommand(name, names) {
12
+ const word = name.toLowerCase();
13
+ if (word.length < 3)
14
+ return null;
15
+ const sorted = (s) => [...s].sort().join("");
16
+ const anagrams = names.filter((n) => n.length === word.length && sorted(n) === sorted(word));
17
+ if (anagrams.length === 1)
18
+ return anagrams[0];
19
+ if (anagrams.length > 1)
20
+ return null;
21
+ const near = names.filter((n) => editDistance(word, n) <= 1);
22
+ return near.length === 1 ? near[0] : null;
23
+ }
24
+ /** Levenshtein distance, stopping early once it's over 1 (all closestCommand needs). */
25
+ function editDistance(a, b) {
26
+ if (Math.abs(a.length - b.length) > 1)
27
+ return 2;
28
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
29
+ for (let i = 1; i <= a.length; i++) {
30
+ const row = [i];
31
+ for (let j = 1; j <= b.length; j++)
32
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
33
+ if (Math.min(...row) > 1)
34
+ return 2;
35
+ prev = row;
36
+ }
37
+ return prev[b.length];
38
+ }
7
39
  export function runsWhileBusy(text) {
8
40
  const [name, ...rest] = text.slice(1).trim().split(/\s+/);
9
- if (name === "showme" || name === "teach")
10
- return !rest.length || /^(next|skip|stop|end)$/.test(rest.join(" "));
11
- return text.startsWith("/") && INSTANT.has(name ?? "");
41
+ if (name === "showme" || name === "teach" || name === "teachme")
42
+ return !rest.length || /^(next|skip|back|prev|previous|stop|end|done)$/.test(rest.join(" "));
43
+ if (!text.startsWith("/") || !name)
44
+ return false;
45
+ return INSTANT.has(name) || INSTANT.has(closestCommand(name, SLASH_COMMANDS.map((c) => c.name)) ?? "");
12
46
  }
47
+ /** Whether a command only changes what's on screen, so running it for a typo can't do harm. */
48
+ export const onlyChangesScreen = (name) => INSTANT.has(name);
13
49
  export const SLASH_COMMANDS = [
14
50
  { name: "model", args: "[provider:model]", description: "show or switch the model" },
15
51
  { name: "models", args: "[provider]", description: "list the models a provider offers" },
16
52
  { name: "connect", args: "<provider>", description: "enter an API key (and URL) for a provider" },
17
53
  { name: "open", args: "<file>[:line]", description: "open a file in the code view (part of its name is enough)" },
54
+ { name: "effort", args: "[fastest|fast|balanced|smarter|smartest]", description: "speed vs smarts: how hard the model thinks (cheaper + faster, or smarter)" },
55
+ { name: "workspace", args: "[new <folder> | n]", description: "several projects in this window, a tab each in the header: open one, or switch" },
56
+ { name: "now", description: "stop Nou and send your queued messages right away (or click them)" },
18
57
  { name: "settings", description: "models, engine, preferences and plugins" },
19
58
  { name: "auto", args: "[ask|auto-edit|auto]", description: "how much Nou does without asking" },
20
59
  { name: "engine", args: "[name]", description: "which agent answers: nou, claude-code, codex or pi" },
@@ -28,8 +67,8 @@ export const SLASH_COMMANDS = [
28
67
  { name: "copy", args: "[code|file]", description: "copy the last reply, its last code block, or the open file" },
29
68
  { name: "compact", description: "summarize the conversation to free up context" },
30
69
  { name: "clear", description: "start a fresh conversation" },
31
- { name: "showme", args: "<what you want>", description: "Nou shows you how, step by step, and you type it (with the code shown ahead of your cursor)" },
32
- { name: "teach", args: "<what you want>", description: "same as /showme: Nou teaches you to make the change yourself, step by step" },
70
+ { name: "showme", args: "<question>", description: "Nou explains it and opens the places in the code, highlighted (next/back step through them); any engine" },
71
+ { name: "teach", args: "<what you want>", description: "Nou walks you through making the change yourself, step by step, with the code shown ahead of your cursor" },
33
72
  { name: "new", description: "a new conversation tab: another agent, working alongside this one" },
34
73
  { name: "tab", args: "[n]", description: "list the conversation tabs, or switch to tab n" },
35
74
  { name: "close", description: "close this conversation tab (it stays saved for /resume)" },
@@ -1,6 +1,9 @@
1
1
  import path from "path";
2
- /** Tokyo Night inspired palette (truecolor; Windows Terminal supports it). */
3
- export const C = {
2
+ /**
3
+ * Tokyo Night inspired palette (truecolor; Windows Terminal supports it). Secondary text ("dim") is still at
4
+ * least 4.5:1 against common dark backgrounds, so hints and help lines stay easy to read.
5
+ */
6
+ const DARK = {
4
7
  accent: "#7aa2f7",
5
8
  accent2: "#bb9af7",
6
9
  cyan: "#7dcfff",
@@ -9,27 +12,70 @@ export const C = {
9
12
  orange: "#ff9e64",
10
13
  red: "#f7768e",
11
14
  text: "#c0caf5",
12
- dim: "#565f89",
15
+ dim: "#8b93c0",
16
+ /** Lines around panes that aren't focused: quieter than text, since they aren't read. */
17
+ border: "#565f89",
13
18
  panel: "#1f2335",
19
+ /** Text on a colored badge (CHAT, CLOUD, EDITING...). */
20
+ onBadge: "#1a1b26",
14
21
  };
22
+ /** For terminals with a light background: the same roles, dark enough to read on white (4.5:1 or more). */
23
+ const LIGHT = {
24
+ accent: "#2e59c9",
25
+ accent2: "#7341c2",
26
+ cyan: "#007197",
27
+ green: "#3b7a1e",
28
+ yellow: "#8f5e00",
29
+ orange: "#b4501b",
30
+ red: "#c2264b",
31
+ text: "#1f2335",
32
+ dim: "#5a607a",
33
+ border: "#a8aecb",
34
+ panel: "#e6e7ed",
35
+ onBadge: "#ffffff",
36
+ };
37
+ /** The colors in use: dark unless the terminal is light (see setPalette). Read at render time, so set it first. */
38
+ export const C = { ...DARK };
39
+ let current = "dark";
40
+ export const palette = () => current;
41
+ /** Switches every color to the dark or light set. Call before the first render. */
42
+ export function setPalette(which) {
43
+ current = which;
44
+ Object.assign(C, which === "light" ? LIGHT : DARK);
45
+ Object.assign(GIT_COLORS, gitColors());
46
+ for (const [ext, color] of Object.entries(iconColors()))
47
+ ICONS[ext][1] = color;
48
+ }
15
49
  /** Every spinner and shimmer ticks on this one interval, so they redraw together in a single frame. */
16
50
  export const ANIMATION_MS = 100;
17
51
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
18
- const GRADIENT = [C.accent, "#8c9cf7", C.accent2, "#c39af2", "#e08ad6", C.red, C.orange, C.yellow, C.green, C.cyan];
52
+ const gradientColors = () => current === "light"
53
+ ? [C.accent, "#4b4fc4", C.accent2, "#8a3fb0", "#a8327f", C.red, C.orange, C.yellow, C.green, C.cyan]
54
+ : [C.accent, "#8c9cf7", C.accent2, "#c39af2", "#e08ad6", C.red, C.orange, C.yellow, C.green, C.cyan];
19
55
  /** Colors for each character of a label, shifted by `frame` so it shimmers when animated. */
20
56
  export function gradient(text, frame = 0) {
21
- return [...text].map((ch, i) => ({ ch, color: GRADIENT[(i + frame) % GRADIENT.length] }));
57
+ const colors = gradientColors();
58
+ return [...text].map((ch, i) => ({ ch, color: colors[(i + frame) % colors.length] }));
22
59
  }
23
- export const GIT_COLORS = {
24
- M: C.yellow, A: C.green, "?": C.green, D: C.red, R: C.cyan, U: C.orange,
60
+ const gitColors = () => ({ M: C.yellow, A: C.green, "?": C.green, D: C.red, R: C.cyan, U: C.orange });
61
+ export const GIT_COLORS = gitColors();
62
+ /** Each file type's icon color: language colors, with JavaScript's yellow darkened on a light background. */
63
+ const iconColors = () => {
64
+ const js = current === "light" ? "#8a7400" : "#f1e05a";
65
+ return {
66
+ ".ts": "#3178c6", ".tsx": C.cyan, ".mts": "#3178c6", ".cts": "#3178c6",
67
+ ".js": js, ".jsx": C.cyan, ".mjs": js, ".cjs": js,
68
+ ".py": "#3572a5", ".json": C.yellow, ".md": C.text, ".css": C.accent2,
69
+ ".html": C.orange, ".yml": C.red, ".yaml": C.red, ".go": C.cyan,
70
+ ".rs": C.orange, ".sh": C.green, ".ps1": C.accent, ".lock": C.dim,
71
+ };
25
72
  };
26
- const ICONS = {
27
- ".ts": ["◆", "#3178c6"], ".tsx": ["◆", C.cyan], ".mts": ["◆", "#3178c6"], ".cts": ["◆", "#3178c6"],
28
- ".js": ["◆", "#f1e05a"], ".jsx": ["◆", C.cyan], ".mjs": ["◆", "#f1e05a"], ".cjs": ["◆", "#f1e05a"],
29
- ".py": ["◆", "#3572a5"], ".json": ["◇", C.yellow], ".md": ["≡", C.text], ".css": ["◆", C.accent2],
30
- ".html": ["◆", C.orange], ".yml": ["◇", C.red], ".yaml": ["◇", C.red], ".go": ["◆", C.cyan],
31
- ".rs": ["◆", C.orange], ".sh": ["$", C.green], ".ps1": ["$", C.accent], ".lock": ["◌", C.dim],
73
+ const ICON_SHAPES = {
74
+ ".ts": "◆", ".tsx": "◆", ".mts": "◆", ".cts": "◆", ".js": "◆", ".jsx": "◆", ".mjs": "◆", ".cjs": "◆",
75
+ ".py": "◆", ".json": "◇", ".md": "≡", ".css": "◆", ".html": "◆", ".yml": "◇", ".yaml": "◇", ".go": "◆",
76
+ ".rs": "◆", ".sh": "$", ".ps1": "$", ".lock": "◌",
32
77
  };
78
+ const ICONS = Object.fromEntries(Object.entries(iconColors()).map(([ext, color]) => [ext, [ICON_SHAPES[ext], color]]));
33
79
  export function fileIcon(file) {
34
80
  const base = path.basename(file).toLowerCase();
35
81
  if (base.startsWith(".env"))
@@ -0,0 +1,69 @@
1
+ import net from "net";
2
+ import path from "path";
3
+ import { useSyncExternalStore } from "react";
4
+ let tabs = [];
5
+ const listeners = new Set();
6
+ let socket = null;
7
+ /** Whether this Nou is one workspace of several (run by the host). */
8
+ export const isHosted = () => !!process.env.NOU_HOST_PORT && !!process.env.NOU_WORKSPACE_ID;
9
+ export function workspaceTabs() {
10
+ return tabs;
11
+ }
12
+ export function useWorkspaceTabs() {
13
+ return useSyncExternalStore((fn) => {
14
+ listeners.add(fn);
15
+ return () => listeners.delete(fn);
16
+ }, workspaceTabs, workspaceTabs);
17
+ }
18
+ /** One JSON message a line. */
19
+ export function lineReader(onMessage) {
20
+ let carry = "";
21
+ return (chunk) => {
22
+ carry += String(chunk);
23
+ let at;
24
+ while ((at = carry.indexOf("\n")) >= 0) {
25
+ const line = carry.slice(0, at);
26
+ carry = carry.slice(at + 1);
27
+ try {
28
+ onMessage(JSON.parse(line));
29
+ }
30
+ catch {
31
+ // not ours
32
+ }
33
+ }
34
+ };
35
+ }
36
+ /** Connects to the host (when hosted) and passes on what it says. */
37
+ export function connectToHost(on) {
38
+ if (!isHosted() || socket)
39
+ return;
40
+ const s = net.connect(Number(process.env.NOU_HOST_PORT), "127.0.0.1");
41
+ s.unref(); // never what keeps Nou running once you quit it
42
+ socket = s;
43
+ s.on("connect", () => sendToHost({ type: "hello", id: process.env.NOU_WORKSPACE_ID, token: process.env.NOU_HOST_TOKEN ?? "" }));
44
+ s.on("data", lineReader((msg) => {
45
+ if (msg.type === "tabs") {
46
+ tabs = msg.tabs;
47
+ for (const fn of listeners)
48
+ fn();
49
+ }
50
+ else if (msg.type === "redraw")
51
+ on.redraw();
52
+ else if (msg.type === "error")
53
+ on.error(msg.text);
54
+ }));
55
+ s.on("error", () => { });
56
+ s.on("close", () => (socket = null));
57
+ }
58
+ export function sendToHost(msg) {
59
+ if (!socket || socket.destroyed)
60
+ return false;
61
+ socket.write(`${JSON.stringify(msg)}\n`);
62
+ return true;
63
+ }
64
+ /** What a tab is called: the folder's name. */
65
+ export const tabName = (folder) => path.basename(path.resolve(folder)) || folder;
66
+ /** Set when this Nou should reopen as a workspaces window as it quits (see cli.ts). */
67
+ let reopenAs = null;
68
+ export const reopenAsWorkspaces = (folders) => (reopenAs = folders);
69
+ export const pendingWorkspaces = () => reopenAs;
@@ -0,0 +1,144 @@
1
+ import { randomBytes } from "crypto";
2
+ import { existsSync, statSync } from "fs";
3
+ import net from "net";
4
+ import os from "os";
5
+ import path from "path";
6
+ import { loadPackage } from "../engines/index.js";
7
+ import { lineReader, tabName } from "./client.js";
8
+ /** The pseudo-terminal package workspaces run in, installed on first use into ~/.nou/engines like an engine. */
9
+ export const PTY_PACKAGE = "@lydell/node-pty";
10
+ /** A folder for a new workspace: `~` and relative paths (from `from`) resolved; null when it isn't a folder. */
11
+ export function workspaceFolder(folder, from) {
12
+ const raw = folder.trim().replace(/^["']|["']$/g, "") || from;
13
+ const full = path.resolve(from, raw.startsWith("~") ? path.join(os.homedir(), raw.slice(1)) : raw);
14
+ try {
15
+ return existsSync(full) && statSync(full).isDirectory() ? full : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ /**
22
+ * Runs the workspaces window: each folder becomes a workspace, a whole Nou in its own pseudo-terminal. Only
23
+ * the active one is drawn; the others keep working (their agents too) and draw everything again when you
24
+ * switch to them. `firstArgs` go to the first workspace (e.g. --continue, to pick its conversation back up).
25
+ * Resolves with the exit code once the last workspace is closed.
26
+ */
27
+ export async function runHost(folders, firstArgs, command = [process.execPath, process.argv[1]]) {
28
+ const mod = await loadPackage(PTY_PACKAGE);
29
+ const spawnPty = mod.spawn ?? mod.default?.spawn;
30
+ const token = randomBytes(16).toString("hex");
31
+ const workspaces = [];
32
+ let active = null;
33
+ let nextId = 1;
34
+ const size = () => ({ cols: process.stdout.columns || 120, rows: process.stdout.rows || 40 });
35
+ const send = (w, msg) => w.socket?.write(`${JSON.stringify(msg)}\n`);
36
+ const tabs = () => workspaces.map((w) => ({ id: w.id, name: tabName(w.folder), folder: w.folder, busy: w.busy, active: w === active }));
37
+ const broadcast = () => {
38
+ const list = tabs();
39
+ for (const w of workspaces)
40
+ send(w, { type: "tabs", tabs: list });
41
+ };
42
+ const activate = (w) => {
43
+ active = w;
44
+ broadcast();
45
+ send(w, { type: "redraw" });
46
+ };
47
+ const server = net.createServer((socket) => {
48
+ socket.on("data", lineReader((msg) => {
49
+ const from = workspaces.find((w) => w.socket === socket);
50
+ if (msg.type === "hello") {
51
+ const w = workspaces.find((x) => x.id === msg.id);
52
+ if (!w || msg.token !== token)
53
+ return socket.destroy(); // only our own workspaces
54
+ w.socket = socket;
55
+ send(w, { type: "tabs", tabs: tabs() });
56
+ if (w === active)
57
+ send(w, { type: "redraw" });
58
+ return;
59
+ }
60
+ if (!from)
61
+ return;
62
+ if (msg.type === "state") {
63
+ from.busy = msg.busy;
64
+ broadcast();
65
+ }
66
+ else if (msg.type === "switch") {
67
+ const to = workspaces[msg.index];
68
+ if (to && to !== active)
69
+ activate(to);
70
+ }
71
+ else if (msg.type === "new") {
72
+ const folder = workspaceFolder(msg.folder, from.folder);
73
+ if (!folder)
74
+ return send(from, { type: "error", text: `No such folder: ${msg.folder}` });
75
+ activate(open(folder, []));
76
+ }
77
+ }));
78
+ socket.on("error", () => { });
79
+ });
80
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
81
+ const port = server.address().port;
82
+ let finish = () => { };
83
+ const done = new Promise((resolve) => (finish = resolve));
84
+ function open(folder, args) {
85
+ const id = String(nextId++);
86
+ const { cols, rows } = size();
87
+ const pty = spawnPty(command[0], [...command.slice(1), ...args], {
88
+ name: "xterm-256color",
89
+ cols,
90
+ rows,
91
+ cwd: folder,
92
+ env: { ...process.env, NOU_HOST_PORT: String(port), NOU_HOST_TOKEN: token, NOU_WORKSPACE_ID: id },
93
+ });
94
+ const w = { id, folder, pty, busy: false, socket: null };
95
+ workspaces.push(w);
96
+ pty.onData((data) => {
97
+ if (w === active)
98
+ process.stdout.write(data);
99
+ });
100
+ pty.onExit(({ exitCode }) => {
101
+ const at = workspaces.indexOf(w);
102
+ if (at >= 0)
103
+ workspaces.splice(at, 1);
104
+ w.socket?.destroy();
105
+ if (!workspaces.length)
106
+ return finish(exitCode);
107
+ if (w === active)
108
+ activate(workspaces[Math.min(at, workspaces.length - 1)]);
109
+ else
110
+ broadcast();
111
+ });
112
+ return w;
113
+ }
114
+ // Keys and mouse go to the workspace on screen; a resize goes to all of them.
115
+ const stdin = process.stdin;
116
+ if (stdin.isTTY)
117
+ stdin.setRawMode(true);
118
+ stdin.resume();
119
+ const onInput = (data) => active?.pty.write(data.toString("utf-8"));
120
+ stdin.on("data", onInput);
121
+ const onResize = () => {
122
+ const { cols, rows } = size();
123
+ for (const w of workspaces)
124
+ w.pty.resize(cols, rows);
125
+ };
126
+ process.stdout.on("resize", onResize);
127
+ const killAll = () => {
128
+ for (const w of workspaces)
129
+ w.pty.kill();
130
+ };
131
+ process.on("exit", killAll);
132
+ folders.forEach((folder, i) => open(folder, i === 0 ? firstArgs : []));
133
+ active = workspaces[workspaces.length - 1] ?? null; // the one just opened is the one you asked for
134
+ broadcast();
135
+ const code = await done;
136
+ stdin.off("data", onInput);
137
+ if (stdin.isTTY)
138
+ stdin.setRawMode(false);
139
+ stdin.pause();
140
+ process.stdout.off("resize", onResize);
141
+ process.off("exit", killAll);
142
+ server.close();
143
+ return code;
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.6",
3
+ "version": "0.5.10",
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": {
@@ -61,6 +61,7 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@eslint/js": "^10.0.1",
64
+ "@lydell/node-pty": "1.2.0-beta.18",
64
65
  "@types/node": "^22.20.5",
65
66
  "@types/react": "^19.3.0",
66
67
  "eslint": "^10.2.0",