nou-cli 0.5.4 → 0.5.10

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";
@@ -22,23 +22,27 @@ import { lsp } from "../lsp/index.js";
22
22
  import { onGrammarLoaded, preload } from "../syntax/index.js";
23
23
  import { setCodeTheme } from "../syntax/themes.js";
24
24
  import { checkForUpdate, currentVersion } from "../update/index.js";
25
- import { changedLines, lineCounts, loadFallbackHighlighter } from "./text.js";
25
+ import { changedLines, lineCounts, loadFallbackHighlighter, splitLines } from "./text.js";
26
26
  import { findDefinition, wordAt } from "./definition.js";
27
27
  import { ProblemsView } from "./ProblemsView.js";
28
- import { useMouse } from "./mouse.js";
29
- import { C } from "./theme.js";
28
+ import { useFocusReporting, useTerminalFocused } from "./focus.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";
30
33
  import { FileTree } from "./FileTree.js";
31
34
  import { CodeView } from "./CodeView.js";
32
35
  import { Editor } from "./Editor.js";
33
- import { SLASH_COMMANDS, runsWhileBusy } from "./commands.js";
36
+ import { SLASH_COMMANDS, closestCommand, onlyChangesScreen, runsWhileBusy } from "./commands.js";
34
37
  import { WHEEL_STEP } from "./scroll.js";
35
38
  import { Chat, chatTabZones } from "./Chat.js";
36
39
  import { copyToClipboard, openInBrowser } from "./clipboard.js";
37
- import { Header } from "./Header.js";
40
+ import { Header, headerZones } from "./Header.js";
38
41
  import { Tabs, layoutTabs } from "./Tabs.js";
39
- import { GAME_PANEL, GamePanel } from "./GamePanel.js";
42
+ import { GAME_PANEL, GameGuard, GamePanel } from "./GamePanel.js";
40
43
  import { Terminal } from "./Terminal.js";
41
- import { showMePrompt, showStepsTool, stepsFromReply } from "../guide/index.js";
44
+ import { forgetScreen } from "./redraw.js";
45
+ import { SHOW_PLACES_NOTE, explainPrompt, showMePrompt, showPlacesTool, showStepsTool, stepsFromReply, stopsFromReply } from "../guide/index.js";
42
46
  import { Shell } from "../terminal/index.js";
43
47
  import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
44
48
  import { Settings } from "./Settings.js";
