nou-cli 0.5.1 → 0.5.3

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.
Files changed (42) hide show
  1. package/README.md +48 -9
  2. package/dist/index.js +9 -99
  3. package/dist/src/agent/index.js +199 -33
  4. package/dist/src/cli.js +117 -0
  5. package/dist/src/commands/index.js +233 -25
  6. package/dist/src/config/index.js +14 -0
  7. package/dist/src/context/index.js +33 -10
  8. package/dist/src/conversations/external.js +165 -0
  9. package/dist/src/conversations/index.js +71 -0
  10. package/dist/src/engines/claude.js +10 -0
  11. package/dist/src/engines/codex.js +60 -5
  12. package/dist/src/games/index.js +217 -0
  13. package/dist/src/mcp/import.js +106 -0
  14. package/dist/src/mcp/index.js +184 -0
  15. package/dist/src/providers/anthropic.js +23 -3
  16. package/dist/src/providers/index.js +13 -0
  17. package/dist/src/providers/openai.js +6 -2
  18. package/dist/src/syntax/index.js +10 -20
  19. package/dist/src/syntax/themes.js +57 -0
  20. package/dist/src/terminal/index.js +127 -0
  21. package/dist/src/tools/index.js +86 -2
  22. package/dist/src/ui/App.js +900 -118
  23. package/dist/src/ui/Chat.js +143 -22
  24. package/dist/src/ui/ChatInput.js +9 -2
  25. package/dist/src/ui/CodeView.js +111 -20
  26. package/dist/src/ui/Editor.js +13 -6
  27. package/dist/src/ui/FileTree.js +6 -5
  28. package/dist/src/ui/GamePanel.js +99 -0
  29. package/dist/src/ui/Header.js +1 -1
  30. package/dist/src/ui/QuickOpen.js +51 -12
  31. package/dist/src/ui/Settings.js +58 -13
  32. package/dist/src/ui/Tabs.js +56 -0
  33. package/dist/src/ui/Terminal.js +84 -0
  34. package/dist/src/ui/clipboard.js +9 -7
  35. package/dist/src/ui/commands.js +36 -0
  36. package/dist/src/ui/definition.js +135 -0
  37. package/dist/src/ui/fast-width.js +62 -0
  38. package/dist/src/ui/mouse.js +4 -1
  39. package/dist/src/ui/render-options.js +4 -0
  40. package/dist/src/ui/text.js +132 -12
  41. package/dist/src/ui/width-shim.js +7 -0
  42. package/package.json +2 -1
