nou-cli 0.5.4 → 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,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 },
@@ -40,9 +42,11 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
40
42
  const [models, setModels] = useState({});
41
43
  const [connecting, setConnecting] = useState(connectTo?.connect ? { step: 0, values: {} } : null);
42
44
  const [field, setField] = useState(() => (connectTo?.connect ? initialValue(connectTo.connect[0]) : ""));
43
- const { frame } = useAnimation({ interval: ANIMATION_MS });
44
45
  const provider = providers[row] ?? providers[0];
45
46
  const listed = models[provider.id];
47
+ // Only "loading models…" animates; a timer running the whole time settings is open would redraw it 10
48
+ // times a second for nothing.
49
+ const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: listed === "loading" });
46
50
  // Load the model list for the highlighted provider.
47
51
  useEffect(() => {
48
52
  if (tab !== "Model" || connecting || provider.unavailable() || models[provider.id])
@@ -157,6 +161,9 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
157
161
  else if (pref.key === "codeTheme") {
158
162
  onChange({ codeTheme: CODE_THEMES[(CODE_THEMES.indexOf(settings.codeTheme) + step + CODE_THEMES.length) % CODE_THEMES.length] });
159
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
+ }
160
167
  else if (pref.key === "autoSave") {
161
168
  onChange({ autoSave: AUTO_SAVE[(AUTO_SAVE.indexOf(settings.autoSave) + step + AUTO_SAVE.length) % AUTO_SAVE.length] });
162
169
  }
@@ -190,6 +197,11 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
190
197
  used += cost(--first);
191
198
  return { first, last };
192
199
  })();
200
+ const gameWindow = (() => {
201
+ const fit = Math.max(1, Math.floor((boxHeight - 4) / 3));
202
+ const first = Math.max(0, Math.min((tab === "Games" ? row : 0) - fit + 1, GAMES.length - fit));
203
+ return { first, last: first + fit };
204
+ })();
193
205
  const toggle = (on, warn) => (_jsx(Text, { color: on ? (warn ? C.orange : C.green) : C.dim, bold: true, children: on ? "● ON " : "○ OFF" }));
194
206
  const body = () => {
195
207
  if (tab === "Model") {
@@ -244,15 +256,17 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
244
256
  const value = settings[pref.key];
245
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"
246
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] })] })
247
- : pref.key === "effort" || pref.key === "autoSave" || pref.key === "followSpeed"
248
- ? _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)) })
249
- : 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
250
264
  ? highlightLines("preview.ts", THEME_PREVIEW).map((line, n) => _jsxs(Text, { wrap: "truncate-end", children: [" ", line] }, n))
251
265
  : null] }, pref.key));
252
266
  }), prefWindow.last < PREFS.length ? _jsxs(Text, { color: C.dim, children: [" \u2193 ", PREFS.length - prefWindow.last, " more"] }) : null] }));
253
267
  }
254
268
  if (tab === "Games") {
255
- return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Little games for a break while Nou works. Added ones show in the Games panel: /games, or ? then \"games\"." }), _jsx(Text, { children: " " }), GAMES.map((g, i) => {
269
+ return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [_jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Little games for a break while Nou works. Added ones show in the Games panel: /games." }), _jsx(Text, { children: " " }), GAMES.map((g, i) => ({ g, i })).slice(gameWindow.first, gameWindow.last).map(({ g, i }) => {
256
270
  const selected = i === row;
257
271
  const added = settings.games.includes(g.id);
258
272
  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 ? "▸ " : " " }), added ? _jsx(Text, { color: C.green, bold: true, children: "\u2713 ADDED" }) : _jsx(Text, { color: C.accent2, bold: true, children: "\u2B07 GET " }), _jsxs(Text, { color: C.text, bold: true, children: [" ", g.title] }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id));
@@ -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(["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,17 +67,20 @@ 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)" },
75
+ { name: "fold", description: "fold every function and block in the open file to its first line (z toggles)" },
76
+ { name: "unfold", description: "open every fold in the open file" },
77
+ { name: "redraw", description: "draw the whole screen again (Ctrl+L), if it ever looks shifted or clicks land a row off" },
36
78
  { name: "terminal", description: "a terminal under the code, like VS Code's (or press `)" },
37
79
  { name: "resume", args: "[number|last]", description: "pick up an earlier conversation in this folder (any engine)" },
38
80
  { name: "pull", args: "<model>", description: "download an Ollama model" },
39
81
  { name: "plugins", args: "[add|new <name>]", description: "your plugins and the starter ones" },
40
82
  { name: "mcp", args: "[add|remove|import]", description: "MCP servers: tools from other programs (/mcp add browser for a browser)" },
41
- { name: "games", description: "open or close the Games panel (Pong, Snake, Breakout)" },
83
+ { name: "games", description: "open or close the Games panel (Pong, Snake, Breakout, Dodge, Hop, Pairs)" },
42
84
  { name: "version", description: "which version of Nou this is" },
43
85
  { name: "help", description: "all commands and keys" },
44
86
  ];
