nou-cli 0.5.17 → 0.5.19

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
@@ -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. |
@@ -110,7 +110,7 @@ Every change is reviewed like a code review:
110
110
  | <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
111
  | <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
112
  | <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) |
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; <kbd>Ctrl</kbd>+<kbd>C</kbd> while it's highlighted copies it again rather than stopping Nou) |
114
114
  | <kbd>s</kbd> | settings: model, preferences, plugins |
115
115
  | <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
116
  | <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 +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]` · `/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` · `/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
 
@@ -172,6 +172,11 @@ export function App({ initialModel, permContext, resume }) {
172
172
  }, [tour]);
173
173
  const shellRef = useRef(null);
174
174
  const beforeTerm = useRef("code");
175
+ /** Names you gave conversations (/rename), by conversation: their tab and /resume show them. */
176
+ const chatNames = useRef(new Map());
177
+ const [namesVersion, setNamesVersion] = useState(0);
178
+ /** What the header calls this workspace when it's the only one (/workspace rename). */
179
+ const [projectName, setProjectName] = useState(path.basename(process.cwd()));
175
180
  const [follow, setFollow] = useState(settings.followMode);
176
181
  // Follow mode replays what the agent reads a step at a time (it reads far faster than you can watch):
177
182
  // the steps waiting, and the timer that shows the next one.
@@ -378,7 +383,10 @@ export function App({ initialModel, permContext, resume }) {
378
383
  return w.has(change.path) ? w : new Set(w).add(change.path);
379
384
  });
380
385
  // Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
381
- if (!background && ((status === "pending" && !draftActiveRef.current) || followRef.current)) {
386
+ // A proposed edit opens only when it waits for your OK; approved by auto-edit, it's just Nou working,
387
+ // which follow mode shows if it's on (it used to open anyway, so follow off still followed edits).
388
+ const waitsForYou = status === "pending" && !settingsRef.current.autoApproveEdits && !guideAsk.current;
389
+ if (!background && ((waitsForYou && !draftActiveRef.current) || followRef.current)) {
382
390
  clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
383
391
  setOpenFile(change.path);
384
392
  setContent(status === "applied" ? change.after : readText(change.path));
@@ -502,8 +510,8 @@ export function App({ initialModel, permContext, resume }) {
502
510
  /** The tabs as the chat's title row shows them: named for what you asked first, marked when working or waiting. */
503
511
  function chatTabItems() {
504
512
  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, " ");
513
+ const t = id === activeTab.current ? { entries, busy, approval, conversationId } : tabStore.current.get(id);
514
+ const first = chatNames.current.get(t?.conversationId ?? "") ?? t?.entries.find((e) => e.kind === "user")?.text.replace(/\s+/g, " ");
507
515
  const label = first ? (first.length > 14 ? `${first.slice(0, 13)}…` : first) : `chat ${i + 1}`;
508
516
  return { id, label, state: t?.approval ? "waiting" : t?.busy ? "busy" : "idle" };
509
517
  });
@@ -518,7 +526,7 @@ export function App({ initialModel, permContext, resume }) {
518
526
  function saveTab(t) {
519
527
  const first = t.entries.find((e) => e.kind === "user");
520
528
  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?.() });
529
+ 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
530
  }
523
531
  if (!agentRef.current) {
524
532
  const saved = loadSettings().engine;
@@ -541,8 +549,8 @@ export function App({ initialModel, permContext, resume }) {
541
549
  if (busy || !first)
542
550
  return;
543
551
  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]);
552
+ 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?.() });
553
+ }, [!busy, entries.length, conversationId, namesVersion]);
546
554
  /** Earlier conversations for the palette, read from disk when it opens. */
547
555
  const recentChats = useMemo(() => (quickOpen !== null ? resumable() : []), [quickOpen !== null]);