@@ -0,0 +1,99 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { memo, useEffect, useRef, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { GAMES, gameById } from "../games/index.js";
5
+ import { C } from "./theme.js";
6
+ /** The panel's tallest height (borders included) when docked under a pane, and the least it needs. */
7
+ export const GAME_PANEL = { height: 19, minWidth: 26, minHeight: 10 };
8
+ const KEYS = { w: "up", s: "down", a: "left", d: "right", k: "up", j: "down", h: "left", l: "right" };
9
+ /** A row of the playfield, colored per character in runs so it stays a handful of Text nodes. */
10
+ function Row({ line, colors }) {
11
+ const runs = [];
12
+ for (const ch of line) {
13
+ const color = colors[ch];
14
+ const last = runs[runs.length - 1];
15
+ if (last && last.color === color)
16
+ last.text += ch;
17
+ else
18
+ runs.push({ text: ch, color });
19
+ }
20
+ return _jsx(Text, { children: runs.map((r, i) => _jsx(Text, { ...(r.color ? { color: r.color } : {}), children: r.text }, i)) });
21
+ }
22
+ /**
23
+ * A panel for little games, docked under a pane (drag its title to another pane; × closes it). The app gives it
24
+ * the keyboard while it's focused; a game only runs while it has the keyboard, so it pauses as you leave.
25
+ */
26
+ export const GamePanel = memo(function GamePanel({ width, height, focused, moving, installed, onLeave, onOpenSettings }) {
27
+ const FIELD = { width: Math.max(10, width - 4), height: Math.max(3, height - 6) };
28
+ const games = installed.map(gameById).filter((g) => !!g);
29
+ const [row, setRow] = useState(0);
30
+ const [playing, setPlaying] = useState(null);
31
+ const [paused, setPaused] = useState(false);
32
+ const inputs = useRef([]);
33
+ const view = playing ? playing.game.view(playing.state) : null;
34
+ const running = !!playing && focused && !paused && !view?.over;
35
+ useEffect(() => {
36
+ if (!running)
37
+ return;
38
+ const timer = setInterval(() => {
39
+ const pressed = inputs.current;
40
+ inputs.current = [];
41
+ setPlaying((p) => (p ? { ...p, state: p.game.tick(p.state, pressed) } : p));
42
+ }, playing.game.tickMs);
43
+ return () => clearInterval(timer);
44
+ }, [running, playing?.game]);
45
+ // A different size (moved to another pane, or the window changed) starts the game over at that size.
46
+ useEffect(() => {
47
+ if (playing)
48
+ setPlaying({ game: playing.game, state: playing.game.create(FIELD.width, FIELD.height) });
49
+ }, [FIELD.width, FIELD.height]);
50
+ const start = (game) => {
51
+ inputs.current = [];
52
+ setPaused(false);
53
+ setPlaying({ game, state: game.create(FIELD.width, FIELD.height) });
54
+ };
55
+ useInput((input, key) => {
56
+ if (key.escape)
57
+ return onLeave();
58
+ if (!playing) {
59
+ if (key.upArrow || input === "k")
60
+ setRow((r) => Math.max(0, r - 1));
61
+ else if (key.downArrow || input === "j")
62
+ setRow((r) => Math.min(games.length - 1, r + 1));
63
+ else if ((key.return || input === " ") && games[row])
64
+ start(games[row]);
65
+ else if (input === "s" && !games.length)
66
+ onOpenSettings();
67
+ return;
68
+ }
69
+ if (input === "q")
70
+ return setPlaying(null);
71
+ if (view?.over) {
72
+ if (key.return || input === " " || input === "r")
73
+ start(playing.game);
74
+ return;
75
+ }
76
+ if (input === " " || input === "p")
77
+ return setPaused((p) => !p);
78
+ const pressed = key.upArrow ? "up" : key.downArrow ? "down" : key.leftArrow ? "left" : key.rightArrow ? "right" : KEYS[input];
79
+ if (pressed)
80
+ inputs.current.push(pressed);
81
+ }, { isActive: focused });
82
+ const color = focused || moving ? C.accent2 : C.dim;
83
+ const inner = width - 2;
84
+ const title = moving ? " ✦ Drop it on a pane: files, code or chat" : playing ? ` ✦ ${playing.game.title} ⠿ drag to move` : " ✦ Games ⠿ drag to move";
85
+ // The title row: the name and the drag handle, and × (close) at the right.
86
+ const head = title.length > inner - 3 ? `${title.slice(0, inner - 4)}…` : title.padEnd(inner - 3);
87
+ const hint = !focused ? "click to play" : playing ? (view?.over ? "Enter again · q games · Esc leave" : `${paused ? "PAUSED · " : ""}Space pause · q games · Esc leave`) : "↑↓ choose · Enter play · Esc leave";
88
+ const small = width < GAME_PANEL.minWidth || height < GAME_PANEL.minHeight;
89
+ return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: "round", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { backgroundColor: C.panel, wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: head }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), small ? (_jsx(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: _jsx(Text, { color: C.dim, wrap: "wrap", children: "Too small here: drag me to a bigger pane, or make this one wider." }) })) : null, small ? null : playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
90
+ // The end-of-game message goes over the middle of the field.
91
+ if (view.over && i === Math.floor(FIELD.height / 2)) {
92
+ const msg = ` ${view.over} `;
93
+ const left = Math.max(0, Math.floor((FIELD.width - msg.length) / 2));
94
+ return _jsxs(Text, { children: [line.slice(0, left), _jsx(Text, { backgroundColor: C.yellow, color: "black", bold: true, children: msg }), line.slice(left + msg.length)] }, i);
95
+ }
96
+ return _jsx(Row, { line: line, colors: playing.game.colors }, i);
97
+ }), _jsx(Text, { color: C.dim, children: "─".repeat(FIELD.width) }), _jsx(Text, { color: C.text, wrap: "truncate-end", children: view.status })] })) : (_jsx(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: games.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.text, children: "No games yet." }), _jsxs(Text, { color: C.dim, wrap: "wrap", children: ["Add some in settings \u2192 Games", focused ? " (press s)" : "", ": Pong, Snake and Breakout."] })] })) : games.map((g, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(i === row && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: i === row ? C.accent2 : C.dim, children: i === row ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: i === row, children: g.title }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id))) })), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", hint] })] }));
98
+ });
99
+ export { GAMES };
@@ -8,5 +8,5 @@ const VERSION = `v${currentVersion()}`;
8
8
  export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
9
9
  const { frame } = useAnimation({ interval: 120, isActive: !!busy });
10
10
  const project = path.basename(process.cwd());
11
- return (_jsxs(Box, { width: width, justifyContent: "space-between", 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] })] }), _jsxs(Text, { children: [update && _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} ` }), " "] })] }));
11
+ 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} ` }), " "] }) })] }));
12
12
  });
@@ -32,7 +32,7 @@ export function listAllFiles(limit = 5000) {
32
32
  return files;
33
33
  }
34
34
  /** Subsequence fuzzy match; higher is better, null if no match. Returns matched indexes for highlighting. */