@@ -84,7 +88,7 @@ Keys:
84
88
  Ctrl+click, Option+click or right-click a name: go to its definition (an import's path opens the file)
85
89
  p problems u undo Nou's edit s settings
86
90
  c (in files) show only changed files Ctrl+C twice quit
87
- 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
91
+ 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
88
92
  In the editor:
89
93
  Shift+arrows / mouse drag select · Ctrl+A all · Ctrl+C/X/V copy/cut/paste · Esc clear selection or leave
90
94
  Ctrl+←/→ word · Ctrl+Backspace or Ctrl+W delete word · Ctrl+Home/End file start/end
@@ -108,13 +112,17 @@ export function App({ initialModel, permContext, resume }) {
108
112
  const { exit } = useApp();
109
113
  const { columns, rows } = useWindowSize();
110
114
  const [settings, setSettings] = useState(loadSettings);
111
- setCodeTheme(settings.codeTheme); // before anything below highlights code
115
+ // Before anything below highlights code. One Dark's light text is unreadable on a light terminal: there the
116
+ // default switches to GitHub Light (any other theme you picked stays).
117
+ setCodeTheme(palette() === "light" && settings.codeTheme === "one-dark" ? "github-light" : settings.codeTheme);
112
118
  const [view, setView] = useState("main");
113
119
  const [connectTo, setConnectTo] = useState();
114
120
  /** Why settings were opened for you (a request failed on the provider's setup). */
115
121
  const [setupReason, setSetupReason] = useState(null);
116
122
  /** The file finder / command palette: closed (null), or open with this query (">" for commands). */
117
123
  const [quickOpen, setQuickOpen] = useState(null);
124
+ /** /resume's picker: the conversations, in the palette's place while it's open. */
125
+ const [resumePicker, setResumePicker] = useState(null);
118
126
  const [focus, setFocus] = useState("chat");
119
127
  /** The Games panel: whether it's open, the pane it's being dragged over, and the pane to go back to. */
120
128
  const [gamesOpen, setGamesOpen] = useState(false);
@@ -127,19 +135,29 @@ export function App({ initialModel, permContext, resume }) {
127
135
  const [termVersion, setTermVersion] = useState(0);
128
136
  /** Its height while its top border is being dragged (saved when you let go). */
129
137
  const [termDrag, setTermDrag] = useState(null);
138
+ /** Bumped to draw every line again after the screen is cleared (/redraw, Ctrl+L). */
139
+ const [, setRepaint] = useState(0);
130
140
  /** The Games panel's height while its top border is being dragged (saved when you let go). */
131
141
  const [gameSizeDrag, setGameSizeDrag] = useState(null);
132
142
  /** The tab strip, kept while nothing in it changes: a new one would redraw the whole code view on every scroll. */
133
143
  const tabStripCache = useRef(null);
134
144
  /**
135
- * "Show me how" (/showme): the steps Nou found, and the one you're on. While Nou looks for them,
145
+ * "Teach me" (/teach): the steps Nou found, and the one you're on. While Nou looks for them,
136
146
  * `guideAsk` holds what you asked (and its edits are turned down: you're doing the typing).
137
147
  */
138
148
  const [guide, setGuide] = useState(null);
139
149
  const guideAsk = useRef(null);
140
150
  /** Where the undo history stood when the steps were asked for: an engine that edits anyway is put back. */
141
151
  const guideMark = useRef(0);
152
+ /** /resume just listed the conversations: the next message, if it's a number, picks one. */
153
+ const pickingResume = useRef(false);
142
154
  const guideFound = useRef(null);
155
+ /**
156
+ * "Show me" (/showme, or any explanation that points at code): the places the agent pointed at, and the
157
+ * one open in the code view. `tourFound` holds what Nou's engine passed to show_places during a turn.
158
+ */
159
+ const [tour, setTour] = useState(null);
160
+ const tourFound = useRef(null);
143
161
  const shellRef = useRef(null);
144
162
  const beforeTerm = useRef("code");
145
163
  const [follow, setFollow] = useState(settings.followMode);
@@ -201,6 +219,9 @@ export function App({ initialModel, permContext, resume }) {
201
219
  const [plugins, setPlugins] = useState([]);
202
220
  const [starters, setStarters] = useState([]);
203
221
  const [viewport, setViewport] = useState(null);
222
+ /** Folding in the code view: a click on a row's fold marker, and /fold, /unfold for the whole file. */
223
+ const [foldClick, setFoldClick] = useState(null);
224
+ const [foldAll, setFoldAll] = useState(null);
204
225
  // The editor reports every keystroke; the app only re-renders when what it shows changes (line, unsaved, selection).
205
226
  const editorStateRef = useRef(null);
206
227
  const [editorState, setEditorState] = useState(null);
@@ -536,15 +557,21 @@ export function App({ initialModel, permContext, resume }) {
536
557
  useEffect(() => {
537
558
  const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
538
559
  // Other engines read AGENTS.md / CLAUDE.md themselves: they get only the NOU.md rules on top.
539
- 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 };
560
+ 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 };
540
561
  agentRef.current.configure(engineConfig.current);
541
562
  }, [settings.gitContext, settings.effort, activePlugins, mcpVersion]);
542
- // Pick up changes made outside Nou (other editors, git commands).
563
+ // Pick up changes made outside Nou (other editors, git commands). Not while Nou's window is in the
564
+ // background (in a big repo each `git status` costs real CPU); coming back checks right away.
543
565
  const polling = useRef(false);
566
+ const wasFocused = useRef(true);
567
+ const windowFocused = useTerminalFocused();
568
+ useFocusReporting(true);
544
569
  useEffect(() => {
545
- if (!repo)
570
+ const cameBack = windowFocused && !wasFocused.current;
571
+ wasFocused.current = windowFocused;
572
+ if (!repo || !windowFocused)
546
573
  return;
547
- const timer = setInterval(() => {
574
+ const poll = () => {
548
575
  if (busy || polling.current)
549
576
  return;
550
577
  polling.current = true;
@@ -553,9 +580,12 @@ export function App({ initialModel, permContext, resume }) {
553
580
  // Only redraw when something changed.
554
581
  setGit((prev) => (sameStatus(prev, next) ? prev : next));
555
582
  });
556
- }, 4000);
583
+ };
584
+ if (cameBack)
585
+ poll();
586
+ const timer = setInterval(poll, 4000);
557
587
  return () => clearInterval(timer);
558
- }, [repo, busy]);
588
+ }, [repo, busy, windowFocused]);
559
589
  useEffect(() => {
560
590
  if (busy)
561
591
  return;
@@ -563,6 +593,24 @@ export function App({ initialModel, permContext, resume }) {
563
593
  if (openFile && diffMode === "off" && !editing)
564
594
  setContent(readText(openFile));
565
595
  }, [busy]);
596
+ /**
597
+ * Workspaces: when this Nou is one tab of several, the host says when it's on screen again (redraw all of
598
+ * it, and turn the mouse and focus reports back on, which another workspace may have turned off on its way
599
+ * out), and hears when it's busy, for the tab's ● .
600
+ */
601
+ const tabsOfWorkspaces = useWorkspaceTabs();
602
+ useEffect(() => {
603
+ connectToHost({
604
+ redraw: () => {
605
+ process.stdout.write(`\x1b[?1049h${settings.mouse ? MOUSE_ON : ""}\x1b[?1004h`);
606
+ redraw();
607
+ },
608
+ error: (text) => info(text, "error"),
609
+ });
610
+ }, []);
611
+ useEffect(() => {
612
+ sendToHost({ type: "state", busy: !!busy });
613
+ }, [!!busy]);
566
614
  const updateSettings = (patch) => setSettings(saveSettings(patch));
567
615
  const switchModel = (next) => {
568
616
  const problem = next.provider.unavailable();
@@ -624,18 +672,31 @@ export function App({ initialModel, permContext, resume }) {
624
672
  const list = resumable();
625
673
  if (!list.length)
626
674
  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.");
675
+ const where = (c) => ("file" in c ? `${engineInfo(c.engine).label} CLI` : engineInfo(isEngineId(c.engine) ? c.engine : "nou").label);
627
676
  if (!arg) {
628
- 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)})`);
629
- return info(`Earlier conversations here. /resume <number> picks one up (or /resume last):\n${rows.join("\n")}`);
677
+ 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)})`);
678
+ pickingResume.current = true; // a bare number, typed next, picks one
679
+ info(`Earlier conversations here (also in the picker: ↑↓ and Enter, or type a number here):\n${rows.join("\n")}`);
680
+ // A picker over the screen: ↑↓ and Enter, or type to filter by title.
681
+ setResumePicker(list.map((c, i) => ({ title: `${i + 1}. ${c.title}`, keys: `${where(c)} · ${ago(c.updated)}`, run: () => void resumePicked(c) })));
682
+ setQuickOpen(">");
683
+ return;
630
684
  }