548
556
  useEffect(() => {
@@ -1106,7 +1114,7 @@ export function App({ initialModel, permContext, resume }) {
1106
1114
  return setView("settings");
1107
1115
  // Workspace tabs in the header: click one to switch to it, + to open another.
1108
1116
  if (event.kind === "click" && event.y === 0) {
1109
- const zone = headerZones(workspaceTabs()).find((z) => event.x >= z.from && event.x < z.to);
1117
+ const zone = headerZones(workspaceTabs(), columns, modelLabel, choice.provider.local, update, projectName).find((z) => event.x >= z.from && event.x < z.to);
1110
1118
  if (zone?.tab === "new")
1111
1119
  return pickWorkspaceFolder();
1112
1120
  if (typeof zone?.tab === "number")
@@ -1140,7 +1148,7 @@ export function App({ initialModel, permContext, resume }) {
1140
1148
  // The chat's title row holds the conversation tabs: a click switches, + opens a new one.
1141
1149
  if (event.kind === "click" && pane === "chat" && event.y === 2) {
1142
1150
  const at = event.x - treeWidth - codeWidth - 1;
1143
- const { zones, plus } = chatTabZones(chatTabItems());
1151
+ const { zones, plus } = chatTabZones(chatTabItems(), activeTab.current, columns - treeWidth - codeWidth);
1144
1152
  if (Math.abs(at - plus) <= 1)
1145
1153
  return newChatTab();
1146
1154
  const zone = zones.find((z) => at >= z.from && at < z.to);
@@ -1222,6 +1230,10 @@ export function App({ initialModel, permContext, resume }) {
1222
1230
  }
1223
1231
  }
1224
1232
  }, settings.mouse);
1233
+ const chatSelecting = useRef(false);
1234
+ const onChatSelecting = useEvent((selecting) => {
1235
+ chatSelecting.current = selecting;
1236
+ });
1225
1237
  // Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
1226
1238
  useInput((input, key) => {
1227
1239
  if (!(key.ctrl && input === "c"))
@@ -1230,6 +1242,8 @@ export function App({ initialModel, permContext, resume }) {
1230
1242
  return; // the editor copies it
1231
1243
  if (focus === "terminal")
1232
1244
  return; // the terminal stops its command
1245
+ if (chatSelecting.current)
1246
+ return; // the chat copies what's highlighted
1233
1247
  if (busy || approval) {
1234
1248
  stop();
1235
1249
  approval?.resolve(false);
@@ -1580,7 +1594,7 @@ export function App({ initialModel, permContext, resume }) {
1580
1594
  setFocus("chat");
1581
1595
  setChatPrefill((p) => ({ text: "/", nonce: (p?.nonce ?? 0) + 1 }));
1582
1596
  }
1583
- else if (input === "f")
1597
+ else if (input === "f" || input === "F")
1584
1598
  toggleFollow();
1585
1599
  else if (input === "m")
1586
1600
  toggleMouse();
@@ -1763,6 +1777,16 @@ ${snippet}
1763
1777
  return info(`No file matches "${wanted}". To find where something is in the code, ask in the chat.`, "error");
1764
1778
  return openRef({ path: file, ...(m[2] ? { line: Number(m[2]) } : {}), ...(m[3] ? { to: Number(m[3]) } : {}) });
1765
1779
  }
1780
+ case "rename": {
1781
+ // Names this conversation: its tab, and /resume, show the name (/rename alone: back to the first message).
1782
+ const name = arg.trim().slice(0, 40);
1783
+ if (name)
1784
+ chatNames.current.set(conversationId, name);
1785
+ else
1786
+ chatNames.current.delete(conversationId);
1787
+ setNamesVersion((v) => v + 1);
1788
+ return info(name ? `This chat is now called ${name}. (/workspace rename <name> names the workspace.)` : "This chat is named by its first message again.");
1789
+ }
1766
1790
  case "follow": {
1767
1791
  const want = arg.trim().toLowerCase();
1768
1792
  if (want && !/^(on|off)$/.test(want))
@@ -2450,8 +2474,16 @@ ${snippet}
2450
2474
  return info(`There's no workspace ${n}. /workspace lists them.`, "error");
2451
2475
  return void sendToHost({ type: "switch", index: n - 1 });
2452
2476
  }
2477
+ if (sub === "rename") {
2478
+ const name = more.join(" ").trim().slice(0, 30);
2479
+ if (isHosted())
2480
+ sendToHost({ type: "rename", name });
2481
+ else
2482
+ setProjectName(name || path.basename(process.cwd()));
2483
+ return info(name ? `This workspace is now called ${name}.` : "This workspace is called by its folder's name again.");
2484
+ }
2453
2485
  if (sub !== "new")
2454
- return info("Try /workspace new <folder>, /workspace <n> or /workspace.", "error");
2486
+ return info("Try /workspace new <folder>, /workspace <n>, /workspace rename <name> or /workspace.", "error");
2455
2487
  const wanted = more.join(" ");
2456
2488
  if (!wanted)
2457
2489
  return pickWorkspaceFolder();
@@ -2655,7 +2687,7 @@ ${snippet}
2655
2687
  }
2656
2688
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
2657
2689
  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
2690
+ 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
2691
  ? _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
2692
  : shownHints.map(([key, action], i) => (_jsxs(Text, { children: [i === 0 ? " " : _jsx(Text, { color: C.dim, children: " \u00B7 " }), _jsx(Text, { color: C.text, children: key }), _jsxs(Text, { color: C.dim, children: [" ", action] })] }, key)))] }) }), layout.current.terminal && shellRef.current && (_jsx(Box, { position: "absolute", marginLeft: layout.current.terminal.x, marginTop: layout.current.terminal.y, children: _jsx(Terminal, { shell: shellRef.current, version: termVersion, width: layout.current.terminal.width, height: layout.current.terminal.height, focused: focus === "terminal", wheelPane: "terminal", onLeave: leaveTerminal, onClose: () => toggleTerminal(false), onResize: resizeTerminal }) })), layout.current.game && (_jsx(Box, { position: "absolute", marginLeft: layout.current.game.x, marginTop: layout.current.game.y, children: _jsx(GameGuard, { children: _jsx(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, best: settings.gameBest ?? {}, onScore: onGameScore, onLeave: leaveGames, onOpenSettings: gamesSettings }) }) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: resumePicker ?? paletteActions, initialQuery: quickOpen, ...(resumePicker ? { placeholder: "type to filter", title: pickerTitle } : {}), onPick: (file) => {
2661
2693
  setQuickOpen(null);
@@ -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(tabs) {
92
+ export function chatTabZones(all, active, paneWidth = 10_000) {
91
93
  // One tab: just "Chat +", to leave the title row its room.
92
- if (tabs.length === 1)
93
- return { zones: [{ id: tabs[0].id, from: 3, to: 7 }], plus: 8, width: 9 };
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 zones = tabs.flatMap((t) => {
113
+ for (const t of tabs.slice(from, to)) {
97
114
  const w = tabText(t).length;
98
- const both = [{ id: t.id, from: x, to: x + w }, { id: t.id, from: x + w, to: x + w + TAB_CLOSE.length, close: true }];
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
- return both;
101
- });
102
- return { zones, plus: x + 1, width: x + 3 };
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 title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " ", tabs && tabs.items.length > 1 ? (_jsxs(_Fragment, { children: [tabs.items.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))), _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
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" })] })
@@ -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
- * Where each workspace tab, and the + that opens one, sits in the header row: for clicks. With one workspace
16
- * (not hosted) there's just the project's name and the +.
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 += path.basename(process.cwd()).length + 1;
50
+ x += project.length + 1;
23
51
  }
24
52
  else {
25
- tabs.forEach((t, i) => {
26
- const w = tabLabel(t, i).length;
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 rightWidth = right.notice.length + right.badge.length + right.model.length + 1;
51
- return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { width: Math.max(0, width - rightWidth - 4), 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
52
- ? tabs.map((t, i) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: t.active ? C.accent : C.text, bold: t.active, children: tabLabel(t, i) }), _jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: C.dim, children: CLOSE }), " "] }, t.id)))
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
  });
@@ -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 ‹ ›.
@@ -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(["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,10 @@ 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: "rename", args: "[name]", description: "name this chat's tab (alone: back to its first message)" },
54
55
  { name: "follow", args: "[on|off]", description: "the code view shows each file the agent reads or edits as it works" },
55
56
  { 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" },
57
+ { 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
58
  { name: "files", description: "find a file by part of its name (also Ctrl+P)" },
58
59
  { name: "actions", description: "every action Nou has, searchable, with its key (also ?)" },
59
60
  { name: "zoom", args: "in|out|reset", description: "make the code panel bigger or smaller (+ and - with the code focused, = resets)" },
@@ -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.17",
3
+ "version": "0.5.19",
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": {