nou-cli 0.5.17 → 0.5.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,8 +50,8 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
50
50
  | 🔀 **Live diffs** | Every edit Nou proposes shows up side by side, and the files it is editing shimmer in the file tree while it works. <kbd>y</kbd> accepts, <kbd>n</kbd> rejects. Nothing touches disk without you. |
51
51
  | ✍️ **Built-in editor** | Press <kbd>e</kbd> to edit the open file yourself, with undo/redo and **auto-save**. |
52
52
  | 📍 **Shows you where things are** | Ask "where is the LiveKit integration?" and Nou finds it and opens it in the middle panel, jumped to the exact lines and highlighted. Any file a reply mentions (`src/a.ts:12`) is a link: click it. `/open name[:line]` opens a file yourself. |
53
- | 🪟 **Workspaces** | Several projects in one window, a tab each in the header: click **+** and pick a folder (or `/workspace new ../api`): it opens beside this one with its own files, chats and agents. Click a tab (or `/workspace 2`) to switch; the ones in the background keep working, and a tab shows ● while its agent is busy. A tab's **×** closes it (so does quitting it, Ctrl+C twice). The first time, Nou installs a small pseudo-terminal package ([@lydell/node-pty](https://www.npmjs.com/package/@lydell/node-pty), prebuilt for macOS, Linux and Windows) and reopens as a workspaces window, with your conversation in the first tab. Or start that way: `nou --workspaces . ../api`. |
54
- | 🗂️ **Several agents at once** | `/new` (or **+** in the chat's title) opens another conversation tab while the first keeps working. Each tab has its own agent and history; a working tab shows ●, one waiting for your OK shows !, and a click (or `/tab 2`) switches. A tab's **×** (or `/close`) closes it, even one in the background; it stays in `/resume`. |
53
+ | 🪟 **Workspaces** | Several projects in one window, a tab each in the header: click **+** and pick a folder (or `/workspace new ../api`): it opens beside this one with its own files, chats and agents. Click a tab (or `/workspace 2`) to switch; the ones in the background keep working, and a tab shows ● while its agent is busy. A tab's **×** closes it (so does quitting it, Ctrl+C twice). `/workspace rename <name>` names a tab. With more tabs than fit, the ones around the current tab show and **‹ ›** step to the rest. The first time, Nou installs a small pseudo-terminal package ([@lydell/node-pty](https://www.npmjs.com/package/@lydell/node-pty), prebuilt for macOS, Linux and Windows) and reopens as a workspaces window, with your conversation in the first tab. Or start that way: `nou --workspaces . ../api`. |
54
+ | 🗂️ **Several agents at once** | `/new` (or **+** in the chat's title) opens another conversation tab while the first keeps working. Each tab has its own agent and history; a working tab shows ●, one waiting for your OK shows !, and a click (or `/tab 2`) switches. A tab's **×** (or `/close`) closes it, even one in the background; it stays in `/resume`. `/rename <name>` names a chat's tab (and its `/resume` entry). Many tabs: names shorten to fit, then **‹ ›** step to the ones that don't. |
55
55
  | 👀 **Show me** | Ask how something works or where it happens (`/showme where do we check the login?`, or just ask) and Nou explains it with the code open: each place it talks about opens in the code view, highlighted, and they're listed in the chat to click. `/showme next` and `/showme back` step through them, and `/showme stop` clears the highlights. While it shows, the code view and the file tree take its purple (◉ SHOWING 2/6, and each file's places), as they take green while you edit. Works with every engine: Nou's points at places with a tool, and Claude Code, Codex and pi are asked to list them at the end of their reply, which Nou reads and hides. |
56
56
  | 🧑‍🏫 **Teach me** | `/teach add a --verbose flag` and Nou finds where the change goes, then walks you through typing it yourself: each step opens the file at the right line, says why, and shows the code to type dim ahead of your cursor, like autocomplete (<kbd>Tab</kbd> fills in a line). It moves on when what you typed matches, and saves as it goes. `/teach next` skips a step, `/teach stop` ends it. Works with every engine (Nou's, Claude Code, Codex and pi): any edit one makes anyway is put back. |
57
57
  | 🎮 **Games for a break** | Add Pong, Snake, Breakout (it gets faster each wall you clear), Dodge, Hop and Pairs in settings → Games, then `/games` opens a panel docked under the chat: it splits that column instead of covering anything. Drag its title onto the files, code or chat to dock it there, and × at its top right closes it. Drag its top edge to make it taller or shorter. A game runs only while the panel has the keyboard: Esc goes back to your code and it waits. Your best score in each is kept, and a panel too small for a game says how much room it needs. |
@@ -70,6 +70,7 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
70
70
  | 🛞 **Pick your agent** | Nou's own, or Claude Code, Codex or pi, switchable like models, with the same diffs, approvals and undo. |
71
71
  | 🧩 **Plugins** | Add your own tools and slash commands with a single file. |
72
72
  | 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> (or `/files`) fuzzy-finds any file; <kbd>?</kbd> (or `/actions`) lists every action. |
73
+ | 🔍 **Search every file** | `/search <text>` (or <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd> in terminals that report it) finds the text in every file as you type, each line as `file:line` with the match highlighted; <kbd>Enter</kbd> opens that line. Lowercase ignores case; a capital letter matches case exactly. |
73
74
  | 🔋 **Easy on your battery** | Idle, Nou uses next to no CPU. When its window is in the background (in terminals that report focus: iTerm2, Terminal.app, kitty, Ghostty, WezTerm, Windows Terminal, VS Code), spinners and git polling pause until you come back, and TypeScript checks are incremental. |
74
75
  | 👓 **Easy to read** | Hints and secondary text keep at least 4.5:1 contrast. Nou asks your terminal whether its background is light or dark and picks matching colors (code switches to GitHub Light on a light background); set it yourself in <kbd>s</kbd> settings → **Nou's colors**. |
75
76
 
@@ -110,7 +111,7 @@ Every change is reviewed like a code review:
110
111
  | <kbd>z</kbd> | in the code view: fold every function and block to its first line, or open them all again (also `/fold`, `/unfold`); click a ▾/▸ beside a line number to fold or open just that one |
111
112
  | <kbd>Ctrl</kbd>+<kbd>L</kbd> | draw the whole screen again (also `/redraw`), if it ever looks shifted or clicks land a row off. Output from libraries goes to `~/.nou/nou.log` instead of the screen |
112
113
  | <kbd>Ctrl</kbd>+<kbd>O</kbd> | in the chat: open or fold every "▸ N steps" line (what the agent read and ran between replies folds into one; click one to open just it) |
113
- | <kbd>m</kbd> | mouse on/off, so you can select and copy text in the terminal (in the chat, just drag over the text: it's copied when you let go; double-click copies a word, path or link, triple-click a paragraph) |
114
+ | <kbd>m</kbd> | mouse on/off, so you can select and copy text in the terminal (in the chat, just drag over the text: it's copied when you let go; double-click copies a word, path or link, triple-click a paragraph; <kbd>Ctrl</kbd>+<kbd>C</kbd> while it's highlighted copies it again rather than stopping Nou) |
114
115
  | <kbd>s</kbd> | settings: model, preferences, plugins |
115
116
  | <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command, from any pane; the rest of Nou keeps working while it waits (<kbd>o</kbd> shows the edit) |
116
117
  | <kbd>Enter</kbd> while Nou works | queue the message: it goes when Nou finishes, together with anything else you queued, as one message; commands that only change the screen, like `/terminal`, `/games` or `/open`, run right away. To send now instead of waiting: <kbd>Alt</kbd>+<kbd>Enter</kbd> (stops Nou and sends it), click the queued message, or `/now`. Nou answers it first, then finishes what it was working on, unless your message says to drop it; or turn on **Send while Nou works** in settings so <kbd>Enter</kbd> sends right away (<kbd>Alt</kbd>+<kbd>Enter</kbd> then queues) |
@@ -131,7 +132,7 @@ In the editor:
131
132
  | <kbd>Ctrl</kbd>+<kbd>K</kbd> · <kbd>Ctrl</kbd>+<kbd>G</kbd> | hover info · go to definition (with a language server) |
132
133
  | <kbd>Esc</kbd> | clear the selection, or leave the editor |
133
134
 
134
- Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/effort [fastest|fast|balanced|smarter|smartest]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/resume [n|last]` · `/terminal` · `/zoom` · `/workspace [new <folder> | n]` · `/files` · `/actions` · `/now` · `/showme <question>` · `/teach <change>` · `/help`
135
+ Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/effort [fastest|fast|balanced|smarter|smartest]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/resume [n|last]` · `/terminal` · `/zoom` · `/workspace [new <folder> | n | rename <name>]` · `/rename [name]` · `/files` · `/search [text]` · `/actions` · `/now` · `/showme <question>` · `/teach <change>` · `/help`
135
136
 
136
137
  A small typo in a command still works when it only changes the screen (`/termnial` opens the terminal); for others Nou asks "Did you mean...?" instead of guessing.
137
138
 
@@ -181,3 +181,56 @@ export function grep(options) {
181
181
  }
182
182
  return `${out.join("\n")}${total > shownMatches ? `\n[${total} matches in ${matchedFiles.length} files; showing the first ${limit}. Narrow the pattern, path or glob, or use files_only.]` : ""}`;
183
183
  }
184
+ /** What's been read, kept while a file doesn't change, so searching as you type doesn't reread the project. */
185
+ const textCache = new Map();
186
+ function linesOf(file) {
187
+ let stat;
188
+ try {
189
+ stat = statSync(file);
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ const cached = textCache.get(file);
195
+ if (cached && cached.mtime === stat.mtimeMs)
196
+ return cached.lines;
197
+ let lines = null;
198
+ if (stat.size <= MAX_FILE_BYTES) {
199
+ try {
200
+ const text = readFileSync(file, "utf-8");
201
+ if (!text.includes("\0"))
202
+ lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
203
+ }
204
+ catch {
205
+ // unreadable: skipped
206
+ }
207
+ }
208
+ textCache.set(file, { mtime: stat.mtimeMs, lines });
209
+ return lines;
210
+ }
211
+ /**
212
+ * Every line in the project's files containing `query` (plain text, not a pattern), for searching as you
213
+ * type. Smart case: all lowercase ignores case, any capital matches case exactly. Stops at `limit` hits.
214
+ */
215
+ export function searchText(query, limit = 200) {
216
+ const hits = [];
217
+ if (!query.trim())
218
+ return { hits, more: false };
219
+ const exact = query !== query.toLowerCase();
220
+ const needle = exact ? query : query.toLowerCase();
221
+ for (const file of projectFiles()) {
222
+ const lines = linesOf(file);
223
+ if (!lines)
224
+ continue;
225
+ for (let i = 0; i < lines.length; i++) {
226
+ const line = lines[i];
227
+ const col = (exact ? line : line.toLowerCase()).indexOf(needle);
228
+ if (col < 0)
229
+ continue;
230
+ if (hits.length >= limit)
231
+ return { hits, more: true };
232
+ hits.push({ file, line: i + 1, col, text: line });
233
+ }
234
+ }
235
+ return { hits, more: false };
236
+ }
@@ -31,7 +31,8 @@ import { ENABLE as MOUSE_ON, useMouse } from "./mouse.js";
31
31
  import { connectToHost, isHosted, reopenAsWorkspaces, sendToHost, useWorkspaceTabs, workspaceTabs } from "../workspaces/client.js";
32
32
  import { PTY_PACKAGE, workspaceFolder } from "../workspaces/host.js";
33
33
  import { C, palette } from "./theme.js";
34
- import { FIND_KEY, isFind } from "./keys.js";
34
+ import { FIND_KEY, isFind, isSearchAll } from "./keys.js";
35
+ import { searchText } from "../search/index.js";
35
36
  import { FileTree } from "./FileTree.js";
36
37
  import { CodeView } from "./CodeView.js";
37
38
  import { Editor } from "./Editor.js";
@@ -54,6 +55,8 @@ const MIN_CODE = 20;
54
55
  const MIN_CHAT = 24;
55
56
  const NO_LINES = new Set();
56
57
  const NO_FILES = new Set();
58
+ const NO_SEARCH_FILES = [];
59
+ const NO_SEARCH_ACTIONS = [];
57
60
  const DOUBLE_CLICK_MS = 500;
58
61
  /** How long each follow step stays on screen before the next. */
59
62
  const FOLLOW_STEP_MS = { slow: 2500, normal: 1200, fast: 450, instant: 0 };
@@ -123,6 +126,8 @@ export function App({ initialModel, permContext, resume }) {
123
126
  const [setupReason, setSetupReason] = useState(null);
124
127
  /** The file finder / command palette: closed (null), or open with this query (">" for commands). */
125
128
  const [quickOpen, setQuickOpen] = useState(null);
129
+ /** Searching the text of every file (Ctrl+Shift+F, /search): what's typed so far, or null when closed. */
130
+ const [searching, setSearching] = useState(null);
126
131
  /** /resume's picker: the conversations, in the palette's place while it's open. */
127
132
  const [resumePicker, setResumePicker] = useState(null);
128
133
  /** What the open picker is for, said in its empty search box. */
@@ -172,6 +177,11 @@ export function App({ initialModel, permContext, resume }) {
172
177
  }, [tour]);
173
178
  const shellRef = useRef(null);
174
179
  const beforeTerm = useRef("code");
180
+ /** Names you gave conversations (/rename), by conversation: their tab and /resume show them. */
181
+ const chatNames = useRef(new Map());
182
+ const [namesVersion, setNamesVersion] = useState(0);
183
+ /** What the header calls this workspace when it's the only one (/workspace rename). */
184
+ const [projectName, setProjectName] = useState(path.basename(process.cwd()));
175
185
  const [follow, setFollow] = useState(settings.followMode);
176
186
  // Follow mode replays what the agent reads a step at a time (it reads far faster than you can watch):
177
187
  // the steps waiting, and the timer that shows the next one.
@@ -378,7 +388,10 @@ export function App({ initialModel, permContext, resume }) {
378
388
  return w.has(change.path) ? w : new Set(w).add(change.path);
379
389
  });
380
390
  // Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
381
- if (!background && ((status === "pending" && !draftActiveRef.current) || followRef.current)) {
391
+ // A proposed edit opens only when it waits for your OK; approved by auto-edit, it's just Nou working,
392
+ // which follow mode shows if it's on (it used to open anyway, so follow off still followed edits).
393
+ const waitsForYou = status === "pending" && !settingsRef.current.autoApproveEdits && !guideAsk.current;
394
+ if (!background && ((waitsForYou && !draftActiveRef.current) || followRef.current)) {
382
395
  clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
383
396
  setOpenFile(change.path);
384
397
  setContent(status === "applied" ? change.after : readText(change.path));
@@ -502,8 +515,8 @@ export function App({ initialModel, permContext, resume }) {
502
515
  /** The tabs as the chat's title row shows them: named for what you asked first, marked when working or waiting. */
503
516
  function chatTabItems() {
504
517
  return chatTabs.map((id, i) => {
505
- const t = id === activeTab.current ? { entries, busy, approval } : tabStore.current.get(id);
506
- const first = t?.entries.find((e) => e.kind === "user")?.text.replace(/\s+/g, " ");
518
+ const t = id === activeTab.current ? { entries, busy, approval, conversationId } : tabStore.current.get(id);
519
+ const first = chatNames.current.get(t?.conversationId ?? "") ?? t?.entries.find((e) => e.kind === "user")?.text.replace(/\s+/g, " ");
507
520
  const label = first ? (first.length > 14 ? `${first.slice(0, 13)}…` : first) : `chat ${i + 1}`;
508
521
  return { id, label, state: t?.approval ? "waiting" : t?.busy ? "busy" : "idle" };
509
522
  });
@@ -518,7 +531,7 @@ export function App({ initialModel, permContext, resume }) {
518
531
  function saveTab(t) {
519
532
  const first = t.entries.find((e) => e.kind === "user");
520
533
  if (first)
521
- saveConversation({ id: t.conversationId, title: first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: t.agent.id, entries: t.entries, state: t.agent.snapshot?.() });
534
+ saveConversation({ id: t.conversationId, title: chatNames.current.get(t.conversationId) ?? first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: t.agent.id, entries: t.entries, state: t.agent.snapshot?.() });
522
535
  }
523
536
  if (!agentRef.current) {
524
537
  const saved = loadSettings().engine;
@@ -541,8 +554,8 @@ export function App({ initialModel, permContext, resume }) {
541
554
  if (busy || !first)
542
555
  return;
543
556
  const engine = agentRef.current;
544
- saveConversation({ id: conversationId, title: first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: engine.id, entries, state: engine.snapshot?.() });
545
- }, [!busy, entries.length, conversationId]);
557
+ saveConversation({ id: conversationId, title: chatNames.current.get(conversationId) ?? first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: engine.id, entries, state: engine.snapshot?.() });
558
+ }, [!busy, entries.length, conversationId, namesVersion]);
546
559
  /** Earlier conversations for the palette, read from disk when it opens. */
547
560
  const recentChats = useMemo(() => (quickOpen !== null ? resumable() : []), [quickOpen !== null]);
548
561
  useEffect(() => {
@@ -1106,7 +1119,7 @@ export function App({ initialModel, permContext, resume }) {
1106
1119
  return setView("settings");
1107
1120
  // Workspace tabs in the header: click one to switch to it, + to open another.
1108
1121
  if (event.kind === "click" && event.y === 0) {
1109
- const zone = headerZones(workspaceTabs()).find((z) => event.x >= z.from && event.x < z.to);
1122
+ const zone = headerZones(workspaceTabs(), columns, modelLabel, choice.provider.local, update, projectName).find((z) => event.x >= z.from && event.x < z.to);
1110
1123
  if (zone?.tab === "new")
1111
1124
  return pickWorkspaceFolder();
1112
1125
  if (typeof zone?.tab === "number")
@@ -1140,7 +1153,7 @@ export function App({ initialModel, permContext, resume }) {
1140
1153
  // The chat's title row holds the conversation tabs: a click switches, + opens a new one.
1141
1154
  if (event.kind === "click" && pane === "chat" && event.y === 2) {
1142
1155
  const at = event.x - treeWidth - codeWidth - 1;
1143
- const { zones, plus } = chatTabZones(chatTabItems());
1156
+ const { zones, plus } = chatTabZones(chatTabItems(), activeTab.current, columns - treeWidth - codeWidth);
1144
1157
  if (Math.abs(at - plus) <= 1)
1145
1158
  return newChatTab();
1146
1159
  const zone = zones.find((z) => at >= z.from && at < z.to);
@@ -1222,6 +1235,10 @@ export function App({ initialModel, permContext, resume }) {
1222
1235
  }
1223
1236
  }
1224
1237
  }, settings.mouse);
1238
+ const chatSelecting = useRef(false);
1239
+ const onChatSelecting = useEvent((selecting) => {
1240
+ chatSelecting.current = selecting;
1241
+ });
1225
1242
  // Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
1226
1243
  useInput((input, key) => {
1227
1244
  if (!(key.ctrl && input === "c"))
@@ -1230,6 +1247,8 @@ export function App({ initialModel, permContext, resume }) {
1230
1247
  return; // the editor copies it
1231
1248
  if (focus === "terminal")
1232
1249
  return; // the terminal stops its command
1250
+ if (chatSelecting.current)
1251
+ return; // the chat copies what's highlighted
1233
1252
  if (busy || approval) {
1234
1253
  stop();
1235
1254
  approval?.resolve(false);
@@ -1530,6 +1549,8 @@ export function App({ initialModel, permContext, resume }) {
1530
1549
  if (key.ctrl && (input === "=" || input === "+" || input === "-" || input === "0") && !key.meta) {
1531
1550
  return zoomCode(input === "0" ? 0 : input === "-" ? -1 : 1);
1532
1551
  }
1552
+ if (isSearchAll(input, key))
1553
+ return setSearching("");
1533
1554
  if (key.ctrl && input === "p") {
1534
1555
  setQuickOpen("");
1535
1556
  return;
@@ -1580,7 +1601,7 @@ export function App({ initialModel, permContext, resume }) {
1580
1601
  setFocus("chat");
1581
1602
  setChatPrefill((p) => ({ text: "/", nonce: (p?.nonce ?? 0) + 1 }));
1582
1603
  }
1583
- else if (input === "f")
1604
+ else if (input === "f" || input === "F")
1584
1605
  toggleFollow();
1585
1606
  else if (input === "m")
1586
1607
  toggleMouse();
@@ -1763,6 +1784,16 @@ ${snippet}
1763
1784
  return info(`No file matches "${wanted}". To find where something is in the code, ask in the chat.`, "error");
1764
1785
  return openRef({ path: file, ...(m[2] ? { line: Number(m[2]) } : {}), ...(m[3] ? { to: Number(m[3]) } : {}) });
1765
1786
  }
1787
+ case "rename": {
1788
+ // Names this conversation: its tab, and /resume, show the name (/rename alone: back to the first message).
1789
+ const name = arg.trim().slice(0, 40);
1790
+ if (name)
1791
+ chatNames.current.set(conversationId, name);
1792
+ else
1793
+ chatNames.current.delete(conversationId);
1794
+ setNamesVersion((v) => v + 1);
1795
+ return info(name ? `This chat is now called ${name}. (/workspace rename <name> names the workspace.)` : "This chat is named by its first message again.");
1796
+ }
1766
1797
  case "follow": {
1767
1798
  const want = arg.trim().toLowerCase();
1768
1799
  if (want && !/^(on|off)$/.test(want))
@@ -1910,6 +1941,9 @@ ${snippet}
1910
1941
  return info("/zoom in makes the code panel bigger, /zoom out smaller, /zoom reset puts it back (or + and - with the code focused, = to reset). The files and chat give or take the room. For bigger text, use your terminal's own zoom (Cmd/Ctrl and +).");
1911
1942
  case "files":
1912
1943
  return setQuickOpen("");
1944
+ case "search":
1945
+ case "grep":
1946
+ return setSearching(arg.trim());
1913
1947
  case "actions":
1914
1948
  case "palette":
1915
1949
  return setQuickOpen(">");
@@ -2450,8 +2484,16 @@ ${snippet}
2450
2484
  return info(`There's no workspace ${n}. /workspace lists them.`, "error");
2451
2485
  return void sendToHost({ type: "switch", index: n - 1 });
2452
2486
  }
2487
+ if (sub === "rename") {
2488
+ const name = more.join(" ").trim().slice(0, 30);
2489
+ if (isHosted())
2490
+ sendToHost({ type: "rename", name });
2491
+ else
2492
+ setProjectName(name || path.basename(process.cwd()));
2493
+ return info(name ? `This workspace is now called ${name}.` : "This workspace is called by its folder's name again.");
2494
+ }
2453
2495
  if (sub !== "new")
2454
- return info("Try /workspace new <folder>, /workspace <n> or /workspace.", "error");
2496
+ return info("Try /workspace new <folder>, /workspace <n>, /workspace rename <name> or /workspace.", "error");
2455
2497
  const wanted = more.join(" ");
2456
2498
  if (!wanted)
2457
2499
  return pickWorkspaceFolder();
@@ -2538,6 +2580,7 @@ ${snippet}
2538
2580
  // Everything Nou can do, for the command palette (? or Ctrl+P then >), with the key that does it.
2539
2581
  const paletteActions = [
2540
2582
  { title: "Find a file", keys: "Ctrl+P", run: () => setQuickOpen("") },
2583
+ { title: "Search the text of every file", keys: "/search", run: () => setSearching("") },
2541
2584
  { title: "Find in the open file", keys: FIND_KEY, run: startFind },
2542
2585
  { title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
2543
2586
  { title: "Go back to the previous file", keys: "b", run: goBack },
@@ -2655,9 +2698,15 @@ ${snippet}
2655
2698
  }
2656
2699
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
2657
2700
  clickable.current = { status: zones, modelFrom: columns - (" LOCAL ".length + ` ${modelLabel} `.length + 1) };
2658
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update, tabs: tabsOfWorkspaces }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, ...(tourFiles ? { showing: tourFiles } : {}), git: git, problemCounts: problemCounts, wheelPane: "tree", scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheelPane: "code", onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheelPane: "code", scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tour && tour.stops[tour.at]?.file === openFile && !diff ? { showing: `${tour.at + 1}/${tour.stops.length}` } : {}), ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheelPane: "code", scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest, foldClick: foldClick, foldAll: foldAll })), _jsx(Chat, { width: chatWidth, height: heights.chat, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheelPane: "chat", scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, sendNowFirst: settings.sendNowWhileBusy, onSendNow: sendNow, onAnswer: onAnswer, click: chatClick, select: chatSelect, tabs: chatTabsProp(), onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: C.onBadge, bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: followChip }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✗ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
2701
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update, tabs: tabsOfWorkspaces, project: projectName }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, ...(tourFiles ? { showing: tourFiles } : {}), git: git, problemCounts: problemCounts, wheelPane: "tree", scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheelPane: "code", onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheelPane: "code", scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tour && tour.stops[tour.at]?.file === openFile && !diff ? { showing: `${tour.at + 1}/${tour.stops.length}` } : {}), ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheelPane: "code", scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest, foldClick: foldClick, foldAll: foldAll })), _jsx(Chat, { width: chatWidth, height: heights.chat, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheelPane: "chat", scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, sendNowFirst: settings.sendNowWhileBusy, onSendNow: sendNow, onSelecting: onChatSelecting, onAnswer: onAnswer, click: chatClick, select: chatSelect, tabs: chatTabsProp(), onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: C.onBadge, bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: followChip }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✗ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
2659
2702
  ? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
2660
- : shownHints.map(([key, action], i) => (_jsxs(Text, { children: [i === 0 ? " " : _jsx(Text, { color: C.dim, children: " \u00B7 " }), _jsx(Text, { color: C.text, children: key }), _jsxs(Text, { color: C.dim, children: [" ", action] })] }, key)))] }) }), layout.current.terminal && shellRef.current && (_jsx(Box, { position: "absolute", marginLeft: layout.current.terminal.x, marginTop: layout.current.terminal.y, children: _jsx(Terminal, { shell: shellRef.current, version: termVersion, width: layout.current.terminal.width, height: layout.current.terminal.height, focused: focus === "terminal", wheelPane: "terminal", onLeave: leaveTerminal, onClose: () => toggleTerminal(false), onResize: resizeTerminal }) })), layout.current.game && (_jsx(Box, { position: "absolute", marginLeft: layout.current.game.x, marginTop: layout.current.game.y, children: _jsx(GameGuard, { children: _jsx(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, best: settings.gameBest ?? {}, onScore: onGameScore, onLeave: leaveGames, onOpenSettings: gamesSettings }) }) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: resumePicker ?? paletteActions, initialQuery: quickOpen, ...(resumePicker ? { placeholder: "type to filter", title: pickerTitle } : {}), onPick: (file) => {
2703
+ : shownHints.map(([key, action], i) => (_jsxs(Text, { children: [i === 0 ? " " : _jsx(Text, { color: C.dim, children: " \u00B7 " }), _jsx(Text, { color: C.text, children: key }), _jsxs(Text, { color: C.dim, children: [" ", action] })] }, key)))] }) }), layout.current.terminal && shellRef.current && (_jsx(Box, { position: "absolute", marginLeft: layout.current.terminal.x, marginTop: layout.current.terminal.y, children: _jsx(Terminal, { shell: shellRef.current, version: termVersion, width: layout.current.terminal.width, height: layout.current.terminal.height, focused: focus === "terminal", wheelPane: "terminal", onLeave: leaveTerminal, onClose: () => toggleTerminal(false), onResize: resizeTerminal }) })), layout.current.game && (_jsx(Box, { position: "absolute", marginLeft: layout.current.game.x, marginTop: layout.current.game.y, children: _jsx(GameGuard, { children: _jsx(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, best: settings.gameBest ?? {}, onScore: onGameScore, onLeave: leaveGames, onOpenSettings: gamesSettings }) }) })), searching !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: NO_SEARCH_FILES, actions: NO_SEARCH_ACTIONS, initialQuery: searching, placeholder: "Search every file\u2026", searchText: searchText, onPick: () => { }, onPickHit: (hit) => {
2704
+ setSearching(null);
2705
+ clearFollow();
2706
+ // Opens the line with the match, highlighted, and puts the keyboard there.
2707
+ openAt(hit.file, hit.line, false, hit.line);
2708
+ setFocus("code");
2709
+ }, onRun: () => { }, onClose: () => setSearching(null) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: resumePicker ?? paletteActions, initialQuery: quickOpen, ...(resumePicker ? { placeholder: "type to filter", title: pickerTitle } : {}), onPick: (file) => {
2661
2710
  setQuickOpen(null);
2662
2711
  clearFollow();
2663
2712
  open(file);
@@ -7,6 +7,7 @@ import path from "path";
7
7
  import { closeOpenMarkdown, linkAt, linkFileRefs, linkUrls, renderMarkdown, wrap } from "./text.js";
8
8
  import { ChatInput, inputHeight } from "./ChatInput.js";
9
9
  import { copyToClipboard } from "./clipboard.js";
10
+ import { fitAround } from "./Tabs.js";
10
11
  import { C, SPINNER, ANIMATION_MS } from "./theme.js";
11
12
  import { useTerminalFocused } from "./focus.js";
12
13
  import { Pane } from "./Pane.js";
@@ -85,28 +86,49 @@ function cachedLines(entry, inner) {
85
86
  }
86
87
  /**
87
88
  * Where each tab sits in the chat's title row (columns from the pane's inner left edge), and the "+"
88
- * that opens a new one: shared by the drawing and the clicks.
89
+ * that opens a new one: shared by the drawing and the clicks. Only the tabs that fit are shown, around the
90
+ * active one; ‹ and › stand for the rest and go to the tab before or after.
89
91
  */
90
- export function chatTabZones(tabs) {
92
+ export function chatTabZones(all, active, paneWidth = 10_000) {
91
93
  // One tab: just "Chat +", to leave the title row its room.
92
- if (tabs.length === 1)
93
- return { zones: [{ id: tabs[0].id, from: 3, to: 7 }], plus: 8, width: 9 };
94
+ if (all.length === 1)
95
+ return { zones: [{ id: all[0].id, from: 3, to: 7 }], plus: 8, width: 9, from: 0, to: 1, tabs: all };
96
+ const room = paneWidth - 2 - 3 - 3; // the borders, " ◆ " and " + "
97
+ const sizeOf = (t) => tabText(t).length + TAB_CLOSE.length + 1;
98
+ // Short of room, names are shortened first (down to a few letters each), so every tab stays in view;
99
+ // only then do the ones that still don't fit go behind ‹ ›.
100
+ let tabs = all;
101
+ for (let max = 14; max >= 6 && tabs.reduce((n, t) => n + sizeOf(t), 0) > room; max--) {
102
+ tabs = all.map((t) => (t.label.length > max ? { ...t, label: `${t.label.slice(0, max - 1)}…` } : t));
103
+ }
104
+ const sizes = tabs.map(sizeOf);
105
+ const { from, to } = fitAround(sizes, Math.max(0, tabs.findIndex((t) => t.id === active)), room, TAB_PREV.length + TAB_NEXT.length);
94
106
  let x = 3; // " ◆ "
107
+ const zones = [];
108
+ if (from > 0) {
109
+ zones.push({ id: tabs[from - 1].id, from: x, to: x + TAB_PREV.length });
110
+ x += TAB_PREV.length;
111
+ }
95
112
  // Each tab, then its × (which closes it).
96
- const zones = tabs.flatMap((t) => {
113
+ for (const t of tabs.slice(from, to)) {
97
114
  const w = tabText(t).length;
98
- const both = [{ id: t.id, from: x, to: x + w }, { id: t.id, from: x + w, to: x + w + TAB_CLOSE.length, close: true }];
115
+ zones.push({ id: t.id, from: x, to: x + w }, { id: t.id, from: x + w, to: x + w + TAB_CLOSE.length, close: true });
99
116
  x += w + TAB_CLOSE.length + 1;
100
- return both;
101
- });
102
- return { zones, plus: x + 1, width: x + 3 };
117
+ }
118
+ if (to < tabs.length) {
119
+ zones.push({ id: tabs[to].id, from: x, to: x + TAB_NEXT.length });
120
+ x += TAB_NEXT.length;
121
+ }
122
+ return { zones, plus: x + 1, width: x + 3, from, to, tabs };
103
123
  }
124
+ const TAB_PREV = "‹ ";
125
+ const TAB_NEXT = "› ";
104
126
  const TAB_CLOSE = "× ";
105
127
  const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
106
128
  /** Characters of a word for a double-click: paths and web addresses come whole (src/a.ts, https://x.io/y). */
107
129
  const WORD_CHAR = /[\p{L}\p{N}_.\-/@:~#%?=&+]/u;
108
130
  const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
109
- export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheelPane, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [], sendNowFirst = false, onSendNow }) {
131
+ export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheelPane, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [], sendNowFirst = false, onSendNow, onSelecting }) {
110
132
  const wheel = usePaneWheel(wheelPane);
111
133
  const [input, setInputText] = useState("");
112
134
  // Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
@@ -343,6 +365,17 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
343
365
  setTimeout(() => setFlash(null), 2000);
344
366
  });
