nou-cli 0.5.6 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@ import { ENGINES, createEngine, engineInfo, installEngine, isEngineId, isInstall
13
13
  import { harness, problemsIntroduced } from "../agent/harness.js";
14
14
  import { describe, providers, resolveModel, saveModel, setupProblem } from "../providers/index.js";
15
15
  import { sameModel } from "../providers/ollama.js";
16
- import { FOLLOW_SPEEDS, isTrustedFolder, loadSettings, saveSettings } from "../config/index.js";
16
+ import { EFFORT_LABELS, EFFORTS, FOLLOW_SPEEDS, effortFrom, isTrustedFolder, loadSettings, saveSettings } from "../config/index.js";
17
17
  import { branch as gitBranch, diffAgainstHead, isRepo, sameStatus, status as gitStatus, statusAsync as gitStatusAsync } from "../git/index.js";
18
18
  import { BUILTIN_PLUGINS, addStarter, discoverPlugins, isEnabled, loadPlugin, scaffoldPlugin, starterPlugins, trustPatch, trustState } from "../plugins/index.js";
19
19
  import { DiagnosticsManager, formatDiagnostic, newProblems } from "../diagnostics/index.js";
@@ -26,21 +26,24 @@ import { changedLines, lineCounts, loadFallbackHighlighter, splitLines } from ".
26
26
  import { findDefinition, wordAt } from "./definition.js";
27
27
  import { ProblemsView } from "./ProblemsView.js";
28
28
  import { useFocusReporting, useTerminalFocused } from "./focus.js";
29
- import { useMouse } from "./mouse.js";
30
- import { C } from "./theme.js";
29
+ import { ENABLE as MOUSE_ON, useMouse } from "./mouse.js";
30
+ import { connectToHost, isHosted, reopenAsWorkspaces, sendToHost, useWorkspaceTabs, workspaceTabs } from "../workspaces/client.js";
31
+ import { PTY_PACKAGE, workspaceFolder } from "../workspaces/host.js";
32
+ import { C, palette } from "./theme.js";
33
+ import { FIND_KEY, isFind } from "./keys.js";
31
34
  import { FileTree } from "./FileTree.js";
32
35
  import { CodeView } from "./CodeView.js";
33
36
  import { Editor } from "./Editor.js";
34
- import { SLASH_COMMANDS, runsWhileBusy } from "./commands.js";
37
+ import { SLASH_COMMANDS, closestCommand, onlyChangesScreen, runsWhileBusy } from "./commands.js";
35
38
  import { WHEEL_STEP } from "./scroll.js";
36
39
  import { Chat, chatTabZones } from "./Chat.js";
37
40
  import { copyToClipboard, openInBrowser } from "./clipboard.js";
38
- import { Header } from "./Header.js";
41
+ import { Header, headerZones } from "./Header.js";
39
42
  import { Tabs, layoutTabs } from "./Tabs.js";
40
43
  import { GAME_PANEL, GameGuard, GamePanel } from "./GamePanel.js";
41
44
  import { Terminal } from "./Terminal.js";
42
45
  import { forgetScreen } from "./redraw.js";
43
- import { showMePrompt, showStepsTool, stepsFromReply } from "../guide/index.js";
46
+ import { SHOW_PLACES_NOTE, explainPrompt, showMePrompt, showPlacesTool, showStepsTool, stepsFromReply, stopsFromReply } from "../guide/index.js";
44
47
  import { Shell } from "../terminal/index.js";
45
48
  import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
46
49
  import { Settings } from "./Settings.js";
@@ -81,16 +84,16 @@ Keys:
81
84
  m mouse on/off (off: select text to copy; Shift+drag also works in most terminals)
82
85
  / start a command from any pane
83
86
  e edit open file (or double-click in the code) v diff (git / Nou / session) f follow mode
84
- Ctrl+F find in the open file (Enter/↓ next, ↑ previous)
87
+ ${FIND_KEY} find in the open file (Enter/↓ next, ↑ previous)
85
88
  b back to the previous file (Ctrl+P lists recent files first) [ ] previous/next tab · x close tab
86
89
  Ctrl+click, Option+click or right-click a name: go to its definition (an import's path opens the file)
87
90
  p problems u undo Nou's edit s settings
88
91
  c (in files) show only changed files Ctrl+C twice quit
89
- Mouse: wheel scrolls (Shift+wheel sideways) · click/drag a scrollbar · double-click in the code to edit there (in the editor: select a word), drag to select
92
+ Mouse: wheel scrolls (Shift+wheel sideways) · click/drag a scrollbar · double-click in the code to edit there (in the editor: select a word), drag to select · in the chat: double-click copies a word, triple-click a paragraph
90
93
  In the editor:
91
94
  Shift+arrows / mouse drag select · Ctrl+A all · Ctrl+C/X/V copy/cut/paste · Esc clear selection or leave
92
95
  Ctrl+←/→ word · Ctrl+Backspace or Ctrl+W delete word · Ctrl+Home/End file start/end
93
- 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
94
97
  Tab/Shift+Tab indent/outdent · Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
95
98
  /** A callback that keeps one identity across renders but always runs the latest code, so memoized panes don't re-render for it. */
96
99
  function useEvent(fn) {
@@ -110,13 +113,17 @@ export function App({ initialModel, permContext, resume }) {
110
113
  const { exit } = useApp();
111
114
  const { columns, rows } = useWindowSize();
112
115
  const [settings, setSettings] = useState(loadSettings);
113
- setCodeTheme(settings.codeTheme); // before anything below highlights code
116
+ // Before anything below highlights code. One Dark's light text is unreadable on a light terminal: there the
117
+ // default switches to GitHub Light (any other theme you picked stays).
118
+ setCodeTheme(palette() === "light" && settings.codeTheme === "one-dark" ? "github-light" : settings.codeTheme);
114
119
  const [view, setView] = useState("main");
115
120
  const [connectTo, setConnectTo] = useState();
116
121
  /** Why settings were opened for you (a request failed on the provider's setup). */
117
122
  const [setupReason, setSetupReason] = useState(null);
118
123
  /** The file finder / command palette: closed (null), or open with this query (">" for commands). */
119
124
  const [quickOpen, setQuickOpen] = useState(null);
125
+ /** /resume's picker: the conversations, in the palette's place while it's open. */
126
+ const [resumePicker, setResumePicker] = useState(null);
120
127
  const [focus, setFocus] = useState("chat");
121
128
  /** The Games panel: whether it's open, the pane it's being dragged over, and the pane to go back to. */
122
129
  const [gamesOpen, setGamesOpen] = useState(false);
@@ -136,7 +143,7 @@ export function App({ initialModel, permContext, resume }) {
136
143
  /** The tab strip, kept while nothing in it changes: a new one would redraw the whole code view on every scroll. */
137
144
  const tabStripCache = useRef(null);
138
145
  /**
139
- * "Show me how" (/showme): the steps Nou found, and the one you're on. While Nou looks for them,
146
+ * "Teach me" (/teach): the steps Nou found, and the one you're on. While Nou looks for them,
140
147
  * `guideAsk` holds what you asked (and its edits are turned down: you're doing the typing).
141
148
  */
142
149
  const [guide, setGuide] = useState(null);
@@ -146,6 +153,20 @@ export function App({ initialModel, permContext, resume }) {
146
153
  /** /resume just listed the conversations: the next message, if it's a number, picks one. */
147
154
  const pickingResume = useRef(false);
148
155
  const guideFound = useRef(null);
156
+ /**
157
+ * "Show me" (/showme, or any explanation that points at code): the places the agent pointed at, and the
158
+ * one open in the code view. `tourFound` holds what Nou's engine passed to show_places during a turn.
159
+ */
160
+ const [tour, setTour] = useState(null);
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]);
149
170
  const shellRef = useRef(null);
150
171
  const beforeTerm = useRef("code");
151
172
  const [follow, setFollow] = useState(settings.followMode);
@@ -243,7 +264,7 @@ export function App({ initialModel, permContext, resume }) {
243
264
  const [findRequest, setFindRequest] = useState(0);
244
265
  const startFind = () => {
245
266
  if (!openFile)
246
- 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.`);
247
268
  setDiffMode("off");
248
269
  setEditing(false);
249
270
  setCodePanel("code");
@@ -258,6 +279,12 @@ export function App({ initialModel, permContext, resume }) {
258
279
  const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0, heights: { tree: 0, code: 0, chat: 0 }, game: null, terminal: null });
259
280
  /** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
260
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))));
261
288
  const paneWidthsRef = useRef(paneWidths);
262
289
  paneWidthsRef.current = paneWidths;
263
290
  /** What a mouse drag is moving: a pane border, a pane's scrollbar, or the editor's selection. */
@@ -545,7 +572,7 @@ export function App({ initialModel, permContext, resume }) {
545
572
  useEffect(() => {
546
573
  const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
547
574
  // Other engines read AGENTS.md / CLAUDE.md themselves: they get only the NOU.md rules on top.
548
- engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, nouRulesOnly(permContext)].filter(Boolean), extraTools: [...extraTools, showStepsTool((steps) => (guideFound.current = steps)), ...mcpRef.current.tools()], effort: settings.effort };
575
+ engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, nouRulesOnly(permContext), SHOW_PLACES_NOTE].filter(Boolean), extraTools: [...extraTools, showStepsTool((steps) => (guideFound.current = steps)), showPlacesTool((stops) => (tourFound.current = stops)), ...mcpRef.current.tools()], effort: settings.effort };
549
576
  agentRef.current.configure(engineConfig.current);
550
577
  }, [settings.gitContext, settings.effort, activePlugins, mcpVersion]);
551
578
  // Pick up changes made outside Nou (other editors, git commands). Not while Nou's window is in the
@@ -581,6 +608,24 @@ export function App({ initialModel, permContext, resume }) {
581
608
  if (openFile && diffMode === "off" && !editing)
582
609
  setContent(readText(openFile));
583
610
  }, [busy]);
611
+ /**
612
+ * Workspaces: when this Nou is one tab of several, the host says when it's on screen again (redraw all of
613
+ * it, and turn the mouse and focus reports back on, which another workspace may have turned off on its way
614
+ * out), and hears when it's busy, for the tab's ● .
615
+ */
616
+ const tabsOfWorkspaces = useWorkspaceTabs();
617
+ useEffect(() => {
618
+ connectToHost({
619
+ redraw: () => {
620
+ process.stdout.write(`\x1b[?1049h${settings.mouse ? MOUSE_ON : ""}\x1b[?1004h`);
621
+ redraw();
622
+ },
623
+ error: (text) => info(text, "error"),
624
+ });
625
+ }, []);
626
+ useEffect(() => {
627
+ sendToHost({ type: "state", busy: !!busy });
628
+ }, [!!busy]);
584
629
  const updateSettings = (patch) => setSettings(saveSettings(patch));
585
630
  const switchModel = (next) => {
586
631
  const problem = next.provider.unavailable();
@@ -642,19 +687,31 @@ export function App({ initialModel, permContext, resume }) {
642
687
  const list = resumable();
643
688
  if (!list.length)
644
689
  return info("No earlier conversations in this folder yet: Nou's are saved after each reply, and Claude Code's and Codex's own sessions here show up too.");
690
+ const where = (c) => ("file" in c ? `${engineInfo(c.engine).label} CLI` : engineInfo(isEngineId(c.engine) ? c.engine : "nou").label);
645
691
  if (!arg) {
646
- const rows = list.slice(0, 10).map((c, i) => ` ${String(i + 1).padStart(2)}. ${c.title} (${c.messages} message${c.messages === 1 ? "" : "s"} · ${"file" in c ? `${engineInfo(c.engine).label} CLI` : engineInfo(isEngineId(c.engine) ? c.engine : "nou").label} · ${ago(c.updated)})`);
692
+ const rows = list.slice(0, 10).map((c, i) => ` ${String(i + 1).padStart(2)}. ${c.title} (${c.messages} message${c.messages === 1 ? "" : "s"} · ${where(c)} · ${ago(c.updated)})`);
647
693
  pickingResume.current = true; // a bare number, typed next, picks one
648
- return info(`Earlier conversations here. Type a number to pick one up (or /resume last):\n${rows.join("\n")}`);
694
+ info(`Earlier conversations here (also in the picker: ↑↓ and Enter, or type a number here):\n${rows.join("\n")}`);
695
+ // A picker over the screen: ↑↓ and Enter, or type to filter by title.
696
+ setResumePicker(list.map((c, i) => ({ title: `${i + 1}. ${c.title}`, keys: `${where(c)} · ${ago(c.updated)}`, run: () => void resumePicked(c) })));
697
+ setQuickOpen(">");
698
+ return;
649
699
  }
650
700
  const pick = arg === "last" ? list[0] : list[Number(arg) - 1] ?? list.find((c) => c.id === arg);
651
701
  if (!pick)
652
702
  return info(`There's no conversation ${arg}: /resume lists them.`, "error");
703
+ return resumePicked(pick);
704
+ };
705
+ /** Picks a conversation back up: Nou's own, or a Claude Code or Codex CLI session. */
706
+ const resumePicked = async (pick) => {
707
+ pickingResume.current = false;
708
+ if (busy)
709
+ return info("Wait for the current reply to finish (or press Esc) before resuming another conversation.", "error");
653
710
  if ("file" in pick)
654
711
  return resumeExternal(pick);
655
712
  const saved = loadConversation(pick.id);
656
713
  if (!saved)
657
- return info(`Couldn't read conversation ${arg}.`, "error");
714
+ return info(`Couldn't read conversation "${pick.title}".`, "error");
658
715
  const engine = engineFor(isEngineId(saved.engine) ? saved.engine : null);
659
716
  const restored = saved.engine === engine.id && !!saved.state && !!engine.restore?.(saved.state);
660
717
  if (!restored) {
@@ -1020,6 +1077,14 @@ export function App({ initialModel, permContext, resume }) {
1020
1077
  }
1021
1078
  if (event.kind === "click" && !approval && event.y === 0 && event.x >= clickable.current.modelFrom)
1022
1079
  return setView("settings");
1080
+ // Workspace tabs in the header: click one to switch to it, + to open another.
1081
+ if (event.kind === "click" && event.y === 0) {
1082
+ const zone = headerZones(workspaceTabs()).find((z) => event.x >= z.from && event.x < z.to);
1083
+ if (zone?.tab === "new")
1084
+ return typeCommand("/workspace new ");
1085
+ if (typeof zone?.tab === "number")
1086
+ return void sendToHost({ type: "switch", index: zone.tab });
1087
+ }
1023
1088
  if (event.kind === "click" && event.y === paneHeight + 1)
1024
1089
  return clickable.current.status.find((z) => event.x >= z.from && event.x < z.to)?.run();
1025
1090
  if (event.y < 1 || event.y > paneHeight)
@@ -1097,8 +1162,14 @@ export function App({ initialModel, permContext, resume }) {
1097
1162
  }
1098
1163
  // Two clicks in the same spot within half a second are a double-click.
1099
1164
  const last = lastClick.current;
1100
- const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
1101
- lastClick.current = double ? null : { x: event.x, y: event.y, time: Date.now() };
1165
+ const again = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
1166
+ const count = again ? last.count + 1 : 1;
1167
+ const double = count === 2;
1168
+ // The chat counts a third click too (the whole paragraph); elsewhere a double-click starts over.
1169
+ lastClick.current = double && pane !== "chat" ? null : count >= 3 ? null : { x: event.x, y: event.y, time: Date.now(), count };
1170
+ // In the chat: a double-click selects and copies the word (or path, or link), a triple-click the paragraph.
1171
+ if (pane === "chat" && event.y >= 3 && count >= 2)
1172
+ chatAt(event.x, event.y, count === 2 ? "word" : "block");
1102
1173
  if (pane === "code" && event.y >= 3 && codePanel === "code") {
1103
1174
  // As the code view counts them (a last empty line after the final newline isn't shown).
1104
1175
  const digits = String(splitLines(content).length).length;
@@ -1423,6 +1494,16 @@ export function App({ initialModel, permContext, resume }) {
1423
1494
  return; // so does the find bar
1424
1495
  if (focus === "game" || focus === "terminal")
1425
1496
  return; // and the Games panel and the terminal
1497
+ // Cmd+F (Mac) or Ctrl+F from the files or the chat: find in the open file (the code view takes it itself
1498
+ // when it has the keyboard). Other Cmd+letters aren't Nou's: a plain letter is, so Cmd+F never also
1499
+ // toggles follow mode (f).
1500
+ if (isFind(input, key)) {
1501
+ if (focus !== "code")
1502
+ startFind();
1503
+ return;
1504
+ }
1505
+ if (key.super)
1506
+ return;
1426
1507
  if (key.tab && key.shift)
1427
1508
  return cycleAutoMode();
1428
1509
  if (key.tab && focus === "chat" && chatMenuOpen.current)
@@ -1470,9 +1551,13 @@ export function App({ initialModel, permContext, resume }) {
1470
1551
  updateSettings({ paneWidths: moveBorder(focus === "tree" ? "tree" : "chat", (focus === "tree" ? treeWidth - 1 : treeWidth + codeWidth) + step) });
1471
1552
  }
1472
1553
  else if (input === "=") {
1554
+ setCodeZoom(0);
1473
1555
  setPaneWidths(null);
1474
1556
  updateSettings({ paneWidths: null });
1475
1557
  }
1558
+ else if ((input === "+" || input === "-") && focus === "code") {
1559
+ zoomCode(input === "+" ? 1 : -1);
1560
+ }
1476
1561
  else if (input === "v")
1477
1562
  cycleDiff();
1478
1563
  else if ((input === "]" || input === "[") && diffMode === "session")
@@ -1563,7 +1648,7 @@ ${snippet}
1563
1648
  if (action)
1564
1649
  return info("Usage: /mcp, /mcp add <name> <command|url>, /mcp add browser, /mcp remove <name>, /mcp import, /mcp reconnect", "error");
1565
1650
  const list = mcpRef.current.status();
1566
- const mark = { ready: "●", connecting: "◌", failed: "✖", off: "○" };
1651
+ const mark = { ready: "●", connecting: "◌", failed: "✗", off: "○" };
1567
1652
  const lines = list.map((x) => ` ${mark[x.state]} ${x.name.padEnd(16)} ${x.state === "ready" ? `${x.tools} tools` : x.state}${x.scope === "project" ? " · this project" : ""}${x.error ? ` · ${x.error}` : ""}`);
1568
1653
  const others = findOtherMcpServers().filter((f) => !list.some((x) => x.name === f.name));
1569
1654
  return info([
@@ -1600,7 +1685,7 @@ ${snippet}
1600
1685
  const mark = { on: "●", off: "○", changed: "⚠" };
1601
1686
  const lines = allPlugins.map((p) => p.scope === "starter"
1602
1687
  ? ` + ${p.id.padEnd(16)} ${p.plugin?.description ?? ""}`
1603
- : ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✖ ${p.error}` : ""}`);
1688
+ : ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✗ ${p.error}` : ""}`);
1604
1689
  return info(`Plugins (● on, ○ off, + starter you can add with /plugins add <name>):\n${lines.join("\n")}\nWrite your own: /plugins new <name>`);
1605
1690
  };
1606
1691
  const runCommand = async (input) => {
@@ -1632,6 +1717,21 @@ ${snippet}
1632
1717
  return info(`No file matches "${wanted}". To find where something is in the code, ask in the chat.`, "error");
1633
1718
  return openRef({ path: file, ...(m[2] ? { line: Number(m[2]) } : {}), ...(m[3] ? { to: Number(m[3]) } : {}) });
1634
1719
  }
1720
+ case "effort": {
1721
+ // Speed vs smarts, by name: /effort smarter (or the effort itself: /effort xhigh).
1722
+ const options = EFFORTS.map((e) => EFFORT_LABELS[e].name.toLowerCase()).join(", ");
1723
+ if (!arg)
1724
+ return info(`Now: ${EFFORT_LABELS[settings.effort].name}. ${EFFORT_LABELS[settings.effort].tradeoff} Change it with /effort ${options}.`);
1725
+ const next = effortFrom(arg);
1726
+ if (!next)
1727
+ return info(`No such level: "${arg}". Try one of: ${options}.`, "error");
1728
+ updateSettings({ effort: next });
1729
+ return info(`Now: ${EFFORT_LABELS[next].name}. ${EFFORT_LABELS[next].tradeoff}`);
1730
+ }
1731
+ case "now":
1732
+ if (!queued.length)
1733
+ return info(busy ? "Nothing is queued. Type a message and press Alt+Enter to stop Nou and send it now." : "Nothing is queued.");
1734
+ return sendNow();
1635
1735
  case "games":
1636
1736
  return toggleGames();
1637
1737
  case "cancel":
@@ -1748,6 +1848,18 @@ ${snippet}
1748
1848
  return toggleTerminal();
1749
1849
  case "redraw":
1750
1850
  return redraw();
1851
+ case "zoom":
1852
+ if (/^(in|\+|bigger)$/.test(arg))
1853
+ return zoomCode(1);
1854
+ if (/^(out|-|smaller)$/.test(arg))
1855
+ return zoomCode(-1);
1856
+ if (/^(reset|0|off)$/.test(arg))
1857
+ return zoomCode(0);
1858
+ return info("/zoom in makes the code panel bigger, /zoom out smaller, /zoom reset puts it back (or + and - with the code focused, = to reset). The files and chat give or take the room. For bigger text, use your terminal's own zoom (Cmd/Ctrl and +).");
1859
+ case "workspace":
1860
+ case "workspaces":
1861
+ case "ws":
1862
+ return void workspaceCommand(arg);
1751
1863
  case "fold":
1752
1864
  case "unfold":
1753
1865
  if (!openFile)
@@ -1766,7 +1878,9 @@ ${snippet}
1766
1878
  return switchChatTab(chatTabs[n - 1]);
1767
1879
  }
1768
1880
  case "showme":
1881
+ return showPlaces(rest.join(" "));
1769
1882
  case "teach":
1883
+ case "teachme":
1770
1884
  return showMe(rest.join(" "));
1771
1885
  case "resume":
1772
1886
  return resumeConversation(rest[0] ?? "");
@@ -1791,6 +1905,13 @@ ${snippet}
1791
1905
  }
1792
1906
  return;
1793
1907
  }
1908
+ // A typo of a command ("/termnial"): one that only changes the screen just runs; others are suggested,
1909
+ // so a slip never clears or undoes anything.
1910
+ const meant = closestCommand(command ?? "", [...SLASH_COMMANDS.map((c) => c.name), ...activePlugins.flatMap(({ plugin }) => plugin.commands?.map((c) => c.name) ?? [])]);
1911
+ if (meant && onlyChangesScreen(meant))
1912
+ return runCommand(`/${meant}${arg ? ` ${arg}` : ""}`);
1913
+ if (meant)
1914
+ return info(`Unknown command /${command}. Did you mean /${meant}${arg ? ` ${arg}` : ""}?`, "error");
1794
1915
  return info(`Unknown command /${command}. Type /help.`, "error");
1795
1916
  }
1796
1917
  }
@@ -1872,6 +1993,60 @@ ${snippet}
1872
1993
  setQueued(queued.slice(take));
1873
1994
  send(queued.slice(0, take).join("\n\n"));
1874
1995
  }, [busy, approval, queued]);
1996
+ // When a reply points at places in the code (Nou's engine with show_places, the others with a ```nou-show
1997
+ // block, which is taken out of the reply), the first one opens highlighted and the rest are listed.
1998
+ useEffect(() => {
1999
+ if (busy || guideAsk.current)
2000
+ return;
2001
+ let stops = tourFound.current;
2002
+ tourFound.current = null;
2003
+ const reply = [...entries].reverse().find((e) => e.kind === "assistant");
2004
+ const found = reply && stopsFromReply(reply.text);
2005
+ if (found) {
2006
+ stops = stops?.length ? stops : found.stops;
2007
+ setEntries((prev) => prev.map((e) => (e === reply ? { ...e, text: found.rest || "Here's where." } : e)));
2008
+ }
2009
+ stops = (stops ?? []).filter((s) => existsSync(s.file));
2010
+ if (!stops.length)
2011
+ return;
2012
+ const list = stops.map((s, i) => `${i + 1}. ${s.file}:${s.line}${s.end > s.line ? `-${s.end}` : ""}${s.note ? ` · ${s.note}` : ""}`).join("\n");
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}`);
2014
+ setTour({ stops, at: 0 });
2015
+ }, [busy]);
2016
+ // Each place: open it in the code view with its lines highlighted.
2017
+ useEffect(() => {
2018
+ const stop = tour?.stops[tour.at];
2019
+ if (!stop)
2020
+ return;
2021
+ openRef({ path: stop.file, line: stop.line, ...(stop.end > stop.line ? { to: stop.end } : {}) });
2022
+ }, [tour?.at, tour?.stops]);
2023
+ /** /showme <question>: explains it and opens the places in the code; /showme next|back|stop steps through them. */
2024
+ const showPlaces = (arg) => {
2025
+ const words = arg.trim();
2026
+ if (/^(stop|end|done|clear)$/.test(words)) {
2027
+ // Done looking: the highlighted lines go back to plain code.
2028
+ setTour(null);
2029
+ const highlighted = !!focusLine?.to;
2030
+ setFocusLine((f) => (f?.to !== undefined ? null : f));
2031
+ return info(tour || highlighted ? "Done showing: the highlights are cleared." : "Nothing is being shown.");
2032
+ }
2033
+ if (/^(next|back|prev|previous)$/.test(words)) {
2034
+ if (!tour)
2035
+ return info("Nothing is being shown. Ask with /showme <question>, e.g. /showme where do we check the login?");
2036
+ const at = tour.at + (words === "next" ? 1 : -1);
2037
+ if (at < 0 || at >= tour.stops.length)
2038
+ return info(at < 0 ? "That's the first place." : "That was the last place.");
2039
+ 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}` : ""}`);
2041
+ return setTour({ ...tour, at });
2042
+ }
2043
+ if (!words)
2044
+ return info("Ask how something works or where it happens, and Nou explains it and opens the places in the code, highlighted: /showme where do we check the login? (To be walked through typing a change yourself: /teach.)");
2045
+ if (busy)
2046
+ return info("Wait for the current reply to finish (or press Esc) first.", "error");
2047
+ setTour(null);
2048
+ send(explainPrompt(words, agentRef.current.id === "nou"));
2049
+ };
1875
2050
  // When Nou has finished looking, the guide starts at its first step.
1876
2051
  useEffect(() => {
1877
2052
  if (busy || !guideAsk.current)
@@ -1897,8 +2072,8 @@ ${snippet}
1897
2072
  info("It edited files anyway, so those changes were put back: you're the one typing them.");
1898
2073
  }
1899
2074
  if (!steps?.length)
1900
- return info("Nou didn't come back with steps to show. Try asking more specifically, e.g. /showme add a --verbose flag to the CLI", "error");
1901
- info(`Here's how, in ${steps.length} step${steps.length > 1 ? "s" : ""}. You type each one in the editor: the dim text ahead of the cursor is what goes next, and Tab fills in the rest of a line. /showme next skips a step, /showme stop ends it.`);
2075
+ return info("Nou didn't come back with steps to show. Try asking more specifically, e.g. /teach add a --verbose flag to the CLI", "error");
2076
+ info(`Here's how, in ${steps.length} step${steps.length > 1 ? "s" : ""}. You type each one in the editor: the dim text ahead of the cursor is what goes next, and Tab fills in the rest of a line. /teach next skips a step, /teach stop ends it.`);
1902
2077
  setGuide({ steps, at: 0, task });
1903
2078
  }, [busy]);
1904
2079
  // Each step: open its file in the editor where the code goes, and say what to type and why.
@@ -1934,7 +2109,7 @@ ${snippet}
1934
2109
  setGuide(null);
1935
2110
  }
1936
2111
  });