631
685
  const pick = arg === "last" ? list[0] : list[Number(arg) - 1] ?? list.find((c) => c.id === arg);
632
686
  if (!pick)
633
687
  return info(`There's no conversation ${arg}: /resume lists them.`, "error");
688
+ return resumePicked(pick);
689
+ };
690
+ /** Picks a conversation back up: Nou's own, or a Claude Code or Codex CLI session. */
691
+ const resumePicked = async (pick) => {
692
+ pickingResume.current = false;
693
+ if (busy)
694
+ return info("Wait for the current reply to finish (or press Esc) before resuming another conversation.", "error");
634
695
  if ("file" in pick)
635
696
  return resumeExternal(pick);
636
697
  const saved = loadConversation(pick.id);
637
698
  if (!saved)
638
- return info(`Couldn't read conversation ${arg}.`, "error");
699
+ return info(`Couldn't read conversation "${pick.title}".`, "error");
639
700
  const engine = engineFor(isEngineId(saved.engine) ? saved.engine : null);
640
701
  const restored = saved.engine === engine.id && !!saved.state && !!engine.restore?.(saved.state);
641
702
  if (!restored) {
@@ -1001,6 +1062,14 @@ export function App({ initialModel, permContext, resume }) {
1001
1062
  }
1002
1063
  if (event.kind === "click" && !approval && event.y === 0 && event.x >= clickable.current.modelFrom)
1003
1064
  return setView("settings");
1065
+ // Workspace tabs in the header: click one to switch to it, + to open another.
1066
+ if (event.kind === "click" && event.y === 0) {
1067
+ const zone = headerZones(workspaceTabs()).find((z) => event.x >= z.from && event.x < z.to);
1068
+ if (zone?.tab === "new")
1069
+ return typeCommand("/workspace new ");
1070
+ if (typeof zone?.tab === "number")
1071
+ return void sendToHost({ type: "switch", index: zone.tab });
1072
+ }
1004
1073
  if (event.kind === "click" && event.y === paneHeight + 1)
1005
1074
  return clickable.current.status.find((z) => event.x >= z.from && event.x < z.to)?.run();
1006
1075
  if (event.y < 1 || event.y > paneHeight)
@@ -1078,17 +1147,32 @@ export function App({ initialModel, permContext, resume }) {
1078
1147
  }
1079
1148
  // Two clicks in the same spot within half a second are a double-click.
1080
1149
  const last = lastClick.current;
1081
- const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
1082
- lastClick.current = double ? null : { x: event.x, y: event.y, time: Date.now() };
1150
+ const again = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
1151
+ const count = again ? last.count + 1 : 1;
1152
+ const double = count === 2;
1153
+ // The chat counts a third click too (the whole paragraph); elsewhere a double-click starts over.
1154
+ lastClick.current = double && pane !== "chat" ? null : count >= 3 ? null : { x: event.x, y: event.y, time: Date.now(), count };
1155
+ // In the chat: a double-click selects and copies the word (or path, or link), a triple-click the paragraph.
1156
+ if (pane === "chat" && event.y >= 3 && count >= 2)
1157
+ chatAt(event.x, event.y, count === 2 ? "word" : "block");
1083
1158
  if (pane === "code" && event.y >= 3 && codePanel === "code") {
1159
+ // As the code view counts them (a last empty line after the final newline isn't shown).
1160
+ const digits = String(splitLines(content).length).length;
1084
1161
  if (showEditor) {
1085
1162
  dragging.current = "editor";
1086
1163
  editorAt(event.x, event.y, double ? "double" : "press");
1087
1164
  }
1165
+ else if (openFile && !diff && event.x - codeLeft - 1 === digits + 1) {
1166
+ // The fold marker (▾/▸) beside the line number: fold or unfold that block.
1167
+ lastClick.current = null;
1168
+ setFoldClick((f) => ({ row: event.y - 3, nonce: (f?.nonce ?? 0) + 1 }));
1169
+ }
1088
1170
  else if (double && openFile && !diff && settleDraft(openFile)) {
1089
1171
  // Double-click in the code: edit right there.
1090
- const gutter = String(content.split("\n").length).length + 1;
1091
- setEditorStart((s) => ({ line: (viewport?.top ?? 1) + event.y - 3, col: Math.max(1, event.x - codeLeft - gutter), nonce: s.nonce + 1 }));
1172
+ // The code view's gutter: the line number, a marker and the fold arrow; folds hide lines between rows.
1173
+ const gutter = digits + 2;
1174
+ const line = viewport?.lines?.[event.y - 3] ?? (viewport?.top ?? 1) + event.y - 3;
1175
+ setEditorStart((s) => ({ line, col: Math.max(1, event.x - codeLeft - gutter), nonce: s.nonce + 1 }));
1092
1176
  setDiffMode("off");
1093
1177
  setEditing(true);
1094
1178
  }
@@ -1414,6 +1498,8 @@ export function App({ initialModel, permContext, resume }) {
1414
1498
  setFocus("chat");
1415
1499
  return;
1416
1500
  }
1501
+ if (key.ctrl && input === "l")
1502
+ return redraw(); // (the editor and terminal use Ctrl+L themselves)
1417
1503
  if (focus === "chat")
1418
1504
  return; // letters belong to the text box
1419
1505
  if (input === "?")
@@ -1602,6 +1688,21 @@ ${snippet}
1602
1688
  return info(`No file matches "${wanted}". To find where something is in the code, ask in the chat.`, "error");
1603
1689
  return openRef({ path: file, ...(m[2] ? { line: Number(m[2]) } : {}), ...(m[3] ? { to: Number(m[3]) } : {}) });
1604
1690
  }
1691
+ case "effort": {
1692
+ // Speed vs smarts, by name: /effort smarter (or the effort itself: /effort xhigh).
1693
+ const options = EFFORTS.map((e) => EFFORT_LABELS[e].name.toLowerCase()).join(", ");
1694
+ if (!arg)
1695
+ return info(`Now: ${EFFORT_LABELS[settings.effort].name}. ${EFFORT_LABELS[settings.effort].tradeoff} Change it with /effort ${options}.`);
1696
+ const next = effortFrom(arg);
1697
+ if (!next)
1698
+ return info(`No such level: "${arg}". Try one of: ${options}.`, "error");
1699
+ updateSettings({ effort: next });
1700
+ return info(`Now: ${EFFORT_LABELS[next].name}. ${EFFORT_LABELS[next].tradeoff}`);
1701
+ }
1702
+ case "now":
1703
+ if (!queued.length)
1704
+ return info(busy ? "Nothing is queued. Type a message and press Alt+Enter to stop Nou and send it now." : "Nothing is queued.");
1705
+ return sendNow();
1605
1706
  case "games":
1606
1707
  return toggleGames();
1607
1708
  case "cancel":
@@ -1716,6 +1817,19 @@ ${snippet}
1716
1817
  return;
1717
1818
  case "terminal":
1718
1819
  return toggleTerminal();
1820
+ case "redraw":
1821
+ return redraw();
1822
+ case "workspace":
1823
+ case "workspaces":
1824
+ case "ws":
1825
+ return void workspaceCommand(arg);
1826
+ case "fold":
1827
+ case "unfold":
1828
+ if (!openFile)
1829
+ return info("Open a file first: /fold folds every function and block in it, /unfold opens them.", "error");
1830
+ setEditing(false);
1831
+ setFocus("code");
1832
+ return setFoldAll((f) => ({ fold: command === "fold", nonce: (f?.nonce ?? 0) + 1 }));
1719
1833
  case "new":
1720
1834
  return newChatTab();
1721
1835
  case "close":
@@ -1727,7 +1841,9 @@ ${snippet}
1727
1841
  return switchChatTab(chatTabs[n - 1]);
1728
1842
  }
1729
1843
  case "showme":
1844
+ return showPlaces(rest.join(" "));
1730
1845
  case "teach":
1846
+ case "teachme":
1731
1847
  return showMe(rest.join(" "));
1732
1848
  case "resume":
1733
1849
  return resumeConversation(rest[0] ?? "");
@@ -1752,6 +1868,13 @@ ${snippet}
1752
1868
  }