35
- function score(file, query) {
35
+ export function score(file, query) {
36
36
  if (!query)
37
37
  return { score: 0, hits: [] };
38
38
  const lower = file.toLowerCase();
@@ -57,35 +57,74 @@ function score(file, query) {
57
57
  }
58
58
  return { score: total - file.length * 0.05, hits };
59
59
  }
60
- export function QuickOpen({ width, top, files, onPick, onClose }) {
61
- const [query, setQuery] = useState("");
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 }) {
62
+ const [query, setQuery] = useState(initialQuery);
62
63
  const [cursor, setCursor] = useState(0);
63
64
  const boxWidth = Math.min(80, Math.max(40, width - 10));
64
65
  const inner = boxWidth - 2; // fill every cell so the screen underneath doesn't show through
66
+ const commandMode = query.startsWith(">");
67
+ const rowsShown = 12;
65
68
  const results = useMemo(() => {
69
+ if (commandMode)
70
+ return [];
71
+ // Recent files first when nothing's typed; a small boost otherwise, so the one you just had wins a tie.
72
+ const rank = new Map(recent.map((f, i) => [f, i]));
73
+ const boost = (f) => (rank.has(f) ? (query ? 3 : 1000) - rank.get(f) * 0.01 : 0);
66
74
  return files
67
75
  .map((f) => ({ file: f, m: score(f, query) }))
68
76
  .filter((r) => r.m !== null)
69
- .sort((a, b) => b.m.score - a.m.score)
70
- .slice(0, 12);
71
- }, [files, query]);
77
+ .sort((a, b) => b.m.score + boost(b.file) - (a.m.score + boost(a.file)))
78
+ .slice(0, rowsShown);
79
+ }, [files, recent, query, commandMode]);
80
+ const found = useMemo(() => {
81
+ if (!commandMode)
82
+ return [];
83
+ const q = query.slice(1).trim();
84
+ return actions
85
+ .map((a, order) => ({ action: a, m: score(a.title, q), order }))
86
+ .filter((r) => r.m !== null)
87
+ .sort((a, b) => (q ? b.m.score - a.m.score : a.order - b.order));
88
+ }, [actions, query, commandMode]);
89
+ const count = commandMode ? found.length : results.length;
90
+ const first = Math.max(0, Math.min(cursor - rowsShown + 1, count - rowsShown));
72
91
  useInput((_input, key) => {
73
92
  if (key.escape)
74
93
  onClose();
75
94
  else if (key.upArrow)
76
- setCursor((c) => Math.max(0, c - 1));
95
+ setCursor((c) => (c - 1 + Math.max(1, count)) % Math.max(1, count));
77
96
  else if (key.downArrow)
78
- setCursor((c) => Math.min(results.length - 1, c + 1));
97
+ setCursor((c) => (c + 1) % Math.max(1, count));
98
+ else if (key.pageUp)
99
+ setCursor((c) => Math.max(0, c - rowsShown));
100
+ else if (key.pageDown)
101
+ setCursor((c) => Math.min(count - 1, c + rowsShown));
79
102
  });
103
+ const recentSet = useMemo(() => new Set(recent), [recent]);
80
104
  const pad = (s) => (s.length > inner ? `…${s.slice(-(inner - 1))}` : s.padEnd(inner));
81
105
  return (_jsxs(Box, { position: "absolute", top: top, left: Math.floor((width - boxWidth) / 2), width: boxWidth, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, children: [_jsxs(Text, { backgroundColor: C.panel, children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(TextInput, { value: query, onChange: (v) => {
82
106
  setQuery(v);
83
107
  setCursor(0);
84
- }, onSubmit: () => results[cursor] && onPick(results[cursor].file), placeholder: "Go to file\u2026" }), _jsx(Text, { children: " ".repeat(Math.max(0, inner - 4 - Math.max(query.length, 11))) })] }), _jsx(Text, { color: C.dim, children: "─".repeat(inner) }), results.length === 0 && _jsx(Text, { color: C.dim, children: pad(" No matching files") }), results.map((r, i) => {
108
+ }, onSubmit: () => {
109
+ if (commandMode) {
110
+ if (found[cursor])
111
+ onRun(found[cursor].action);
112
+ }
113
+ else if (results[cursor])
114
+ 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) => {
116
+ const selected = first + i === cursor;
117
+ const keys = r.action.keys ?? "";
118
+ const room = inner - 3 - keys.length - 1;
119
+ const title = r.action.title.length > room ? `${r.action.title.slice(0, room - 1)}…` : r.action.title.padEnd(room);
120
+ const hits = new Set(r.m.hits);
121
+ return (_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▌" : " " }), _jsx(Text, { color: C.accent2, children: "\u203A " }), [...title].map((ch, j) => (_jsx(Text, { color: hits.has(j) ? C.yellow : selected ? C.text : C.dim, bold: hits.has(j), children: ch }, j))), _jsxs(Text, { color: C.accent, children: [" ", keys] })] }, r.action.title));
122
+ }), !commandMode && results.length === 0 && _jsx(Text, { color: C.dim, children: pad(" No matching files") }), results.map((r, i) => {
85
123
  const selected = i === cursor;
86
124
  const { icon, color } = fileIcon(r.file);
87
- const label = pad(` ${r.file}`).slice(0, inner - 2);
125
+ const tag = recentSet.has(r.file) ? " recent" : "";
126
+ const label = pad(` ${r.file}`).slice(0, inner - 2 - tag.length);
88
127
  const hits = new Set(r.m.hits.map((h) => h + 1));
89
- return (_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▌" : " " }), _jsx(Text, { color: color, children: icon }), [...label].map((ch, j) => (_jsx(Text, { color: hits.has(j) ? C.yellow : selected ? C.text : C.dim, bold: hits.has(j), children: ch }, j)))] }, r.file));
90
- }), _jsx(Text, { color: C.dim, children: pad(" ↑↓ select · Enter open · Esc close") })] }));
128
+ return (_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▌" : " " }), _jsx(Text, { color: color, children: icon }), [...label].map((ch, j) => (_jsx(Text, { color: hits.has(j) ? C.yellow : selected ? C.text : C.dim, bold: hits.has(j), children: ch }, j))), _jsx(Text, { color: C.dim, children: tag })] }, r.file));
129
+ }), _jsx(Text, { color: C.dim, children: pad(commandMode ? ` ↑↓ select · Enter run · Esc close ${found.length} commands` : " ↑↓ select · Enter open · > commands · Esc close") })] }));
91
130
  }