345
367
  };
368
+ useEffect(() => {
369
+ onSelecting?.(!!selection);
370
+ }, [!!selection]);
371
+ // Ctrl+C with text highlighted copies it (again), as it does everywhere else, and clears the highlight:
372
+ // it doesn't stop Nou. With nothing highlighted, Ctrl+C stops Nou as before.
373
+ useInput((input, key) => {
374
+ if (!key.ctrl || input !== "c" || !selection)
375
+ return;
376
+ copySelection(selection);
377
+ setSelection(null);
378
+ }, { isActive: !!selection });
346
379
  // Typing or new messages leave the selection be; a new conversation clears it.
347
380
  useEffect(() => {
348
381
  if (!entries.length)
@@ -404,7 +437,8 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
404
437
  ? line.spans.map((span, i) => (_jsx(Text, { bold: !!span.bold || !!line.bold, italic: !!span.italic, dimColor: !!span.dim && !span.link, underline: !!span.link, ...(span.link ? { color: C.cyan } : span.code ? { color: C.yellow } : line.color ? { color: line.color } : { color: C.text }), children: span.text }, i)))
405
438
  : _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
406
439
  const empty = entries.length === 0 && !streaming;
407
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " ", tabs && tabs.items.length > 1 ? (_jsxs(_Fragment, { children: [tabs.items.map((t) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.id === tabs.active ? { backgroundColor: C.panel, color: C.text, bold: true } : { color: t.state === "waiting" ? C.yellow : C.dim }), children: tabText(t) }), _jsx(Text, { color: C.dim, ...(t.id === tabs.active ? { backgroundColor: C.panel } : {}), children: TAB_CLOSE }), " "] }, t.id))), _jsx(Text, { color: C.accent2, bold: true, children: " + " })] })) : _jsxs(_Fragment, { children: ["Chat", tabs ? _jsx(Text, { color: C.accent2, bold: true, children: " +" }) : null] }), flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
440
+ const strip = tabs ? chatTabZones(tabs.items, tabs.active, width) : { from: 0, to: 0, tabs: [] };
441
+ const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " ", tabs && tabs.items.length > 1 ? (_jsxs(_Fragment, { children: [strip.from > 0 ? _jsx(Text, { color: C.accent2, children: TAB_PREV }) : null, strip.tabs.slice(strip.from, strip.to).map((t) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.id === tabs.active ? { backgroundColor: C.panel, color: C.text, bold: true } : { color: t.state === "waiting" ? C.yellow : C.dim }), children: tabText(t) }), _jsx(Text, { color: C.dim, ...(t.id === tabs.active ? { backgroundColor: C.panel } : {}), children: TAB_CLOSE }), " "] }, t.id))), strip.to < tabs.items.length ? _jsx(Text, { color: C.accent2, children: TAB_NEXT }) : null, _jsx(Text, { color: C.accent2, bold: true, children: " + " })] })) : _jsxs(_Fragment, { children: ["Chat", tabs ? _jsx(Text, { color: C.accent2, bold: true, children: " +" }) : null] }), flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
408
442
  ? unread