1937
- /** /showme <what you want>: Nou finds where the change goes and walks you through typing it. */
2112
+ /** /teach <what you want>: Nou finds where the change goes and walks you through typing it. */
1938
2113
  const showMe = (arg) => {
1939
2114
  const words = arg.trim();
1940
2115
  if (words === "stop" || words === "end") {
@@ -1952,8 +2127,8 @@ ${snippet}
1952
2127
  }
1953
2128
  if (!words) {
1954
2129
  if (guide)
1955
- return info(`On step ${guide.at + 1} of ${guide.steps.length}: ${guideStep.file}:${guideStep.line}. /showme next skips it, /showme stop ends it.`);
1956
- return info("Tell Nou what you want to do and it shows you how, step by step, for you to type: /showme add a --verbose flag to the CLI");
2130
+ return info(`On step ${guide.at + 1} of ${guide.steps.length}: ${guideStep.file}:${guideStep.line}. /teach next skips it, /teach stop ends it.`);
2131
+ return info("Tell Nou what you want to do and it shows you how, step by step, for you to type: /teach add a --verbose flag to the CLI");
1957
2132
  }
1958
2133
  if (busy)
1959
2134
  return info("Wait for the current reply to finish (or press Esc) first.", "error");
@@ -1967,10 +2142,21 @@ ${snippet}
1967
2142
  if (busy)
1968
2143
  stopped.current = false;
1969
2144
  }, [busy]);