@@ -2,7 +2,9 @@ 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, EFFORTS } from "../config/index.js";
5
+ import { AUTO_SAVE, CODE_THEMES, EFFORTS, FOLLOW_SPEEDS } from "../config/index.js";
6
+ import { highlightLines } from "./text.js";
7
+ import { GAMES } from "../games/index.js";
6
8
  import { saveCredentials } from "../config/credentials.js";
7
9
  import { originOf, setOutsideNou } from "../config/origins.js";
8
10
  import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
@@ -10,9 +12,16 @@ import { currentVersion } from "../update/index.js";
10
12
  import { describe, providers, rejectedKey } from "../providers/index.js";
11
13
  import { ENGINES, isInstalled } from "../engines/index.js";
12
14
  import { C, SPINNER } from "./theme.js";
13
- const TABS = ["Model", "Engine", "Preferences", "Plugins"];
15
+ /** A few lines of code to show what a code theme looks like. */
16
+ const THEME_PREVIEW = `// Greets someone, politely
17
+ export function greet(name: string, times = 2): string {
18
+ return \`Hello \${name}!\`.repeat(times) || null;
19
+ }`;
20
+ const TABS = ["Model", "Engine", "Preferences", "Plugins", "Games"];
14
21
  const PREFS = [
15
22
  { key: "effort", label: "Reasoning effort", help: "How hard Claude thinks (low → max). Higher is smarter but slower and pricier." },
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: "codeTheme", label: "Code colors", help: "The theme for code. ←/→ to try them; github-light is for light terminals." },
16
25
  { key: "autoSave", label: "Auto-save", help: "delay: a second after you stop typing · focus: when you leave the editor · off: only Ctrl+S." },
17
26
  { key: "mouse", label: "Mouse", help: "Wheel scrolls the pane under the pointer; click to focus or open files. Hold Shift to select text." },
18
27
  { key: "followMode", label: "Follow mode on start", help: "The code view jumps to files Nou reads and edits." },
@@ -21,7 +30,7 @@ const PREFS = [
21
30
  { key: "autoApproveEdits", label: "Auto-approve edits", help: "Apply file edits without asking (diffs are still shown).", warn: true },
22
31
  { key: "autoApproveCommands", label: "Auto-approve commands", help: "Run shell commands without asking. Only turn on if you trust the model.", warn: true },
23
32
  ];