409
443
  ? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
410
444
  : _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
@@ -5,50 +5,77 @@ import { Box, Text, useAnimation } from "ink";
5
5
  import { C, gradient, ANIMATION_MS } from "./theme.js";
6
6
  import { useTerminalFocused } from "./focus.js";
7
7
  import { currentVersion } from "../update/index.js";
8
+ import { fitAround } from "./Tabs.js";
8
9
  const VERSION = `v${currentVersion()}`;
9
10
  /** What comes before the project (or the workspace tabs): " ◆ NOU v1.2.3 │ ". */
10
11
  const LEAD = ` ◆ NOU ${VERSION} │ `;
11
12
  const tabLabel = (t, i) => ` ${i + 1} ${t.name}${t.busy ? " ●" : ""} `;
12
13
  /** Each workspace tab ends with a × that closes it. */
13
14
  const CLOSE = "× ";
15
+ /** The markers for workspace tabs that don't fit: click to go to the one before or after. */
16
+ const PREV = "‹ ";
17
+ const NEXT = "› ";
14
18
  /**
15
- * Where each workspace tab, and the + that opens one, sits in the header row: for clicks. With one workspace
16
- * (not hosted) there's just the project's name and the +.
19
+ * The header's right side: the update notice (when there's room), where the model runs and which. Plain
20
+ * characters only: symbols that are also emoji (☁, ⬆) are drawn two columns wide by some terminals.
21
+ */
22
+ const rightSide = (width, model, local, update) => ({
23
+ notice: update && width >= 110 ? `↑ v${update} available · run \`nou update\` ` : "",
24
+ badge: ` ${local ? "LOCAL" : "CLOUD"} `,
25
+ model: ` ${model} `,
26
+ });
27
+ /** Columns the left side of the header gets: what the right leaves, less a margin. */
28
+ const leftWidth = (width, model, local, update) => {
29
+ const right = rightSide(width, model, local, update);
30
+ return Math.max(0, width - (right.notice.length + right.badge.length + right.model.length + 1) - 4);
31
+ };
32
+ /**
33
+ * Which workspace tabs the header shows, around the active one: as many as fit, with ‹ › for the rest (the
34
+ * + always shows). The drawing and the clicks both use it.
35
+ */
36
+ export function headerLayout(tabs, width, model, local, update) {
37
+ const room = leftWidth(width, model, local, update) - LEAD.length - 3 - 2; // " + " and a little air
38
+ const sizes = tabs.map((t, i) => tabLabel(t, i).length + CLOSE.length + 1);
39
+ const { from, to } = fitAround(sizes, tabs.findIndex((t) => t.active), room, PREV.length + NEXT.length);
40
+ return { from, to, before: from > 0, after: to < tabs.length };
41
+ }
42
+ /**
43
+ * Where each workspace tab, its ×, the ‹ › and the + that opens one sit in the header row: for clicks. With
44
+ * one workspace (not hosted) there's just the project's name and the +.
17
45
  */
