nou-cli 0.5.19 → 0.5.21

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
@@ -69,7 +69,7 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
69
69
  | 🎚️ **Speed vs smarts** | Pick how hard the model thinks in plain words: **Fastest** and **Fast** (cheaper + faster), **Balanced** (the default), **Smarter** and **Smartest** (thinks longer, costs more). In <kbd>s</kbd> settings, or `/effort smarter`. |
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
- | 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> (or `/files`) fuzzy-finds any file; <kbd>?</kbd> (or `/actions`) lists every action. |
72
+ | 🔎 **Quick open and search** | <kbd>Ctrl</kbd>+<kbd>P</kbd> (or `/files`) finds a file by name; <kbd>Tab</kbd> switches the same popup to searching the text inside every file (and back), keeping what you typed: each line as `file:line` with the match highlighted, <kbd>Enter</kbd> opens it. `/search <text>` (or <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd> where the terminal reports it) opens it straight on searching inside. Lowercase ignores case; a capital matches case exactly. <kbd>?</kbd> (or `/actions`) lists every action. |
73
73
  | 🔋 **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
74
  | 👓 **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
75
 
@@ -131,7 +131,7 @@ In the editor:
131
131
  | <kbd>Ctrl</kbd>+<kbd>K</kbd> · <kbd>Ctrl</kbd>+<kbd>G</kbd> | hover info · go to definition (with a language server) |
132
132
  | <kbd>Esc</kbd> | clear the selection, or leave the editor |
133
133
 
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 | rename <name>]` · `/rename [name]` · `/files` · `/actions` · `/now` · `/showme <question>` · `/teach <change>` · `/help`
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 | rename <name>]` · `/rename [name]` · `/files` · `/search [text]` · `/actions` · `/now` · `/showme <question>` · `/teach <change>` · `/help`
135
135
 
136
136
  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
137
 