24
- export function Settings({ width, height, settings, choice, plugins, connectTo, onModel, onEngine, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
33
+ export function Settings({ width, height, settings, choice, plugins, connectTo, reason, onModel, onEngine, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
25
34
  const [tab, setTab] = useState("Model");
26
35
  const [row, setRow] = useState(() => Math.max(0, providers.indexOf(connectTo ?? choice.provider)));
27
36
  const [column, setColumn] = useState("providers");
@@ -72,7 +81,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
72
81
  }
73
82
  };
74
83
  const modelItems = Array.isArray(listed) ? listed : [];
75
- const rowsFor = (t) => (t === "Model" ? providers.length : t === "Engine" ? ENGINES.length : t === "Preferences" ? PREFS.length : plugins.length);
84
+ const rowsFor = (t) => (t === "Model" ? providers.length : t === "Engine" ? ENGINES.length : t === "Preferences" ? PREFS.length : t === "Games" ? GAMES.length : plugins.length);
76
85
  useInput((input, key) => {
77
86
  if (connecting) {
78
87
  if (key.escape)
@@ -142,6 +151,12 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
142
151
  if (pref.key === "effort") {
143
152
  onChange({ effort: EFFORTS[(EFFORTS.indexOf(settings.effort) + step + EFFORTS.length) % EFFORTS.length] });
144
153
  }
154
+ else if (pref.key === "followSpeed") {
155
+ onChange({ followSpeed: FOLLOW_SPEEDS[(FOLLOW_SPEEDS.indexOf(settings.followSpeed) + step + FOLLOW_SPEEDS.length) % FOLLOW_SPEEDS.length] });
156
+ }
157
+ else if (pref.key === "codeTheme") {
158
+ onChange({ codeTheme: CODE_THEMES[(CODE_THEMES.indexOf(settings.codeTheme) + step + CODE_THEMES.length) % CODE_THEMES.length] });
159
+ }
145
160
  else if (pref.key === "autoSave") {
146
161
  onChange({ autoSave: AUTO_SAVE[(AUTO_SAVE.indexOf(settings.autoSave) + step + AUTO_SAVE.length) % AUTO_SAVE.length] });
147
162
  }
@@ -149,6 +164,10 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
149
164
  onChange({ [pref.key]: !settings[pref.key] });
150
165
  }
151
166
  }
167
+ else if (tab === "Games" && (key.return || input === " ") && GAMES[row]) {
168
+ const id = GAMES[row].id;
169
+ onChange({ games: settings.games.includes(id) ? settings.games.filter((g) => g !== id) : [...settings.games, id] });
170
+ }
152
171
  else if (tab === "Plugins") {
153
172
  if (input === "r")
154
173
  onRescanPlugins();
@@ -157,6 +176,20 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
157
176
  }
158
177
  });
159
178
  const boxHeight = height - 6;
179
+ // Preferences take three rows each (label, help, gap), the code theme's preview four more: show the ones
180
+ // around the selected one that fit.
181
+ const prefWindow = (() => {
182
+ const room = Math.max(3, boxHeight - 4);
183
+ const cost = (i) => 3 + (tab === "Preferences" && i === row && PREFS[i]?.key === "codeTheme" ? 4 : 0);
184
+ let first = tab === "Preferences" ? row : 0;
185
+ let last = first + 1;
186
+ let used = cost(first);
187
+ while (last < PREFS.length && used + cost(last) <= room)
188
+ used += cost(last++);
189
+ while (first > 0 && used + cost(first - 1) <= room)
190
+ used += cost(--first);
191
+ return { first, last };
192
+ })();
160
193
  const toggle = (on, warn) => (_jsx(Text, { color: on ? (warn ? C.orange : C.green) : C.dim, bold: true, children: on ? "● ON " : "○ OFF" }));
161
194
  const body = () => {
162
195
  if (tab === "Model") {
@@ -168,7 +201,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
168
201
  const selected = i === row;
169
202
  const current = p.id === choice.provider.id;
170
203
  return (_jsxs(Text, { wrap: "truncate-end", ...(selected && column === "providers" ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? " ● " : " " }), _jsx(Text, { color: problem ? C.dim : C.text, bold: selected, children: p.label.padEnd(labelWidth).slice(0, labelWidth) }), _jsx(Text, { color: p.local ? C.green : C.accent2, children: p.local ? " local" : " cloud" }), problem ? _jsx(Text, { color: C.dim, children: " \uD83D\uDD12" }) : null] }, p.id));
171
- })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: column === "models" ? C.accent : C.dim, paddingX: 1, children: [_jsxs(Text, { color: C.dim, bold: true, children: ["MODELS \u00B7 ", provider.label] }), connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
204
+ })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: column === "models" ? C.accent : C.dim, paddingX: 1, children: [_jsxs(Text, { color: C.dim, bold: true, children: ["MODELS \u00B7 ", provider.label] }), reason ? _jsxs(Text, { color: C.red, wrap: "wrap", children: ["\u2716 ", reason] }) : null, connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
172
205
  const entered = connecting.values[f.env];
173
206
  const shown = (v) => (f.secret ? "•".repeat(Math.min(v.length, 12)) : v);
174
207
  if (i < connecting.step) {
@@ -205,13 +238,25 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
205
238
  }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "The engine is the agent that answers in the chat. Diffs, approvals, undo and problems work with each one." })] }));
206
239
  }
207
240
  if (tab === "Preferences") {
208
- return (_jsx(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: PREFS.map((pref, i) => {
209
- const selected = i === row;
210
- const value = settings[pref.key];
211
- 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 === "effort" || pref.key === "autoSave"
212
- ? _jsx(Text, { children: (pref.key === "effort" ? EFFORTS : AUTO_SAVE).map((e) => _jsx(Text, { color: e === value ? C.accent2 : C.dim, bold: e === value, children: e === value ? `[${e}]` : ` ${e} ` }, e)) })
213
- : toggle(value, pref.warn)] }), _jsxs(Text, { color: C.dim, children: [" ", pref.help] })] }, pref.key));
214
- }) }));
241
+ return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, overflow: "hidden", children: [prefWindow.first > 0 ? _jsxs(Text, { color: C.dim, children: [" \u2191 ", prefWindow.first, " more"] }) : null, PREFS.slice(prefWindow.first, prefWindow.last).map((pref, at) => {
242
+ const i = prefWindow.first + at;
243
+ const selected = i === row;
244
+ const value = settings[pref.key];
245
+ 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
+ ? _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
250
+ ? highlightLines("preview.ts", THEME_PREVIEW).map((line, n) => _jsxs(Text, { wrap: "truncate-end", children: [" ", line] }, n))
251
+ : null] }, pref.key));
252
+ }), prefWindow.last < PREFS.length ? _jsxs(Text, { color: C.dim, children: [" \u2193 ", PREFS.length - prefWindow.last, " more"] }) : null] }));
253
+ }
254
+ 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) => {
256
+ const selected = i === row;
257
+ const added = settings.games.includes(g.id);
258
+ 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));
259
+ })] }));
215
260
  }
