nou-cli 0.5.10 → 0.5.12

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>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
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,11 +131,11 @@ 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
 
137
- New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol), or type `\` and press <kbd>Enter</kbd> in any terminal.
138
+ New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol, which Nou checks for before it starts; also xterm's modifyOtherKeys form), or type `\` and press <kbd>Enter</kbd> in any terminal.
138
139
 
139
140
  Conversations are saved per folder after each reply. `/resume` opens a picker (↑↓ and Enter, type to filter, or type its number) and `/resume 2` (or `nou --continue` for the last one, `nou --resume` for the list) picks one back up with the engine it was on: Nou's own engine restores its messages, Claude Code its session and Codex its thread; pi is handed the conversation so far. Sessions you started in Claude Code's or Codex's own CLI in this folder are listed too (marked "CLI"), and resuming one carries it on in that engine inside Nou.
140
141
 
package/dist/src/cli.js CHANGED
@@ -15,7 +15,7 @@ import { App } from "./ui/App.js";
15
15
  import { installFastOutput } from "./ui/fast-output.js";
16
16
  import { RENDER_OPTIONS } from "./ui/render-options.js";
17
17
  import { setScreenClear } from "./ui/redraw.js";
18
- import { detectBackground } from "./ui/background.js";
18
+ import { detectTerminal } from "./ui/background.js";
19
19
  import { setPalette } from "./ui/theme.js";
20
20
  import { runHost, workspaceFolder } from "./workspaces/host.js";
21
21
  import { pendingWorkspaces } from "./workspaces/client.js";
@@ -96,7 +96,11 @@ async function main() {
96
96
  console.error(`Not a folder, so not opened: ${missing.join(", ")}`);
97
97
  const uiTheme = loadSettings().uiTheme;
98
98
  // Every workspace uses the colors found for this terminal (they can't ask it themselves from the background).
99
- process.env.NOU_PALETTE = uiTheme === "dark" || uiTheme === "light" ? uiTheme : (await detectBackground()) ?? "dark";
99
+ // The host asks the terminal once and tells every workspace (in the background they can't ask it).
100
+ const term = await detectTerminal();
101
+ process.env.NOU_PALETTE = uiTheme === "dark" || uiTheme === "light" ? uiTheme : term.palette ?? "dark";
102
+ if (term.kitty !== null)
103
+ process.env.NOU_KITTY = term.kitty ? "1" : "0";
100
104
  const firstArgs = args.includes("--continue") || args.includes("-c") ? ["--continue"] : [];
101
105
  process.exit(await runHost(folders.length ? folders : [process.cwd()], firstArgs));
102
106
  }
@@ -163,7 +167,13 @@ async function main() {
163
167
  // Colors that are readable on this terminal: a dark or a light set, from the setting or the background.
164
168
  const uiTheme = loadSettings().uiTheme;
165
169
  const fromHost = process.env.NOU_PALETTE;
166
- setPalette(uiTheme === "dark" || uiTheme === "light" ? uiTheme : fromHost === "light" || fromHost === "dark" ? fromHost : (await detectBackground()) ?? "dark");
170
+ const hostKnows = fromHost === "light" || fromHost === "dark";
171
+ const term = hostKnows ? { palette: fromHost, kitty: process.env.NOU_KITTY ? process.env.NOU_KITTY === "1" : null } : await detectTerminal();
172
+ setPalette(uiTheme === "dark" || uiTheme === "light" ? uiTheme : term.palette ?? "dark");
173
+ // Turn the kitty keyboard protocol on when the terminal said it speaks it (Shift/Ctrl/Cmd+Enter, Cmd+F and
174
+ // Ctrl+= need it), off when it answered without it; ask the way Ink does only if it answered nothing.
175
+ if (term.kitty !== null && RENDER_OPTIONS.kittyKeyboard)
176
+ RENDER_OPTIONS.kittyKeyboard = { ...RENDER_OPTIONS.kittyKeyboard, mode: term.kitty ? "enabled" : "disabled" };
167
177
  await installFastOutput();
168
178
  // While Nou is on screen, anything a library prints (console.log, warnings, stderr) goes to a log file:
169
179
  // written into the terminal, it would scroll the screen out from under the mouse.
@@ -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. */
@@ -1469,6 +1484,12 @@ export function App({ initialModel, permContext, resume }) {
1469
1484
  if (focus !== "chat" && input === "o" && pendingChange)
1470
1485
  return openPending();
1471
1486
  }
1487
+ // Zoom the code panel from anywhere: Ctrl+= / Ctrl+- / Ctrl+0, in terminals that report Ctrl with those
1488
+ // keys (the kitty keyboard protocol Nou asks for). Elsewhere Ctrl+- is the same key as Ctrl+/, so it's
1489
+ // left alone; + and - in the code or the files work in every terminal.
1490
+ if (key.ctrl && (input === "=" || input === "+" || input === "-" || input === "0") && !key.meta) {
1491
+ return zoomCode(input === "0" ? 0 : input === "-" ? -1 : 1);
1492
+ }
1472
1493
  if (key.ctrl && input === "p") {
1473
1494
  setQuickOpen("");
1474
1495
  return;
@@ -1479,6 +1500,16 @@ export function App({ initialModel, permContext, resume }) {
1479
1500
  return; // so does the find bar
1480
1501
  if (focus === "game" || focus === "terminal")
1481
1502
  return; // and the Games panel and the terminal
1503
+ // Cmd+F (Mac) or Ctrl+F from the files or the chat: find in the open file (the code view takes it itself
1504
+ // when it has the keyboard). Other Cmd+letters aren't Nou's: a plain letter is, so Cmd+F never also
1505
+ // toggles follow mode (f).
1506
+ if (isFind(input, key)) {
1507
+ if (focus !== "code")
1508
+ startFind();
1509
+ return;
1510
+ }
1511
+ if (key.super)
1512
+ return;
1482
1513
  if (key.tab && key.shift)
1483
1514
  return cycleAutoMode();
1484
1515
  if (key.tab && focus === "chat" && chatMenuOpen.current)
@@ -1526,9 +1557,13 @@ export function App({ initialModel, permContext, resume }) {
1526
1557
  updateSettings({ paneWidths: moveBorder(focus === "tree" ? "tree" : "chat", (focus === "tree" ? treeWidth - 1 : treeWidth + codeWidth) + step) });
1527
1558
  }
1528
1559
  else if (input === "=") {
1560
+ setCodeZoom(0);
1529
1561
  setPaneWidths(null);
1530
1562
  updateSettings({ paneWidths: null });
1531
1563
  }
1564
+ else if ((input === "+" || input === "-") && (focus === "code" || focus === "tree")) {
1565
+ zoomCode(input === "+" ? 1 : -1);
1566
+ }
1532
1567
  else if (input === "v")
1533
1568
  cycleDiff();
1534
1569
  else if ((input === "]" || input === "[") && diffMode === "session")
@@ -1619,7 +1654,7 @@ ${snippet}
1619
1654
  if (action)
1620
1655
  return info("Usage: /mcp, /mcp add <name> <command|url>, /mcp add browser, /mcp remove <name>, /mcp import, /mcp reconnect", "error");
1621
1656
  const list = mcpRef.current.status();
1622
- const mark = { ready: "●", connecting: "◌", failed: "✖", off: "○" };
1657
+ const mark = { ready: "●", connecting: "◌", failed: "✗", off: "○" };
1623
1658
  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
1659
  const others = findOtherMcpServers().filter((f) => !list.some((x) => x.name === f.name));
1625
1660
  return info([
@@ -1656,7 +1691,7 @@ ${snippet}
1656
1691
  const mark = { on: "●", off: "○", changed: "⚠" };
1657
1692
  const lines = allPlugins.map((p) => p.scope === "starter"
1658
1693
  ? ` + ${p.id.padEnd(16)} ${p.plugin?.description ?? ""}`
1659
- : ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✖ ${p.error}` : ""}`);
1694
+ : ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✗ ${p.error}` : ""}`);
1660
1695
  return info(`Plugins (● on, ○ off, + starter you can add with /plugins add <name>):\n${lines.join("\n")}\nWrite your own: /plugins new <name>`);
1661
1696
  };
1662
1697
  const runCommand = async (input) => {
@@ -1819,6 +1854,14 @@ ${snippet}
1819
1854
  return toggleTerminal();
1820
1855
  case "redraw":
1821
1856
  return redraw();
1857
+ case "zoom":
1858
+ if (/^(in|\+|bigger)$/.test(arg))
1859
+ return zoomCode(1);
1860
+ if (/^(out|-|smaller)$/.test(arg))
1861
+ return zoomCode(-1);
1862
+ if (/^(reset|0|off)$/.test(arg))
1863
+ return zoomCode(0);
1864
+ 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
1865
  case "workspace":
1823
1866
  case "workspaces":
1824
1867
  case "ws":
@@ -1973,7 +2016,7 @@ ${snippet}
1973
2016
  if (!stops.length)
1974
2017
  return;
1975
2018
  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}`);
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}`);
1977
2020
  setTour({ stops, at: 0 });
1978
2021
  }, [busy]);
1979
2022
  // Each place: open it in the code view with its lines highlighted.
@@ -1986,9 +2029,12 @@ ${snippet}
1986
2029
  /** /showme <question>: explains it and opens the places in the code; /showme next|back|stop steps through them. */
1987
2030
  const showPlaces = (arg) => {
1988
2031
  const words = arg.trim();
1989
- if (/^(stop|end|done)$/.test(words)) {
2032
+ if (/^(stop|end|done|clear)$/.test(words)) {
2033
+ // Done looking: the highlighted lines go back to plain code.
1990
2034
  setTour(null);
1991
- return info(tour ? "Done showing." : "Nothing is being shown.");
2035
+ const highlighted = !!focusLine?.to;
2036
+ setFocusLine((f) => (f?.to !== undefined ? null : f));
2037
+ return info(tour || highlighted ? "Done showing: the highlights are cleared." : "Nothing is being shown.");
1992
2038
  }
1993
2039
  if (/^(next|back|prev|previous)$/.test(words)) {
1994
2040
  if (!tour)
@@ -2239,8 +2285,16 @@ ${snippet}
2239
2285
  } }));
2240
2286
  }
2241
2287
  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));
2288
+ const baseTree = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
2289
+ 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));
2290
+ // Zooming the code panel: each step moves a few columns between it and the files and chat, as far as their
2291
+ // minimums (zooming in) or its own (zooming out) allow.
2292
+ const zoomCols = codeZoom * Math.max(4, Math.round(columns * 0.06));
2293
+ const spare = { tree: baseTree - MIN_TREE, chat: baseChat - MIN_CHAT };
2294
+ const taken = zoomCols > 0 ? Math.min(zoomCols, spare.tree + spare.chat) : -Math.min(-zoomCols, Math.max(0, columns - baseTree - baseChat - MIN_CODE));
2295
+ const fromTree = zoomCols > 0 ? Math.round(taken * (spare.tree / Math.max(1, spare.tree + spare.chat))) : Math.round(taken / 2);
2296
+ const treeWidth = baseTree - fromTree;
2297
+ const chatWidth = baseChat - (taken - fromTree);
2244
2298
  const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
2245
2299
  const tabKey = tabs.length && openFile ? `${tabs.join("\0")}|${openFile}|${tabsWidth(codeWidth)}|${showEditor && !!editorState?.dirty}` : "";
2246
2300
  if (tabStripCache.current?.key !== tabKey) {
@@ -2376,7 +2430,7 @@ ${snippet}
2376
2430
  // Everything Nou can do, for the command palette (? or Ctrl+P then >), with the key that does it.
2377
2431
  const paletteActions = [
2378
2432
  { title: "Find a file", keys: "Ctrl+P", run: () => setQuickOpen("") },
2379
- { title: "Find in the open file", keys: "Ctrl+F", run: startFind },
2433
+ { title: "Find in the open file", keys: FIND_KEY, run: startFind },
2380
2434
  { title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
2381
2435
  { title: "Go back to the previous file", keys: "b", run: goBack },
2382
2436
  { title: "Cancel your last message (edit it and resend)", keys: "/cancel", run: cancelLast },
@@ -2442,7 +2496,7 @@ ${snippet}
2442
2496
  : showEditor && focus === "code"
2443
2497
  ? editorState?.selection
2444
2498
  ? [["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"]]
2499
+ : [["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
2500
  : focus === "chat"
2447
2501
  ? chatMenu ? [["↑↓", "choose"], ["Tab", "complete"], ["Enter", "run"], ["Esc", "close"]]
2448
2502
  : busy
@@ -2452,9 +2506,9 @@ ${snippet}
2452
2506
  ? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
2453
2507
  : diff
2454
2508
  ? [["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"]];
2509
+ : [["double-click/e", "edit"], [FIND_KEY, "find"], ["v", "diff"], ...(recent.length > 1 ? [["b", "back"]] : []), ["u", "undo"], ["/", "command"], ["p", "problems"], ["+ -", "zoom"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
2456
2510
  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;
2511
+ 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
2512
  const shownHints = [];
2459
2513
  for (let used = statusUsed, i = 0; i < hints.length; i++) {
2460
2514
  used += hints[i][0].length + hints[i][1].length + 4;
@@ -2467,7 +2521,7 @@ ${snippet}
2467
2521
  y: () => answer(true), n: approval ? () => answer(false) : undefined, o: openPending,
2468
2522
  "Shift+Tab": () => cycleAutoMode(), "/": () => typeCommand("/"), "Ctrl+P": () => setQuickOpen(""),
2469
2523
  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,
2524
+ "+ -": () => zoomCode(1), e: startEditing, "double-click/e": startEditing, b: goBack, [FIND_KEY]: showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
2471
2525
  s: () => setView("settings"), "/help": () => void runCommand("/help"),
2472
2526
  Esc: busy ? stop : diff && !showEditor ? () => setDiffMode("off") : undefined,
2473
2527
  };
@@ -2481,7 +2535,7 @@ ${snippet}
2481
2535
  };
2482
2536
  add(` ${autoMode} `.length, () => cycleAutoMode());
2483
2537
  add(followChip.length, () => setFollow((f) => !f));
2484
- add(` ✖ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
2538
+ add(` ✗ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
2485
2539
  if (checking.length)
2486
2540
  add(3 + checking.join(", ").length);
2487
2541
  if (!exitArmed) {
@@ -2492,8 +2546,8 @@ ${snippet}
2492
2546
  }
2493
2547
  }
2494
2548
  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
2549
+ 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
2497
2551
  ? _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
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) => {
2499
2553
  setQuickOpen(null);
@@ -9,7 +9,7 @@ import { copyToClipboard } from "./clipboard.js";
9
9
  import { C, SPINNER, ANIMATION_MS } from "./theme.js";
10
10
  import { useTerminalFocused } from "./focus.js";
11
11
  import { Pane } from "./Pane.js";
12
- import { matchCommands } from "./commands.js";
12
+ import { SLASH_COMMANDS, matchCommands } from "./commands.js";
13
13
  import { Scrollbar, topFor } from "./scroll.js";
14
14
  /**
15
15
  * What the agent did between replies (files read, commands run...) folds into one line, like Claude's
@@ -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 } : {}) });
@@ -203,6 +203,14 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
203
203
  const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
204
204
  const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
205
205
  const showMenu = matches.length > 0;
206
+ // A known command typed at the start of the input ("/zoom in") is drawn in the command color.
207
+ const commandLength = useMemo(() => {
208
+ const name = /^\/(\S+)/.exec(input)?.[1];
209
+ if (!name)
210
+ return 0;
211
+ const known = SLASH_COMMANDS.some((c) => c.name === name) || !!extraCommands?.some((c) => c.name === name);
212
+ return known ? name.length + 1 : 0;
213
+ }, [input, extraCommands]);
206
214
  useEffect(() => onMenu?.(showMenu), [showMenu]);
207
215
  const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
208
216
  const menuStart = Math.max(0, Math.min(menuRow - menuRows + 1, matches.length - menuRows));
@@ -408,6 +416,11 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
408
416
  }) })] })) })), [lines, firstShown, visible.length, selection, inner, empty]);
409
417
  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: messages }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), showMenu ? (_jsx(Box, { flexDirection: "column", children: matches.slice(menuStart, menuStart + menuRows).map((c, i) => {
410
418
  const selected = menuStart + i === menuRow;
411
- return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsxs(Text, { color: selected ? C.accent : C.text, bold: selected, children: ["/", c.name] }), c.args && inner >= 56 ? _jsxs(Text, { color: C.dim, children: [" ", c.args] }) : null /* narrow: the description matters more */, _jsxs(Text, { color: C.dim, children: [" ", c.description] })] }, c.name));
412
- }) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " })), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? (sendNowFirst ? "Enter sends now · Alt+Enter queues · Esc stops" : "Enter queues · Alt+Enter sends now · Esc stops") : sendNowFirst ? "Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
419
+ // What's typed so far shows in the command color; the rest of the name follows. A plugin's
420
+ // command says so, so Nou's own stand apart.
421
+ const typed = input.slice(1).toLowerCase();
422
+ const typedLen = typed && c.name.startsWith(typed) ? typed.length : 0;
423
+ const fromPlugin = !!extraCommands?.some((e) => e.name === c.name);
424
+ return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsx(Text, { color: C.accent2, bold: true, children: "/" }), _jsx(Text, { color: C.yellow, bold: true, children: c.name.slice(0, typedLen) }), _jsx(Text, { color: C.accent2, bold: selected, children: c.name.slice(typedLen) }), fromPlugin && _jsx(Text, { color: C.cyan, children: " plugin" }), c.args && inner >= 56 ? _jsxs(Text, { color: C.dim, children: [" ", c.args] }) : null /* narrow: the description matters more */, _jsxs(Text, { color: C.dim, children: [" ", c.description] })] }, c.name));
425
+ }) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " })), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, command: commandLength, placeholder: busy ? (inner >= 44 ? (sendNowFirst ? "Enter sends now · Alt+Enter queues · Esc stops" : "Enter queues · Alt+Enter sends now · Esc stops") : sendNowFirst ? "Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
413
426
  });
@@ -1,6 +1,6 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
- import { Box, Text, useInput, usePaste } from "ink";
3
+ import { Box, Text, useInput, usePaste, useStdin } from "ink";
4
4
  import { C } from "./theme.js";
5
5
  /** Word-wraps `text` into segments of at most `width` characters, honoring newlines. */
6
6
  export function layout(text, width) {
@@ -35,7 +35,7 @@ function cursorLine(segments, cursor) {
35
35
  }
36
36
  return segments.length - 1;
37
37
  }
38
- export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6, arrowsTaken = false }) {
38
+ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6, arrowsTaken = false, command = 0 }) {
39
39
  const [cursor, setCursor] = useState(value.length);
40
40
  const [recall, setRecall] = useState(null);
41
41
  const stash = useRef("");
@@ -57,6 +57,24 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
57
57
  setCursor(Math.max(0, Math.min(next.length, nextCursor)));
58
58
  };
59
59
  const insert = (text) => set(value.slice(0, pos) + text + value.slice(pos), pos + text.length);
60
+ // Shift/Ctrl/Cmd+Enter as xterm's modifyOtherKeys sends them (ESC [27;<mods>;13~, from terminals set up that
61
+ // way): Ink drops these, so they're caught here and add a line, like the kitty protocol's form does.
62
+ const { stdin } = useStdin();
63
+ const insertLine = useRef(() => { });
64
+ insertLine.current = () => insert("\n");
65
+ useEffect(() => {
66
+ if (!focus || !stdin)
67
+ return;
68
+ const onData = (data) => {
69
+ // eslint-disable-next-line no-control-regex -- the sequence starts with ESC
70
+ if (/\x1b\[27;([2-9]|1[0-6]);13~/.test(String(data)))
71
+ insertLine.current();
72
+ };
73
+ stdin.on("data", onData);
74
+ return () => {
75
+ stdin.off("data", onData);
76
+ };
77
+ }, [focus, stdin]);
60
78
  const recallHistory = (step) => {
61
79
  if (!history.length)
62
80
  return false;
@@ -131,11 +149,16 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
131
149
  return (_jsx(Box, { flexDirection: "column", children: shown.map((s, i) => {
132
150
  const text = value.slice(s.start, s.end).replace(/\n/g, "");
133
151
  const isCursor = focus && first + i === line;
152
+ // A known command at the start ("/zoom in") in the command color, the rest as typed.
153
+ const paint = (from, to) => {
154
+ const cmdEnd = Math.max(from, Math.min(to, command - s.start));
155
+ return (_jsxs(_Fragment, { children: [cmdEnd > from && _jsx(Text, { color: C.accent2, bold: true, children: text.slice(from, cmdEnd) }), _jsx(Text, { color: C.text, children: text.slice(cmdEnd, to) })] }));
156
+ };
134
157
  if (!isCursor) {
135
- return _jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text || " " })] }, first + i);
158
+ return _jsxs(Text, { children: [prompt(i), text ? paint(0, text.length) : " "] }, first + i);
136
159
  }
137
160
  const at = pos - s.start;
138
- return (_jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text.slice(0, at) }), _jsx(Text, { inverse: true, children: text[at] ?? " " }), _jsx(Text, { color: C.text, children: text.slice(at + 1) })] }, first + i));
161
+ return (_jsxs(Text, { children: [prompt(i), paint(0, at), _jsx(Text, { inverse: true, children: text[at] ?? " " }), paint(at + 1, text.length)] }, first + i));
139
162
  }) }));
140
163
  }
141
164
  /** How many rows the input will take, so the chat can size its history above it. */
@@ -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
  };
@@ -1,42 +1,51 @@
1
1
  /**
2
- * Whether the terminal's background is light or dark, so Nou can pick colors that are readable on it.
3
- *
4
- * COLORFGBG (set by rxvt, Konsole and others: "foreground;background" as color numbers) answers at once.
5
- * Otherwise Nou asks the terminal for its background color (OSC 11, answered by iTerm2, Terminal.app,
6
- * kitty, Ghostty, WezTerm, Alacritty, foot, GNOME's and VS Code's terminals and recent Windows Terminal).
7
- * A terminal that doesn't answer within `timeoutMs` gets null, and Nou keeps its dark colors.
2
+ * Asks the terminal, once, before Nou draws anything:
3
+ * - its background color (OSC 11), so the colors are readable on it (COLORFGBG answers that without asking);
4
+ * - whether it speaks the kitty keyboard protocol (CSI ? u), which tells Shift/Ctrl/Cmd+Enter, Cmd+F and
5
+ * Ctrl+= apart from plain keys. Ink can ask too, but only after the first frame is drawn, and with a big
6
+ * project the answer lands after its 200 ms timeout, so the protocol never came on (Shift+Enter sent).
7
+ * Then DA1 (CSI c), which every terminal answers, and in order: once its answer is in, so are the others.
8
8
  */
9
- export async function detectBackground(timeoutMs = 150) {
9
+ export async function detectTerminal(timeoutMs = 400) {
10
10
  const fromEnv = paletteFromColorFgBg(process.env.COLORFGBG);
11
- if (fromEnv)
12
- return fromEnv;
13
11
  const stdin = process.stdin;
14
12
  if (!stdin.isTTY || !process.stdout.isTTY || typeof stdin.setRawMode !== "function")
15
- return null;
13
+ return { palette: fromEnv, kitty: null };
16
14
  return new Promise((resolve) => {
17
15
  let reply = "";
18
16
  const wasRaw = stdin.isRaw;
19
- const finish = (result) => {
17
+ const finish = () => {
20
18
  clearTimeout(timer);
21
19
  stdin.off("data", onData);
22
20
  stdin.setRawMode(wasRaw);
23
21
  stdin.pause();
24
- resolve(result);
22
+ resolve(readTerminalReply(reply, fromEnv));
25
23
  };
26
24
  const onData = (data) => {
27
25
  reply += data.toString("latin1");
28
- const palette = paletteFromOsc11(reply);
29
- if (palette)
30
- finish(palette);
26
+ if (DA1.test(reply))
27
+ finish();
31
28
  };
32
- const timer = setTimeout(() => finish(null), timeoutMs);
29
+ const timer = setTimeout(finish, timeoutMs);
33
30
  stdin.setRawMode(true);
34
31
  stdin.resume();
35
32
  stdin.on("data", onData);
36
- // ST-terminated (ESC \): the form most terminals answer in kind.
37
- process.stdout.write("\x1b]11;?\x1b\\");
33
+ // OSC 11 is ST-terminated (ESC \): the form most terminals answer in kind.
34
+ process.stdout.write(`${fromEnv ? "" : "\x1b]11;?\x1b\\"}\x1b[?u\x1b[c`);
38
35
  });
39
36
  }
37
+ // eslint-disable-next-line no-control-regex -- terminal answers start with ESC
38
+ const KITTY = /\x1b\[\?\d+u/;
39
+ // eslint-disable-next-line no-control-regex -- terminal answers start with ESC
40
+ const DA1 = /\x1b\[\?[\d;]*c/;
41
+ /** What the terminal's answers say (see detectTerminal); `fromEnv` is the background COLORFGBG gave, if any. */
42
+ export function readTerminalReply(reply, fromEnv = null) {
43
+ return { palette: fromEnv ?? paletteFromOsc11(reply), kitty: KITTY.test(reply) ? true : DA1.test(reply) ? false : null };
44
+ }
45
+ /** Just the background (see detectTerminal). */
46
+ export async function detectBackground(timeoutMs = 400) {
47
+ return (await detectTerminal(timeoutMs)).palette;
48
+ }
40
49
  /** "15;0" (light text on black) → dark; "0;15" → light. The last number is the background's color index. */
41
50
  export function paletteFromColorFgBg(value) {
42
51
  const bg = Number(value?.split(";").at(-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(["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.12",
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": {