18
- export function headerZones(tabs) {
46
+ export function headerZones(tabs, width = 10_000, model = "", local = true, update = null, project = path.basename(process.cwd())) {
19
47
  const zones = [];
20
48
  let x = LEAD.length;
21
49
  if (!tabs.length) {
22
- x += path.basename(process.cwd()).length + 1;
50
+ x += project.length + 1;
23
51
  }
24
52
  else {
25
- tabs.forEach((t, i) => {
26
- const w = tabLabel(t, i).length;
53
+ const { from, to, before, after } = headerLayout(tabs, width, model, local, update);
54
+ if (before) {
55
+ zones.push({ from: x, to: x + PREV.length, tab: from - 1 });
56
+ x += PREV.length;
57
+ }
58
+ for (let i = from; i < to; i++) {
59
+ const w = tabLabel(tabs[i], i).length;
27
60
  zones.push({ from: x, to: x + w, tab: i });
28
61
  zones.push({ from: x + w, to: x + w + CLOSE.length, tab: i, close: true });
29
62
  x += w + CLOSE.length + 1;
30
- });
63
+ }
64
+ if (after) {
65
+ zones.push({ from: x, to: x + NEXT.length, tab: to });
66
+ x += NEXT.length;
67
+ }
31
68
  }
32
69
  zones.push({ from: x, to: x + 3, tab: "new" });
33
70
  return zones;
34
71
  }
35
- /**
36
- * The header's right side: the update notice (when there's room), where the model runs and which. Plain
37
- * characters only: symbols that are also emoji (☁, ⬆) are drawn two columns wide by some terminals.
38
- */
39
- const rightSide = (width, model, local, update) => ({
40
- notice: update && width >= 110 ? `↑ v${update} available · run \`nou update\` ` : "",
41
- badge: ` ${local ? "LOCAL" : "CLOUD"} `,
42
- model: ` ${model} `,
43
- });
44
- export const Header = memo(function Header({ width, branch, changes, model, local, busy, update, tabs = [] }) {
72
+ export const Header = memo(function Header({ width, branch, changes, model, local, busy, update, tabs = [], project = path.basename(process.cwd()) }) {
45
73
  // The spinner stops while Nou's window is in the background: nobody sees it, and each frame is a redraw.
46
74
  const windowFocused = useTerminalFocused();
47
75
  const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
48
- const project = path.basename(process.cwd());
49
76
  const right = rightSide(width, model, local, update);
50
- const rightWidth = right.notice.length + right.badge.length + right.model.length + 1;
51
- return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { width: Math.max(0, width - rightWidth - 4), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), tabs.length
52
- ? tabs.map((t, i) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: t.active ? C.accent : C.text, bold: t.active, children: tabLabel(t, i) }), _jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: C.dim, children: CLOSE }), " "] }, t.id)))
77
+ const shown = headerLayout(tabs, width, model, local, update);
78
+ return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { width: leftWidth(width, model, local, update), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), tabs.length
79
+ ? _jsxs(_Fragment, { children: [shown.before ? _jsx(Text, { color: C.accent, children: PREV }) : null, tabs.slice(shown.from, shown.to).map((t, j) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: t.active ? C.accent : C.text, bold: t.active, children: tabLabel(t, shown.from + j) }), _jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: C.dim, children: CLOSE }), " "] }, t.id))), shown.after ? _jsx(Text, { color: C.accent, children: NEXT }) : null] })
53
80
  : _jsxs(Text, { color: C.text, children: [project, " "] }), _jsx(Text, { color: C.accent2, children: " + " }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] }))] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [right.notice && _jsx(Text, { color: C.green, bold: true, children: right.notice }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: C.onBadge, bold: true, children: right.badge }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: right.model }), " "] }) })] }));
