nou-cli 0.5.10 → 0.5.11

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
@@ -52,7 +52,7 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
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
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
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`. |
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. 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. |
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. |
58
58
  | 🧠 **A capable engine** | Reads and searches in parallel, numbered reads for exact line references, edits that survive small whitespace mismatches, several edits to a file as one change, a plan for longer tasks, full command output (stderr too) with exit codes and timeouts, retries when a provider is overloaded or a connection drops (Codex too: it picks the turn back up instead of stopping at "Reconnecting..."), and conversation caching with Claude. It follows your repo's `AGENTS.md`, `CLAUDE.md` and `NOU.md` (and `~/.nou/NOU.md`). |
@@ -94,8 +94,9 @@ 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>+</kbd> / <kbd>-</kbd> (in the code) or `/zoom in` / `/zoom out` | make the code panel bigger or smaller, a step at a time: the files and chat give or take the room; <kbd>=</kbd> or `/zoom reset` puts it back (for bigger text, use your terminal's zoom, Cmd/Ctrl and +) |
97
98
  | <kbd>b</kbd> | back to the previous file (press again to flip back) |
98
- | <kbd>Ctrl</kbd>+<kbd>F</kbd> | find in the open file: matches are highlighted and counted; <kbd>Enter</kbd>/<kbd>↓</kbd> next, <kbd>↑</kbd> previous, <kbd>Esc</kbd> close |
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 |
99
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 |
100
101
  | <kbd>Ctrl</kbd>+click | on a name in the code (or <kbd>Option</kbd>+click, or right-click): go to its definition; an imported name goes to its import, and the import's path opens that file |
101
102
  | <kbd>e</kbd> or a double-click | edit the open file (see the editor keys below) |
@@ -130,7 +131,7 @@ In the editor:
130
131
  | <kbd>Ctrl</kbd>+<kbd>K</kbd> · <kbd>Ctrl</kbd>+<kbd>G</kbd> | hover info · go to definition (with a language server) |
131
132
  | <kbd>Esc</kbd> | clear the selection, or leave the editor |
132
133
 
133
- 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` · `/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]` · `/now` · `/showme <question>` · `/teach <change>` · `/help`
134
135
 
135
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.
136
137
 
@@ -30,6 +30,7 @@ import { ENABLE as MOUSE_ON, useMouse } from "./mouse.js";
30
30
  import { connectToHost, isHosted, reopenAsWorkspaces, sendToHost, useWorkspaceTabs, workspaceTabs } from "../workspaces/client.js";
31
31
  import { PTY_PACKAGE, workspaceFolder } from "../workspaces/host.js";
32
32
  import { C, palette } from "./theme.js";
33
+ import { FIND_KEY, isFind } from "./keys.js";
33
34
  import { FileTree } from "./FileTree.js";
34
35
  import { CodeView } from "./CodeView.js";
35
36
  import { Editor } from "./Editor.js";
@@ -83,7 +84,7 @@ Keys:
83
84
  m mouse on/off (off: select text to copy; Shift+drag also works in most terminals)
84
85
  / start a command from any pane
85
86
  e edit open file (or double-click in the code) v diff (git / Nou / session) f follow mode
86
- Ctrl+F find in the open file (Enter/↓ next, ↑ previous)
87
+ ${FIND_KEY} find in the open file (Enter/↓ next, ↑ previous)
87
88
  b back to the previous file (Ctrl+P lists recent files first) [ ] previous/next tab · x close tab
