nou-cli 0.5.11 → 0.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,8 +50,8 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
50
50
  | 🔀 **Live diffs** | Every edit Nou proposes shows up side by side, and the files it is editing shimmer in the file tree while it works. <kbd>y</kbd> accepts, <kbd>n</kbd> rejects. Nothing touches disk without you. |
51
51
  | ✍️ **Built-in editor** | Press <kbd>e</kbd> to edit the open file yourself, with undo/redo and **auto-save**. |
52
52
  | 📍 **Shows you where things are** | Ask "where is the LiveKit integration?" and Nou finds it and opens it in the middle panel, jumped to the exact lines and highlighted. Any file a reply mentions (`src/a.ts:12`) is a link: click it. `/open name[:line]` opens a file yourself. |
53
- | 🪟 **Workspaces** | Several projects in one window, a tab each in the header: click **+** (or `/workspace new ../api`) and the other folder opens beside this one with its own files, chats and agents. Click a tab (or `/workspace 2`) to switch; the ones in the background keep working, and a tab shows ● while its agent is busy. Quitting one (Ctrl+C twice) closes its tab. The first time, Nou installs a small pseudo-terminal package ([@lydell/node-pty](https://www.npmjs.com/package/@lydell/node-pty), prebuilt for macOS, Linux and Windows) and reopens as a workspaces window, with your conversation in the first tab. Or start that way: `nou --workspaces . ../api`. |
54
- | 🗂️ **Several agents at once** | `/new` (or **+** in the chat's title) opens another conversation tab while the first keeps working. Each tab has its own agent and history; a working tab shows ●, one waiting for your OK shows !, and a click (or `/tab 2`) switches. `/close` closes one; it stays in `/resume`. |
53
+ | 🪟 **Workspaces** | Several projects in one window, a tab each in the header: click **+** and pick a folder (or `/workspace new ../api`): it opens beside this one with its own files, chats and agents. Click a tab (or `/workspace 2`) to switch; the ones in the background keep working, and a tab shows ● while its agent is busy. A tab's **×** closes it (so does quitting it, Ctrl+C twice). The first time, Nou installs a small pseudo-terminal package ([@lydell/node-pty](https://www.npmjs.com/package/@lydell/node-pty), prebuilt for macOS, Linux and Windows) and reopens as a workspaces window, with your conversation in the first tab. Or start that way: `nou --workspaces . ../api`. |
54
+ | 🗂️ **Several agents at once** | `/new` (or **+** in the chat's title) opens another conversation tab while the first keeps working. Each tab has its own agent and history; a working tab shows ●, one waiting for your OK shows !, and a click (or `/tab 2`) switches. A tab's **×** (or `/close`) closes it, even one in the background; it stays in `/resume`. |
55
55
  | 👀 **Show me** | Ask how something works or where it happens (`/showme where do we check the login?`, or just ask) and Nou explains it with the code open: each place it talks about opens in the code view, highlighted, and they're listed in the chat to click. `/showme next` and `/showme back` step through them, and `/showme stop` clears the highlights. While it shows, the code view and the file tree take its purple (◉ SHOWING 2/6, and each file's places), as they take green while you edit. Works with every engine: Nou's points at places with a tool, and Claude Code, Codex and pi are asked to list them at the end of their reply, which Nou reads and hides. |
56
56
  | 🧑‍🏫 **Teach me** | `/teach add a --verbose flag` and Nou finds where the change goes, then walks you through typing it yourself: each step opens the file at the right line, says why, and shows the code to type dim ahead of your cursor, like autocomplete (<kbd>Tab</kbd> fills in a line). It moves on when what you typed matches, and saves as it goes. `/teach next` skips a step, `/teach stop` ends it. Works with every engine (Nou's, Claude Code, Codex and pi): any edit one makes anyway is put back. |
57
57
  | 🎮 **Games for a break** | Add Pong, Snake, Breakout (it gets faster each wall you clear), Dodge, Hop and Pairs in settings → Games, then `/games` opens a panel docked under the chat: it splits that column instead of covering anything. Drag its title onto the files, code or chat to dock it there, and × at its top right closes it. Drag its top edge to make it taller or shorter. A game runs only while the panel has the keyboard: Esc goes back to your code and it waits. Your best score in each is kept, and a panel too small for a game says how much room it needs. |
@@ -69,7 +69,7 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
69
69
  | 🎚️ **Speed vs smarts** | Pick how hard the model thinks in plain words: **Fastest** and **Fast** (cheaper + faster), **Balanced** (the default), **Smarter** and **Smartest** (thinks longer, costs more). In <kbd>s</kbd> settings, or `/effort smarter`. |
70
70
  | 🛞 **Pick your agent** | Nou's own, or Claude Code, Codex or pi, switchable like models, with the same diffs, approvals and undo. |
71
71
  | 🧩 **Plugins** | Add your own tools and slash commands with a single file. |
72
- | 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> fuzzy-finds any file. |
72
+ | 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> (or `/files`) fuzzy-finds any file; <kbd>?</kbd> (or `/actions`) lists every action. |
73
73
  | 🔋 **Easy on your battery** | Idle, Nou uses next to no CPU. When its window is in the background (in terminals that report focus: iTerm2, Terminal.app, kitty, Ghostty, WezTerm, Windows Terminal, VS Code), spinners and git polling pause until you come back, and TypeScript checks are incremental. |
74
74
  | 👓 **Easy to read** | Hints and secondary text keep at least 4.5:1 contrast. Nou asks your terminal whether its background is light or dark and picks matching colors (code switches to GitHub Light on a light background); set it yourself in <kbd>s</kbd> settings → **Nou's colors**. |
75
75
 
@@ -94,7 +94,7 @@ Every change is reviewed like a code review:
94
94
  | <kbd>Tab</kbd> | switch between files, code and chat |
95
95
  | <kbd>Shift</kbd>+<kbd>Tab</kbd> | ask → auto-edit (edits apply without asking) → auto (commands too); everything stays undoable |
96
96
  | <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file; the files you've opened are listed first |
97
- | <kbd>+</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
+ | <kbd>Ctrl</kbd>+<kbd>=</kbd> / <kbd>Ctrl</kbd>+<kbd>-</kbd> from anywhere (or <kbd>+</kbd> / <kbd>-</kbd> in the code or files) | widen or narrow the code panel, a step at a time: the files and chat give or take the room (a terminal can't change one pane's text size); <kbd>Ctrl</kbd>+<kbd>0</kbd> or <kbd>=</kbd> puts it back. Ctrl+= and Ctrl+- need a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, iTerm2). For bigger text, use your terminal's own zoom |
98
98
  | <kbd>b</kbd> | back to the previous file (press again to flip back) |
99
99
  | <kbd>Ctrl</kbd>+<kbd>F</kbd> (<kbd>Cmd</kbd>+<kbd>F</kbd> on a Mac, in terminals that report Cmd: Ghostty, kitty, WezTerm, iTerm2) | find in the open file, from any pane: matches are highlighted and counted; <kbd>Enter</kbd>/<kbd>↓</kbd> next, <kbd>↑</kbd> previous, <kbd>Esc</kbd> close |
100
100
  | 🗂️ Tabs | every file you open gets a tab over the code: click one to switch, any tab's × to close it, ‹ › for ones that don't fit · <kbd>[</kbd>/<kbd>]</kbd> previous/next · <kbd>x</kbd> or <kbd>Ctrl</kbd>+<kbd>W</kbd> close |
@@ -131,11 +131,11 @@ In the editor:
131
131
  | <kbd>Ctrl</kbd>+<kbd>K</kbd> · <kbd>Ctrl</kbd>+<kbd>G</kbd> | hover info · go to definition (with a language server) |
132
132
  | <kbd>Esc</kbd> | clear the selection, or leave the editor |
133
133
 
134
- Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/effort [fastest|fast|balanced|smarter|smartest]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/resume [n|last]` · `/terminal` · `/zoom` · `/workspace [new <folder> | n]` · `/now` · `/showme <question>` · `/teach <change>` · `/help`
134
+ Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/effort [fastest|fast|balanced|smarter|smartest]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/resume [n|last]` · `/terminal` · `/zoom` · `/workspace [new <folder> | n]` · `/files` · `/actions` · `/now` · `/showme <question>` · `/teach <change>` · `/help`
135
135
 
136
136
  A small typo in a command still works when it only changes the screen (`/termnial` opens the terminal); for others Nou asks "Did you mean...?" instead of guessing.
137
137
 
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), 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.
139
139
 
140
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.
141
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.
@@ -221,7 +221,7 @@ export const breakout = {
221
221
  return {
222
222
  lines: grid.map((r) => r.join("")),
223
223
  score: s.score,
224
- status: `level ${s.level} · score ${s.score} · lives ${"♥".repeat(Math.max(0, s.lives))}`,
224
+ status: `level ${s.level} · score ${s.score} · lives ${"◆".repeat(Math.max(0, s.lives))}`,
225
225
  ...(s.lives <= 0 ? { over: `Game over: ${s.score} points, level ${s.level}.` } : {}),
226
226
  };
227
227
  },
@@ -230,13 +230,13 @@ export const breakout = {
230
230
  export const dodge = {
231
231
  id: "dodge",
232
232
  title: "Dodge",
233
- blurb: "Rocks fall faster and thicker the longer you last. Catch ★ for points.",
233
+ blurb: "Rocks fall faster and thicker the longer you last. Catch ✦ for points.",
234
234
  controls: "←→ or a/d move",
235
235
  tickMs: 90,
236
236
  minWidth: 12,
237
237
  minHeight: 6,
238
238
  speed: (s) => Math.max(45, 90 - Math.floor(s.ticks / 60) * 5),
239
- colors: { "▲": "#61afef", "●": "#d19a66", "★": "#e5c07b" },
239
+ colors: { "▲": "#61afef", "●": "#d19a66", "✦": "#e5c07b" },
240
240
  create(w, h, seed = Date.now()) {
241
241
  [w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
242
242
  return { w, h, x: Math.floor(w / 2), rocks: [], stars: [], score: 0, ticks: 0, dead: false, random: rng(seed) };
@@ -271,7 +271,7 @@ export const dodge = {
271
271
  for (const r of s.rocks)
272
272
  put(grid, r.x, r.y, "●");
273
273
  for (const r of s.stars)
274
- put(grid, r.x, r.y, "★");
274
+ put(grid, r.x, r.y, "✦");
275
275
  put(grid, s.x, s.h - 1, "▲");
276
276
  return { lines: grid.map((r) => r.join("")), score: s.score, status: `score ${s.score}`, ...(s.dead ? { over: `Hit! ${s.score} points.` } : {}) };
277
277
  },
@@ -322,7 +322,7 @@ export const hop = {
322
322
  return { lines: grid.map((r) => r.join("")), score: s.score, status: `walls hopped ${s.score}`, ...(s.dead ? { over: `Bonk! ${s.score} hopped.` } : {}) };
323
323
  },
324
324
  };
325
- const SYMBOLS = ["◆", "●", "▲", "■", "★", "♥", "♣", "♠", "☀", "☂", "♪", "✿"];
325
+ const SYMBOLS = ["◆", "●", "▲", "■", "✦", "◈", "⊕", "⊗", "◎", "△", "○", "✧"];
326
326
  /** Cards face down: flip two at a time and find every matching pair in as few moves as you can. */
327
327
  export const pairs = {
328
328
  id: "pairs",
@@ -332,7 +332,7 @@ export const pairs = {
332
332
  tickMs: 100,
333
333
  minWidth: 16,
334
334
  minHeight: 5,
335
- colors: { "◆": "#e06c75", "●": "#e5c07b", "▲": "#61afef", "■": "#98c379", "★": "#e5c07b", "♥": "#e06c75", "♣": "#98c379", "♠": "#c678dd", "☀": "#d19a66", "☂": "#56b6c2", "♪": "#c678dd", "✿": "#56b6c2", "▒": "#5c6370", "▸": "#e5c07b", "◂": "#e5c07b" },
335
+ colors: { "◆": "#e06c75", "●": "#e5c07b", "▲": "#61afef", "■": "#98c379", "✦": "#e5c07b", "◈": "#e06c75", "⊕": "#98c379", "⊗": "#c678dd", "◎": "#d19a66", "△": "#56b6c2", "○": "#c678dd", "✧": "#56b6c2", "▒": "#5c6370", "▸": "#e5c07b", "◂": "#e5c07b" },
336
336
  create(w, h, seed = Date.now()) {
337
337
  [w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
338
338
  // As many cards as fit (each 4 wide, rows 2 apart), an even number, up to every symbol twice.
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
4
+ import os from "os";
4
5
  import path from "path";
5
6
  import { Box, Text, useApp, useInput, useWindowSize } from "ink";
6
7
  import { nouRulesOnly } from "../context/index.js";
@@ -35,7 +36,7 @@ import { FileTree } from "./FileTree.js";
35
36
  import { CodeView } from "./CodeView.js";
36
37
  import { Editor } from "./Editor.js";
37
38
  import { SLASH_COMMANDS, closestCommand, onlyChangesScreen, runsWhileBusy } from "./commands.js";
38
- import { WHEEL_STEP } from "./scroll.js";
39
+ import { WHEEL_STEP, sendWheel } from "./scroll.js";
39
40
  import { Chat, chatTabZones } from "./Chat.js";
40
41
  import { copyToClipboard, openInBrowser } from "./clipboard.js";
41
42
  import { Header, headerZones } from "./Header.js";
@@ -124,6 +125,8 @@ export function App({ initialModel, permContext, resume }) {
124
125
  const [quickOpen, setQuickOpen] = useState(null);
125
126
  /** /resume's picker: the conversations, in the palette's place while it's open. */
126
127
  const [resumePicker, setResumePicker] = useState(null);
128
+ /** What the open picker is for, said in its empty search box. */
129
+ const [pickerTitle, setPickerTitle] = useState("Resume a conversation");
127
130
  const [focus, setFocus] = useState("chat");
128
131
  /** The Games panel: whether it's open, the pane it's being dragged over, and the pane to go back to. */
129
132
  const [gamesOpen, setGamesOpen] = useState(false);
@@ -245,7 +248,6 @@ export function App({ initialModel, permContext, resume }) {
245
248
  const [pendingNote, setPendingNote] = useState(null);
246
249
  const [exitArmed, setExitArmed] = useState(false);
247
250
  const [update, setUpdate] = useState(null);
248
- const [wheel, setWheel] = useState(null);
249
251
  const [scrollTo, setScrollTo] = useState(null);
250
252
  const [editorMouse, setEditorMouse] = useState([]);
251
253
  /** Wheel events waiting for the next frame, added up per pane. */
@@ -284,6 +286,16 @@ export function App({ initialModel, permContext, resume }) {
284
286
  * wider (taken from the files and chat) or narrower (given to them). 0 is the normal layout.
285
287
  */
286
288
  const [codeZoom, setCodeZoom] = useState(0);
289
+ // A resized terminal is drawn again in full: drawing only the lines that changed, over a screen the
290
+ // terminal has rewrapped, can leave pieces of the old layout behind.
291
+ const firstSize = useRef(true);
292
+ useEffect(() => {
293
+ if (firstSize.current) {
294
+ firstSize.current = false;
295
+ return;
296
+ }
297
+ redraw();
298
+ }, [columns, rows]);
287
299
  const zoomCode = (by) => setCodeZoom((z) => (by === 0 ? 0 : Math.max(-6, Math.min(8, z + by))));
288
300
  const paneWidthsRef = useRef(paneWidths);
289
301
  paneWidthsRef.current = paneWidths;
@@ -462,10 +474,20 @@ export function App({ initialModel, permContext, resume }) {
462
474
  switchChatTab(id);
463
475
  }
464
476
  /** Closes the conversation tab on screen (stopping its engine); the conversation stays saved for /resume. */
465
- function closeChatTab() {
477
+ function closeChatTab(id = activeTab.current) {
466
478
  if (chatTabs.length < 2)
467
479
  return info("That's the only conversation tab. /clear starts a fresh conversation in it.");
468
- const id = activeTab.current;
480
+ if (id !== activeTab.current) {
481
+ // A tab in the background (its ×): stop its agent and drop it; the one on screen stays.
482
+ const t = tabStore.current.get(id);
483
+ t?.approval?.resolve(false);
484
+ t?.agent.cancel();
485
+ t?.agent.dispose?.();
486
+ tabStore.current.delete(id);
487
+ tabHooks.current.delete(id);
488
+ setChatTabs((tabs) => tabs.filter((x) => x !== id));
489
+ return;
490
+ }
469
491
  const at = chatTabs.indexOf(id);
470
492
  const next = chatTabs[at + 1] ?? chatTabs[at - 1];
471
493
  const agent = agentRef.current;
@@ -621,6 +643,7 @@ export function App({ initialModel, permContext, resume }) {
621
643
  redraw();
622
644
  },
623
645
  error: (text) => info(text, "error"),
646
+ quit: () => quit(),
624
647
  });
625
648
  }, []);
626
649
  useEffect(() => {
@@ -694,6 +717,7 @@ export function App({ initialModel, permContext, resume }) {
694
717
  info(`Earlier conversations here (also in the picker: ↑↓ and Enter, or type a number here):\n${rows.join("\n")}`);
695
718
  // A picker over the screen: ↑↓ and Enter, or type to filter by title.
696
719
  setResumePicker(list.map((c, i) => ({ title: `${i + 1}. ${c.title}`, keys: `${where(c)} · ${ago(c.updated)}`, run: () => void resumePicked(c) })));
720
+ setPickerTitle("Resume a conversation · ↑↓ and Enter, or type its number");
697
721
  setQuickOpen(">");
698
722
  return;
699
723
  }
@@ -880,7 +904,7 @@ export function App({ initialModel, permContext, resume }) {
880
904
  return;
881
905
  const added = newProblems(before, after);
882
906
  setPendingNote(added.length
883
- ? { short: `⚠ +${added.length} problem${added.length === 1 ? "" : "s"}`, detail: added.slice(0, 3).map((d) => formatDiagnostic(d)).join("\n") }
907
+ ? { short: `▲ +${added.length} problem${added.length === 1 ? "" : "s"}`, detail: added.slice(0, 3).map((d) => formatDiagnostic(d)).join("\n") }
884
908
  : { short: "✓ no new problems", detail: null });
885
909
  })();
886
910
  return () => {
@@ -907,6 +931,9 @@ export function App({ initialModel, permContext, resume }) {
907
931
  const tree = border === "tree" ? Math.min(Math.max(x + 1, MIN_TREE), columns - chatWidth - MIN_CODE) : treeWidth;
908
932
  const chat = border === "chat" ? Math.min(Math.max(columns - x, MIN_CHAT), columns - treeWidth - MIN_CODE) : chatWidth;
909
933
  const next = { tree: tree / columns, chat: chat / columns };
934
+ // Widths come from the panes as they are on screen (zoom included), so the zoom folds into them: the
935
+ // border lands where it's dropped instead of a zoom step away.
936
+ setCodeZoom(0);
910
937
  setPaneWidths(next);
911
938
  return next;
912
939
  };
@@ -946,7 +973,7 @@ export function App({ initialModel, permContext, resume }) {
946
973
  const { pane } = q;
947
974
  q.lines -= lines;
948
975
  q.columns -= columns;
949
- setWheel((w) => ({ pane, lines, columns, nonce: (w?.nonce ?? 0) + 1 }));
976
+ sendWheel(pane, lines, columns);
950
977
  if (q.lines || q.columns)
951
978
  q.timer = setTimeout(flushWheel, 16);
952
979
  };
@@ -1081,9 +1108,9 @@ export function App({ initialModel, permContext, resume }) {
1081
1108
  if (event.kind === "click" && event.y === 0) {
1082
1109
  const zone = headerZones(workspaceTabs()).find((z) => event.x >= z.from && event.x < z.to);
1083
1110
  if (zone?.tab === "new")
1084
- return typeCommand("/workspace new ");
1111
+ return pickWorkspaceFolder();
1085
1112
  if (typeof zone?.tab === "number")
1086
- return void sendToHost({ type: "switch", index: zone.tab });
1113
+ return void sendToHost({ type: zone.close ? "close" : "switch", index: zone.tab });
1087
1114
  }
1088
1115
  if (event.kind === "click" && event.y === paneHeight + 1)
1089
1116
  return clickable.current.status.find((z) => event.x >= z.from && event.x < z.to)?.run();
@@ -1117,6 +1144,8 @@ export function App({ initialModel, permContext, resume }) {
1117
1144
  if (Math.abs(at - plus) <= 1)
1118
1145
  return newChatTab();
1119
1146
  const zone = zones.find((z) => at >= z.from && at < z.to);
1147
+ if (zone?.close)
1148
+ return closeChatTab(zone.id);
1120
1149
  if (zone)
1121
1150
  return switchChatTab(zone.id);
1122
1151
  }
@@ -1484,6 +1513,12 @@ export function App({ initialModel, permContext, resume }) {
1484
1513
  if (focus !== "chat" && input === "o" && pendingChange)
1485
1514
  return openPending();
1486
1515
  }
1516
+ // Zoom the code panel from anywhere: Ctrl+= / Ctrl+- / Ctrl+0, in terminals that report Ctrl with those
1517
+ // keys (the kitty keyboard protocol Nou asks for). Elsewhere Ctrl+- is the same key as Ctrl+/, so it's
1518
+ // left alone; + and - in the code or the files work in every terminal.
1519
+ if (key.ctrl && (input === "=" || input === "+" || input === "-" || input === "0") && !key.meta) {
1520
+ return zoomCode(input === "0" ? 0 : input === "-" ? -1 : 1);
1521
+ }
1487
1522
  if (key.ctrl && input === "p") {
1488
1523
  setQuickOpen("");
1489
1524
  return;
@@ -1555,7 +1590,7 @@ export function App({ initialModel, permContext, resume }) {
1555
1590
  setPaneWidths(null);
1556
1591
  updateSettings({ paneWidths: null });
1557
1592
  }
1558
- else if ((input === "+" || input === "-") && focus === "code") {
1593
+ else if ((input === "+" || input === "-") && (focus === "code" || focus === "tree")) {
1559
1594
  zoomCode(input === "+" ? 1 : -1);
1560
1595
  }
1561
1596
  else if (input === "v")
@@ -1606,7 +1641,7 @@ ${snippet}
1606
1641
  </editor_context>`;
1607
1642
  };
1608
1643
  const contextChip = openFile
1609
- ? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ✎ unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
1644
+ ? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ± unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
1610
1645
  : null;
1611
1646
  /** /mcp: list servers; add (a preset, a command, or a URL), remove, or import the ones from other tools. */
1612
1647
  const mcpCommand = async ([action, name, ...more]) => {
@@ -1682,7 +1717,7 @@ ${snippet}
1682
1717
  }
1683
1718
  if (action)
1684
1719
  return info("Usage: /plugins, /plugins add <name>, /plugins new <name>", "error");
1685
- const mark = { on: "●", off: "○", changed: "⚠" };
1720
+ const mark = { on: "●", off: "○", changed: "▲" };
1686
1721
  const lines = allPlugins.map((p) => p.scope === "starter"
1687
1722
  ? ` + ${p.id.padEnd(16)} ${p.plugin?.description ?? ""}`
1688
1723
  : ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✗ ${p.error}` : ""}`);
@@ -1856,6 +1891,11 @@ ${snippet}
1856
1891
  if (/^(reset|0|off)$/.test(arg))
1857
1892
  return zoomCode(0);
1858
1893
  return info("/zoom in makes the code panel bigger, /zoom out smaller, /zoom reset puts it back (or + and - with the code focused, = to reset). The files and chat give or take the room. For bigger text, use your terminal's own zoom (Cmd/Ctrl and +).");
1894
+ case "files":
1895
+ return setQuickOpen("");
1896
+ case "actions":
1897
+ case "palette":
1898
+ return setQuickOpen(">");
1859
1899
  case "workspace":
1860
1900
  case "workspaces":
1861
1901
  case "ws":
@@ -2010,7 +2050,7 @@ ${snippet}
2010
2050
  if (!stops.length)
2011
2051
  return;
2012
2052
  const list = stops.map((s, i) => `${i + 1}. ${s.file}:${s.line}${s.end > s.line ? `-${s.end}` : ""}${s.note ? ` · ${s.note}` : ""}`).join("\n");
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}`);
2053
+ 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}`);
2014
2054
  setTour({ stops, at: 0 });
2015
2055
  }, [busy]);
2016
2056
  // Each place: open it in the code view with its lines highlighted.
@@ -2037,7 +2077,7 @@ ${snippet}
2037
2077
  if (at < 0 || at >= tour.stops.length)
2038
2078
  return info(at < 0 ? "That's the first place." : "That was the last place.");
2039
2079
  const stop = tour.stops[at];
2040
- info(`📍 ${at + 1} of ${tour.stops.length}: ${stop.file}:${stop.line}${stop.end > stop.line ? `-${stop.end}` : ""}${stop.note ? ` · ${stop.note}` : ""}`);
2080
+ info(`◉ ${at + 1} of ${tour.stops.length}: ${stop.file}:${stop.line}${stop.end > stop.line ? `-${stop.end}` : ""}${stop.note ? ` · ${stop.note}` : ""}`);
2041
2081
  return setTour({ ...tour, at });
2042
2082
  }
2043
2083
  if (!words)
@@ -2087,7 +2127,7 @@ ${snippet}
2087
2127
  writeFileSync(step.file, "");
2088
2128
  setTreeVersion((v) => v + 1);
2089
2129
  }
2090
- info(`👉 Step ${guide.at + 1} of ${guide.steps.length}: ${step.file}:${step.line}\n${step.why}\n\n\`\`\`\n${step.code}\n\`\`\``);
2130
+ info(`▸ Step ${guide.at + 1} of ${guide.steps.length}: ${step.file}:${step.line}\n${step.why}\n\n\`\`\`\n${step.code}\n\`\`\``);
2091
2131
  openAt(step.file, step.line, true);
2092
2132
  }, [guide?.at, guide?.steps]);
2093
2133
  const guideStep = guide?.steps[guide.at] ?? null;
@@ -2105,7 +2145,7 @@ ${snippet}
2105
2145
  setGuide({ ...guide, steps, at: guide.at + 1 });
2106
2146
  }
2107
2147
  else {
2108
- info(`🎉 That's it: all ${steps.length} step${steps.length > 1 ? "s" : ""} done and saved. Ask Nou to check it over if you like.`);
2148
+ info(`✓ That's it: all ${steps.length} step${steps.length > 1 ? "s" : ""} done and saved. Ask Nou to check it over if you like.`);
2109
2149
  setGuide(null);
2110
2150
  }
2111
2151
  });
@@ -2305,7 +2345,6 @@ ${snippet}
2305
2345
  const dockLeft = gameDock === "tree" ? 0 : gameDock === "code" ? treeWidth : treeWidth + codeWidth;
2306
2346
  const dockWidth = gameDock === "tree" ? treeWidth : gameDock === "code" ? codeWidth : chatWidth;
2307
2347
  layout.current = { treeWidth, codeWidth, paneHeight: height, heights, game: gamesOpen ? { x: dockLeft, y: 1 + heights[gameDock] + (gameDock === "code" ? termHeight : 0), width: dockWidth, height: gameHeight } : null, terminal: termOpen ? { x: treeWidth, y: 1 + heights.code, width: codeWidth, height: termHeight } : null };
2308
- const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
2309
2348
  const scrollFor = (pane) => (scrollTo?.pane === pane ? scrollTo : null);
2310
2349
  const totals = diagnostics.counts();
2311
2350
  const checking = diagnostics.busy;
@@ -2324,6 +2363,48 @@ ${snippet}
2324
2363
  setRepaint((n) => n + 1);
2325
2364
  }
2326
2365
  /** /workspace: list them, `new [folder]` opens one, a number switches. */
2366
+ /**
2367
+ * + in the header (or /workspace new on its own): a picker of folders to open as a workspace: this project
2368
+ * again (a second workspace on the same code), then the projects beside it, latest first. Enter opens it.
2369
+ */
2370
+ function pickWorkspaceFolder() {
2371
+ const here = process.cwd();
2372
+ const parent = path.dirname(here);
2373
+ const home = os.homedir();
2374
+ // Where each folder is, kept short (its end is what tells them apart) so the names keep their room.
2375
+ const short = (p) => {
2376
+ const shown = p.startsWith(home) ? `~${p.slice(home.length)}` : p;
2377
+ return shown.length > 22 ? `…${shown.slice(-21)}` : shown;
2378
+ };
2379
+ let beside = [];
2380
+ try {
2381
+ beside = readdirSync(parent, { withFileTypes: true })
2382
+ .filter((d) => d.isDirectory() && !d.name.startsWith(".") && path.join(parent, d.name) !== here)
2383
+ .map((d) => {
2384
+ const full = path.join(parent, d.name);
2385
+ let time = 0;
2386
+ try {
2387
+ time = statSync(full).mtimeMs;
2388
+ }
2389
+ catch {
2390
+ // unreadable: listed last
2391
+ }
2392
+ return { path: full, time };
2393
+ })
2394
+ .sort((a, b) => b.time - a.time)
2395
+ .slice(0, 60);
2396
+ }
2397
+ catch {
2398
+ // the parent can't be listed: just this project, or type a path with /workspace new <folder>
2399
+ }
2400
+ const open = (folder) => () => void workspaceCommand(`new ${folder}`);
2401
+ setResumePicker([
2402
+ { title: `${path.basename(here)} (this project)`, keys: short(here), run: open(here) },
2403
+ ...beside.map((b) => ({ title: path.basename(b.path), keys: short(b.path), run: open(b.path) })),
2404
+ ]);
2405
+ setPickerTitle("Open a folder as a new workspace · any other: /workspace new <path>");
2406
+ setQuickOpen(">");
2407
+ }
2327
2408
  async function workspaceCommand(arg) {
2328
2409
  const [sub, ...more] = arg.split(/\s+/);
2329
2410
  const tabs = workspaceTabs();
@@ -2341,6 +2422,8 @@ ${snippet}
2341
2422
  if (sub !== "new")
2342
2423
  return info("Try /workspace new <folder>, /workspace <n> or /workspace.", "error");
2343
2424
  const wanted = more.join(" ");
2425
+ if (!wanted)
2426
+ return pickWorkspaceFolder();
2344
2427
  if (isHosted())
2345
2428
  return void sendToHost({ type: "new", folder: wanted });
2346
2429
  const folder = workspaceFolder(wanted, process.cwd());
@@ -2500,7 +2583,7 @@ ${snippet}
2500
2583
  ? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
2501
2584
  : diff
2502
2585
  ? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
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"]];
2586
+ : [["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"]];
2504
2587
  const followChip = follow ? ` ◉ follow${followLag ? ` ·${followLag}` : ""} ` : " ○ follow ";
2505
2588
  const statusUsed = mode.label.length + 2 + autoMode.length + 2 + followChip.length + ` ✗ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
2506
2589
  const shownHints = [];
@@ -2515,7 +2598,7 @@ ${snippet}
2515
2598
  y: () => answer(true), n: approval ? () => answer(false) : undefined, o: openPending,
2516
2599
  "Shift+Tab": () => cycleAutoMode(), "/": () => typeCommand("/"), "Ctrl+P": () => setQuickOpen(""),
2517
2600
  Tab: () => setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]),
2518
- e: startEditing, "double-click/e": startEditing, b: goBack, [FIND_KEY]: showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
2601
+ "+ -": () => zoomCode(1), e: startEditing, "double-click/e": startEditing, b: goBack, [FIND_KEY]: showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
2519
2602
  s: () => setView("settings"), "/help": () => void runCommand("/help"),
2520
2603
  Esc: busy ? stop : diff && !showEditor ? () => setDiffMode("off") : undefined,
2521
2604
  };
@@ -2541,9 +2624,9 @@ ${snippet}
2541
2624
  }
2542
2625
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
2543
2626
  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
2627
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update, tabs: tabsOfWorkspaces }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, ...(tourFiles ? { showing: tourFiles } : {}), git: git, problemCounts: problemCounts, wheelPane: "tree", scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheelPane: "code", onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheelPane: "code", scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tour && tour.stops[tour.at]?.file === openFile && !diff ? { showing: `${tour.at + 1}/${tour.stops.length}` } : {}), ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheelPane: "code", scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest, foldClick: foldClick, foldAll: foldAll })), _jsx(Chat, { width: chatWidth, height: heights.chat, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheelPane: "chat", scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, sendNowFirst: settings.sendNowWhileBusy, onSendNow: sendNow, onAnswer: onAnswer, click: chatClick, select: chatSelect, tabs: chatTabsProp(), onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: C.onBadge, bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: followChip }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✗ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
2545
2628
  ? _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)` : ""] })
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) => {
2629
+ : shownHints.map(([key, action], i) => (_jsxs(Text, { children: [i === 0 ? " " : _jsx(Text, { color: C.dim, children: " \u00B7 " }), _jsx(Text, { color: C.text, children: key }), _jsxs(Text, { color: C.dim, children: [" ", action] })] }, key)))] }) }), layout.current.terminal && shellRef.current && (_jsx(Box, { position: "absolute", marginLeft: layout.current.terminal.x, marginTop: layout.current.terminal.y, children: _jsx(Terminal, { shell: shellRef.current, version: termVersion, width: layout.current.terminal.width, height: layout.current.terminal.height, focused: focus === "terminal", wheelPane: "terminal", onLeave: leaveTerminal, onClose: () => toggleTerminal(false), onResize: resizeTerminal }) })), layout.current.game && (_jsx(Box, { position: "absolute", marginLeft: layout.current.game.x, marginTop: layout.current.game.y, children: _jsx(GameGuard, { children: _jsx(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, best: settings.gameBest ?? {}, onScore: onGameScore, onLeave: leaveGames, onOpenSettings: gamesSettings }) }) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: resumePicker ?? paletteActions, initialQuery: quickOpen, ...(resumePicker ? { placeholder: "type to filter", title: pickerTitle } : {}), onPick: (file) => {
2547
2630
  setQuickOpen(null);
2548
2631
  clearFollow();
2549
2632
  open(file);