1970
- /** From the chat: while Nou works, a message waits its turn, or (`now`) stops Nou and goes right away. */
1971
- const submit = (text, now = false) => {
2145
+ /** Stops Nou and sends what's queued right away (a click on the queue, or /now). */
2146
+ const sendNow = () => {
2147
+ if (!queued.length)
2148
+ return;
2149
+ stop();
2150
+ stopped.current = false; // what's queued is meant to go
2151
+ };
2152
+ /**
2153
+ * From the chat: while Nou works, a message waits its turn, or (`now`: Alt+Enter) stops Nou and goes right
2154
+ * away. The "Send while Nou works" setting swaps the two, so Enter sends now and Alt+Enter queues.
2155
+ */
2156
+ const submit = (text, alt = false) => {
1972
2157
  if (!busy || (text.startsWith("/") && runsWhileBusy(text)))
1973
2158
  return send(text);
2159
+ const now = settings.sendNowWhileBusy ? !alt : alt;
1974
2160
  if (now) {
1975
2161
  stop();
1976
2162
  stopped.current = false; // this one is meant to go
@@ -2093,8 +2279,16 @@ ${snippet}
2093
2279
  } }));
2094
2280
  }
2095
2281
  const height = Math.max(10, rows - 2);
2096
- const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
2097
- const chatWidth = paneWidths ? Math.max(MIN_CHAT, Math.min(Math.round(columns * paneWidths.chat), columns - treeWidth - MIN_CODE)) : Math.max(34, Math.floor(columns * 0.3));
2282
+ const baseTree = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
2283
+ const baseChat = paneWidths ? Math.max(MIN_CHAT, Math.min(Math.round(columns * paneWidths.chat), columns - baseTree - MIN_CODE)) : Math.max(34, Math.floor(columns * 0.3));
2284
+ // Zooming the code panel: each step moves a few columns between it and the files and chat, as far as their
2285
+ // minimums (zooming in) or its own (zooming out) allow.
2286
+ const zoomCols = codeZoom * Math.max(4, Math.round(columns * 0.06));
2287
+ const spare = { tree: baseTree - MIN_TREE, chat: baseChat - MIN_CHAT };
2288
+ const taken = zoomCols > 0 ? Math.min(zoomCols, spare.tree + spare.chat) : -Math.min(-zoomCols, Math.max(0, columns - baseTree - baseChat - MIN_CODE));
2289
+ const fromTree = zoomCols > 0 ? Math.round(taken * (spare.tree / Math.max(1, spare.tree + spare.chat))) : Math.round(taken / 2);
2290
+ const treeWidth = baseTree - fromTree;
2291
+ const chatWidth = baseChat - (taken - fromTree);
2098
2292
  const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
