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 +2 -2
- package/dist/src/search/index.js +53 -0
- package/dist/src/ui/App.js +25 -2
- package/dist/src/ui/QuickOpen.js +48 -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
|
@@ -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`)
|
|
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
|
|
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";
|
|
@@ -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 } : {}),
|
|
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
|
}
|
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,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
|
|
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
|
|
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 (
|
|
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
|
-
}),
|
|
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(
|
|
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
|
}
|
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.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": {
|