54
81
  });
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useState } from "react";
2
+ import { useEffect, useMemo, useState } from "react";
3
3
  import { readdirSync } from "fs";
4
4
  import path from "path";
5
5
  import { Box, Text, useInput } from "ink";
@@ -58,15 +58,24 @@ export function score(file, query) {
58
58
  return { score: total - file.length * 0.05, hits };
59
59
  }
60
60
  /** Ctrl+P: find a file. Start with ">" (or press ?) for every action Nou has, with its shortcut. */
61
- export function QuickOpen({ width, top, files, recent = [], actions, initialQuery = "", placeholder = "Go to file… (> for commands)", title, onPick, onRun, onClose }) {
61
+ export function QuickOpen({ width, top, files, recent = [], actions, initialQuery = "", placeholder = "Go to file… (> for commands)", title, onPick, searchText, onPickHit, onRun, onClose }) {
62
62
  const [query, setQuery] = useState(initialQuery);
63
63
  const [cursor, setCursor] = useState(0);
64
+ // Search mode reads every file, so it waits for a pause in the typing (each search is one pass, cached).
65
+ const textMode = !!searchText;
66
+ const [searched, setSearched] = useState({ query: "", hits: [], more: false });
67
+ useEffect(() => {
68
+ if (!searchText)
69
+ return;
70
+ const timer = setTimeout(() => setSearched({ query, ...searchText(query) }), query === searched.query ? 0 : 120);
71
+ return () => clearTimeout(timer);
72
+ }, [query, !!searchText]);
64
73
  const boxWidth = Math.min(80, Math.max(40, width - 10));
65
74
  const inner = boxWidth - 2; // fill every cell so the screen underneath doesn't show through
66
- const commandMode = query.startsWith(">");
75
+ const commandMode = !textMode && query.startsWith(">");
67
76
  const rowsShown = 12;
68
77
  const results = useMemo(() => {
69
- if (commandMode)
78
+ if (commandMode || textMode)
70
79
  return [];
71
80
  // Recent files first when nothing's typed; a small boost otherwise, so the one you just had wins a tie.
72
81
  const rank = new Map(recent.map((f, i) => [f, i]));
@@ -86,7 +95,8 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
86
95
  .filter((r) => r.m !== null)
87
96
  .sort((a, b) => (q ? b.m.score - a.m.score : a.order - b.order));
88
97
  }, [actions, query, commandMode]);
89
- const count = commandMode ? found.length : results.length;
98
+ const hits = textMode ? searched.hits : [];
99
+ const count = textMode ? hits.length : commandMode ? found.length : results.length;
90
100
  const first = Math.max(0, Math.min(cursor - rowsShown + 1, count - rowsShown));
91
101
  useInput((_input, key) => {
92
102
  if (key.escape)
@@ -104,11 +114,15 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
104
114
  /** Cut at the end (a title reads from the start). */
105
115
  const fit = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n));
106
116
  const pad = (s) => (s.length > inner ? `…${s.slice(-(inner - 1))}` : s.padEnd(inner));
107
- 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: fit(` ${title ?? (commandMode ? "Every action · type to filter (? or /actions)" : "Find a file · type part of its name (Ctrl+P or /files)")}`, inner - 6) }), _jsx(Text, { color: C.dim, children: "Esc ×".padStart(6) })] }), _jsxs(Text, { backgroundColor: C.panel, children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(TextInput, { value: query, onChange: (v) => {
117
+ 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: fit(` ${title ?? (textMode ? "Search every file · type a word or phrase (/search)" : commandMode ? "Every action · type to filter (? or /actions)" : "Find a file · type part of its name (Ctrl+P or /files)")}`, inner - 6) }), _jsx(Text, { color: C.dim, children: "Esc ×".padStart(6) })] }), _jsxs(Text, { backgroundColor: C.panel, children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(TextInput, { value: query, onChange: (v) => {
108
118
  setQuery(v);
109
119
  setCursor(0);
110
120
  }, onSubmit: () => {
111
- if (commandMode) {
121
+ if (textMode) {
122
+ if (hits[cursor])
123
+ onPickHit?.(hits[cursor]);
124
+ }
125
+ else if (commandMode) {
112
126
  // A number picks that row of a numbered list (/resume's: "1. ...", "2. ...").
113
127
  const n = /^\s*(\d+)\s*$/.exec(query.slice(1))?.[1];
114
128
  const numbered = n ? actions.find((a) => a.title.startsWith(`${n}. `)) : undefined;
@@ -126,12 +140,30 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
126
140
  const title = r.action.title.length > room ? `${r.action.title.slice(0, room - 1)}…` : r.action.title.padEnd(room);
127
141
  const hits = new Set(r.m.hits);
128
142
  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));
129
- }), !commandMode && results.length === 0 && _jsx(Text, { color: C.dim, children: pad(" No matching files") }), results.map((r, i) => {
143
+ }), textMode && (!query.trim() ? _jsx(Text, { color: C.dim, children: pad(" Type to search the text of every file in the project") })
144
+ : searched.query !== query ? _jsx(Text, { color: C.dim, children: pad(" Searching…") })
145
+ : !hits.length ? _jsx(Text, { color: C.dim, children: pad(` No file contains "${query}"`) })
146
+ : null), textMode && searched.query === query && hits.slice(first, first + rowsShown).map((h, i) => {
147
+ const selected = first + i === cursor;
148
+ const { icon, color } = fileIcon(h.file);
149
+ const where = ` ${h.file}:${h.line} `;
150
+ // The line around the match, so the match is always in view however long the line.
151
+ const room = Math.max(10, inner - 2 - where.length);
152
+ const lead = h.text.slice(0, h.col).trimStart();
153
+ const keep = Math.max(0, Math.min(lead.length, Math.floor(room / 3)));
154
+ const before = (lead.length > keep ? "…" : "") + lead.slice(lead.length - keep);
155
+ const match = h.text.slice(h.col, h.col + query.length);
156
+ const after = h.text.slice(h.col + query.length);
157
+ const rest = Math.max(0, room - before.length - match.length);
158
+ return (_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▌" : " " }), _jsx(Text, { color: color, children: icon }), _jsx(Text, { color: selected ? C.text : C.dim, children: where }), _jsx(Text, { color: selected ? C.text : C.dim, children: before }), _jsx(Text, { color: C.yellow, bold: true, children: match }), _jsx(Text, { color: selected ? C.text : C.dim, children: (after.length > rest ? `${after.slice(0, Math.max(0, rest - 1))}…` : after).padEnd(rest) })] }, `${h.file}:${h.line}`));
159
+ }), !commandMode && !textMode && results.length === 0 && _jsx(Text, { color: C.dim, children: pad(" No matching files") }), results.map((r, i) => {
130
160
  const selected = i === cursor;
131
161
  const { icon, color } = fileIcon(r.file);
132
162
  const tag = recentSet.has(r.file) ? " recent" : "";
133
163
  const label = pad(` ${r.file}`).slice(0, inner - 2 - tag.length);
134
164
  const hits = new Set(r.m.hits.map((h) => h + 1));
135
165
  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));
136
- }), _jsx(Text, { color: C.dim, children: pad(commandMode ? ` ↑↓ select · Enter run · Esc close ${found.length} commands` : " ↑↓ select · Enter open · > commands · Esc close") })] }));
166
+ }), _jsx(Text, { color: C.dim, children: pad(textMode
167
+ ? ` ↑↓ select · Enter opens the line · Esc close ${searched.query === query && query.trim() ? `${hits.length}${searched.more ? "+" : ""} lines` : ""}`
168
+ : commandMode ? ` ↑↓ select · Enter run · Esc close ${found.length} commands` : " ↑↓ select · Enter open · > commands · Esc close") })] }));
137
169
  }
