nou-cli 0.5.19 → 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 +2 -1
- package/dist/src/search/index.js +53 -0
- package/dist/src/ui/App.js +19 -2
- package/dist/src/ui/QuickOpen.js +41 -9
- package/dist/src/ui/commands.js +2 -1
- package/dist/src/ui/keys.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
|
|
@@ -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 | rename <name>]` · `/rename [name]` · `/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
|
|
package/dist/src/search/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/ui/App.js
CHANGED
|
@@ -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. */
|
|
@@ -1544,6 +1549,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1544
1549
|
if (key.ctrl && (input === "=" || input === "+" || input === "-" || input === "0") && !key.meta) {
|
|
1545
1550
|
return zoomCode(input === "0" ? 0 : input === "-" ? -1 : 1);
|
|
1546
1551
|
}
|
|
1552
|
+
if (isSearchAll(input, key))
|
|
1553
|
+
return setSearching("");
|
|
1547
1554
|
if (key.ctrl && input === "p") {
|
|
1548
1555
|
setQuickOpen("");
|
|
1549
1556
|
return;
|
|
@@ -1934,6 +1941,9 @@ ${snippet}
|
|
|
1934
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 +).");
|
|
1935
1942
|
case "files":
|
|
1936
1943
|
return setQuickOpen("");
|
|
1944
|
+
case "search":
|
|
1945
|
+
case "grep":
|
|
1946
|
+
return setSearching(arg.trim());
|
|
1937
1947
|
case "actions":
|
|
1938
1948
|
case "palette":
|
|
1939
1949
|
return setQuickOpen(">");
|
|
@@ -2570,6 +2580,7 @@ ${snippet}
|
|
|
2570
2580
|
// Everything Nou can do, for the command palette (? or Ctrl+P then >), with the key that does it.
|
|
2571
2581
|
const paletteActions = [
|
|
2572
2582
|
{ title: "Find a file", keys: "Ctrl+P", run: () => setQuickOpen("") },
|
|
2583
|
+
{ title: "Search the text of every file", keys: "/search", run: () => setSearching("") },
|
|
2573
2584
|
{ title: "Find in the open file", keys: FIND_KEY, run: startFind },
|
|
2574
2585
|
{ title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
|
|
2575
2586
|
{ title: "Go back to the previous file", keys: "b", run: goBack },
|
|
@@ -2689,7 +2700,13 @@ ${snippet}
|
|
|
2689
2700
|
clickable.current = { status: zones, modelFrom: columns - (" LOCAL ".length + ` ${modelLabel} `.length + 1) };
|
|
2690
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
|
|
2691
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)` : ""] })
|
|
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 }) }) })),
|
|
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) => {
|
|
2693
2710
|
setQuickOpen(null);
|
|
2694
2711
|
clearFollow();
|
|
2695
2712
|
open(file);
|
package/dist/src/ui/QuickOpen.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
}),
|
|
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(
|
|
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
|
}
|
package/dist/src/ui/commands.js
CHANGED
|
@@ -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)" },
|
package/dist/src/ui/keys.js
CHANGED
|
@@ -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.
|
|
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": {
|