nou-cli 0.5.12 → 0.5.14

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 **+** (or `/workspace new ../api`) and the other folder 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. Quitting one (Ctrl+C twice) closes its tab. 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. `/close` closes one; 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). 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`. |
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. |
@@ -69,7 +69,7 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
69
69
  | 🎚️ **Speed vs smarts** | Pick how hard the model thinks in plain words: **Fastest** and **Fast** (cheaper + faster), **Balanced** (the default), **Smarter** and **Smartest** (thinks longer, costs more). In <kbd>s</kbd> settings, or `/effort smarter`. |
70
70
  | 🛞 **Pick your agent** | Nou's own, or Claude Code, Codex or pi, switchable like models, with the same diffs, approvals and undo. |
71
71
  | 🧩 **Plugins** | Add your own tools and slash commands with a single file. |
72
- | 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> fuzzy-finds any file. |
72
+ | 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> (or `/files`) fuzzy-finds any file; <kbd>?</kbd> (or `/actions`) lists every action. |
73
73
  | 🔋 **Easy on your battery** | Idle, Nou uses next to no CPU. When its window is in the background (in terminals that report focus: iTerm2, Terminal.app, kitty, Ghostty, WezTerm, Windows Terminal, VS Code), spinners and git polling pause until you come back, and TypeScript checks are incremental. |
74
74
  | 👓 **Easy to read** | Hints and secondary text keep at least 4.5:1 contrast. Nou asks your terminal whether its background is light or dark and picks matching colors (code switches to GitHub Light on a light background); set it yourself in <kbd>s</kbd> settings → **Nou's colors**. |
75
75
 
@@ -94,7 +94,7 @@ Every change is reviewed like a code review:
94
94
  | <kbd>Tab</kbd> | switch between files, code and chat |
95
95
  | <kbd>Shift</kbd>+<kbd>Tab</kbd> | ask → auto-edit (edits apply without asking) → auto (commands too); everything stays undoable |
96
96
  | <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file; the files you've opened are listed first |
97
- | <kbd>Ctrl</kbd>+<kbd>=</kbd> / <kbd>Ctrl</kbd>+<kbd>-</kbd> from anywhere (or <kbd>+</kbd> / <kbd>-</kbd> in the code or files) | zoom the code panel bigger or smaller, a step at a time: the files and chat give or take the room; <kbd>Ctrl</kbd>+<kbd>0</kbd> or <kbd>=</kbd> puts it back. Ctrl+= and Ctrl+- need a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, iTerm2). For bigger text, use your terminal's own zoom |
97
+ | <kbd>Ctrl</kbd>+<kbd>=</kbd> / <kbd>Ctrl</kbd>+<kbd>-</kbd> from anywhere (or <kbd>+</kbd> / <kbd>-</kbd> in the code or files) | widen or narrow the code panel, a step at a time: the files and chat give or take the room (a terminal can't change one pane's text size); <kbd>Ctrl</kbd>+<kbd>0</kbd> or <kbd>=</kbd> puts it back. Ctrl+= and Ctrl+- need a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, iTerm2). For bigger text, use your terminal's own zoom |
98
98
  | <kbd>b</kbd> | back to the previous file (press again to flip back) |
99
99
  | <kbd>Ctrl</kbd>+<kbd>F</kbd> (<kbd>Cmd</kbd>+<kbd>F</kbd> on a Mac, in terminals that report Cmd: Ghostty, kitty, WezTerm, iTerm2) | find in the open file, from any pane: matches are highlighted and counted; <kbd>Enter</kbd>/<kbd>↓</kbd> next, <kbd>↑</kbd> previous, <kbd>Esc</kbd> close |
100
100
  | 🗂️ Tabs | every file you open gets a tab over the code: click one to switch, any tab's × to close it, ‹ › for ones that don't fit · <kbd>[</kbd>/<kbd>]</kbd> previous/next · <kbd>x</kbd> or <kbd>Ctrl</kbd>+<kbd>W</kbd> close |
@@ -113,7 +113,7 @@ Every change is reviewed like a code review:
113
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
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
- | <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`; or turn on **Send while Nou works** in settings so <kbd>Enter</kbd> sends right away (<kbd>Alt</kbd>+<kbd>Enter</kbd> then queues) |
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) |
117
117
  | <kbd>Esc</kbd> | stop Nou (anything queued goes back in the input) · close a diff · back to chat |
118
118
  | <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
119
119
 
@@ -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]` · `/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]` · `/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
 
@@ -221,7 +221,7 @@ export const breakout = {
221
221
  return {
222
222
  lines: grid.map((r) => r.join("")),
223
223
  score: s.score,
224
- status: `level ${s.level} · score ${s.score} · lives ${"♥".repeat(Math.max(0, s.lives))}`,
224
+ status: `level ${s.level} · score ${s.score} · lives ${"◆".repeat(Math.max(0, s.lives))}`,
225
225
  ...(s.lives <= 0 ? { over: `Game over: ${s.score} points, level ${s.level}.` } : {}),
226
226
  };
227
227
  },