@@ -11,6 +11,33 @@ function labels(tabs) {
11
11
  return twin ? `${path.posix.basename(path.posix.dirname(file))}/${name}` : name;
12
12
  });
13
13
  }
14
+ /**
15
+ * Which of a row of tabs fit in `room` columns, kept around the active one: grows right, then left, while
16
+ * they fit, keeping `markers` columns for the ‹ › that stand for the ones left out. Returns [from, to).
17
+ */
18
+ export function fitAround(sizes, active, room, markers = 2) {
19
+ const at = Math.max(0, Math.min(sizes.length - 1, active));
20
+ if (!sizes.length)
21
+ return { from: 0, to: 0 };
22
+ let from = at;
23
+ let to = at + 1;
24
+ let used = sizes[at];
25
+ const total = sizes.reduce((a, b) => a + b, 0);
26
+ if (total <= room)
27
+ return { from: 0, to: sizes.length };
28
+ for (let grew = true; grew;) {
29
+ grew = false;
30
+ if (to < sizes.length && used + sizes[to] <= room - markers) {
31
+ used += sizes[to++];
32
+ grew = true;
33
+ }
34
+ if (from > 0 && used + sizes[from - 1] <= room - markers) {
35
+ used += sizes[--from];
36
+ grew = true;
37
+ }
38
+ }
39
+ return { from, to };
40
+ }
14
41
  /**
15
42
  * Lays out the tabs that fit in `width` columns, keeping the active one in view: the ones that don't fit
16
43
  * are left out at the ends, marked with ‹ ›.
@@ -2,7 +2,7 @@
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(["follow", "files", "actions", "palette", "zoom", "now", "fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
5
+ const INSTANT = new Set(["search", "grep", "rename", "follow", "files", "actions", "palette", "zoom", "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
7
  /**
8
8
  * The command a mistyped name means: the same letters in another order ("/termnial"), or one letter off
@@ -51,9 +51,11 @@ export const SLASH_COMMANDS = [
51
51
  { name: "models", args: "[provider]", description: "list the models a provider offers" },
52
52
  { name: "connect", args: "<provider>", description: "enter an API key (and URL) for a provider" },
53
53
  { name: "open", args: "<file>[:line]", description: "open a file in the code view (part of its name is enough)" },
54
+ { name: "search", args: "[text]", description: "search the text of every file; Enter opens the line (Ctrl+Shift+F)" },
55
+ { name: "rename", args: "[name]", description: "name this chat's tab (alone: back to its first message)" },
54
56
  { name: "follow", args: "[on|off]", description: "the code view shows each file the agent reads or edits as it works" },
55
57
  { name: "effort", args: "[fastest|fast|balanced|smarter|smartest]", description: "speed vs smarts: how hard the model thinks (cheaper + faster, or smarter)" },
56
- { name: "workspace", args: "[new <folder> | n]", description: "several projects in this window, a tab each in the header: open one, or switch" },
58
+ { name: "workspace", args: "[new <folder> | n | rename <name>]", description: "several projects in this window, a tab each in the header: open one, or switch" },
57
59
  { name: "files", description: "find a file by part of its name (also Ctrl+P)" },
58
60
  { name: "actions", description: "every action Nou has, searchable, with its key (also ?)" },
59
61
  { name: "zoom", args: "in|out|reset", description: "make the code panel bigger or smaller (+ and - with the code focused, = resets)" },
@@ -4,4 +4,9 @@
4
4
  */