1753
1869
  return;
1754
1870
  }
1871
+ // A typo of a command ("/termnial"): one that only changes the screen just runs; others are suggested,
1872
+ // so a slip never clears or undoes anything.
1873
+ const meant = closestCommand(command ?? "", [...SLASH_COMMANDS.map((c) => c.name), ...activePlugins.flatMap(({ plugin }) => plugin.commands?.map((c) => c.name) ?? [])]);
1874
+ if (meant && onlyChangesScreen(meant))
1875
+ return runCommand(`/${meant}${arg ? ` ${arg}` : ""}`);
1876
+ if (meant)
1877
+ return info(`Unknown command /${command}. Did you mean /${meant}${arg ? ` ${arg}` : ""}?`, "error");
1755
1878
  return info(`Unknown command /${command}. Type /help.`, "error");
1756
1879
  }
1757
1880
  }
@@ -1833,6 +1956,57 @@ ${snippet}
1833
1956
  setQueued(queued.slice(take));
1834
1957
  send(queued.slice(0, take).join("\n\n"));
1835
1958
  }, [busy, approval, queued]);
1959
+ // When a reply points at places in the code (Nou's engine with show_places, the others with a ```nou-show
1960
+ // block, which is taken out of the reply), the first one opens highlighted and the rest are listed.
1961
+ useEffect(() => {
1962
+ if (busy || guideAsk.current)
1963
+ return;
1964
+ let stops = tourFound.current;
1965
+ tourFound.current = null;
1966
+ const reply = [...entries].reverse().find((e) => e.kind === "assistant");
1967
+ const found = reply && stopsFromReply(reply.text);
1968
+ if (found) {
1969
+ stops = stops?.length ? stops : found.stops;
1970
+ setEntries((prev) => prev.map((e) => (e === reply ? { ...e, text: found.rest || "Here's where." } : e)));
1971
+ }
1972
+ stops = (stops ?? []).filter((s) => existsSync(s.file));
1973
+ if (!stops.length)
1974
+ return;
1975
+ const list = stops.map((s, i) => `${i + 1}. ${s.file}:${s.line}${s.end > s.line ? `-${s.end}` : ""}${s.note ? ` · ${s.note}` : ""}`).join("\n");
1976
+ info(`📍 ${stops.length === 1 ? "Here it is" : `${stops.length} places`}, open in the code view (click one to jump there${stops.length > 1 ? "; /showme next and /showme back step through them" : ""}):\n${list}`);
1977
+ setTour({ stops, at: 0 });
1978
+ }, [busy]);
1979
+ // Each place: open it in the code view with its lines highlighted.
1980
+ useEffect(() => {
1981
+ const stop = tour?.stops[tour.at];
1982
+ if (!stop)
1983
+ return;
1984
+ openRef({ path: stop.file, line: stop.line, ...(stop.end > stop.line ? { to: stop.end } : {}) });
1985
+ }, [tour?.at, tour?.stops]);
1986
+ /** /showme <question>: explains it and opens the places in the code; /showme next|back|stop steps through them. */
1987
+ const showPlaces = (arg) => {
1988
+ const words = arg.trim();
1989
+ if (/^(stop|end|done)$/.test(words)) {
1990
+ setTour(null);
1991
+ return info(tour ? "Done showing." : "Nothing is being shown.");
1992
+ }
1993
+ if (/^(next|back|prev|previous)$/.test(words)) {
1994
+ if (!tour)
1995
+ return info("Nothing is being shown. Ask with /showme <question>, e.g. /showme where do we check the login?");
1996
+ const at = tour.at + (words === "next" ? 1 : -1);
1997
+ if (at < 0 || at >= tour.stops.length)
1998
+ return info(at < 0 ? "That's the first place." : "That was the last place.");
1999
+ const stop = tour.stops[at];
2000
+ info(`📍 ${at + 1} of ${tour.stops.length}: ${stop.file}:${stop.line}${stop.end > stop.line ? `-${stop.end}` : ""}${stop.note ? ` · ${stop.note}` : ""}`);
2001
+ return setTour({ ...tour, at });
2002
+ }
2003
+ if (!words)
2004
+ 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.)");
2005
+ if (busy)
2006
+ return info("Wait for the current reply to finish (or press Esc) first.", "error");
2007
+ setTour(null);
2008
+ send(explainPrompt(words, agentRef.current.id === "nou"));
2009
+ };
1836
2010
  // When Nou has finished looking, the guide starts at its first step.