88
89
  Ctrl+click, Option+click or right-click a name: go to its definition (an import's path opens the file)
89
90
  p problems u undo Nou's edit s settings
@@ -92,7 +93,7 @@ Mouse: wheel scrolls (Shift+wheel sideways) · click/drag a scrollbar · double-
92
93
  In the editor:
93
94
  Shift+arrows / mouse drag select · Ctrl+A all · Ctrl+C/X/V copy/cut/paste · Esc clear selection or leave
94
95
  Ctrl+←/→ word · Ctrl+Backspace or Ctrl+W delete word · Ctrl+Home/End file start/end
95
- Ctrl+F find · Ctrl+L go to line · Ctrl+/ comment · Ctrl+D duplicate line · Alt+↑/↓ move line
96
+ ${FIND_KEY} find · Ctrl+L go to line · Ctrl+/ comment · Ctrl+D duplicate line · Alt+↑/↓ move line
96
97
  Tab/Shift+Tab indent/outdent · Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
97
98
  /** A callback that keeps one identity across renders but always runs the latest code, so memoized panes don't re-render for it. */
98
99
  function useEvent(fn) {
@@ -158,6 +159,14 @@ export function App({ initialModel, permContext, resume }) {
158
159
  */
159
160
  const [tour, setTour] = useState(null);
160
161
  const tourFound = useRef(null);
162
+ /** The files on /showme's tour and which of its places are in each, for the file tree. */
163
+ const tourFiles = useMemo(() => {
164
+ if (!tour)
165
+ return null;
166
+ const map = new Map();
167
+ tour.stops.forEach((s, i) => map.set(s.file, [...(map.get(s.file) ?? []), i + 1]));
168
+ return map;
169
+ }, [tour]);
161
170
  const shellRef = useRef(null);
162
171
  const beforeTerm = useRef("code");
163
172
  const [follow, setFollow] = useState(settings.followMode);
@@ -255,7 +264,7 @@ export function App({ initialModel, permContext, resume }) {
255
264
  const [findRequest, setFindRequest] = useState(0);
256
265
  const startFind = () => {
257
266
  if (!openFile)
258
- return info("Open a file first (Ctrl+P), then Ctrl+F searches it.");
267
+ return info(`Open a file first (Ctrl+P), then ${FIND_KEY} searches it.`);
259
268
  setDiffMode("off");
260
269
  setEditing(false);
261
270
  setCodePanel("code");
@@ -270,6 +279,12 @@ export function App({ initialModel, permContext, resume }) {
270
279
  const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0, heights: { tree: 0, code: 0, chat: 0 }, game: null, terminal: null });
271
280
  /** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
272
281
  const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
282
+ /**
283
+ * How far the code panel is zoomed (+ and - in the code, /zoom in|out): each step makes it a few columns
284
+ * wider (taken from the files and chat) or narrower (given to them). 0 is the normal layout.
285
+ */
286
+ const [codeZoom, setCodeZoom] = useState(0);
287
+ const zoomCode = (by) => setCodeZoom((z) => (by === 0 ? 0 : Math.max(-6, Math.min(8, z + by))));
273
288
  const paneWidthsRef = useRef(paneWidths);
274
289
  paneWidthsRef.current = paneWidths;
275
290
  /** What a mouse drag is moving: a pane border, a pane's scrollbar, or the editor's selection. */
@@ -1479,6 +1494,16 @@ export function App({ initialModel, permContext, resume }) {
1479
1494
  return; // so does the find bar
1480
1495
  if (focus === "game" || focus === "terminal")
1481
1496
  return; // and the Games panel and the terminal
1497
+ // Cmd+F (Mac) or Ctrl+F from the files or the chat: find in the open file (the code view takes it itself
1498
+ // when it has the keyboard). Other Cmd+letters aren't Nou's: a plain letter is, so Cmd+F never also
1499
+ // toggles follow mode (f).
1500
+ if (isFind(input, key)) {
1501
+ if (focus !== "code")
1502
+ startFind();
1503
+ return;
1504
+ }
1505
+ if (key.super)
1506
+ return;
1482
1507
  if (key.tab && key.shift)
1483
1508
  return cycleAutoMode();
1484
1509
  if (key.tab && focus === "chat" && chatMenuOpen.current)
@@ -1526,9 +1551,13 @@ export function App({ initialModel, permContext, resume }) {
1526
1551
  updateSettings({ paneWidths: moveBorder(focus === "tree" ? "tree" : "chat", (focus === "tree" ? treeWidth - 1 : treeWidth + codeWidth) + step) });
1527
1552
  }
1528
1553
  else if (input === "=") {
1554
+ setCodeZoom(0);
1529
1555
  setPaneWidths(null);
1530
1556
  updateSettings({ paneWidths: null });
1531
1557
  }
1558
+ else if ((input === "+" || input === "-") && focus === "code") {
1559
+ zoomCode(input === "+" ? 1 : -1);
1560
+ }
1532
1561
  else if (input === "v")
1533
1562
  cycleDiff();
1534
1563
  else if ((input === "]" || input === "[") && diffMode === "session")
@@ -1619,7 +1648,7 @@ ${snippet}
1619
1648
  if (action)
1620
1649
  return info("Usage: /mcp, /mcp add <name> <command|url>, /mcp add browser, /mcp remove <name>, /mcp import, /mcp reconnect", "error");
1621
1650
  const list = mcpRef.current.status();
1622
- const mark = { ready: "●", connecting: "◌", failed: "✖", off: "○" };
1651
+ const mark = { ready: "●", connecting: "◌", failed: "✗", off: "○" };
1623
1652
  const lines = list.map((x) => ` ${mark[x.state]} ${x.name.padEnd(16)} ${x.state === "ready" ? `${x.tools} tools` : x.state}${x.scope === "project" ? " · this project" : ""}${x.error ? ` · ${x.error}` : ""}`);
1624
1653
  const others = findOtherMcpServers().filter((f) => !list.some((x) => x.name === f.name));
1625
1654
  return info([
@@ -1656,7 +1685,7 @@ ${snippet}
1656
1685
  const mark = { on: "●", off: "○", changed: "⚠" };
1657
1686
  const lines = allPlugins.map((p) => p.scope === "starter"
1658
1687
  ? ` + ${p.id.padEnd(16)} ${p.plugin?.description ?? ""}`
1659
- : ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✖ ${p.error}` : ""}`);
1688
+ : ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✗ ${p.error}` : ""}`);
1660
1689
  return info(`Plugins (● on, ○ off, + starter you can add with /plugins add <name>):\n${lines.join("\n")}\nWrite your own: /plugins new <name>`);
1661
1690
  };
1662
1691
  const runCommand = async (input) => {
@@ -1819,6 +1848,14 @@ ${snippet}
1819
1848
  return toggleTerminal();
1820
1849
  case "redraw":
1821
1850
  return redraw();
1851
+ case "zoom":
1852
+ if (/^(in|\+|bigger)$/.test(arg))
1853
+ return zoomCode(1);
1854
+ if (/^(out|-|smaller)$/.test(arg))
1855
+ return zoomCode(-1);
1856
+ if (/^(reset|0|off)$/.test(arg))
1857
+ return zoomCode(0);
1858
+ 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 +).");
1822
1859
  case "workspace":
1823
1860
  case "workspaces":
1824
1861
  case "ws":
@@ -1973,7 +2010,7 @@ ${snippet}
1973
2010
  if (!stops.length)
1974
2011
  return;
1975
2012
  const list = stops.map((s, i) => `${i + 1}. ${s.file}:${s.line}${s.end > s.line ? `-${s.end}` : ""}${s.note ? ` · ${s.note}` : ""}`).join("\n");
1976
- 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" : ""}):\n${list}`);
2013
+ 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}`);
1977
2014
  setTour({ stops, at: 0 });
1978
2015
  }, [busy]);
1979
2016
  // Each place: open it in the code view with its lines highlighted.
@@ -1986,9 +2023,12 @@ ${snippet}
1986
2023
  /** /showme <question>: explains it and opens the places in the code; /showme next|back|stop steps through them. */
1987
2024
  const showPlaces = (arg) => {
1988
2025
  const words = arg.trim();
1989
- if (/^(stop|end|done)$/.test(words)) {
2026
+ if (/^(stop|end|done|clear)$/.test(words)) {
2027
+ // Done looking: the highlighted lines go back to plain code.
1990
2028
  setTour(null);
1991
- return info(tour ? "Done showing." : "Nothing is being shown.");
2029
+ const highlighted = !!focusLine?.to;
2030
+ setFocusLine((f) => (f?.to !== undefined ? null : f));
2031
+ return info(tour || highlighted ? "Done showing: the highlights are cleared." : "Nothing is being shown.");
1992
2032
  }
1993
2033
  if (/^(next|back|prev|previous)$/.test(words)) {
1994
2034
  if (!tour)
@@ -2239,8 +2279,16 @@ ${snippet}
2239
2279
  } }));
2240
2280
  }
2241
2281
  const height = Math.max(10, rows - 2);
2242
- const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
2243
- const chatWidth = paneWidths ? Math.max(MIN_CHAT, Math.min(Math.round(columns * paneWidths.chat), columns - treeWidth - MIN_CODE)) : Math.max(34, Math.floor(columns * 0.3));
2282
+ const baseTree = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
2283
+ const baseChat = paneWidths ? Math.max(MIN_CHAT, Math.min(Math.round(columns * paneWidths.chat), columns - baseTree - MIN_CODE)) : Math.max(34, Math.floor(columns * 0.3));
2284
+ // Zooming the code panel: each step moves a few columns between it and the files and chat, as far as their
2285
+ // minimums (zooming in) or its own (zooming out) allow.
2286
+ const zoomCols = codeZoom * Math.max(4, Math.round(columns * 0.06));
2287
+ const spare = { tree: baseTree - MIN_TREE, chat: baseChat - MIN_CHAT };
2288
+ const taken = zoomCols > 0 ? Math.min(zoomCols, spare.tree + spare.chat) : -Math.min(-zoomCols, Math.max(0, columns - baseTree - baseChat - MIN_CODE));
2289
+ const fromTree = zoomCols > 0 ? Math.round(taken * (spare.tree / Math.max(1, spare.tree + spare.chat))) : Math.round(taken / 2);
2290
+ const treeWidth = baseTree - fromTree;
2291
+ const chatWidth = baseChat - (taken - fromTree);
2244
2292
  const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
2245
2293
  const tabKey = tabs.length && openFile ? `${tabs.join("\0")}|${openFile}|${tabsWidth(codeWidth)}|${showEditor && !!editorState?.dirty}` : "";
2246
2294
  if (tabStripCache.current?.key !== tabKey) {
@@ -2376,7 +2424,7 @@ ${snippet}
2376
2424
  // Everything Nou can do, for the command palette (? or Ctrl+P then >), with the key that does it.
2377
2425
  const paletteActions = [
2378
2426
  { title: "Find a file", keys: "Ctrl+P", run: () => setQuickOpen("") },
2379
- { title: "Find in the open file", keys: "Ctrl+F", run: startFind },
2427
+ { title: "Find in the open file", keys: FIND_KEY, run: startFind },
2380
2428
  { title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
2381
2429
  { title: "Go back to the previous file", keys: "b", run: goBack },
2382
2430
  { title: "Cancel your last message (edit it and resend)", keys: "/cancel", run: cancelLast },
@@ -2442,7 +2490,7 @@ ${snippet}
2442
2490
  : showEditor && focus === "code"
2443
2491
  ? editorState?.selection
2444
2492
  ? [["Ctrl+C", "copy"], ["Ctrl+X", "cut"], ["Tab", "indent"], ["Ctrl+/", "comment"], ["Esc", "deselect"]]
2445
- : [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Shift+→", "select"], ["Ctrl+F", "find"], ["Ctrl+/", "comment"], ["/help", "all keys"]]
2493
+ : [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Shift+→", "select"], [FIND_KEY, "find"], ["Ctrl+/", "comment"], ["/help", "all keys"]]
2446
2494
  : focus === "chat"
2447
2495
  ? chatMenu ? [["↑↓", "choose"], ["Tab", "complete"], ["Enter", "run"], ["Esc", "close"]]
2448
2496
  : busy
@@ -2452,9 +2500,9 @@ ${snippet}
2452
2500
  ? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
2453
2501
  : diff
2454
2502
  ? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
2455
- : [["double-click/e", "edit"], ["Ctrl+F", "find"], ["v", "diff"], ...(recent.length > 1 ? [["b", "back"]] : []), ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
2503
+ : [["double-click/e", "edit"], [FIND_KEY, "find"], ["v", "diff"], ...(recent.length > 1 ? [["b", "back"]] : []), ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
2456
2504
  const followChip = follow ? ` ◉ follow${followLag ? ` ·${followLag}` : ""} ` : " ○ follow ";
2457
- const statusUsed = mode.label.length + 2 + autoMode.length + 2 + followChip.length + ` ✖ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
2505
+ const statusUsed = mode.label.length + 2 + autoMode.length + 2 + followChip.length + ` ✗ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
2458
2506
  const shownHints = [];
2459
2507
  for (let used = statusUsed, i = 0; i < hints.length; i++) {
2460
2508
  used += hints[i][0].length + hints[i][1].length + 4;
@@ -2467,7 +2515,7 @@ ${snippet}
2467
2515
  y: () => answer(true), n: approval ? () => answer(false) : undefined, o: openPending,
2468
2516
  "Shift+Tab": () => cycleAutoMode(), "/": () => typeCommand("/"), "Ctrl+P": () => setQuickOpen(""),
2469
2517
  Tab: () => setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]),
2470
- e: startEditing, "double-click/e": startEditing, b: goBack, "Ctrl+F": showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
2518
+ e: startEditing, "double-click/e": startEditing, b: goBack, [FIND_KEY]: showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
2471
2519
  s: () => setView("settings"), "/help": () => void runCommand("/help"),
2472
2520
  Esc: busy ? stop : diff && !showEditor ? () => setDiffMode("off") : undefined,
2473
2521
  };
@@ -2481,7 +2529,7 @@ ${snippet}
2481
2529
  };
2482
2530
  add(` ${autoMode} `.length, () => cycleAutoMode());
2483
2531
  add(followChip.length, () => setFollow((f) => !f));
2484
- add(` ✖ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
2532
+ add(` ✗ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
2485
2533
  if (checking.length)
2486
2534
  add(3 + checking.join(", ").length);
2487
2535
  if (!exitArmed) {
@@ -2492,8 +2540,8 @@ ${snippet}
2492
2540
  }
2493
2541
  }
2494
2542
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
2495
- clickable.current = { status: zones, modelFrom: columns - (" ⌂ LOCAL ".length + ` ${modelLabel} `.length + 1) };
2496
- 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, 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, { ...(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
2543
+ clickable.current = { status: zones, modelFrom: columns - (" LOCAL ".length + ` ${modelLabel} `.length + 1) };
2544
+ 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
2497
2545
  ? _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)` : ""] })
2498
2546
  : 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) => {
2499
2547
  setQuickOpen(null);
@@ -63,7 +63,7 @@ function entryLines(entry, inner, links = true) {
63
63
  else {
64
64
  const style = entry.kind === "tool"
65
65
  ? { icon: "⚙", color: C.dim }
66
- : entry.kind === "error" ? { icon: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
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 } : {}) });
@@ -5,6 +5,7 @@ import { SEVERITY_STYLE } from "./ProblemsView.js";
5
5
  import { artWork } from "../util/index.js";
6
6
  import { foldRanges, highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines, unfoldedLines } from "./text.js";
7
7
  import { C, fileIcon, gradient } from "./theme.js";
8
+ import { isFind } from "./keys.js";
8
9
  import { Pane } from "./Pane.js";
9
10
  import { Scrollbar, topFor } from "./scroll.js";
10
11
  /** The current match: a yellow background; the others are shown inverted. */
@@ -12,7 +13,7 @@ const CURRENT_MATCH = ["\x1b[43m\x1b[30m", "\x1b[49m\x1b[39m"];
12
13
  /** Columns ←/→ and Shift+wheel move sideways. */
13
14
  const SIDE_STEP = 8;
14
15
  const EDIT_HINT = " double-click or e to edit · v diff";
15
- export const CodeView = memo(function CodeView({ 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, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
16
17
  const [top, setTop] = useState(0);
17
18
  // Columns scrolled sideways (colors are kept: see sliceAnsi).
18
19
  const [left, setLeft] = useState(0);
@@ -165,7 +166,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
165
166
  return setFind((f) => f && { ...f, index: f.index - 1 });
166
167
  if (key.backspace || key.delete)
167
168
  return setFind((f) => f && { query: f.query.slice(0, -1), index: 0 });
168
- if (key.ctrl && input === "f")
169
+ if (isFind(input, key))
169
170
  return;
170
171
  if (input && !key.ctrl && !key.meta && input >= " ")
171
172
  setFind((f) => f && { query: f.query + input, index: 0 });
@@ -173,7 +174,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
173
174
  useInput((input, key) => {
174
175
  if (find)
175
176
  return;
176
- if (key.ctrl && input === "f" && !diff)
177
+ if (isFind(input, key) && !diff)
177
178
  return setFind({ query: "", index: 0 });
178
179
  if (key.upArrow || input === "k")
179
180
  setTop((t) => Math.max(0, Math.min(t, maxTop) - 1));
@@ -238,8 +239,8 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
238
239
  const warningCount = problems.filter((d) => d.severity === "warning").length;
239
240
  // The edit hint only when it fits: a cut-off hint reads as noise.
240
241
  const titleRoom = tabs ? 0 : width - 4 - (3 + filePath.length + (errorCount ? 5 + String(errorCount).length : 0) + (warningCount ? 5 + String(warningCount).length : 0) + scroll.length + (total > rows ? 7 : 0));
241
- const title = (_jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath] }), errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused && titleRoom >= EDIT_HINT.length ? EDIT_HINT : ""] })] }));
242
- return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: shown.slice(start, start + rows).map((n) => {
242
+ const title = (_jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath] }), showing && _jsxs(_Fragment, { children: [" ", _jsx(Text, { backgroundColor: C.accent2, color: C.onBadge, bold: true, children: ` ◉ SHOWING ${showing} ` })] }), errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2717 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused && titleRoom >= EDIT_HINT.length ? EDIT_HINT : ""] })] }));
243
+ return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, ...(showing ? { accent: C.accent2 } : {}), children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: shown.slice(start, start + rows).map((n) => {
243
244
  const line = lines[n - 1] ?? "";
244
245
  const fold = ranges.get(n);
245
246
  const isFolded = fold !== undefined && folded.has(n);
@@ -4,6 +4,7 @@ import path from "path";
4
4
  import { Box, Text, useInput, usePaste } from "ink";
5
5
  import { highlightLines, sliceAnsi } from "./text.js";
6
6
  import { C, fileIcon } from "./theme.js";
7
+ import { FIND_KEY, isFind } from "./keys.js";
7
8
  import { SEVERITY_STYLE } from "./ProblemsView.js";
8
9
  import { Pane } from "./Pane.js";
9
10
  import { Scrollbar, topFor } from "./scroll.js";
@@ -401,7 +402,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
401
402
  return find(prompt.text, key.upArrow);
402
403
  if (key.backspace || key.delete)
403
404
  return setPrompt({ ...prompt, text: prompt.text.slice(0, -1) });
404
- if (key.ctrl && input === "f")
405
+ if (isFind(input, key))
405
406
  return find(prompt.text);
406
407
  if (input && !key.ctrl && !key.meta && (prompt.kind === "find" || /^\d+$/.test(input))) {
407
408
  const text = prompt.text + input;
@@ -433,7 +434,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
433
434
  return step(undo, redo);
434
435
  if (key.ctrl && input === "y")
435
436
  return step(redo, undo);
436
- if (key.ctrl && input === "f") {
437
+ if (isFind(input, key)) {
437
438
  const word = selectedText(buf);
438
439
  return setPrompt({ kind: "find", text: word.includes("\n") ? "" : word });
439
440
  }
@@ -631,7 +632,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
631
632
  ? prompt.kind === "find" ? `Find: ${prompt.text}▏ Enter/↓ next · Shift+Enter/↑ previous · Esc close` : `Go to line: ${prompt.text}▏ Enter go · Esc cancel`
632
633
  : flash ?? (cursorProblem
633
634
  ? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
634
- : `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""} Ctrl+F find · Ctrl+/ comment · Esc done`);
635
+ : `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""} ${FIND_KEY} find · Ctrl+/ comment · Esc done`);
635
636
  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;
636
637
  const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
637
638
  const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
@@ -32,7 +32,7 @@ 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, 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, wheel, scrollTo, click, onOpen, onHover }) {
36
36
  const [expanded, setExpanded] = useState(() => new Set(["src"]));
37
37
  const [cursor, setCursor] = useState(0);
38
38
  // The first row shown. The wheel moves it on its own; the keyboard moves the cursor and the view follows.
@@ -182,11 +182,12 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
182
182
  ? { icon: expanded.has(node.path) ? "▾" : "▸", color: C.accent }
183
183
  : fileIcon(node.path);
184
184
  const counts = problemCounts.get(node.path);
185
- const problemMark = counts?.errors ? "✖" : counts?.warnings ? "▲" : "";
186
- const marker = problemMark || code || (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
187
- const markerColor = counts?.errors ? C.red : counts?.warnings ? C.yellow : code ? GIT_COLORS[code] ?? C.yellow : changed.has(node.path) ? C.green : C.yellow;
185
+ const problemMark = counts?.errors ? "✗" : counts?.warnings ? "▲" : "";
186
+ const stops = showing?.get(node.path);
187
+ const marker = stops ? `◉${stops.join(",")}` : problemMark || code || (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
188
+ const markerColor = stops ? C.accent2 : counts?.errors ? C.red : counts?.warnings ? C.yellow : code ? GIT_COLORS[code] ?? C.yellow : changed.has(node.path) ? C.green : C.yellow;
188
189
  const label = `${" ".repeat(node.depth)}${icon} ${node.name}`;
189
- const nameColor = isOpen ? C.cyan : counts?.errors ? C.red : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
190
+ const nameColor = stops ? C.accent2 : isOpen ? C.cyan : counts?.errors ? C.red : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
190
191
  const room = Math.max(1, inner - marker.length - 1);
191
192
  const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
192
193
  const iconAt = shown.indexOf(icon);
@@ -29,12 +29,23 @@ export function headerZones(tabs) {
29
29
  zones.push({ from: x, to: x + 3, tab: "new" });
30
30
  return zones;
31
31
  }
32
+ /**
33
+ * The header's right side: the update notice (when there's room), where the model runs and which. Plain
34
+ * characters only: symbols that are also emoji (☁, ⬆) are drawn two columns wide by some terminals.
35
+ */
36
+ const rightSide = (width, model, local, update) => ({
37
+ notice: update && width >= 110 ? `↑ v${update} available · run \`nou update\` ` : "",
38
+ badge: ` ${local ? "LOCAL" : "CLOUD"} `,
39
+ model: ` ${model} `,
40
+ });
32
41
  export const Header = memo(function Header({ width, branch, changes, model, local, busy, update, tabs = [] }) {
33
42
  // The spinner stops while Nou's window is in the background: nobody sees it, and each frame is a redraw.
34
43
  const windowFocused = useTerminalFocused();
35
44
  const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
36
45
  const project = path.basename(process.cwd());
37
- return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, flexGrow: 1, 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
46
+ const right = rightSide(width, model, local, update);
47
+ const rightWidth = right.notice.length + right.badge.length + right.model.length + 1;
48
+ 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
38
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)))
39
- : _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: [update && width >= 110 && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: C.onBadge, bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] }) })] }));
50
+ : _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 }), " "] }) })] }));
40
51
  });