@@ -230,13 +230,13 @@ export const breakout = {
230
230
  export const dodge = {
231
231
  id: "dodge",
232
232
  title: "Dodge",
233
- blurb: "Rocks fall faster and thicker the longer you last. Catch ★ for points.",
233
+ blurb: "Rocks fall faster and thicker the longer you last. Catch ✦ for points.",
234
234
  controls: "←→ or a/d move",
235
235
  tickMs: 90,
236
236
  minWidth: 12,
237
237
  minHeight: 6,
238
238
  speed: (s) => Math.max(45, 90 - Math.floor(s.ticks / 60) * 5),
239
- colors: { "▲": "#61afef", "●": "#d19a66", "★": "#e5c07b" },
239
+ colors: { "▲": "#61afef", "●": "#d19a66", "✦": "#e5c07b" },
240
240
  create(w, h, seed = Date.now()) {
241
241
  [w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
242
242
  return { w, h, x: Math.floor(w / 2), rocks: [], stars: [], score: 0, ticks: 0, dead: false, random: rng(seed) };
@@ -271,7 +271,7 @@ export const dodge = {
271
271
  for (const r of s.rocks)
272
272
  put(grid, r.x, r.y, "●");
273
273
  for (const r of s.stars)
274
- put(grid, r.x, r.y, "★");
274
+ put(grid, r.x, r.y, "✦");
275
275
  put(grid, s.x, s.h - 1, "▲");
276
276
  return { lines: grid.map((r) => r.join("")), score: s.score, status: `score ${s.score}`, ...(s.dead ? { over: `Hit! ${s.score} points.` } : {}) };
277
277
  },
@@ -322,7 +322,7 @@ export const hop = {
322
322
  return { lines: grid.map((r) => r.join("")), score: s.score, status: `walls hopped ${s.score}`, ...(s.dead ? { over: `Bonk! ${s.score} hopped.` } : {}) };
323
323
  },
324
324
  };
325
- const SYMBOLS = ["◆", "●", "▲", "■", "★", "♥", "♣", "♠", "☀", "☂", "♪", "✿"];
325
+ const SYMBOLS = ["◆", "●", "▲", "■", "✦", "◈", "⊕", "⊗", "◎", "△", "○", "✧"];
326
326
  /** Cards face down: flip two at a time and find every matching pair in as few moves as you can. */
327
327
  export const pairs = {
328
328
  id: "pairs",
@@ -332,7 +332,7 @@ export const pairs = {
332
332
  tickMs: 100,
333
333
  minWidth: 16,
334
334
  minHeight: 5,
335
- colors: { "◆": "#e06c75", "●": "#e5c07b", "▲": "#61afef", "■": "#98c379", "★": "#e5c07b", "♥": "#e06c75", "♣": "#98c379", "♠": "#c678dd", "☀": "#d19a66", "☂": "#56b6c2", "♪": "#c678dd", "✿": "#56b6c2", "▒": "#5c6370", "▸": "#e5c07b", "◂": "#e5c07b" },
335
+ colors: { "◆": "#e06c75", "●": "#e5c07b", "▲": "#61afef", "■": "#98c379", "✦": "#e5c07b", "◈": "#e06c75", "⊕": "#98c379", "⊗": "#c678dd", "◎": "#d19a66", "△": "#56b6c2", "○": "#c678dd", "✧": "#56b6c2", "▒": "#5c6370", "▸": "#e5c07b", "◂": "#e5c07b" },
336
336
  create(w, h, seed = Date.now()) {
337
337
  [w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
338
338
  // As many cards as fit (each 4 wide, rows 2 apart), an even number, up to every symbol twice.
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
4
+ import os from "os";
4
5
  import path from "path";
5
6
  import { Box, Text, useApp, useInput, useWindowSize } from "ink";
6
7
  import { nouRulesOnly } from "../context/index.js";
@@ -34,8 +35,8 @@ import { FIND_KEY, isFind } from "./keys.js";
34
35
  import { FileTree } from "./FileTree.js";
35
36
  import { CodeView } from "./CodeView.js";
36
37
  import { Editor } from "./Editor.js";
37
- import { SLASH_COMMANDS, closestCommand, onlyChangesScreen, runsWhileBusy } from "./commands.js";
38
- import { WHEEL_STEP } from "./scroll.js";
38
+ import { SLASH_COMMANDS, closestCommand, finishAfter, onlyChangesScreen, runsWhileBusy } from "./commands.js";
39
+ import { WHEEL_STEP, sendWheel } from "./scroll.js";
39
40
  import { Chat, chatTabZones } from "./Chat.js";
40
41
  import { copyToClipboard, openInBrowser } from "./clipboard.js";
41
42
  import { Header, headerZones } from "./Header.js";
@@ -124,6 +125,8 @@ export function App({ initialModel, permContext, resume }) {
124
125
  const [quickOpen, setQuickOpen] = useState(null);
125
126
  /** /resume's picker: the conversations, in the palette's place while it's open. */
126
127
  const [resumePicker, setResumePicker] = useState(null);
128
+ /** What the open picker is for, said in its empty search box. */
129
+ const [pickerTitle, setPickerTitle] = useState("Resume a conversation");
127
130
  const [focus, setFocus] = useState("chat");
128
131
  /** The Games panel: whether it's open, the pane it's being dragged over, and the pane to go back to. */
129
132
  const [gamesOpen, setGamesOpen] = useState(false);
@@ -245,7 +248,6 @@ export function App({ initialModel, permContext, resume }) {
245
248
  const [pendingNote, setPendingNote] = useState(null);
246
249
  const [exitArmed, setExitArmed] = useState(false);
247
250
  const [update, setUpdate] = useState(null);
248
- const [wheel, setWheel] = useState(null);
249
251
  const [scrollTo, setScrollTo] = useState(null);
250
252
  const [editorMouse, setEditorMouse] = useState([]);
251
253
  /** Wheel events waiting for the next frame, added up per pane. */
@@ -284,6 +286,16 @@ export function App({ initialModel, permContext, resume }) {
284
286
  * wider (taken from the files and chat) or narrower (given to them). 0 is the normal layout.
285
287
  */
286
288
  const [codeZoom, setCodeZoom] = useState(0);
289
+ // A resized terminal is drawn again in full: drawing only the lines that changed, over a screen the
290
+ // terminal has rewrapped, can leave pieces of the old layout behind.
291
+ const firstSize = useRef(true);
292
+ useEffect(() => {
293
+ if (firstSize.current) {
294
+ firstSize.current = false;
295
+ return;
296
+ }
297
+ redraw();
298
+ }, [columns, rows]);
287
299
  const zoomCode = (by) => setCodeZoom((z) => (by === 0 ? 0 : Math.max(-6, Math.min(8, z + by))));
288
300
  const paneWidthsRef = useRef(paneWidths);
289
301
  paneWidthsRef.current = paneWidths;
@@ -462,10 +474,20 @@ export function App({ initialModel, permContext, resume }) {
462
474
  switchChatTab(id);
463
475
  }
464
476
  /** Closes the conversation tab on screen (stopping its engine); the conversation stays saved for /resume. */
465
- function closeChatTab() {
477
+ function closeChatTab(id = activeTab.current) {
466
478
  if (chatTabs.length < 2)
467
479
  return info("That's the only conversation tab. /clear starts a fresh conversation in it.");
468
- const id = activeTab.current;
480
+ if (id !== activeTab.current) {
481
+ // A tab in the background (its ×): stop its agent and drop it; the one on screen stays.
482
+ const t = tabStore.current.get(id);
483
+ t?.approval?.resolve(false);
484
+ t?.agent.cancel();
485
+ t?.agent.dispose?.();
486
+ tabStore.current.delete(id);
487
+ tabHooks.current.delete(id);
488
+ setChatTabs((tabs) => tabs.filter((x) => x !== id));
489
+ return;
490
+ }
469
491
  const at = chatTabs.indexOf(id);
470
492
  const next = chatTabs[at + 1] ?? chatTabs[at - 1];
471
493
  const agent = agentRef.current;
@@ -621,6 +643,7 @@ export function App({ initialModel, permContext, resume }) {
621
643
  redraw();
622
644
  },
623
645
  error: (text) => info(text, "error"),
646
+ quit: () => quit(),
624
647
  });
625
648
  }, []);
626
649
  useEffect(() => {
@@ -694,6 +717,7 @@ export function App({ initialModel, permContext, resume }) {
694
717
  info(`Earlier conversations here (also in the picker: ↑↓ and Enter, or type a number here):\n${rows.join("\n")}`);
695
718
  // A picker over the screen: ↑↓ and Enter, or type to filter by title.
696
719
  setResumePicker(list.map((c, i) => ({ title: `${i + 1}. ${c.title}`, keys: `${where(c)} · ${ago(c.updated)}`, run: () => void resumePicked(c) })));
720
+ setPickerTitle("Resume a conversation · ↑↓ and Enter, or type its number");
697
721
  setQuickOpen(">");
698
722
  return;
699
723
  }
@@ -880,7 +904,7 @@ export function App({ initialModel, permContext, resume }) {
880
904
  return;
881
905
  const added = newProblems(before, after);
882
906
  setPendingNote(added.length
883
- ? { short: `⚠ +${added.length} problem${added.length === 1 ? "" : "s"}`, detail: added.slice(0, 3).map((d) => formatDiagnostic(d)).join("\n") }
907
+ ? { short: `▲ +${added.length} problem${added.length === 1 ? "" : "s"}`, detail: added.slice(0, 3).map((d) => formatDiagnostic(d)).join("\n") }
884
908
  : { short: "✓ no new problems", detail: null });
885
909
  })();
886
910
  return () => {
@@ -907,6 +931,9 @@ export function App({ initialModel, permContext, resume }) {
907
931
  const tree = border === "tree" ? Math.min(Math.max(x + 1, MIN_TREE), columns - chatWidth - MIN_CODE) : treeWidth;
908
932
  const chat = border === "chat" ? Math.min(Math.max(columns - x, MIN_CHAT), columns - treeWidth - MIN_CODE) : chatWidth;
909
933
  const next = { tree: tree / columns, chat: chat / columns };
934
+ // Widths come from the panes as they are on screen (zoom included), so the zoom folds into them: the
935
+ // border lands where it's dropped instead of a zoom step away.
936
+ setCodeZoom(0);
910
937
  setPaneWidths(next);
911
938
  return next;
912
939
  };
@@ -946,7 +973,7 @@ export function App({ initialModel, permContext, resume }) {
946
973
  const { pane } = q;
947
974
  q.lines -= lines;
948
975
  q.columns -= columns;
949
- setWheel((w) => ({ pane, lines, columns, nonce: (w?.nonce ?? 0) + 1 }));
976
+ sendWheel(pane, lines, columns);
950
977
  if (q.lines || q.columns)
951
978
  q.timer = setTimeout(flushWheel, 16);
952
979
  };
@@ -1081,9 +1108,9 @@ export function App({ initialModel, permContext, resume }) {
1081
1108
  if (event.kind === "click" && event.y === 0) {
1082
1109
  const zone = headerZones(workspaceTabs()).find((z) => event.x >= z.from && event.x < z.to);
1083
1110
  if (zone?.tab === "new")
1084
- return typeCommand("/workspace new ");
1111
+ return pickWorkspaceFolder();
1085
1112
  if (typeof zone?.tab === "number")
1086
- return void sendToHost({ type: "switch", index: zone.tab });
1113
+ return void sendToHost({ type: zone.close ? "close" : "switch", index: zone.tab });
1087
1114
  }
1088
1115
  if (event.kind === "click" && event.y === paneHeight + 1)
1089
1116
  return clickable.current.status.find((z) => event.x >= z.from && event.x < z.to)?.run();
@@ -1117,6 +1144,8 @@ export function App({ initialModel, permContext, resume }) {
1117
1144
  if (Math.abs(at - plus) <= 1)
1118
1145
  return newChatTab();
1119
1146
  const zone = zones.find((z) => at >= z.from && at < z.to);
1147
+ if (zone?.close)
1148
+ return closeChatTab(zone.id);
1120
1149
  if (zone)
1121
1150
  return switchChatTab(zone.id);
1122
1151
  }
@@ -1612,7 +1641,7 @@ ${snippet}
1612
1641
  </editor_context>`;
1613
1642
  };
1614
1643
  const contextChip = openFile
1615
- ? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ✎ unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
1644
+ ? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ± unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
1616
1645
  : null;
1617
1646
  /** /mcp: list servers; add (a preset, a command, or a URL), remove, or import the ones from other tools. */
1618
1647
  const mcpCommand = async ([action, name, ...more]) => {
@@ -1688,7 +1717,7 @@ ${snippet}
1688
1717
  }
1689
1718
  if (action)
1690
1719
  return info("Usage: /plugins, /plugins add <name>, /plugins new <name>", "error");
1691
- const mark = { on: "●", off: "○", changed: "⚠" };
1720
+ const mark = { on: "●", off: "○", changed: "▲" };
1692
1721
  const lines = allPlugins.map((p) => p.scope === "starter"
1693
1722
  ? ` + ${p.id.padEnd(16)} ${p.plugin?.description ?? ""}`
1694
1723
  : ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✗ ${p.error}` : ""}`);
@@ -1862,6 +1891,11 @@ ${snippet}
1862
1891
  if (/^(reset|0|off)$/.test(arg))
1863
1892
  return zoomCode(0);
1864
1893
  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 +).");
1894
+ case "files":
1895
+ return setQuickOpen("");
1896
+ case "actions":
1897
+ case "palette":
1898
+ return setQuickOpen(">");
1865
1899
  case "workspace":
1866
1900
  case "workspaces":
1867
1901
  case "ws":
@@ -1983,6 +2017,17 @@ ${snippet}
1983
2017
  agentRef.current?.cancel();
1984
2018
  stopped.current = true;
1985
2019
  };
2020
+ /** What Nou was working on when a message cut in (see interruptToSend), so it finishes that too. */
2021
+ const unfinished = useRef(null);
2022
+ const lastAsked = useRef(null);
2023
+ /** Stops Nou so what's queued goes now; the request it was on isn't dropped, it's handed back with it. */
2024
+ const interruptToSend = () => {
2025
+ // What the agent was last asked, unfinished work handed back included: cutting in twice keeps both.
2026
+ if (lastAsked.current)
2027
+ unfinished.current = lastAsked.current;
2028
+ stop();
2029
+ stopped.current = false; // what's queued is meant to go
2030
+ };
1986
2031
  useEffect(() => {
1987
2032
  if (busy || approval || !queued.length)
1988
2033
  return;
@@ -1997,7 +2042,10 @@ ${snippet}
1997
2042
  const isCommand = (text) => text.startsWith("/");
1998
2043
  const take = isCommand(queued[0]) ? 1 : Math.max(1, queued.findIndex(isCommand) === -1 ? queued.length : queued.findIndex(isCommand));
1999
2044
  setQueued(queued.slice(take));
2000
- send(queued.slice(0, take).join("\n\n"));
2045
+ const text = queued.slice(0, take).join("\n\n");
2046
+ const previous = unfinished.current;
2047
+ unfinished.current = null;
2048
+ send(text, previous && !isCommand(text) ? finishAfter(text, previous) : undefined);
2001
2049
  }, [busy, approval, queued]);
2002
2050
  // When a reply points at places in the code (Nou's engine with show_places, the others with a ```nou-show
2003
2051
  // block, which is taken out of the reply), the first one opens highlighted and the rest are listed.
@@ -2016,7 +2064,7 @@ ${snippet}
2016
2064
  if (!stops.length)
2017
2065
  return;
2018
2066
  const list = stops.map((s, i) => `${i + 1}. ${s.file}:${s.line}${s.end > s.line ? `-${s.end}` : ""}${s.note ? ` · ${s.note}` : ""}`).join("\n");
2019
- info(`📍 ${stops.length === 1 ? "Here it is" : `${stops.length} places`}, open in the code view (click one to jump there${stops.length > 1 ? "; /showme next and /showme back step through them" : ""}; /showme stop clears the highlights):\n${list}`);
2067
+ info(`◉ ${stops.length === 1 ? "Here it is" : `${stops.length} places`}, open in the code view (click one to jump there${stops.length > 1 ? "; /showme next and /showme back step through them" : ""}; /showme stop clears the highlights):\n${list}`);
2020
2068
  setTour({ stops, at: 0 });
2021
2069
  }, [busy]);
2022
2070
  // Each place: open it in the code view with its lines highlighted.
@@ -2043,7 +2091,7 @@ ${snippet}
2043
2091
  if (at < 0 || at >= tour.stops.length)
2044
2092
  return info(at < 0 ? "That's the first place." : "That was the last place.");
2045
2093
  const stop = tour.stops[at];
2046
- info(`📍 ${at + 1} of ${tour.stops.length}: ${stop.file}:${stop.line}${stop.end > stop.line ? `-${stop.end}` : ""}${stop.note ? ` · ${stop.note}` : ""}`);
2094
+ info(`◉ ${at + 1} of ${tour.stops.length}: ${stop.file}:${stop.line}${stop.end > stop.line ? `-${stop.end}` : ""}${stop.note ? ` · ${stop.note}` : ""}`);
2047
2095
  return setTour({ ...tour, at });
2048
2096
  }
2049
2097
  if (!words)
@@ -2093,7 +2141,7 @@ ${snippet}
2093
2141
  writeFileSync(step.file, "");
2094
2142
  setTreeVersion((v) => v + 1);
2095
2143
  }
2096
- info(`👉 Step ${guide.at + 1} of ${guide.steps.length}: ${step.file}:${step.line}\n${step.why}\n\n\`\`\`\n${step.code}\n\`\`\``);
2144
+ info(`▸ Step ${guide.at + 1} of ${guide.steps.length}: ${step.file}:${step.line}\n${step.why}\n\n\`\`\`\n${step.code}\n\`\`\``);
2097
2145
  openAt(step.file, step.line, true);
2098
2146
  }, [guide?.at, guide?.steps]);
2099
2147
  const guideStep = guide?.steps[guide.at] ?? null;
@@ -2111,7 +2159,7 @@ ${snippet}
2111
2159
  setGuide({ ...guide, steps, at: guide.at + 1 });
2112
2160
  }
2113
2161
  else {
2114
- info(`🎉 That's it: all ${steps.length} step${steps.length > 1 ? "s" : ""} done and saved. Ask Nou to check it over if you like.`);
2162
+ info(`✓ That's it: all ${steps.length} step${steps.length > 1 ? "s" : ""} done and saved. Ask Nou to check it over if you like.`);
2115
2163
  setGuide(null);
2116
2164
  }
2117
2165
  });
@@ -2152,8 +2200,7 @@ ${snippet}
2152
2200
  const sendNow = () => {
2153
2201
  if (!queued.length)
2154
2202
  return;
2155
- stop();
2156
- stopped.current = false; // what's queued is meant to go
2203
+ interruptToSend();
2157
2204
  };
2158
2205
  /**
2159
2206
  * From the chat: while Nou works, a message waits its turn, or (`now`: Alt+Enter) stops Nou and goes right
@@ -2164,14 +2211,14 @@ ${snippet}
2164
2211
  return send(text);
2165
2212
  const now = settings.sendNowWhileBusy ? !alt : alt;
2166
2213
  if (now) {
2167
- stop();
2168
- stopped.current = false; // this one is meant to go
2214
+ interruptToSend();
2169
2215
  setQueued((q) => [text, ...q]);
2170
2216
  }
2171
2217
  else
2172
2218
  setQueued((q) => [...q, text]);
2173
2219
  };
2174
- const send = (text) => {
2220
+ /** `forAgent`: what the agent gets instead of `text` (the chat still shows `text`). */
2221
+ const send = (text, forAgent) => {
2175
2222
  // Right after /resume lists them, a number picks a conversation instead of being sent to the agent.
2176
2223
  const picking = pickingResume.current;
2177
2224
  pickingResume.current = false;
@@ -2188,7 +2235,8 @@ ${snippet}
2188
2235
  return;
2189
2236
  }
2190
2237
  setEntries((prev) => [...prev, { kind: "user", text }]);
2191
- void agentRef.current.send(text, editorContext() ?? undefined);
2238
+ lastAsked.current = forAgent ?? text;
2239
+ void agentRef.current.send(forAgent ?? text, editorContext() ?? undefined);
2192
2240
  };