216
261
  return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [plugins.map((p, i) => {
217
262
  const selected = i === row;
@@ -233,7 +278,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
233
278
  };
234
279
  const hints = tab === "Model"
235
280
  ? connecting ? "Enter next · Esc cancel" : column === "models" ? `↑↓ choose · Enter use model · ← providers${provider.connect ? " · k change key" : ""}` : `↑↓ provider · Enter/→ ${provider.unavailable() ? "connect" : "pick a model"}${provider.connect ? " · k change key" : ""}`
236
- : tab === "Engine" ? "↑↓ move · Enter use this engine" : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : "↑↓ move · Enter toggle · r rescan";
281
+ : tab === "Engine" ? "↑↓ move · Enter use this engine" : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : tab === "Games" ? "↑↓ move · Enter add / remove · /games opens the panel" : "↑↓ move · Enter toggle · r rescan";
237
282
  return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u2699 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) }), _jsxs(Text, { color: C.dim, children: [" nou v", currentVersion()] })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
238
283
  }
239
284
  /** What a connect field starts with: the current URL to edit (keys stay hidden, so they start empty). */
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Fragment } from "react";
3
+ import path from "path";
4
+ import { Text } from "ink";
5
+ import { C, fileIcon } from "./theme.js";
6
+ /** Tab labels: file names, with the folder added when two open files share a name. */
7
+ function labels(tabs) {
8
+ return tabs.map((file) => {
9
+ const name = path.posix.basename(file);
10
+ const twin = tabs.some((other) => other !== file && path.posix.basename(other) === name);
11
+ return twin ? `${path.posix.basename(path.posix.dirname(file))}/${name}` : name;
12
+ });
13
+ }
14
+ /**
15
+ * Lays out the tabs that fit in `width` columns, keeping the active one in view: the ones that don't fit
16
+ * are left out at the ends, marked with ‹ ›.
17
+ */
18
+ export function layoutTabs(tabs, active, width) {
19
+ const all = labels(tabs).map((label, i) => ({ file: tabs[i], label: label.length > 24 ? `${label.slice(0, 23)}…` : label }));
20
+ const size = (t) => t.label.length + 6 + 1; // " ◆ name × " and "│"
21
+ const at = Math.max(0, all.findIndex((t) => t.file === active));
22
+ let from = at;
23
+ let to = at + 1;
24
+ let used = all[at] ? size(all[at]) : 0;
25
+ // Grow to the right, then to the left, while there's room (2 columns kept for ‹ ›).
26
+ for (let grew = true; grew;) {
27
+ grew = false;
28
+ if (to < all.length && used + size(all[to]) <= width - 2) {
29
+ used += size(all[to++]);
30
+ grew = true;
31
+ }
32
+ if (from > 0 && used + size(all[from - 1]) <= width - 2) {
33
+ used += size(all[--from]);
34
+ grew = true;
35
+ }
36
+ }
37
+ const shown = all.slice(from, to);
38
+ const zones = [];
39
+ let x = from > 0 ? 1 : 0;
40
+ for (const t of shown) {
41
+ const w = size(t) - 1;
42
+ zones.push({ file: t.file, from: x, to: x + w, close: x + w - 2 });
43
+ x += w + 1;
44
+ }
45
+ // ‹ and › (tabs that don't fit) are clickable too: they go to the previous / next tab.
46
+ return { shown, before: from > 0, after: to < all.length, zones, prev: from > 0 ? 0 : null, next: to < all.length ? x : null };
47
+ }
48
+ /** The open files as tabs, for a pane's title row: the active one highlighted, with × to close it. */
49
+ export function Tabs({ tabs, active, width, dirty }) {
50
+ const { shown, before, after } = layoutTabs(tabs, active, width);
51
+ return (_jsxs(_Fragment, { children: [before ? _jsx(Text, { color: C.dim, children: "\u2039" }) : null, shown.map((t) => {
52
+ const on = t.file === active;
53
+ const { icon, color } = fileIcon(t.file);
54
+ return (_jsxs(Fragment, { children: [_jsxs(Text, { ...(on ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { children: " " }), _jsx(Text, { color: color, children: icon }), _jsxs(Text, { color: on ? C.text : C.dim, bold: on, children: [" ", t.label] }), _jsxs(Text, { color: on && dirty ? C.yellow : on ? C.text : C.dim, children: [" ", on && dirty ? "●" : "×"] }), _jsx(Text, { children: " " })] }), _jsx(Text, { color: C.dim, children: "\u2502" })] }, t.file));
55
+ }), after ? _jsx(Text, { color: C.dim, children: "\u203A" }) : null] }));
56
+ }
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useEffect, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { C } from "./theme.js";
5
+ const COLORS = { out: C.text, command: C.accent, info: C.dim, error: C.red };
6
+ /**
7
+ * The terminal panel, under the code like VS Code's: type a command and Enter runs it. While one runs,
8
+ * what you type goes to it; Ctrl+C stops it. ↑↓ go through what you ran, Ctrl+L clears, Esc leaves.
9
+ */
10
+ export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave }) {
11
+ const [input, setInput] = useState("");
12
+ const [history, setHistory] = useState([]);
13
+ const [back, setBack] = useState(-1);
14
+ const [scroll, setScroll] = useState(0);
15
+ const inner = width - 2;
16
+ const rows = Math.max(1, height - 3); // borders and the title
17
+ const lines = shell.view();
18
+ // Fit long lines by wrapping them, newest at the bottom.
19
+ const wrapped = [];
20
+ for (const line of lines.slice(-rows * 4)) {
21
+ const text = line.text || " ";
22
+ for (let i = 0; i < text.length; i += inner)
23
+ wrapped.push({ text: text.slice(i, i + inner), color: COLORS[line.kind] });
24
+ }
25
+ const promptText = shell.running ? "› " : shell.prompt();
26
+ const shownPrompt = `${promptText}${input}`;
27
+ const promptRows = Math.max(1, Math.ceil((shownPrompt.length + 1) / inner));
28
+ const outRows = Math.max(0, rows - promptRows);
29
+ const maxScroll = Math.max(0, wrapped.length - outRows);
30
+ const at = Math.min(scroll, maxScroll);
31
+ const shown = wrapped.slice(Math.max(0, wrapped.length - outRows - at), wrapped.length - at);
32
+ useEffect(() => setScroll(0), [version]); // new output: back to the bottom
33
+ useEffect(() => {
34
+ if (wheel?.lines)
35
+ setScroll((s) => Math.max(0, Math.min(maxScroll, s - wheel.lines)));
36
+ }, [wheel?.nonce]);
37
+ useInput((typed, key) => {
38
+ if (key.escape)
39
+ return onLeave();
40
+ if (key.ctrl && typed === "c") {
41
+ if (shell.running)
42
+ shell.interrupt();
43
+ else
44
+ setInput("");
45
+ return;
46
+ }
47
+ if (key.ctrl && typed === "l")
48
+ return shell.clear();
49
+ if (key.return) {
50
+ if (!shell.running && input.trim())
51
+ setHistory((h) => [...h.filter((c) => c !== input.trim()), input.trim()].slice(-100));
52
+ if (shell.running)
53
+ shell.input(input);
54
+ else
55
+ shell.run(input);
56
+ setInput("");
57
+ setBack(-1);
58
+ return;
59
+ }
60
+ if (key.upArrow || key.downArrow) {
61
+ if (!history.length)
62
+ return;
63
+ const next = key.upArrow ? Math.min(history.length - 1, back + 1) : back - 1;
64
+ setBack(next);
65
+ setInput(next < 0 ? "" : history[history.length - 1 - next]);
66
+ return;
67
+ }
68
+ if (key.pageUp)
69
+ return setScroll((s) => Math.min(maxScroll, s + outRows - 1));
70
+ if (key.pageDown)
71
+ return setScroll((s) => Math.max(0, s - outRows + 1));
72
+ if (key.backspace || key.delete)
73
+ return setInput((v) => v.slice(0, -1));
74
+ if (key.ctrl && typed === "u")
75
+ return setInput("");
76
+ if (key.ctrl || key.meta || key.tab)
77
+ return;
78
+ if (typed)
79
+ setInput((v) => v + typed.replace(/[\r\n]+/g, " "));
80
+ }, { isActive: focused });
81
+ const color = focused ? C.accent : C.dim;
82
+ const title = ` ▸ Terminal${shell.running ? " ● running · Ctrl+C stops" : ""}${at ? ` ↑ ${at} lines` : ""}`;
83
+ return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: focused ? "round" : "single", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: title.slice(0, inner - 3).padEnd(inner - 3) }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), _jsx(Box, { flexDirection: "column", height: outRows, flexShrink: 0, children: shown.map((l, i) => _jsx(Text, { color: l.color, wrap: "truncate-end", children: l.text }, i)) }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: shell.running ? C.yellow : C.green, children: promptText }), _jsx(Text, { color: C.text, children: input }), focused ? _jsx(Text, { inverse: true, children: " " }) : null] })] }));
84
+ });
@@ -1,18 +1,20 @@
1
1
  import { spawn } from "child_process";