@@ -5,7 +5,7 @@ import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
5
5
  import { useTerminalFocused } from "./focus.js";
6
6
  import { Pane } from "./Pane.js";
7
7
  export const SEVERITY_STYLE = {
8
- error: { icon: "✖", color: C.red },
8
+ error: { icon: "✗", color: C.red },
9
9
  warning: { icon: "▲", color: C.yellow },
10
10
  info: { icon: "ℹ", color: C.accent },
11
11
  };
@@ -63,13 +63,13 @@ export function ProblemsView({ width, height, focused, problems, checking, wheel
63
63
  const errors = problems.filter((d) => d.severity === "error").length;
64
64
  const warnings = problems.filter((d) => d.severity === "warning").length;
65
65
  const start = Math.max(0, Math.min(cursor - Math.floor(visibleRows / 2), rows.length - visibleRows));
66
- const title = (_jsxs(_Fragment, { children: [" ", _jsxs(Text, { color: C.red, children: ["\u2716 ", errors] }), " ", _jsxs(Text, { color: C.yellow, children: ["\u25B2 ", warnings] }), " Problems", checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " checking ", checking.join(", ")] }), _jsx(Text, { color: C.dim, children: onlyErrors ? " (errors only)" : "" })] }));
66
+ const title = (_jsxs(_Fragment, { children: [" ", _jsxs(Text, { color: C.red, children: ["\u2717 ", errors] }), " ", _jsxs(Text, { color: C.yellow, children: ["\u25B2 ", warnings] }), " Problems", checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " checking ", checking.join(", ")] }), _jsx(Text, { color: C.dim, children: onlyErrors ? " (errors only)" : "" })] }));
67
67
  return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, ...(focused ? { accent: C.red } : {}), children: [_jsxs(Box, { flexDirection: "column", height: visibleRows, children: [rows.length === 0 && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.green, children: checking.length ? "Checking…" : "✓ No problems found" }), _jsx(Text, { color: C.dim, children: "Checked by the syntax parser, plus ESLint, TypeScript, Ruff and language servers when your project has them." })] })), rows.slice(start, start + visibleRows).map((row, i) => {
68
68
  const selected = focused && start + i === cursor;
69
69
  const bg = selected ? { backgroundColor: C.panel } : {};
70
70
  if (row.kind === "file") {
71
71
  const { icon, color } = fileIcon(row.file);
72
- return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [_jsx(Text, { color: color, children: icon }), " ", _jsx(Text, { bold: true, color: C.text, children: row.file }), row.errors > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", row.errors] }), row.warnings > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", row.warnings] })] }, `f${row.file}`));
72
+ return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [_jsx(Text, { color: color, children: icon }), " ", _jsx(Text, { bold: true, color: C.text, children: row.file }), row.errors > 0 && _jsxs(Text, { color: C.red, children: [" \u2717 ", row.errors] }), row.warnings > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", row.warnings] })] }, `f${row.file}`));
73
73
  }