@@ -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";
@@ -123,6 +124,12 @@ export function App({ initialModel, permContext, resume }) {
123
124
  const [setupReason, setSetupReason] = useState(null);
124
125
  /** The file finder / command palette: closed (null), or open with this query (">" for commands). */
125
126
  const [quickOpen, setQuickOpen] = useState(null);
127
+ /** The quick-open popup opens on searching inside files (/search, Ctrl+Shift+F) rather than file names. */
128
+ const [quickText, setQuickText] = useState(false);
129
+ const setSearching = (query) => {
130
+ setQuickText(true);
131
+ setQuickOpen(query);
132
+ };
126
133
  /** /resume's picker: the conversations, in the palette's place while it's open. */
127
134
  const [resumePicker, setResumePicker] = useState(null);
128
135
  /** What the open picker is for, said in its empty search box. */
@@ -1544,6 +1551,8 @@ export function App({ initialModel, permContext, resume }) {
1544
1551
  if (key.ctrl && (input === "=" || input === "+" || input === "-" || input === "0") && !key.meta) {
1545
1552
  return zoomCode(input === "0" ? 0 : input === "-" ? -1 : 1);
1546
1553
  }
1554
+ if (isSearchAll(input, key))
1555
+ return setSearching("");
1547
1556
  if (key.ctrl && input === "p") {
1548
1557
  setQuickOpen("");
1549
1558
  return;
@@ -1934,6 +1943,9 @@ ${snippet}
1934
1943
  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 +).");
1935
1944
  case "files":
1936
1945
  return setQuickOpen("");
1946
+ case "search":
1947
+ case "grep":
1948
+ return setSearching(arg.trim());
1937
1949
  case "actions":
1938
1950
  case "palette":
1939
1951
  return setQuickOpen(">");
@@ -2570,6 +2582,7 @@ ${snippet}
2570
2582
  // Everything Nou can do, for the command palette (? or Ctrl+P then >), with the key that does it.
2571
2583
  const paletteActions = [
2572
2584
  { title: "Find a file", keys: "Ctrl+P", run: () => setQuickOpen("") },
2585
+ { title: "Search the text of every file", keys: "/search", run: () => setSearching("") },
2573
2586
  { title: "Find in the open file", keys: FIND_KEY, run: startFind },
2574
2587
  { title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
2575
2588
  { title: "Go back to the previous file", keys: "b", run: goBack },
@@ -2689,18 +2702,28 @@ ${snippet}
2689
2702
  clickable.current = { status: zones, modelFrom: columns - (" LOCAL ".length + ` ${modelLabel} `.length + 1) };
2690
2703
  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
2691
2704
  ? _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)` : ""] })
2692
- : 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) => {
2705
+ : 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 } : { searchText, startInText: quickText }), onPickHit: (hit) => {
2706
+ setQuickOpen(null);
2707
+ setQuickText(false);
2708
+ clearFollow();
2709
+ // Opens the line with the match, highlighted, and puts the keyboard there.
2710
+ openAt(hit.file, hit.line, false, hit.line);
2711
+ setFocus("code");
2712
+ }, onPick: (file) => {
2693
2713
  setQuickOpen(null);
2714
+ setQuickText(false);
2694
2715
  clearFollow();
2695
2716
  open(file);
2696
2717
  setReveal(file);
2697
2718
  setFocus("code");
2698
2719
  }, onRun: (action) => {
2699
2720
  setQuickOpen(null);
2721
+ setQuickText(false);
2700
2722
  setResumePicker(null);
2701
2723
  action.run();
2702
2724
  }, onClose: () => {
2703
2725
  setQuickOpen(null);
2726
+ setQuickText(false);
2704
2727
  setResumePicker(null);
2705
2728
  } }))] }));
2706
2729
  }
@@ -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,26 @@ 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, startInText = false, onPickHit, onRun, onClose }) {
62
62
  const [query, setQuery] = useState(initialQuery);
63
63
  const [cursor, setCursor] = useState(0);
64
+ // One popup for both: file names, or (Tab) the text inside every file. Searching inside reads every file,
65
+ // so it waits for a pause in the typing (each search is one pass, cached).
66
+ const [inText, setInText] = useState(startInText);
67
+ const textMode = !!searchText && inText;
68
+ const [searched, setSearched] = useState({ query: "", hits: [], more: false });
69
+ useEffect(() => {
70
+ if (!searchText || !textMode)
71
+ return;
72
+ const timer = setTimeout(() => setSearched({ query, ...searchText(query) }), query === searched.query ? 0 : 120);
73
+ return () => clearTimeout(timer);
74
+ }, [query, textMode]);
64
75
  const boxWidth = Math.min(80, Math.max(40, width - 10));
65
76
  const inner = boxWidth - 2; // fill every cell so the screen underneath doesn't show through
66
- const commandMode = query.startsWith(">");
77
+ const commandMode = !textMode && query.startsWith(">");
67
78
  const rowsShown = 12;
68
79
  const results = useMemo(() => {
69
- if (commandMode)
80
+ if (commandMode || textMode)
70
81
  return [];
71
82
  // Recent files first when nothing's typed; a small boost otherwise, so the one you just had wins a tie.
72
83
  const rank = new Map(recent.map((f, i) => [f, i]));
@@ -86,11 +97,17 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
86
97
  .filter((r) => r.m !== null)
87
98
  .sort((a, b) => (q ? b.m.score - a.m.score : a.order - b.order));
88
99
  }, [actions, query, commandMode]);
89
- const count = commandMode ? found.length : results.length;
100
+ const hits = textMode ? searched.hits : [];
101
+ const count = textMode ? hits.length : commandMode ? found.length : results.length;
90
102
  const first = Math.max(0, Math.min(cursor - rowsShown + 1, count - rowsShown));
91
103
  useInput((_input, key) => {
92
104
  if (key.escape)
93
105
  onClose();
106
+ else if (key.tab && searchText && !query.startsWith(">")) {
107
+ // Tab: file names <-> the text inside files, keeping what's typed.
108
+ setInText((t) => !t);
109
+ setCursor(0);
110
+ }
94
111
  else if (key.upArrow)
95
112
  setCursor((c) => (c - 1 + Math.max(1, count)) % Math.max(1, count));
96
113
  else if (key.downArrow)
@@ -104,11 +121,15 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
104
121
  /** Cut at the end (a title reads from the start). */
105
122
  const fit = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n));
106
123
  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) => {
124
+ 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 inside files · Tab: file names instead" : commandMode ? "Every action · type to filter (? or /actions)" : searchText ? "Find a file by name · Tab: search inside files" : "Find a file · type part of its name")}`, 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
125
  setQuery(v);
109
126
  setCursor(0);
110
127
  }, onSubmit: () => {
111
- if (commandMode) {
128
+ if (textMode) {
129
+ if (hits[cursor])
130
+ onPickHit?.(hits[cursor]);
131
+ }
132
+ else if (commandMode) {
112
133
  // A number picks that row of a numbered list (/resume's: "1. ...", "2. ...").
113
134
  const n = /^\s*(\d+)\s*$/.exec(query.slice(1))?.[1];
114
135
  const numbered = n ? actions.find((a) => a.title.startsWith(`${n}. `)) : undefined;
@@ -126,12 +147,30 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
126
147
  const title = r.action.title.length > room ? `${r.action.title.slice(0, room - 1)}…` : r.action.title.padEnd(room);
127
148
  const hits = new Set(r.m.hits);
128
149
  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) => {
150
+ }), textMode && (!query.trim() ? _jsx(Text, { color: C.dim, children: pad(" Type to search the text of every file in the project") })
151
+ : searched.query !== query ? _jsx(Text, { color: C.dim, children: pad(" Searching…") })
152
+ : !hits.length ? _jsx(Text, { color: C.dim, children: pad(` No file contains "${query}"`) })
153
+ : null), textMode && searched.query === query && hits.slice(first, first + rowsShown).map((h, i) => {
154
+ const selected = first + i === cursor;
155
+ const { icon, color } = fileIcon(h.file);
156
+ const where = ` ${h.file}:${h.line} `;
157
+ // The line around the match, so the match is always in view however long the line.
158
+ const room = Math.max(10, inner - 2 - where.length);
159
+ const lead = h.text.slice(0, h.col).trimStart();
160
+ const keep = Math.max(0, Math.min(lead.length, Math.floor(room / 3)));
161
+ const before = (lead.length > keep ? "…" : "") + lead.slice(lead.length - keep);
162
+ const match = h.text.slice(h.col, h.col + query.length);
163
+ const after = h.text.slice(h.col + query.length);
164
+ const rest = Math.max(0, room - before.length - match.length);
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 }), _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}`));
166
+ }), !commandMode && !textMode && results.length === 0 && _jsx(Text, { color: C.dim, children: pad(" No matching files") }), results.map((r, i) => {
130
167
  const selected = i === cursor;
131
168
  const { icon, color } = fileIcon(r.file);
132
169
  const tag = recentSet.has(r.file) ? " recent" : "";
133
170
  const label = pad(` ${r.file}`).slice(0, inner - 2 - tag.length);
134
171
  const hits = new Set(r.m.hits.map((h) => h + 1));
135
172
  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") })] }));
173
+ }), _jsx(Text, { color: C.dim, children: pad(textMode
174
+ ? ` ↑↓ select · Enter opens the line · Tab names · Esc close ${searched.query === query && query.trim() ? `${hits.length}${searched.more ? "+" : ""} lines` : ""}`
175
+ : commandMode ? ` ↑↓ select · Enter run · Esc close ${found.length} commands` : ` ↑↓ select · Enter open${searchText ? " · Tab inside files" : ""} · > commands · Esc close`) })] }));
137
176
  }
@@ -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(["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"]);
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,6 +51,7 @@ 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)" },
54
55
  { name: "rename", args: "[name]", description: "name this chat's tab (alone: back to its first message)" },
55
56
  { name: "follow", args: "[on|off]", description: "the code view shows each file the agent reads or edits as it works" },
56
57
  { name: "effort", args: "[fastest|fast|balanced|smarter|smartest]", description: "speed vs smarts: how hard the model thinks (cheaper + faster, or smarter)" },
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
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": {