2
2
  /** Native clipboard commands to try, in order, for this platform. */
3
3
  function commands() {
4
+ // clip.exe is built in and instant; PowerShell can take seconds to start, so it's only the fallback.
4
5
  if (process.platform === "win32")
5
- return [["powershell", ["-NoProfile", "-Command", "Set-Clipboard -Value ([Console]::In.ReadToEnd())"]]];
6
+ return [["clip", [], "utf16"], ["powershell", ["-NoProfile", "-Command", "Set-Clipboard -Value ([Console]::In.ReadToEnd())"], "utf8"]];
6
7
  if (process.platform === "darwin")
7
- return [["pbcopy", []]];
8
- return [["wl-copy", []], ["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]];
8
+ return [["pbcopy", [], "utf8"]];
9
+ return [["wl-copy", [], "utf8"], ["xclip", ["-selection", "clipboard"], "utf8"], ["xsel", ["--clipboard", "--input"], "utf8"]];
9
10
  }
10
- function run(cmd, args, text) {
11
+ function run(cmd, args, text, encoding) {
11
12
  return new Promise((resolve) => {
12
13
  const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
13
14
  child.on("error", () => resolve(false));
14
15
  child.on("close", (code) => resolve(code === 0));
15
- child.stdin.end(text);
16
+ child.stdin.on("error", () => { }); // the command is missing: "error" above answers
17
+ child.stdin.end(encoding === "utf16" ? Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, "utf16le")]) : text);
16
18
  });
17
19
  }