2193
2241
  function saveFromEditor(text, file = openFile) {
2194
2242
  if (!file)
@@ -2311,7 +2359,6 @@ ${snippet}
2311
2359
  const dockLeft = gameDock === "tree" ? 0 : gameDock === "code" ? treeWidth : treeWidth + codeWidth;
2312
2360
  const dockWidth = gameDock === "tree" ? treeWidth : gameDock === "code" ? codeWidth : chatWidth;
2313
2361
  layout.current = { treeWidth, codeWidth, paneHeight: height, heights, game: gamesOpen ? { x: dockLeft, y: 1 + heights[gameDock] + (gameDock === "code" ? termHeight : 0), width: dockWidth, height: gameHeight } : null, terminal: termOpen ? { x: treeWidth, y: 1 + heights.code, width: codeWidth, height: termHeight } : null };
2314
- const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
2315
2362
  const scrollFor = (pane) => (scrollTo?.pane === pane ? scrollTo : null);
2316
2363
  const totals = diagnostics.counts();
2317
2364
  const checking = diagnostics.busy;
@@ -2330,6 +2377,48 @@ ${snippet}
2330
2377
  setRepaint((n) => n + 1);
2331
2378
  }
2332
2379
  /** /workspace: list them, `new [folder]` opens one, a number switches. */