1837
2011
  useEffect(() => {
1838
2012
  if (busy || !guideAsk.current)
@@ -1858,8 +2032,8 @@ ${snippet}
1858
2032
  info("It edited files anyway, so those changes were put back: you're the one typing them.");
1859
2033
  }
1860
2034
  if (!steps?.length)
1861
- 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");
1862
- 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.`);
2035
+ 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");
2036
+ 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.`);
1863
2037
  setGuide({ steps, at: 0, task });
1864
2038
  }, [busy]);
1865
2039
  // Each step: open its file in the editor where the code goes, and say what to type and why.
@@ -1895,7 +2069,7 @@ ${snippet}
1895
2069
  setGuide(null);
1896
2070
  }
1897
2071
  });
1898
- /** /showme <what you want>: Nou finds where the change goes and walks you through typing it. */
2072
+ /** /teach <what you want>: Nou finds where the change goes and walks you through typing it. */
1899
2073
  const showMe = (arg) => {
1900
2074
  const words = arg.trim();
1901
2075
  if (words === "stop" || words === "end") {
@@ -1913,8 +2087,8 @@ ${snippet}
1913
2087
  }
1914
2088
  if (!words) {
1915
2089
  if (guide)
1916
- return info(`On step ${guide.at + 1} of ${guide.steps.length}: ${guideStep.file}:${guideStep.line}. /showme next skips it, /showme stop ends it.`);
1917
- 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");
2090
+ return info(`On step ${guide.at + 1} of ${guide.steps.length}: ${guideStep.file}:${guideStep.line}. /teach next skips it, /teach stop ends it.`);
2091
+ 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");
1918
2092
  }
1919
2093
  if (busy)
1920
2094
  return info("Wait for the current reply to finish (or press Esc) first.", "error");
@@ -1928,10 +2102,21 @@ ${snippet}
1928
2102
  if (busy)
1929
2103
  stopped.current = false;
1930
2104
  }, [busy]);