18
20
  /**
@@ -22,8 +24,8 @@ function run(cmd, args, text) {
22
24
  export async function copyToClipboard(text) {
23
25
  if (process.stdout.isTTY)
24
26
  process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
25
- for (const [cmd, args] of commands())
26
- if (await run(cmd, args, text))
27
+ for (const [cmd, args, encoding] of commands())
28
+ if (await run(cmd, args, text, encoding))
27
29
  return true;
28
30
  return process.stdout.isTTY === true;
29
31
  }
@@ -0,0 +1,36 @@
1
+ export const SLASH_COMMANDS = [
2
+ { name: "model", args: "[provider:model]", description: "show or switch the model" },
3
+ { name: "models", args: "[provider]", description: "list the models a provider offers" },
4
+ { name: "connect", args: "<provider>", description: "enter an API key (and URL) for a provider" },
5
+ { name: "open", args: "<file>[:line]", description: "open a file in the code view (part of its name is enough)" },
6
+ { name: "settings", description: "models, engine, preferences and plugins" },
7
+ { name: "auto", args: "[ask|auto-edit|auto]", description: "how much Nou does without asking" },
8
+ { name: "engine", args: "[name]", description: "which agent answers: nou, claude-code, codex or pi" },
9
+ { name: "cancel", description: "take back your last message (stops Nou; edit it and resend)" },
10
+ { name: "undo", description: "undo Nou's last change" },
11
+ { name: "rewind", description: "undo everything from Nou's last turn" },
12
+ { name: "diff", args: "[file|n]", description: "everything changed this session (or session n)" },
13
+ { name: "sessions", description: "earlier sessions with changes" },
14
+ { name: "history", description: "recent changes, yours and Nou's" },
15
+ { name: "problems", description: "every error and warning in the project" },
16
+ { name: "copy", args: "[code|file]", description: "copy the last reply, its last code block, or the open file" },
17
+ { name: "compact", description: "summarize the conversation to free up context" },
18
+ { name: "clear", description: "start a fresh conversation" },
19
+ { name: "terminal", description: "a terminal under the code, like VS Code's (or press `)" },
20
+ { name: "resume", args: "[number|last]", description: "pick up an earlier conversation in this folder (any engine)" },
21
+ { name: "pull", args: "<model>", description: "download an Ollama model" },
22
+ { name: "plugins", args: "[add|new <name>]", description: "your plugins and the starter ones" },
23
+ { name: "mcp", args: "[add|remove|import]", description: "MCP servers: tools from other programs (/mcp add browser for a browser)" },
24
+ { name: "games", description: "open or close the Games panel (Pong, Snake, Breakout)" },
25
+ { name: "version", description: "which version of Nou this is" },
26
+ { name: "help", description: "all commands and keys" },
27
+ ];
28
+ /** Commands whose name starts with what's typed after the slash (exact match first), plus plugin commands. */
29
+ export function matchCommands(typed, extra = []) {
30
+ const query = typed.replace(/^\//, "").toLowerCase();
31
+ const all = [...SLASH_COMMANDS, ...extra];
32
+ const starts = all.filter((c) => c.name.startsWith(query));
33
+ // Then ones that merely contain it, so "/conn" and "/key"-style guesses still find something.
34
+ const contains = query ? all.filter((c) => !c.name.startsWith(query) && (c.name.includes(query) || c.description.includes(query))) : [];
35
+ return [...starts.sort((a, b) => Number(b.name === query) - Number(a.name === query)), ...contains];
36
+ }