2380
+ /**
2381
+ * + in the header (or /workspace new on its own): a picker of folders to open as a workspace: this project
2382
+ * again (a second workspace on the same code), then the projects beside it, latest first. Enter opens it.
2383
+ */
2384
+ function pickWorkspaceFolder() {
2385
+ const here = process.cwd();
2386
+ const parent = path.dirname(here);
2387
+ const home = os.homedir();
2388
+ // Where each folder is, kept short (its end is what tells them apart) so the names keep their room.
2389
+ const short = (p) => {
2390
+ const shown = p.startsWith(home) ? `~${p.slice(home.length)}` : p;
2391
+ return shown.length > 22 ? `…${shown.slice(-21)}` : shown;
2392
+ };
2393
+ let beside = [];
2394
+ try {
2395
+ beside = readdirSync(parent, { withFileTypes: true })
2396
+ .filter((d) => d.isDirectory() && !d.name.startsWith(".") && path.join(parent, d.name) !== here)
2397
+ .map((d) => {
2398
+ const full = path.join(parent, d.name);
2399
+ let time = 0;
2400
+ try {
2401
+ time = statSync(full).mtimeMs;
2402
+ }
2403
+ catch {
2404
+ // unreadable: listed last
2405
+ }
2406
+ return { path: full, time };
2407
+ })
2408
+ .sort((a, b) => b.time - a.time)
2409
+ .slice(0, 60);
2410
+ }
2411
+ catch {
2412
+ // the parent can't be listed: just this project, or type a path with /workspace new <folder>
2413
+ }
2414
+ const open = (folder) => () => void workspaceCommand(`new ${folder}`);
2415
+ setResumePicker([
2416
+ { title: `${path.basename(here)} (this project)`, keys: short(here), run: open(here) },
2417
+ ...beside.map((b) => ({ title: path.basename(b.path), keys: short(b.path), run: open(b.path) })),
2418
+ ]);
2419
+ setPickerTitle("Open a folder as a new workspace · any other: /workspace new <path>");
2420
+ setQuickOpen(">");
2421
+ }
2333
2422
  async function workspaceCommand(arg) {
2334
2423
  const [sub, ...more] = arg.split(/\s+/);
2335
2424
  const tabs = workspaceTabs();
@@ -2347,6 +2436,8 @@ ${snippet}
2347
2436
  if (sub !== "new")
2348
2437
  return info("Try /workspace new <folder>, /workspace <n> or /workspace.", "error");
2349
2438
  const wanted = more.join(" ");
2439
+ if (!wanted)
2440
+ return pickWorkspaceFolder();
2350
2441
  if (isHosted())
2351
2442
  return void sendToHost({ type: "new", folder: wanted });
2352
2443
  const folder = workspaceFolder(wanted, process.cwd());
@@ -2547,9 +2638,9 @@ ${snippet}
2547
2638
  }
2548
2639
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
2549
2640
  clickable.current = { status: zones, modelFrom: columns - (" LOCAL ".length + ` ${modelLabel} `.length + 1) };