5
5
  export const FIND_KEY = "Ctrl+F";
6
6
  /** Ctrl+F, or Cmd+F where the terminal reports Cmd. */
7
- export const isFind = (input, key) => input === "f" && (!!key.ctrl || !!key.super);
7
+ export const isFind = (input, key) => input === "f" && (!!key.ctrl || !!key.super) && !key.shift;
8
+ /**
9
+ * Ctrl+Shift+F (Cmd+Shift+F on a Mac): search every file, as in VS Code. Terminals report Shift with Ctrl only
10
+ * with the kitty keyboard protocol; elsewhere it arrives as Ctrl+F, so /search is the way in everywhere.
11
+ */
12
+ export const isSearchAll = (input, key) => (input === "f" || input === "F") && (!!key.ctrl || !!key.super) && !!key.shift;
@@ -33,7 +33,7 @@ export async function runHost(folders, firstArgs, command = [process.execPath, p
33
33
  let nextId = 1;
34
34
  const size = () => ({ cols: process.stdout.columns || 120, rows: process.stdout.rows || 40 });
35
35
  const send = (w, msg) => w.socket?.write(`${JSON.stringify(msg)}\n`);
36
- const tabs = () => workspaces.map((w) => ({ id: w.id, name: tabName(w.folder), folder: w.folder, busy: w.busy, active: w === active }));
36
+ const tabs = () => workspaces.map((w) => ({ id: w.id, name: w.name || tabName(w.folder), folder: w.folder, busy: w.busy, active: w === active }));
37
37
  const broadcast = () => {
38
38
  const list = tabs();
39
39
  for (const w of workspaces)
@@ -63,6 +63,10 @@ export async function runHost(folders, firstArgs, command = [process.execPath, p
63
63
  from.busy = msg.busy;
64
64
  broadcast();
65
65
  }
66
+ else if (msg.type === "rename") {
67
+ from.name = msg.name.trim().slice(0, 30) || undefined;
68
+ broadcast();
69
+ }
66
70
  else if (msg.type === "switch") {
67
71
  const to = workspaces[msg.index];
68
72
  if (to && to !== active)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.17",
3
+ "version": "0.5.20",
4
4
  "description": "Nou: an IDE-style AI coding assistant for your terminal. Local (Ollama, LM Studio) and cloud (Claude, OpenAI, Gemini...) models, live diffs, git-aware, built-in editor.",
5
5
  "type": "module",
6
6
  "bin": {