1931
- /** From the chat: while Nou works, a message waits its turn, or (`now`) stops Nou and goes right away. */
1932
- const submit = (text, now = false) => {
2105
+ /** Stops Nou and sends what's queued right away (a click on the queue, or /now). */
2106
+ const sendNow = () => {
2107
+ if (!queued.length)
2108
+ return;
2109
+ stop();
2110
+ stopped.current = false; // what's queued is meant to go
2111
+ };
2112
+ /**
2113
+ * From the chat: while Nou works, a message waits its turn, or (`now`: Alt+Enter) stops Nou and goes right
2114
+ * away. The "Send while Nou works" setting swaps the two, so Enter sends now and Alt+Enter queues.
2115
+ */
2116
+ const submit = (text, alt = false) => {
1933
2117
  if (!busy || (text.startsWith("/") && runsWhileBusy(text)))
1934
2118
  return send(text);
2119
+ const now = settings.sendNowWhileBusy ? !alt : alt;
1935
2120
  if (now) {
1936
2121
  stop();
1937
2122
  stopped.current = false; // this one is meant to go
@@ -1941,6 +2126,11 @@ ${snippet}
1941
2126
  setQueued((q) => [...q, text]);
1942
2127
  };
1943
2128
  const send = (text) => {
2129
+ // Right after /resume lists them, a number picks a conversation instead of being sent to the agent.
2130
+ const picking = pickingResume.current;
2131
+ pickingResume.current = false;
2132
+ if (picking && /^\s*\d+\s*$/.test(text))
2133
+ return void resumeConversation(text.trim());
1944
2134
  if (text.startsWith("/")) {
1945
2135
  void runCommand(text);
1946
2136
  return;
@@ -1984,7 +2174,7 @@ ${snippet}
1984
2174
  const onTreeHover = useEvent(() => { });
1985
2175
  const onJump = useEvent((file, line) => openAt(file, line));
1986
2176
  const onCloseProblems = useEvent(() => setCodePanel("code"));
1987
- const onViewport = useEvent((top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })));
2177
+ const onViewport = useEvent((top, bottom, lines) => setViewport((v) => (v?.top === top && v.bottom === bottom && v.lines?.join() === lines?.join() ? v : { top, bottom, ...(lines ? { lines } : {}) })));
1988
2178
  const onSend = useEvent(submit);
1989
2179
  const onChatMenu = useEvent((open) => {
1990
2180
  chatMenuOpen.current = open;
@@ -2080,6 +2270,56 @@ ${snippet}
2080
2270
  updateSettings({ mouse: !settings.mouse });
2081
2271
  info(settings.mouse ? "Mouse off: drag to select text and copy it as usual in your terminal. m turns it back on." : "Mouse on: wheel scrolls, click focuses, drag borders to resize.");
2082
2272
  };
2273
+ /** /redraw, Ctrl+L: draws the whole screen again, in case anything else wrote to the terminal and shifted it. */
2274
+ function redraw() {
2275
+ forgetScreen();
2276
+ setRepaint((n) => n + 1);
2277
+ }
2278
+ /** /workspace: list them, `new [folder]` opens one, a number switches. */
2279
+ async function workspaceCommand(arg) {
2280
+ const [sub, ...more] = arg.split(/\s+/);
2281
+ const tabs = workspaceTabs();
2282
+ if (!sub) {
2283
+ if (!isHosted())
2284
+ 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).`);
2285
+ 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.`);
2286
+ }
2287
+ if (/^\d+$/.test(sub)) {
2288
+ const n = Number(sub);
2289
+ if (!isHosted() || !tabs[n - 1])
2290
+ return info(`There's no workspace ${n}. /workspace lists them.`, "error");
2291
+ return void sendToHost({ type: "switch", index: n - 1 });
2292
+ }
2293
+ if (sub !== "new")
2294
+ return info("Try /workspace new <folder>, /workspace <n> or /workspace.", "error");
2295
+ const wanted = more.join(" ");
2296
+ if (isHosted())
2297
+ return void sendToHost({ type: "new", folder: wanted });
2298
+ const folder = workspaceFolder(wanted, process.cwd());
2299
+ if (!folder)
2300
+ return info(`No such folder: ${wanted}`, "error");
2301
+ if (busy)
2302
+ return info("Wait for the current reply to finish (or press Esc) first: Nou reopens as a workspaces window.", "error");
2303
+ // The pseudo-terminals workspaces run in come from a small native package, installed once like an engine.
2304
+ const pty = { id: "nou", label: "workspaces", description: "", approvals: "before", model: () => "", package: PTY_PACKAGE };
2305
+ if (!isInstalled(pty)) {
2306
+ info(`Workspaces need ${PTY_PACKAGE} (pseudo-terminals, prebuilt for macOS, Linux and Windows). Installing it once into ~/.nou/engines...`);
2307
+ setBusy("Installing workspaces support (npm)...");
2308
+ try {
2309
+ await installEngine(pty, (line) => setBusy(`Installing workspaces support (npm): ${line.slice(0, 70)}`));
2310
+ }
2311
+ catch (error) {
2312
+ return info(`Couldn't install ${PTY_PACKAGE}: ${error.message}`, "error");
2313
+ }
2314
+ finally {
2315
+ setBusy(null);
2316
+ }
2317
+ }
2318
+ if (draft.current && settings.autoSave !== "off")
2319
+ saveFromEditor(draft.current.text, draft.current.path);
2320
+ reopenAsWorkspaces([process.cwd(), folder]);
2321
+ exit();
2322
+ }
2083
2323
  /** Opens the terminal panel (starting its shell the first time) and gives it the keyboard, or closes it. */
2084
2324
  function toggleTerminal(open = !termOpen) {
2085
2325
  shellRef.current ??= new Shell(() => setTermVersion((v) => v + 1));
@@ -2113,6 +2353,7 @@ ${snippet}
2113
2353
  setFocus(beforeGame.current);
2114
2354
  };
2115
2355
  // Plain functions, not hooks: this runs after the settings screen's early return.
2356
+ const onGameScore = (game, score) => updateSettings({ gameBest: { ...(settings.gameBest ?? {}), [game]: score } });
2116
2357
  const leaveGames = () => setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
2117
2358
  const gamesSettings = () => {
2118
2359
  setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
@@ -2145,7 +2386,7 @@ ${snippet}
2145
2386
  { title: "MCP servers: tools from other programs", keys: "/mcp", run: () => void runCommand("/mcp") },
2146
2387
  { title: "Browser control (Playwright MCP)", keys: "/mcp add browser", run: () => void runCommand("/mcp add browser") },
2147
2388
  { title: "Import MCP servers from Claude Code, Cursor, Codex...", keys: "/mcp import", run: () => void runCommand("/mcp import") },
2148
- { title: gamesOpen ? "Close the Games panel" : "Games: Pong, Snake, Breakout", keys: "/games", run: () => toggleGames() },
2389
+ { title: gamesOpen ? "Close the Games panel" : "Games: Pong, Snake, Breakout, Dodge, Hop, Pairs", keys: "/games", run: () => toggleGames() },
2149
2390
  { title: "Next tab", keys: "]", run: () => cycleTab(1) },
2150
2391
  { title: "Previous tab", keys: "[", run: () => cycleTab(-1) },
2151
2392
  { title: "Close the tab", keys: "x", run: () => closeTab() },
@@ -2252,9 +2493,9 @@ ${snippet}
2252
2493
  }
2253
2494
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
2254
2495
  clickable.current = { status: zones, modelFrom: columns - (" ⌂ LOCAL ".length + ` ${modelLabel} `.length + 1) };
2255
- 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 })), _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
2496
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update, tabs: tabsOfWorkspaces }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, git: git, problemCounts: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest, foldClick: foldClick, foldAll: foldAll })), _jsx(Chat, { width: chatWidth, height: heights.chat, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, sendNowFirst: settings.sendNowWhileBusy, onSendNow: sendNow, onAnswer: onAnswer, click: chatClick, select: chatSelect, tabs: chatTabsProp(), onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: C.onBadge, bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: followChip }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
2256
2497
  ? _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)` : ""] })
2257
- : 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(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, onLeave: leaveGames, onOpenSettings: gamesSettings }) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: paletteActions, initialQuery: quickOpen, onPick: (file) => {
2498
+ : 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) => {
2258
2499
  setQuickOpen(null);
2259
2500
  clearFollow();
2260
2501
  open(file);
@@ -2262,6 +2503,10 @@ ${snippet}
2262
2503
  setFocus("code");
2263
2504
  }, onRun: (action) => {
2264
2505
  setQuickOpen(null);
2506
+ setResumePicker(null);
2265
2507
  action.run();
2266
- }, onClose: () => setQuickOpen(null) }))] }));
2508
+ }, onClose: () => {
2509
+ setQuickOpen(null);
2510
+ setResumePicker(null);
2511
+ } }))] }));
2267
2512
  }