2550
- 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, wheel: wheelFor("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, wheel: wheelFor("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, wheel: wheelFor("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, wheel: wheelFor("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, wheel: wheelFor("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
2641
+ 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
2551
2642
  ? _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)` : ""] })
2552
- : 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", wheel: wheelFor("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: "Pick a conversation to resume (type to filter)" } : {}), onPick: (file) => {
2643
+ : 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) => {
2553
2644
  setQuickOpen(null);
2554
2645
  clearFollow();
2555
2646
  open(file);
@@ -10,7 +10,7 @@ import { C, SPINNER, ANIMATION_MS } from "./theme.js";
10
10
  import { useTerminalFocused } from "./focus.js";
11
11
  import { Pane } from "./Pane.js";
12
12
  import { SLASH_COMMANDS, matchCommands } from "./commands.js";
13
- import { Scrollbar, topFor } from "./scroll.js";
13
+ import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
14
14
  /**
15
15
  * What the agent did between replies (files read, commands run...) folds into one line, like Claude's
16
16
  * "▸ 5 steps": click it (or Ctrl+O for all of them) to see each step. `open` holds the runs shown open.
@@ -18,8 +18,8 @@ import { Scrollbar, topFor } from "./scroll.js";
18
18
  function stepLines(steps, open, live, inner) {
19
19
  const last = steps[steps.length - 1].text.replace(/\s+/g, " ");
20
20
  const summary = `${open ? "▾" : "▸"} ${steps.length} steps${open ? "" : ` · ${live ? "now: " : "last: "}${last}`}`;
21
- const head = { text: ` ${summary.length > inner - 4 ? `${summary.slice(0, inner - 5)}…` : summary}`, color: open ? C.accent2 : C.dim, lead: " ⚙", leadColor: C.dim, toggle: steps[0] };
22
- return open ? [head, ...steps.flatMap((e) => cachedLines(e, inner).map((l) => (l.lead === " ⚙" ? { ...l, lead: " " } : l)))] : [head];
21
+ const head = { text: ` ${summary.length > inner - 4 ? `${summary.slice(0, inner - 5)}…` : summary}`, color: open ? C.accent2 : C.dim, lead: " ◇", leadColor: C.dim, toggle: steps[0] };
22
+ return open ? [head, ...steps.flatMap((e) => cachedLines(e, inner).map((l) => (l.lead === " ◇" ? { ...l, lead: " " } : l)))] : [head];
23
23
  }
24
24
  const BAR = "▌ ";
25
25
  // Whether a path in a message is a file in the project, remembered so each one is checked once.
@@ -62,8 +62,8 @@ function entryLines(entry, inner, links = true) {
62
62
  }
63
63
  else {
64
64
  const style = entry.kind === "tool"
65
- ? { icon: "⚙", color: C.dim }
66
- : entry.kind === "error" ? { icon: "✗", color: C.red } : { icon: "ℹ", color: C.accent };
65
+ ? { icon: "◇", color: C.dim }
66
+ : entry.kind === "error" ? { icon: "✗", color: C.red } : { icon: "ⓘ", color: C.accent };
67
67
  wrap(entry.text, inner - 4).forEach((line, i) => {
68
68
  const spans = links && entry.kind !== "user" ? linkFileRefs(linkUrls([{ text: ` ${line}` }]), isProjectFile) : [];
69
69
  out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
@@ -91,19 +91,22 @@ export function chatTabZones(tabs) {
91
91
  if (tabs.length === 1)
92
92
  return { zones: [{ id: tabs[0].id, from: 3, to: 7 }], plus: 8, width: 9 };
93
93
  let x = 3; // " ◆ "
94
- const zones = tabs.map((t) => {
94
+ // Each tab, then its × (which closes it).
95
+ const zones = tabs.flatMap((t) => {
95
96
  const w = tabText(t).length;
96
- const zone = { id: t.id, from: x, to: x + w };
97
- x += w + 1;
98
- return zone;
97
+ const both = [{ id: t.id, from: x, to: x + w }, { id: t.id, from: x + w, to: x + w + TAB_CLOSE.length, close: true }];
98
+ x += w + TAB_CLOSE.length + 1;
99
+ return both;
99
100
  });
100
101
  return { zones, plus: x + 1, width: x + 3 };
101
102
  }
103
+ const TAB_CLOSE = "× ";
102
104
  const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
103
105
  /** Characters of a word for a double-click: paths and web addresses come whole (src/a.ts, https://x.io/y). */
104
106
  const WORD_CHAR = /[\p{L}\p{N}_.\-/@:~#%?=&+]/u;
105
107
  const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
106
- export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [], sendNowFirst = false, onSendNow }) {
108
+ 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 }) {
109
+ const wheel = usePaneWheel(wheelPane);
107
110
  const [input, setInputText] = useState("");
108
111
  // Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
109
112
  // still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
@@ -171,21 +174,21 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
171
174
  }, [scrollBack]);
172
175
  const footer = [];
173
176
  if (approval?.kind === "command") {
174
- footer.push({ text: "⚡ Run this command?", color: C.yellow, bold: true });
177
+ footer.push({ text: "› Run this command?", color: C.yellow, bold: true });
175
178
  for (const line of wrap(`$ ${approval.command}`, inner - 2))
176
179
  footer.push({ text: ` ${line}`, color: C.text });
177
180
  footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
178
181
  }
179
182
  else if (approval?.kind === "tool") {
180
- footer.push({ text: `🔌 ${approval.name.startsWith("mcp__") ? `MCP tool ${approval.name.split("__")[1]} → ${approval.name.split("__").slice(2).join("__")}` : `Plugin tool: ${approval.name}`}`, color: C.yellow, bold: true });
183
+ footer.push({ text: `◇ ${approval.name.startsWith("mcp__") ? `MCP tool ${approval.name.split("__")[1]} → ${approval.name.split("__").slice(2).join("__")}` : `Plugin tool: ${approval.name}`}`, color: C.yellow, bold: true });
181
184
  for (const line of wrap(JSON.stringify(approval.args), inner - 2).slice(0, 4))
182
185
  footer.push({ text: ` ${line}`, color: C.text });
183
186
  footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
184
187
  }
185
188
  else if (approval?.kind === "change") {
186
- footer.push({ text: `✎ Nou wants to ${approval.change.isNew ? "create" : "edit"} ${approval.change.path}`, color: C.yellow, bold: true });
189
+ footer.push({ text: `± Nou wants to ${approval.change.isNew ? "create" : "edit"} ${approval.change.path}`, color: C.yellow, bold: true });
187
190
  if (approvalNote) {
188
- footer.push({ text: "⚠ This edit adds problems:", color: C.red, bold: true });
191
+ footer.push({ text: "▲ This edit adds problems:", color: C.red, bold: true });
189
192
  for (const line of wrap(approvalNote, inner - 2).slice(0, 6))
190
193
  footer.push({ text: ` ${line}`, color: C.red });
191
194
  }
@@ -196,7 +199,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
196
199
  }
197
200
  // Click what's queued (or /now) to stop Nou and send it right away.
198
201
  for (const text of queued)
199
- footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim, sendNow: true });
202
+ footer.push({ text: `⧗ next: ${text.replace(/\s+/g, " ")}`, color: C.dim, sendNow: true });
200
203
  if (queued.length && busy)
201
204
  footer.push({ text: " ↳ click to send now, or /now", color: C.accent2, sendNow: true });
202
205
  // Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
@@ -400,7 +403,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
400
403
  ? 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)))
401
404
  : _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
402
405
  const empty = entries.length === 0 && !streaming;
403
- 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) }), " "] }, 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
406
+ 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
404
407
  ? unread
405
408
  ? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
406
409
  : _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
@@ -7,13 +7,14 @@ import { foldRanges, highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines
7
7
  import { C, fileIcon, gradient } from "./theme.js";
8
8
  import { isFind } from "./keys.js";
9
9
  import { Pane } from "./Pane.js";
10
- import { Scrollbar, topFor } from "./scroll.js";
10
+ import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
11
11
  /** The current match: a yellow background; the others are shown inverted. */
12
12
  const CURRENT_MATCH = ["\x1b[43m\x1b[30m", "\x1b[49m\x1b[39m"];
13
13
  /** Columns ←/→ and Shift+wheel move sideways. */
14
14
  const SIDE_STEP = 8;
15
15
  const EDIT_HINT = " double-click or e to edit · v diff";
16
- export const CodeView = memo(function CodeView({ showing, width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
16
+ export const CodeView = memo(function CodeView({ showing, width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheelPane, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
17
+ const wheel = usePaneWheel(wheelPane);
17
18
  const [top, setTop] = useState(0);
18
19
  // Columns scrolled sideways (colors are kept: see sliceAnsi).
19
20
  const [left, setLeft] = useState(0);
@@ -231,7 +232,7 @@ export const CodeView = memo(function CodeView({ showing, width, height, focused
231
232
  ["drag", "a pane's border to resize it"],
232
233
  ["s", "settings: models, keys, plugins"],
233
234
  ];
234
- return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, flexShrink: 0, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! \uD83C\uDF1E Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
235
+ return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, flexShrink: 0, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
235
236
  }
236
237
  const gutter = String(lines.length).length;
237
238
  const { icon, color } = fileIcon(filePath);
@@ -7,7 +7,7 @@ import { C, fileIcon } from "./theme.js";
7
7
  import { FIND_KEY, isFind } from "./keys.js";
8
8
  import { SEVERITY_STYLE } from "./ProblemsView.js";
9
9
  import { Pane } from "./Pane.js";
10
- import { Scrollbar, topFor } from "./scroll.js";
10
+ import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
11
11
  import { copyToClipboard, readClipboard } from "./clipboard.js";
12
12
  import { guideProgress } from "../guide/index.js";
13
13
  const TAB = " ";
@@ -94,7 +94,8 @@ const PlainRow = memo(function PlainRow({ num, numColor, text }) {
94
94
  return _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: numColor, children: num }), text || " "] });
95
95
  });
96
96
  /** A text editor: arrows and the mouse to move, Shift to select, Ctrl+C/X/V clipboard, Ctrl+S save, Ctrl+Z undo, Esc leave. */
97
- export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs, guide, onGuideDone }) {
97
+ export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheelPane, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs, guide, onGuideDone }) {
98
+ const wheel = usePaneWheel(wheelPane);
98
99
  const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
99
100
  const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
100
101
  const comment = useMemo(() => commentFor(filePath), [filePath]);
@@ -636,7 +637,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
636
637
  const statusColor = guideStatus && !confirmExit && !prompt ? (progress < 0 ? C.red : C.accent2) : confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
637
638
  const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
638
639
  const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
639
- return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: C.onBadge, children: " \u270E EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
640
+ return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: C.onBadge, children: " \u00B1 EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
640
641
  const n = v.top + i;
641
642
  const isCursorLine = n === buf.row;
642
643
  const problem = byLine.get(n + 1)?.[0];
@@ -7,7 +7,7 @@ import { IGNORED } from "../commands/index.js";
7
7
  import { C, GIT_COLORS, fileIcon, gradient, ANIMATION_MS } from "./theme.js";
8
8
  import { useTerminalFocused } from "./focus.js";
9
9
  import { Pane } from "./Pane.js";
10
- import { Scrollbar, topFor } from "./scroll.js";
10
+ import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
11
11
  function listDir(dir) {
12
12
  try {
13
13
  return readdirSync(path.resolve(process.cwd(), dir), { withFileTypes: true })
@@ -32,7 +32,8 @@ function flatten(expanded) {
32
32
  walk(".", 0);
33
33
  return nodes;
34
34
  }
35
- export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, showing, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
35
+ export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, showing, git, problemCounts, wheelPane, scrollTo, click, onOpen, onHover }) {
36
+ const wheel = usePaneWheel(wheelPane);
36
37
  const [expanded, setExpanded] = useState(() => new Set(["src"]));
37
38
  const [cursor, setCursor] = useState(0);
38
39
  // The first row shown. The wheel moves it on its own; the keyboard moves the cursor and the view follows.
@@ -9,6 +9,8 @@ const VERSION = `v${currentVersion()}`;
9
9
  /** What comes before the project (or the workspace tabs): " ◆ NOU v1.2.3 │ ". */
10
10
  const LEAD = ` ◆ NOU ${VERSION} │ `;
11
11
  const tabLabel = (t, i) => ` ${i + 1} ${t.name}${t.busy ? " ●" : ""} `;
12
+ /** Each workspace tab ends with a × that closes it. */
13
+ const CLOSE = "× ";
12
14
  /**
13
15
  * Where each workspace tab, and the + that opens one, sits in the header row: for clicks. With one workspace
14
16
  * (not hosted) there's just the project's name and the +.
@@ -23,7 +25,8 @@ export function headerZones(tabs) {
23
25
  tabs.forEach((t, i) => {
24
26
  const w = tabLabel(t, i).length;
25
27
  zones.push({ from: x, to: x + w, tab: i });
26
- x += w + 1;
28
+ zones.push({ from: x + w, to: x + w + CLOSE.length, tab: i, close: true });
29
+ x += w + CLOSE.length + 1;
27
30
  });
28
31
  }
29
32
  zones.push({ from: x, to: x + 3, tab: "new" });
@@ -46,6 +49,6 @@ export const Header = memo(function Header({ width, branch, changes, model, loca
46
49
  const right = rightSide(width, model, local, update);
47
50
  const rightWidth = right.notice.length + right.badge.length + right.model.length + 1;
48
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
49
- ? 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) }), " "] }, t.id)))
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)))
50
53
  : _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 }), " "] }) })] }));
51
54
  });
@@ -4,12 +4,14 @@ import { Box, Text, useAnimation, useInput } from "ink";
4
4
  import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
5
5
  import { useTerminalFocused } from "./focus.js";
6
6
  import { Pane } from "./Pane.js";
7
+ import { usePaneWheel } from "./scroll.js";
7
8
  export const SEVERITY_STYLE = {
8
9
  error: { icon: "✗", color: C.red },
9
10
  warning: { icon: "▲", color: C.yellow },
10
- info: { icon: "ℹ", color: C.accent },
11
+ info: { icon: "ⓘ", color: C.accent },
11
12
  };
12
- export function ProblemsView({ width, height, focused, problems, checking, wheel, onJump, onClose }) {
13
+ export function ProblemsView({ width, height, focused, problems, checking, wheelPane, onJump, onClose }) {
14
+ const wheel = usePaneWheel(wheelPane);
13
15
  const [cursor, setCursor] = useState(0);
14
16
  const [onlyErrors, setOnlyErrors] = useState(false);
15
17
  const windowFocused = useTerminalFocused();
@@ -58,7 +58,7 @@ 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)", onPick, onRun, onClose }) {
61
+ export function QuickOpen({ width, top, files, recent = [], actions, initialQuery = "", placeholder = "Go to file… (> for commands)", title, onPick, onRun, onClose }) {
62
62
  const [query, setQuery] = useState(initialQuery);
63
63
  const [cursor, setCursor] = useState(0);
64
64
  const boxWidth = Math.min(80, Math.max(40, width - 10));
@@ -101,8 +101,10 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
101
101
  setCursor((c) => Math.min(count - 1, c + rowsShown));
102
102
  });
103
103
  const recentSet = useMemo(() => new Set(recent), [recent]);
104
+ /** Cut at the end (a title reads from the start). */
105
+ const fit = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n));
104
106
  const pad = (s) => (s.length > inner ? `…${s.slice(-(inner - 1))}` : s.padEnd(inner));
105
- 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: " \u2315 " }), _jsx(TextInput, { value: query, onChange: (v) => {
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) => {
106
108
  setQuery(v);
107
109
  setCursor(0);
108
110
  }, onSubmit: () => {
@@ -212,7 +212,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
212
212
  const problem = p.unavailable();
213
213
  const selected = i === row;
214
214
  const current = p.id === choice.provider.id;
215
- return (_jsxs(Text, { wrap: "truncate-end", ...(selected && column === "providers" ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? " ● " : " " }), _jsx(Text, { color: problem ? C.dim : C.text, bold: selected, children: p.label.padEnd(labelWidth).slice(0, labelWidth) }), _jsx(Text, { color: p.local ? C.green : C.accent2, children: p.local ? " local" : " cloud" }), problem ? _jsx(Text, { color: C.dim, children: " \uD83D\uDD12" }) : null] }, p.id));
215
+ return (_jsxs(Text, { wrap: "truncate-end", ...(selected && column === "providers" ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? " ● " : " " }), _jsx(Text, { color: problem ? C.dim : C.text, bold: selected, children: p.label.padEnd(labelWidth).slice(0, labelWidth) }), _jsx(Text, { color: p.local ? C.green : C.accent2, children: p.local ? " local" : " cloud" }), problem ? _jsx(Text, { color: C.dim, children: " \u25A3" }) : null] }, p.id));
216
216
  })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: column === "models" ? C.accent : C.dim, paddingX: 1, children: [_jsxs(Text, { color: C.dim, bold: true, children: ["MODELS \u00B7 ", provider.label] }), reason ? _jsxs(Text, { color: C.red, wrap: "wrap", children: ["\u2717 ", reason] }) : null, connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
217
217
  const entered = connecting.values[f.env];
218
218
  const shown = (v) => (f.secret ? "•".repeat(Math.min(v.length, 12)) : v);
@@ -221,14 +221,14 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
221
221
  }
222
222
  if (i > connecting.step)
223
223
  return _jsxs(Text, { color: C.dim, children: [" ", f.label, f.optional ? " (optional)" : ""] }, f.env);
224
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { color: C.accent2, children: ["\u270E ", f.label, f.optional ? " (optional)" : "", ": "] }), _jsx(TextInput, { value: field, onChange: setField, onSubmit: submitField, ...(f.secret ? { mask: "•" } : {}), ...(f.placeholder ? { placeholder: f.placeholder } : {}) })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", f.env, process.env[f.env] ? (f.secret ? ` is set (from ${originOf(f.env)}); Enter keeps it` : "") : f.optional ? "; Enter skips" : ""] }), setOutsideNou(f.env) && (_jsxs(Text, { color: C.yellow, wrap: "wrap", children: [" A new one works now, but ", originOf(f.env), " sets ", f.env, " too and wins when Nou restarts: change or remove it there."] }))] }, f.env));
224
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { color: C.accent2, children: ["\u00B1 ", f.label, f.optional ? " (optional)" : "", ": "] }), _jsx(TextInput, { value: field, onChange: setField, onSubmit: submitField, ...(f.secret ? { mask: "•" } : {}), ...(f.placeholder ? { placeholder: f.placeholder } : {}) })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", f.env, process.env[f.env] ? (f.secret ? ` is set (from ${originOf(f.env)}); Enter keeps it` : "") : f.optional ? "; Enter skips" : ""] }), setOutsideNou(f.env) && (_jsxs(Text, { color: C.yellow, wrap: "wrap", children: [" A new one works now, but ", originOf(f.env), " sets ", f.env, " too and wins when Nou restarts: change or remove it there."] }))] }, f.env));
225
225
  }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Saved to ~/.nou/credentials.json (only you can read it). Your environment and .env still win." })] })) : provider.unavailable() ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.yellow, children: ["Not set up: ", provider.unavailable()] }), provider.connect
226
226
  ? _jsx(Text, { color: C.accent2, children: "Press Enter to connect it: paste your key here, no restart needed." })
227
- : null, _jsx(Text, { color: C.dim, children: "Or add it to a .env file in your project (see .env.example) or your environment." })] })) : (_jsxs(_Fragment, { children: [typing ? (_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, children: "\u270E " }), _jsx(TextInput, { value: custom, onChange: setCustom, onSubmit: (v) => {
227
+ : null, _jsx(Text, { color: C.dim, children: "Or add it to a .env file in your project (see .env.example) or your environment." })] })) : (_jsxs(_Fragment, { children: [typing ? (_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, children: "\u00B1 " }), _jsx(TextInput, { value: custom, onChange: setCustom, onSubmit: (v) => {
228
228
  setTyping(false);
229
229
  if (v.trim())
230
230
  onModel({ provider, model: v.trim() });
231
- }, placeholder: `e.g. ${provider.defaultModel}` })] })) : (_jsx(Text, { ...(column === "models" && modelRow === 0 ? { backgroundColor: C.panel } : {}), color: C.accent2, children: "\u270E Type a model name\u2026" })), listed === "loading" && _jsxs(Text, { color: C.accent2, children: [SPINNER[frame % SPINNER.length], " loading models\u2026"] }), listed && typeof listed === "object" && !Array.isArray(listed) && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.red, children: ["Couldn't list models: ", listed.error] }), rejectedKey(listed.error) && provider.connect?.some((f) => f.secret) && (() => {
231
+ }, placeholder: `e.g. ${provider.defaultModel}` })] })) : (_jsx(Text, { ...(column === "models" && modelRow === 0 ? { backgroundColor: C.panel } : {}), color: C.accent2, children: "\u00B1 Type a model name\u2026" })), listed === "loading" && _jsxs(Text, { color: C.accent2, children: [SPINNER[frame % SPINNER.length], " loading models\u2026"] }), listed && typeof listed === "object" && !Array.isArray(listed) && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.red, children: ["Couldn't list models: ", listed.error] }), rejectedKey(listed.error) && provider.connect?.some((f) => f.secret) && (() => {
232
232
  const key = provider.connect.find((f) => f.secret).env;
233
233
  return _jsxs(Text, { color: C.yellow, wrap: "wrap", children: ["The ", key, " ", originOf(key) ? `from ${originOf(key)} ` : "", "was rejected. Press k to enter a different key."] });
234
234
  })()] })), (() => {
@@ -269,7 +269,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
269
269
  return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [_jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Little games for a break while Nou works. Added ones show in the Games panel: /games." }), _jsx(Text, { children: " " }), GAMES.map((g, i) => ({ g, i })).slice(gameWindow.first, gameWindow.last).map(({ g, i }) => {
270
270
  const selected = i === row;
271
271
  const added = settings.games.includes(g.id);
272
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), added ? _jsx(Text, { color: C.green, bold: true, children: "\u2713 ADDED" }) : _jsx(Text, { color: C.accent2, bold: true, children: "\u2B07 GET " }), _jsxs(Text, { color: C.text, bold: true, children: [" ", g.title] }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id));
272
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), added ? _jsx(Text, { color: C.green, bold: true, children: "\u2713 ADDED" }) : _jsx(Text, { color: C.accent2, bold: true, children: "\u2193 GET " }), _jsxs(Text, { color: C.text, bold: true, children: [" ", g.title] }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id));
273
273
  })] }));
274
274
  }
275
275
  return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [plugins.map((p, i) => {
@@ -284,7 +284,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
284
284
  ].filter(Boolean)
285
285
  : [];
286
286
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), p.scope === "starter" ? _jsx(Text, { color: C.accent2, bold: true, children: "+ ADD" }) : toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope === "starter" ? "starter, optional" : p.scope, parts.length ? ` · ${parts.join(", ")}` : ""] })] }), state === "changed"
287
- ? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u26A0 Changed since you enabled it. Review the code, then press Enter to enable again." })
287
+ ? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u25B2 Changed since you enabled it. Review the code, then press Enter to enable again." })
288
288
  : p.error
289
289
  ? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \u2717 ", p.error] })
290
290
  : _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", p.plugin?.description ?? (enabled ? "" : "Enable to load it.")] })] }, p.id));
@@ -293,7 +293,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
293
293
  const hints = tab === "Model"
294
294
  ? connecting ? "Enter next · Esc cancel" : column === "models" ? `↑↓ choose · Enter use model · ← providers${provider.connect ? " · k change key" : ""}` : `↑↓ provider · Enter/→ ${provider.unavailable() ? "connect" : "pick a model"}${provider.connect ? " · k change key" : ""}`
295
295
  : tab === "Engine" ? "↑↓ move · Enter use this engine" : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : tab === "Games" ? "↑↓ move · Enter add / remove · /games opens the panel" : "↑↓ move · Enter toggle · r rescan";
296
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u2699 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) }), _jsxs(Text, { color: C.dim, children: [" nou v", currentVersion()] })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
296
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u25C7 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) }), _jsxs(Text, { color: C.dim, children: [" nou v", currentVersion()] })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
297
297
  }
298
298
  /** What a connect field starts with: the current URL to edit (keys stay hidden, so they start empty). */
299
299
  function initialValue(f) {
@@ -2,13 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { memo, useEffect, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { C } from "./theme.js";
5
+ import { usePaneWheel } from "./scroll.js";
5
6
  const COLORS = { out: C.text, command: C.accent, info: C.dim, error: C.red };
6
7
  /**
7
8
  * The terminal panel, under the code like VS Code's: type a command and Enter runs it. While one runs,
8
9
  * what you type goes to it; Ctrl+C stops it. ↑↓ go through what you ran, Ctrl+L clears, Esc leaves.
9
10
  * Drag its top border (or Alt+↑/↓) to make it taller or shorter.
10
11
  */
11
- export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave, onClose, onResize }) {
12
+ export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheelPane, onLeave, onClose, onResize }) {
13
+ const wheel = usePaneWheel(wheelPane);
12
14
  const [input, setInput] = useState("");
13
15
  const [history, setHistory] = useState([]);
14
16
  const [back, setBack] = useState(-1);
@@ -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(["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(["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
@@ -53,6 +53,8 @@ export const SLASH_COMMANDS = [
53
53
  { name: "open", args: "<file>[:line]", description: "open a file in the code view (part of its name is enough)" },
54
54
  { name: "effort", args: "[fastest|fast|balanced|smarter|smartest]", description: "speed vs smarts: how hard the model thinks (cheaper + faster, or smarter)" },
55
55
  { name: "workspace", args: "[new <folder> | n]", description: "several projects in this window, a tab each in the header: open one, or switch" },
56
+ { name: "files", description: "find a file by part of its name (also Ctrl+P)" },
57
+ { name: "actions", description: "every action Nou has, searchable, with its key (also ?)" },
56
58
  { name: "zoom", args: "in|out|reset", description: "make the code panel bigger or smaller (+ and - with the code focused, = resets)" },
57
59
  { name: "now", description: "stop Nou and send your queued messages right away (or click them)" },
58
60
  { name: "settings", description: "models, engine, preferences and plugins" },
@@ -94,3 +96,11 @@ export function matchCommands(typed, extra = []) {
94
96
  const contains = query ? all.filter((c) => !c.name.startsWith(query) && (c.name.includes(query) || c.description.includes(query))) : [];
95
97
  return [...starts.sort((a, b) => Number(b.name === query) - Number(a.name === query)), ...contains];
96
98
  }
99
+ /**
100
+ * A message sent while Nou was working stops it so it's answered now, but what Nou was on isn't dropped:
101
+ * the agent is told what that was and to finish it after, unless the new message says otherwise.
102
+ */
103
+ export function finishAfter(text, previous) {
104
+ const was = previous.length > 6000 ? `${previous.slice(0, 6000)}…` : previous;
105
+ return `${text}\n\n[Sent while you were still working on my previous message, so you were interrupted. Handle this first, then carry on with and finish the previous request, picking up where you left off (don't redo finished steps), unless this message cancels or replaces it. The previous request was:\n${was}]`;
106
+ }
@@ -48,9 +48,14 @@ export function installFastWidth() {
48
48
  if (typeof registerHooks !== "function" || process.env.NOU_NO_FAST_WIDTH)
49
49
  return false;
50
50
  const shim = new URL(`./width-shim${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url).href;
51
+ const lineShim = new URL(`./line-update-shim${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url).href;
51
52
  registerHooks({
52
53
  resolve(specifier, context, nextResolve) {
53
54
  const resolved = nextResolve(specifier, context);
55
+ // Ink's line-update gets a fast path too (see line-update-shim.ts).
56
+ if (specifier === "./line-update.js" && /[\\/]ink[\\/]build[\\/]log-update\.js$/.test(context.parentURL ?? "")) {
57
+ return { ...resolved, url: `${lineShim}?of=${encodeURIComponent(resolved.url)}` };
58
+ }
54
59
  if (specifier !== "string-width" || context.parentURL?.startsWith(shim))
55
60
  return resolved;
56
61
  // Each copy of string-width (Ink may have its own) gets its own wrapper.
@@ -1,8 +1,7 @@
1
1
  /**
2
- * Keys named the way the platform names them. On a Mac, Find is Cmd+F: terminals that report the Cmd key
3
- * (kitty's keyboard protocol, which Nou asks for: Ghostty, kitty, WezTerm, iTerm2) send it as "super". Ctrl+F
4
- * works everywhere too, for terminals that keep Cmd for themselves.
2
+ * Find is shown as Ctrl+F everywhere (it works in every terminal). On a Mac, Cmd+F works too where the
3
+ * terminal reports Cmd (the kitty keyboard protocol, which Nou asks for: Ghostty, kitty, WezTerm, iTerm2).
5
4
  */
6
- export const FIND_KEY = process.platform === "darwin" ? "Cmd+F" : "Ctrl+F";
5
+ export const FIND_KEY = "Ctrl+F";
7
6
  /** Ctrl+F, or Cmd+F where the terminal reports Cmd. */
8
7
  export const isFind = (input, key) => input === "f" && (!!key.ctrl || !!key.super);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Stands in for Ink's line-update (see fast-width.ts), which works out how much of a changed line can stay
3
+ * on screen. For any line with a character outside plain ASCII (every line with a box border, so nearly all
4
+ * of Nou's) it splits both versions into graphemes, every frame. Lines made only of one-column characters
5
+ * that never combine (fast-width's NARROW) have one grapheme per character, so this answers those directly,
6
+ * with exactly what Ink would, and hands every other line to Ink's own code.
7
+ */
8
+ import { NARROW } from "./fast-width.js";
9
+ // eslint-disable-next-line no-control-regex -- SGR starts with ESC
10
+ const SGR = /\u001b\[[\d;]*m/gu;
11
+ /** Ink's lineUpdate with the fast path in front. Same answers; exported for tests. */
12
+ export function fastLineUpdate(real) {
13
+ return (previous, next, columns) => {
14
+ if (columns === undefined || Number.isNaN(columns) || columns < 1)
15
+ return real(previous, next, columns);
16
+ let common = 0;
17
+ while (common < previous.length && common < next.length && previous[common] === next[common])
18
+ common++;
19
+ if (common === 0)
20
+ return real(previous, next, columns);
21
+ const previousText = previous.replaceAll(SGR, "");
22
+ const nextText = next.replaceAll(SGR, "");
23
+ // Controls are excluded by NARROW too (it starts at the space).
24
+ if (!NARROW.test(previousText) || !NARROW.test(nextText))
25
+ return real(previous, next, columns);
26
+ const codes = [...next.matchAll(SGR)];
27
+ let textLimit = common;
28
+ for (const match of codes) {
29
+ if (match.index >= common)
30
+ break;
31
+ textLimit -= Math.min(match[0].length, common - match.index);
32
+ }
33
+ // One character a grapheme and a column, so this is the grapheme walk Ink does.
34
+ let textLength = 0;
35
+ while (textLength < textLimit && previousText[textLength] === nextText[textLength])
36
+ textLength++;
37
+ const column = textLength;
38
+ const full = `\u001b[1G\u001b[K${next}`;
39
+ if (column === 0 || column >= columns || previousText.length > columns || nextText.length > columns)
40
+ return full;
41
+ let offset = textLength;
42
+ let styles = "";
43
+ for (const match of codes) {
44
+ if (match.index > offset || match.index + match[0].length > common)
45
+ break;
46
+ offset += match[0].length;
47
+ styles += match[0];
48
+ }
49
+ const partial = `\u001b[${column + 1}G\u001b[K${styles}${next.slice(offset)}`;
50
+ return partial.length < full.length ? partial : full;
51
+ };
52
+ }
53
+ const of = new URL(import.meta.url).searchParams.get("of");
54
+ const real = of ? (await import(of)).default : null;
55
+ export default real ? fastLineUpdate(real) : null;
@@ -1,6 +1,33 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { useState, useSyncExternalStore } from "react";
3
4
  import { C } from "./theme.js";
5
+ const wheels = new Map();
6
+ const wheelListeners = new Map();
7
+ /**
8
+ * Hands a pane its wheel total. Only that pane draws again, not the whole app: scrolling is the most
9
+ * frequent thing there is, and every pane redrawing for it was most of its cost.
10
+ */
11
+ export function sendWheel(pane, lines, columns) {
12
+ wheels.set(pane, { lines, columns, nonce: (wheels.get(pane)?.nonce ?? 0) + 1 });
13
+ for (const fn of wheelListeners.get(pane) ?? [])
14
+ fn();
15
+ }
16
+ /** The latest wheel total for `pane` (see sendWheel) since this pane appeared; null before that. */
17
+ export function usePaneWheel(pane) {
18
+ // A pane that opens later (the editor, say) mustn't replay a wheel meant for what was there before.
19
+ const [since] = useState(() => (pane ? wheels.get(pane)?.nonce ?? 0 : 0));
20
+ const wheel = useSyncExternalStore((fn) => {
21
+ if (!pane)
22
+ return () => { };
23
+ let set = wheelListeners.get(pane);
24
+ if (!set)
25
+ wheelListeners.set(pane, (set = new Set()));
26
+ set.add(fn);
27
+ return () => set.delete(fn);
28
+ }, () => (pane ? wheels.get(pane) ?? null : null));
29
+ return wheel && wheel.nonce > since ? wheel : null;
30
+ }
4
31
  /** Lines a single wheel notch moves. */
5
32
  export const WHEEL_STEP = 3;
6
33
  /** Where to scroll to for a scrollbar click at `fraction` of the track. */
@@ -274,7 +274,7 @@ export function renderMarkdown(text, width, isFile) {
274
274
  const item = raw.match(/^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?(.*)/);
275
275
  if (item) {
276
276
  const depth = Math.floor(item[1].length / 2) * 2;
277
- const task = item[3] ? (/x/i.test(item[3]) ? "☑ " : "☐ ") : "";
277
+ const task = item[3] ? (/x/i.test(item[3]) ? "■ " : "□ ") : "";
278
278
  const bullet = `${" ".repeat(depth)}${/\d/.test(item[2]) ? item[2] : "•"} ${task}`;
279
279
  for (const line of wrapSpans([{ text: bullet }, ...spansOf(item[4])], width, bullet.length))
280
280
  out.push({ kind: "text", spans: line });
@@ -79,7 +79,7 @@ const ICONS = Object.fromEntries(Object.entries(iconColors()).map(([ext, color])
79
79
  export function fileIcon(file) {
80
80
  const base = path.basename(file).toLowerCase();
81
81
  if (base.startsWith(".env"))
82
- return { icon: "⚿", color: C.yellow };
82
+ return { icon: "⌁", color: C.yellow };
83
83
  if (base.startsWith(".git"))
84
84
  return { icon: "◌", color: C.orange };
85
85
  if (base === "package.json")
@@ -51,6 +51,8 @@ export function connectToHost(on) {
51
51
  on.redraw();
52
52
  else if (msg.type === "error")
53
53
  on.error(msg.text);
54
+ else if (msg.type === "quit")
55
+ on.quit();
54
56
  }));
55
57
  s.on("error", () => { });
56
58
  s.on("close", () => (socket = null));
@@ -68,6 +68,17 @@ export async function runHost(folders, firstArgs, command = [process.execPath, p
68
68
  if (to && to !== active)
69
69
  activate(to);
70
70
  }
71
+ else if (msg.type === "close") {
72
+ const w = workspaces[msg.index];
73
+ if (!w)
74
+ return;
75
+ // Asked to quit, it saves and exits as when you quit it; one that doesn't is stopped.
76
+ if (w.socket)
77
+ send(w, { type: "quit" });
78
+ else
79
+ w.pty.kill();
80
+ setTimeout(() => workspaces.includes(w) && w.pty.kill(), 3000);
81
+ }
71
82
  else if (msg.type === "new") {
72
83
  const folder = workspaceFolder(msg.folder, from.folder);
73
84
  if (!folder)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
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": {