74
74
  const style = SEVERITY_STYLE[row.d.severity];
75
75
  return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [" ", _jsx(Text, { color: style.color, children: style.icon }), _jsxs(Text, { color: C.dim, children: [" ", String(row.d.line).padStart(4), ":", String(row.d.col).padEnd(3), " "] }), _jsx(Text, { ...(selected ? { color: C.text } : {}), children: row.d.message }), _jsxs(Text, { color: C.dim, children: [" ", row.d.source, row.d.code ? ` ${row.d.code}` : ""] })] }, `${row.d.file}:${row.d.line}:${row.d.col}:${i}`));
@@ -213,7 +213,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
213
213
  const selected = i === row;
214
214
  const current = p.id === choice.provider.id;
215
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));
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: ["\u2716 ", reason] }) : null, connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
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);
219
219
  if (i < connecting.step) {
@@ -286,7 +286,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
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
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." })
288
288
  : p.error
289
- ? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \u2716 ", p.error] })
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));
291
291
  }), _jsx(Text, { children: " " }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Starters: Enter adds one to ", PLUGIN_DIRS.find((d) => d.scope === "user").dir, ", where you can edit it."] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Write your own: /plugins new <name>, or drop a .js/.mjs file into ", PLUGIN_DIRS.map((d) => d.dir).join(" or "), ", then press r."] })] }));
