nou-cli 0.5.0 → 0.5.2

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.
@@ -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,55 @@
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 }));
20
+ const size = (t) => t.label.length + 4 + (t.file === active ? 2 : 0) + 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: t.file === active ? x + w - 2 : null });
43
+ x += w + 1;
44
+ }
45
+ return { shown, before: from > 0, after: to < all.length, zones };
46
+ }
47
+ /** The open files as tabs, for a pane's title row: the active one highlighted, with × to close it. */
48
+ export function Tabs({ tabs, active, width, dirty }) {
49
+ const { shown, before, after } = layoutTabs(tabs, active, width);
50
+ return (_jsxs(_Fragment, { children: [before ? _jsx(Text, { color: C.dim, children: "\u2039" }) : null, shown.map((t) => {
51
+ const on = t.file === active;
52
+ const { icon, color } = fileIcon(t.file);
53
+ 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] }), on ? _jsxs(Text, { color: dirty ? C.yellow : C.dim, children: [" ", dirty ? "●" : "×"] }) : null, _jsx(Text, { children: " " })] }), _jsx(Text, { color: C.dim, children: "\u2502" })] }, t.file));
54
+ }), after ? _jsx(Text, { color: C.dim, children: "\u203A" }) : null] }));
55
+ }
@@ -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,33 @@
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: "undo", description: "undo Nou's last change" },
10
+ { name: "rewind", description: "undo everything from Nou's last turn" },
11
+ { name: "diff", args: "[file|n]", description: "everything changed this session (or session n)" },
12
+ { name: "sessions", description: "earlier sessions with changes" },
13
+ { name: "history", description: "recent changes, yours and Nou's" },
14
+ { name: "problems", description: "every error and warning in the project" },
15
+ { name: "copy", args: "[code|file]", description: "copy the last reply, its last code block, or the open file" },
16
+ { name: "compact", description: "summarize the conversation to free up context" },
17
+ { name: "clear", description: "start a fresh conversation" },
18
+ { name: "pull", args: "<model>", description: "download an Ollama model" },
19
+ { name: "plugins", args: "[add|new <name>]", description: "your plugins and the starter ones" },
20
+ { name: "mcp", args: "[add|remove|import]", description: "MCP servers: tools from other programs (/mcp add browser for a browser)" },
21
+ { name: "games", description: "open or close the Games panel (Pong, Snake, Breakout)" },
22
+ { name: "version", description: "which version of Nou this is" },
23
+ { name: "help", description: "all commands and keys" },
24
+ ];
25
+ /** Commands whose name starts with what's typed after the slash (exact match first), plus plugin commands. */
26
+ export function matchCommands(typed, extra = []) {
27
+ const query = typed.replace(/^\//, "").toLowerCase();
28
+ const all = [...SLASH_COMMANDS, ...extra];
29
+ const starts = all.filter((c) => c.name.startsWith(query));
30
+ // Then ones that merely contain it, so "/conn" and "/key"-style guesses still find something.
31
+ const contains = query ? all.filter((c) => !c.name.startsWith(query) && (c.name.includes(query) || c.description.includes(query))) : [];
32
+ return [...starts.sort((a, b) => Number(b.name === query) - Number(a.name === query)), ...contains];
33
+ }
@@ -0,0 +1,135 @@
1
+ import { existsSync, readFileSync, statSync } from "fs";
2
+ import path from "path";
3
+ import { projectFiles } from "../search/index.js";
4
+ const IDENT = /[A-Za-z_$][\w$]*/g;
5
+ /** Lines that bring names in from another file, with the module path as group 1 (or 2 for Python's from). */
6
+ const IMPORT_LINE = /^\s*(?:import\b.*?from\s+["']([^"']+)["']|import\s+["']([^"']+)["']|export\b.*?from\s+["']([^"']+)["']|.*\brequire\(\s*["']([^"']+)["']\s*\)|from\s+([\w.]+)\s+import\b|import\s+([\w.]+))/;
7
+ const STRING = /(["'`])((?:\\.|(?!\1).)*)\1/g;
8
+ /** The identifier at column `col` (1-based) of a line, if any. */
9
+ export function wordAt(text, col) {
10
+ for (const m of text.matchAll(IDENT)) {
11
+ if (col - 1 >= m.index && col - 1 < m.index + m[0].length)
12
+ return { word: m[0], start: m.index + 1 };
13
+ }
14
+ return null;
15
+ }
16
+ /** The module an import line names, e.g. "./b.js" or "pkg.mod". */
17
+ function importedModule(line) {
18
+ const m = line.match(IMPORT_LINE);
19
+ return m ? m.slice(1).find((g) => g) ?? null : null;
20
+ }
21
+ /** The file a module path resolves to from `fromFile`: relative imports and Python modules in the project. */
22
+ export function resolveModule(fromFile, spec) {
23
+ const dir = path.dirname(fromFile);
24
+ const candidates = [];
25
+ if (spec.startsWith(".") || spec.startsWith("/")) {
26
+ const base = path.join(dir, spec);
27
+ // TypeScript imports name the .js the .ts compiles to.
28
+ const stripped = base.replace(/\.(m|c)?js$/, "");
29
+ for (const b of [base, stripped]) {
30
+ candidates.push(b);
31
+ for (const ext of [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json", ".py"])
32
+ candidates.push(b + ext);
33
+ for (const ext of [".ts", ".tsx", ".js", ".jsx", ".py"])
34
+ candidates.push(path.join(b, `index${ext}`));
35
+ }
36
+ if (spec.startsWith(".") && /^\.+[\w.]/.test(spec) && fromFile.endsWith(".py")) {
37
+ // Python relative: from .mod import x / from ..pkg.mod import x
38
+ const dots = spec.match(/^\.+/)[0].length;
39
+ const up = path.join(dir, ...Array(dots - 1).fill(".."), ...spec.slice(dots).split("."));
40
+ candidates.push(`${up}.py`, path.join(up, "__init__.py"));
41
+ }
42
+ }
43
+ else if (/^[\w.]+$/.test(spec)) {
44
+ const parts = spec.split(".");
45
+ for (const root of [".", "src", dir])
46
+ candidates.push(path.join(root, ...parts) + ".py", path.join(root, ...parts, "__init__.py"));
47
+ }
48
+ const found = candidates.find((c) => {
49
+ try {
50
+ return statSync(c).isFile();
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ });
56
+ return found ? path.normalize(found).replace(/\\/g, "/") : null;
57
+ }
58
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
59
+ /** Lines that declare `name`, most specific first. */
60
+ function declares(name) {
61
+ const n = escape(name);
62
+ return new RegExp("^\\s*(?:export\\s+)?(?:default\\s+)?(?:declare\\s+)?(?:async\\s+)?(?:" +
63
+ `function\\*?\\s+${n}\\b|(?:abstract\\s+)?class\\s+${n}\\b|interface\\s+${n}\\b|type\\s+${n}\\b|enum\\s+${n}\\b|` +
64
+ `(?:const|let|var)\\s+${n}\\b|(?:const|let|var)\\s*\\{[^}]*\\b${n}\\b[^}]*\\}\\s*=|def\\s+${n}\\b|class\\s+${n}\\b|func\\s+(?:\\([^)]*\\)\\s*)?${n}\\b|fn\\s+${n}\\b|${n}\\s*=[^=])` +
65
+ `|^\\s*(?:public|private|protected|static|readonly|async|\\s)*${n}\\s*\\([^)]*\\)\\s*(?::[^{;]*)?\\{`);
66
+ }
67
+ function declarationIn(lines, name, skip = -1) {
68
+ const re = declares(name);
69
+ return lines.findIndex((l, i) => i !== skip && re.test(l));
70
+ }
71
+ function readLines(file) {
72
+ try {
73
+ const text = readFileSync(file, "utf-8");
74
+ return text.length > 2_000_000 ? null : text.split(/\r?\n/);
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ /** Where to go for a Ctrl+click at `line`:`col` (1-based) in `file`, whose current text is `text`. */
81
+ export function findDefinition(file, text, line, col) {
82
+ const lines = text.split(/\r?\n/);
83
+ const here = lines[line - 1] ?? "";
84
+ const spec = importedModule(here);
85
+ // On the path of an import: open that file.
86
+ if (spec) {
87
+ for (const m of here.matchAll(STRING)) {
88
+ if (col - 1 >= m.index && col - 1 < m.index + m[0].length) {
89
+ const target = resolveModule(file, m[2]);
90
+ return target ? { file: target, line: 1 } : null;
91
+ }
92
+ }
93
+ }
94
+ const at = wordAt(here, col);
95
+ if (!at)
96
+ return null;
97
+ const { word } = at;
98
+ // On a name in an import line: where that file declares it (or just the file).
99
+ if (spec) {
100
+ const target = resolveModule(file, spec);
101
+ if (!target)
102
+ return null;
103
+ const theirs = readLines(target);
104
+ const index = theirs ? declarationIn(theirs, word) : -1;
105
+ return { file: target, line: index + 1 || 1 };
106
+ }
107
+ // Imported here: its import line, where the path is a click away.
108
+ const importLine = lines.findIndex((l) => importedModule(l) && new RegExp(`\\b${escape(word)}\\b`).test(l.replace(STRING, "")));
109
+ if (importLine >= 0 && importLine !== line - 1)
110
+ return { file, line: importLine + 1, col: lines[importLine].search(new RegExp(`\\b${escape(word)}\\b`)) + 1 };
111
+ // Declared in this file.
112
+ const local = declarationIn(lines, word, line - 1);
113
+ if (local >= 0)
114
+ return { file, line: local + 1 };
115
+ // Declared somewhere else in the project.
116
+ const ext = path.extname(file);
117
+ // A click waits on this, so it reads at most a few megabytes before giving up.
118
+ const files = projectFiles().filter((f) => f !== file && path.extname(f) === ext).slice(0, 3000);
119
+ let budget = 8_000_000;
120
+ for (const other of files) {
121
+ if (!existsSync(other) || budget <= 0)
122
+ continue;
123
+ const size = statSync(other).size;
124
+ if (size > 1_000_000)
125
+ continue;
126
+ budget -= size;
127
+ const theirs = readLines(other);
128
+ if (!theirs)
129
+ continue;
130
+ const index = declarationIn(theirs, word);
131
+ if (index >= 0)
132
+ return { file: other, line: index + 1 };
133
+ }
134
+ return null;
135
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * A faster `string-width` for Ink. Ink measures every line of text on screen each frame (to wrap it, and
3
+ * again to work out how much of a changed line it can leave on screen), and string-width splits each one
4
+ * into graphemes and runs emoji regexes over them: a fifth of all rendering time, mostly spent on lines
5
+ * of plain text and box-drawing characters whose width is simply their length.
6
+ *
7
+ * This answers those with one regex test, remembers the rest, and hands anything unusual to the real
8
+ * string-width. Ink and its dependencies import string-width by name, so a module hook points that name
9
+ * here; it has to be installed before Ink loads (index.ts does it first).
10
+ */
11
+ // A namespace import: Node before 22.15 has no registerHooks, and a named import of it would fail to load.
12
+ import * as nodeModule from "module";
13
+ /**
14
+ * Characters string-width counts as one column each and that never combine with what's next to them:
15
+ * ASCII, Latin, Greek, Cyrillic, punctuation, arrows, math, box drawing and the symbols Nou's UI uses.
16
+ * Emoji, combining marks and invisible characters are left out. The unit tests check every one.
17
+ */
18
+ export const NARROW = /^[\u0020-\u007e\u00a0-\u00ac\u00ae-\u02ff\u0370-\u0482\u048a-\u04ff\u2010-\u2027\u202f-\u205e\u2190-\u2319\u231c-\u2328\u232b-\u23e8\u23ed-\u23ef\u23f1-\u23f2\u23f4-\u25fc\u25ff-\u2613\u2616-\u262f\u2638-\u2647\u2654-\u267e\u2680-\u2689\u2690-\u2692\u2694-\u26a0\u26a2-\u26a9\u26ac-\u26bc\u26bf-\u26c3\u26c6-\u26cd\u26cf-\u26d3\u26d5-\u26e9\u26eb-\u26f1\u26f4\u26f6-\u26f9\u26fb-\u26fc\u26fe-\u2704\u2706-\u2709\u270c-\u2727\u2729-\u274b\u274d\u274f-\u2752\u2756\u2758-\u2794\u2798-\u27af\u27b1-\u27be\u2b00-\u2b1a\u2b1d-\u2b4f\u2b51-\u2b54\u2b56-\u2bff]*$/;
19
+ /** Plenty for every line on screen and in scrollback; cleared rather than evicted one by one. */
20
+ const MAX_CACHED = 20_000;
21
+ /** Wraps a string-width function with the fast path and a cache. Same answers, exported for tests. */
22
+ export function fastStringWidth(real) {
23
+ const cache = new Map();
24
+ return (text, options) => {
25
+ if (options && (options.ambiguousIsNarrow === false || options.countAnsiEscapeCodes))
26
+ return real(text, options);
27
+ if (typeof text !== "string")
28
+ return real(text, options);
29
+ if (NARROW.test(text))
30
+ return text.length;
31
+ let width = cache.get(text);
32
+ if (width === undefined) {
33
+ if (cache.size > MAX_CACHED)
34
+ cache.clear();
35
+ width = real(text, options);
36
+ cache.set(text, width);
37
+ }
38
+ return width;
39
+ };
40
+ }
41
+ let installed = false;
42
+ /** Points every `import "string-width"` that loads from now on at the fast version. Returns whether it could. */
43
+ export function installFastWidth() {
44
+ if (installed)
45
+ return true;
46
+ // registerHooks runs in-thread and synchronously (Node 22.15+); older versions keep the original.
47
+ const { registerHooks } = nodeModule;
48
+ if (typeof registerHooks !== "function" || process.env.NOU_NO_FAST_WIDTH)
49
+ return false;
50
+ const shim = new URL(`./width-shim${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url).href;
51
+ registerHooks({
52
+ resolve(specifier, context, nextResolve) {
53
+ const resolved = nextResolve(specifier, context);
54
+ if (specifier !== "string-width" || context.parentURL?.startsWith(shim))
55
+ return resolved;
56
+ // Each copy of string-width (Ink may have its own) gets its own wrapper.
57
+ return { ...resolved, url: `${shim}?of=${encodeURIComponent(resolved.url)}` };
58
+ },
59
+ });
60
+ installed = true;
61
+ return true;
62
+ }
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import { useStdin } from "ink";
3
- // Report clicks and wheel (1000) using SGR coordinates (1006), which work past column 223.
4
- const ENABLE = "\x1b[?1000h\x1b[?1006h";
5
- const DISABLE = "\x1b[?1000l\x1b[?1006l";
3
+ // Report clicks and wheel (1000), and movement while a button is held (1002, without it terminals never
4
+ // send drags: no resizing panes, dragging scrollbars or selecting), using SGR coordinates (1006), which
5
+ // work past column 223.
6
+ export const ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
7
+ const DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
6
8
  /** Mouse events in a chunk of terminal input (SGR format: ESC [ < button ; x ; y M|m). */
7
9
  export function parseMouse(chunk) {
8
10
  const events = [];
@@ -13,6 +15,7 @@ export function parseMouse(chunk) {
13
15
  const y = Number(m[3]) - 1;
14
16
  const button = code & 0b11000011; // drop the shift/alt/ctrl bits
15
17
  const shift = (code & 4) !== 0;
18
+ const modified = (code & (8 | 16)) !== 0; // Alt (Option) or Ctrl
16
19
  if (button === 64 || button === 65)
17
20
  events.push({ kind: "wheel", dir: button === 64 ? -1 : 1, x, y, horizontal: shift });
18
21
  else if (button === 66 || button === 67)
@@ -20,7 +23,9 @@ export function parseMouse(chunk) {
20
23
  else if (button === 0 && code & 32)
21
24
  events.push({ kind: "drag", x, y }); // 32: moved with the left button held
22
25
  else if (button === 0)
23
- events.push({ kind: m[4] === "M" ? "click" : "release", x, y });
26
+ events.push(m[4] === "M" ? { kind: "click", x, y, ...(modified ? { define: true } : {}) } : { kind: "release", x, y });
27
+ else if (button === 2 && m[4] === "M")
28
+ events.push({ kind: "click", x, y, define: true }); // right-click
24
29
  }
25
30
  return events;
26
31
  }