nou-cli 0.5.20 → 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,8 +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. |
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. |
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. |
74
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. |
75
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**. |
76
75
 
@@ -55,8 +55,6 @@ const MIN_CODE = 20;
55
55
  const MIN_CHAT = 24;
56
56
  const NO_LINES = new Set();
57
57
  const NO_FILES = new Set();
58
- const NO_SEARCH_FILES = [];
59
- const NO_SEARCH_ACTIONS = [];
60
58
  const DOUBLE_CLICK_MS = 500;
61
59
  /** How long each follow step stays on screen before the next. */
62
60
  const FOLLOW_STEP_MS = { slow: 2500, normal: 1200, fast: 450, instant: 0 };
@@ -126,8 +124,12 @@ export function App({ initialModel, permContext, resume }) {
126
124
  const [setupReason, setSetupReason] = useState(null);
127
125
  /** The file finder / command palette: closed (null), or open with this query (">" for commands). */
128
126
  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);
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
+ };
131
133
  /** /resume's picker: the conversations, in the palette's place while it's open. */
132
134
  const [resumePicker, setResumePicker] = useState(null);
133
135
  /** What the open picker is for, said in its empty search box. */
@@ -2700,24 +2702,28 @@ ${snippet}
2700
2702
  clickable.current = { status: zones, modelFrom: columns - (" LOCAL ".length + ` ${modelLabel} `.length + 1) };
2701
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
2702
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)` : ""] })
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
+ : 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);
2705
2708
  clearFollow();
2706
2709
  // Opens the line with the match, highlighted, and puts the keyboard there.
2707
2710
  openAt(hit.file, hit.line, false, hit.line);
2708
2711
  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) => {
2712
+ }, onPick: (file) => {
2710
2713
  setQuickOpen(null);
2714
+ setQuickText(false);
2711
2715
  clearFollow();
2712
2716
  open(file);
2713
2717
  setReveal(file);
2714
2718
  setFocus("code");
2715
2719
  }, onRun: (action) => {
2716
2720
  setQuickOpen(null);
2721
+ setQuickText(false);
2717
2722
  setResumePicker(null);
2718
2723
  action.run();
2719
2724
  }, onClose: () => {
2720
2725
  setQuickOpen(null);
2726
+ setQuickText(false);
2721
2727
  setResumePicker(null);
2722
2728
  } }))] }));
2723
2729
  }
@@ -58,18 +58,20 @@ 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, searchText, onPickHit, 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
- // Search mode reads every file, so it waits for a pause in the typing (each search is one pass, cached).
65
- const textMode = !!searchText;
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;
66
68
  const [searched, setSearched] = useState({ query: "", hits: [], more: false });
67
69
  useEffect(() => {
68
- if (!searchText)
70
+ if (!searchText || !textMode)
69
71
  return;
70
72
  const timer = setTimeout(() => setSearched({ query, ...searchText(query) }), query === searched.query ? 0 : 120);
71
73
  return () => clearTimeout(timer);
72
- }, [query, !!searchText]);
74
+ }, [query, textMode]);
73
75
  const boxWidth = Math.min(80, Math.max(40, width - 10));
74
76
  const inner = boxWidth - 2; // fill every cell so the screen underneath doesn't show through
75
77
  const commandMode = !textMode && query.startsWith(">");
@@ -101,6 +103,11 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
101
103
  useInput((_input, key) => {
102
104
  if (key.escape)
103
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
+ }
104
111
  else if (key.upArrow)
105
112
  setCursor((c) => (c - 1 + Math.max(1, count)) % Math.max(1, count));
106
113
  else if (key.downArrow)
@@ -114,7 +121,7 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
114
121
  /** Cut at the end (a title reads from the start). */
115
122
  const fit = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n));
116
123
  const pad = (s) => (s.length > inner ? `…${s.slice(-(inner - 1))}` : s.padEnd(inner));
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) => {
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) => {
118
125
  setQuery(v);
119
126
  setCursor(0);
120
127
  }, onSubmit: () => {
@@ -164,6 +171,6 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
164
171
  const hits = new Set(r.m.hits.map((h) => h + 1));
165
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));
166
173
  }), _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") })] }));
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`) })] }));
169
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.20",
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": {