292
292
  };
@@ -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(["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(["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
@@ -39,7 +39,7 @@ function editDistance(a, b) {
39
39
  export function runsWhileBusy(text) {
40
40
  const [name, ...rest] = text.slice(1).trim().split(/\s+/);
41
41
  if (name === "showme" || name === "teach" || name === "teachme")
42
- return !rest.length || /^(next|skip|back|prev|previous|stop|end|done)$/.test(rest.join(" "));
42
+ return !rest.length || /^(next|skip|back|prev|previous|stop|end|done|clear)$/.test(rest.join(" "));
43
43
  if (!text.startsWith("/") || !name)
44
44
  return false;
45
45
  return INSTANT.has(name) || INSTANT.has(closestCommand(name, SLASH_COMMANDS.map((c) => c.name)) ?? "");
@@ -53,6 +53,7 @@ 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: "zoom", args: "in|out|reset", description: "make the code panel bigger or smaller (+ and - with the code focused, = resets)" },
56
57
  { name: "now", description: "stop Nou and send your queued messages right away (or click them)" },
57
58
  { name: "settings", description: "models, engine, preferences and plugins" },
58
59
  { name: "auto", args: "[ask|auto-edit|auto]", description: "how much Nou does without asking" },
@@ -0,0 +1,8 @@
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.
5
+ */
6
+ export const FIND_KEY = process.platform === "darwin" ? "Cmd+F" : "Ctrl+F";
7
+ /** Ctrl+F, or Cmd+F where the terminal reports Cmd. */
8
+ export const isFind = (input, key) => input === "f" && (!!key.ctrl || !!key.super);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.10",
3
+ "version": "0.5.11",
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": {