2099
2293
  const tabKey = tabs.length && openFile ? `${tabs.join("\0")}|${openFile}|${tabsWidth(codeWidth)}|${showEditor && !!editorState?.dirty}` : "";
2100
2294
  if (tabStripCache.current?.key !== tabKey) {
@@ -2129,6 +2323,51 @@ ${snippet}
2129
2323
  forgetScreen();
2130
2324
  setRepaint((n) => n + 1);
2131
2325
  }
2326
+ /** /workspace: list them, `new [folder]` opens one, a number switches. */
2327
+ async function workspaceCommand(arg) {
2328
+ const [sub, ...more] = arg.split(/\s+/);
2329
+ const tabs = workspaceTabs();
2330
+ if (!sub) {
2331
+ if (!isHosted())
2332
+ return info(`One workspace: ${path.basename(process.cwd())}. /workspace new <folder> opens another in this window, a tab each in the header (click + there too).`);
2333
+ return info(`Workspaces: ${tabs.map((t, i) => `${i + 1}. ${t.name}${t.busy ? " (working)" : ""}${t.active ? " ← here" : ""}`).join(" ")}. /workspace <n> switches, /workspace new <folder> opens one, /quit closes this one.`);
2334
+ }
2335
+ if (/^\d+$/.test(sub)) {
2336
+ const n = Number(sub);
2337
+ if (!isHosted() || !tabs[n - 1])
2338
+ return info(`There's no workspace ${n}. /workspace lists them.`, "error");
2339
+ return void sendToHost({ type: "switch", index: n - 1 });
2340
+ }
2341
+ if (sub !== "new")
2342
+ return info("Try /workspace new <folder>, /workspace <n> or /workspace.", "error");
2343
+ const wanted = more.join(" ");
2344
+ if (isHosted())
2345
+ return void sendToHost({ type: "new", folder: wanted });
2346
+ const folder = workspaceFolder(wanted, process.cwd());
2347
+ if (!folder)
2348
+ return info(`No such folder: ${wanted}`, "error");
2349
+ if (busy)
2350
+ return info("Wait for the current reply to finish (or press Esc) first: Nou reopens as a workspaces window.", "error");
2351
+ // The pseudo-terminals workspaces run in come from a small native package, installed once like an engine.
2352
+ const pty = { id: "nou", label: "workspaces", description: "", approvals: "before", model: () => "", package: PTY_PACKAGE };
2353
+ if (!isInstalled(pty)) {
2354
+ info(`Workspaces need ${PTY_PACKAGE} (pseudo-terminals, prebuilt for macOS, Linux and Windows). Installing it once into ~/.nou/engines...`);
2355
+ setBusy("Installing workspaces support (npm)...");
2356
+ try {
2357
+ await installEngine(pty, (line) => setBusy(`Installing workspaces support (npm): ${line.slice(0, 70)}`));
2358
+ }
2359
+ catch (error) {
2360
+ return info(`Couldn't install ${PTY_PACKAGE}: ${error.message}`, "error");
2361
+ }
2362
+ finally {
2363
+ setBusy(null);
2364
+ }
2365
+ }
2366
+ if (draft.current && settings.autoSave !== "off")
2367
+ saveFromEditor(draft.current.text, draft.current.path);
2368
+ reopenAsWorkspaces([process.cwd(), folder]);
2369
+ exit();
2370
+ }
2132
2371
  /** Opens the terminal panel (starting its shell the first time) and gives it the keyboard, or closes it. */
