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