@@ -0,0 +1,59 @@
1
+ import { useEffect, useSyncExternalStore } from "react";
2
+ import { useStdin } from "ink";
3
+ /**
4
+ * Whether Nou's terminal window is the one in front. Terminals that report focus (xterm's mode 1004:
5
+ * iTerm2, Terminal.app, kitty, Ghostty, WezTerm, Windows Terminal, VS Code's, tmux with focus-events on)
6
+ * send ESC [ I and ESC [ O as you switch windows; Nou then stops its spinners and git polling while you're
7
+ * elsewhere, so it doesn't keep the CPU (and the terminal's redraws) busy for something nobody is watching.
8
+ * Terminals that don't report it never send either, and Nou stays "focused" as before.
9
+ */
10
+ let focused = true;
11
+ const listeners = new Set();
12
+ export function setTerminalFocused(next) {
13
+ if (next === focused)
14
+ return;
15
+ focused = next;
16
+ for (const listener of listeners)
17
+ listener();
18
+ }
19
+ export const terminalFocused = () => focused;
20
+ export function onTerminalFocus(listener) {
21
+ listeners.add(listener);
22
+ return () => listeners.delete(listener);
23
+ }
24
+ export function useTerminalFocused() {
25
+ return useSyncExternalStore(onTerminalFocus, terminalFocused, terminalFocused);
26
+ }
27
+ /** The last focus report in a chunk of terminal input, if there is one. Ink ignores these, so they're never typed. */
28
+ export function parseFocus(chunk) {
29
+ const at = Math.max(chunk.lastIndexOf("\x1b[I"), chunk.lastIndexOf("\x1b[O"));
30
+ return at < 0 ? null : chunk[at + 2] === "I";
31
+ }
32
+ const ENABLE = "\x1b[?1004h";
33
+ const DISABLE = "\x1b[?1004l";
34
+ let exitHookInstalled = false;
35
+ /** Asks the terminal to report focus while mounted, and keeps `useTerminalFocused` up to date. */
36
+ export function useFocusReporting(enabled) {
37
+ const { stdin } = useStdin();
38
+ useEffect(() => {
39
+ if (!enabled || !stdin)
40
+ return;
41
+ const terminal = process.stdout.isTTY ? process.stdout : null;
42
+ terminal?.write(ENABLE);
43
+ if (terminal && !exitHookInstalled) {
44
+ process.on("exit", () => process.stdout.write(DISABLE));
45
+ exitHookInstalled = true;
46
+ }
47
+ const onData = (data) => {
48
+ const next = parseFocus(String(data));
49
+ if (next !== null)
50
+ setTerminalFocused(next);
51
+ };
52
+ stdin.on("data", onData);
53
+ return () => {
54
+ stdin.off("data", onData);
55
+ terminal?.write(DISABLE);
56
+ setTerminalFocused(true);
57
+ };
58
+ }, [enabled, stdin]);
59
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * A full repaint: the app normally rewrites only the lines that changed, so if anything outside it ever
3
+ * wrote to the terminal, the screen would stay shifted (clicks landing a row off) until something forces
4
+ * every line out again. The CLI hands over Ink's clear here; /redraw and Ctrl+L call it.
5
+ */
6
+ let clearScreen = null;
7
+ export function setScreenClear(fn) {
8
+ clearScreen = fn;
9
+ }
10
+ /** Forgets what's on screen, so the next frame is drawn in full; false where there's no terminal (tests). */
11
+ export function forgetScreen() {
12
+ if (!clearScreen)
13
+ return false;
14
+ clearScreen();
15
+ return true;
16
+ }
@@ -8,6 +8,9 @@ export const RENDER_OPTIONS = {
8
8
  incrementalRendering: true,
9
9
  // Up to 60 frames a second, so scrolling and typing keep up with the keyboard and mouse.
10
10
  maxFps: 60,
11
+ // Library output goes to ~/.nou/nou.log instead (see cli.ts): Ink's console patch would print it into
12
+ // the full-screen app, scrolling it so clicks land a row off.
13
+ patchConsole: false,
11
14
  // Terminals that speak the kitty keyboard protocol (kitty, Ghostty, WezTerm, iTerm2, foot...) then tell
12
15
  // Enter from Ctrl+Enter, Cmd+Enter and Shift+Enter, so those can add a line in the chat. Others just
13
16
  // don't answer the query and keys work as before.
@@ -413,3 +413,48 @@ export function closeOpenMarkdown(text) {
413
413
  out += "**";
414
414
  return out;
415
415
  }
416
+ /**
417
+ * Regions that can fold, from indentation (like VS Code without a language server): a line followed by
418
+ * more-indented lines folds them, and the closing bracket that ends the block. 1-based line -> last line.
419
+ */
420
+ export function foldRanges(text) {
421
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
422
+ const indent = (s) => s.match(/^[ \t]*/)[0].replace(/\t/g, " ").length;
423
+ const blank = (s) => !s.trim();
424
+ const out = new Map();
425
+ for (let i = 0; i < lines.length; i++) {
426
+ if (blank(lines[i]))
427
+ continue;
428
+ const base = indent(lines[i]);
429
+ let j = i + 1;
430
+ while (j < lines.length && blank(lines[j]))
431
+ j++;
432
+ if (j >= lines.length || indent(lines[j]) <= base)
433
+ continue;
434
+ let end = j;
435
+ for (let k = j; k < lines.length; k++) {
436
+ if (blank(lines[k]))
437
+ continue;
438
+ if (indent(lines[k]) <= base)
439
+ break;
440
+ end = k;
441
+ }
442
+ // The line that closes the block (`}`, `)`, `]`, `end`...) folds with it.
443
+ const next = end + 1;
444
+ if (next < lines.length && indent(lines[next]) === base && /^\s*([}\])]|end\b|<\/)/.test(lines[next]))
445
+ end = next;
446
+ if (end > i)
447
+ out.set(i + 1, end + 1);
448
+ }
449
+ return out;
450
+ }
451
+ /** The file lines (1-based) left showing with `folded` regions (start lines) closed. */
452
+ export function unfoldedLines(count, ranges, folded) {
453
+ const out = [];
454
+ for (let n = 1; n <= count; n++) {
455
+ out.push(n);
456
+ if (folded.has(n) && ranges.has(n))
457
+ n = ranges.get(n);
458
+ }
459
+ return out;
460
+ }
@@ -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;