2133
2372
  function toggleTerminal(open = !termOpen) {
2134
2373
  shellRef.current ??= new Shell(() => setTermVersion((v) => v + 1));
@@ -2185,7 +2424,7 @@ ${snippet}
2185
2424
  // Everything Nou can do, for the command palette (? or Ctrl+P then >), with the key that does it.
2186
2425
  const paletteActions = [
2187
2426
  { title: "Find a file", keys: "Ctrl+P", run: () => setQuickOpen("") },
2188
- { title: "Find in the open file", keys: "Ctrl+F", run: startFind },
2427
+ { title: "Find in the open file", keys: FIND_KEY, run: startFind },
2189
2428
  { title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
2190
2429
  { title: "Go back to the previous file", keys: "b", run: goBack },
2191
2430
  { title: "Cancel your last message (edit it and resend)", keys: "/cancel", run: cancelLast },
@@ -2251,7 +2490,7 @@ ${snippet}
2251
2490
  : showEditor && focus === "code"
2252
2491
  ? editorState?.selection
2253
2492
  ? [["Ctrl+C", "copy"], ["Ctrl+X", "cut"], ["Tab", "indent"], ["Ctrl+/", "comment"], ["Esc", "deselect"]]
2254
- : [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Shift+→", "select"], ["Ctrl+F", "find"], ["Ctrl+/", "comment"], ["/help", "all keys"]]
2493
+ : [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Shift+→", "select"], [FIND_KEY, "find"], ["Ctrl+/", "comment"], ["/help", "all keys"]]
2255
2494
  : focus === "chat"
2256
2495
  ? chatMenu ? [["↑↓", "choose"], ["Tab", "complete"], ["Enter", "run"], ["Esc", "close"]]
2257
2496
  : busy
@@ -2261,9 +2500,9 @@ ${snippet}
2261
2500
  ? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
2262
2501
  : diff
2263
2502
  ? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
2264
- : [["double-click/e", "edit"], ["Ctrl+F", "find"], ["v", "diff"], ...(recent.length > 1 ? [["b", "back"]] : []), ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
2503
+ : [["double-click/e", "edit"], [FIND_KEY, "find"], ["v", "diff"], ...(recent.length > 1 ? [["b", "back"]] : []), ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
2265
2504
  const followChip = follow ? ` ◉ follow${followLag ? ` ·${followLag}` : ""} ` : " ○ follow ";
2266
- const statusUsed = mode.label.length + 2 + autoMode.length + 2 + followChip.length + ` ✖ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
2505
+ const statusUsed = mode.label.length + 2 + autoMode.length + 2 + followChip.length + ` ✗ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
2267
2506
  const shownHints = [];
2268
2507
  for (let used = statusUsed, i = 0; i < hints.length; i++) {
2269
2508
  used += hints[i][0].length + hints[i][1].length + 4;
@@ -2276,7 +2515,7 @@ ${snippet}
2276
2515
  y: () => answer(true), n: approval ? () => answer(false) : undefined, o: openPending,
2277
2516
  "Shift+Tab": () => cycleAutoMode(), "/": () => typeCommand("/"), "Ctrl+P": () => setQuickOpen(""),
2278
2517
  Tab: () => setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]),
2279
- e: startEditing, "double-click/e": startEditing, b: goBack, "Ctrl+F": showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
2518
+ e: startEditing, "double-click/e": startEditing, b: goBack, [FIND_KEY]: showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
2280
2519
  s: () => setView("settings"), "/help": () => void runCommand("/help"),
2281
2520
  Esc: busy ? stop : diff && !showEditor ? () => setDiffMode("off") : undefined,
2282
2521
  };
@@ -2290,7 +2529,7 @@ ${snippet}
2290
2529
  };
2291
2530
  add(` ${autoMode} `.length, () => cycleAutoMode());
2292
2531
  add(followChip.length, () => setFollow((f) => !f));
2293
- add(` ✖ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
2532
+ add(` ✗ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
2294
2533
  if (checking.length)
2295
2534
  add(3 + checking.join(", ").length);
2296
2535
  if (!exitArmed) {
@@ -2301,10 +2540,10 @@ ${snippet}
2301
2540
  }
2302
2541
  }
2303
2542
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
2304
- clickable.current = { status: zones, modelFrom: columns - (" ⌂ LOCAL ".length + ` ${modelLabel} `.length + 1) };
2305
- 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 }), _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, 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: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: followChip }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
2543
+ clickable.current = { status: zones, modelFrom: columns - (" LOCAL ".length + ` ${modelLabel} `.length + 1) };
2544
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update, tabs: tabsOfWorkspaces }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, ...(tourFiles ? { showing: tourFiles } : {}), git: git, problemCounts: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tour && tour.stops[tour.at]?.file === openFile && !diff ? { showing: `${tour.at + 1}/${tour.stops.length}` } : {}), ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest, foldClick: foldClick, foldAll: foldAll })), _jsx(Chat, { width: chatWidth, height: heights.chat, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, sendNowFirst: settings.sendNowWhileBusy, onSendNow: sendNow, onAnswer: onAnswer, click: chatClick, select: chatSelect, tabs: chatTabsProp(), onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: C.onBadge, bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: followChip }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✗ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
2306
2545
  ? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
2307
- : 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, 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: paletteActions, initialQuery: quickOpen, onPick: (file) => {
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) => {
2308
2547
  setQuickOpen(null);
2309
2548
  clearFollow();
2310
2549
  open(file);
@@ -2312,6 +2551,10 @@ ${snippet}
2312
2551
  setFocus("code");
2313
2552
  }, onRun: (action) => {
2314
2553
  setQuickOpen(null);
2554
+ setResumePicker(null);
2315
2555
  action.run();
2316
- }, onClose: () => setQuickOpen(null) }))] }));
2556
+ }, onClose: () => {
2557
+ setQuickOpen(null);
2558
+ setResumePicker(null);
2559
+ } }))] }));
2317
2560
  }