nou-cli 0.5.6 → 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";
@@ -26,21 +26,23 @@ 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";
31
33
  import { FileTree } from "./FileTree.js";
32
34
  import { CodeView } from "./CodeView.js";
33
35
  import { Editor } from "./Editor.js";
34
- import { SLASH_COMMANDS, runsWhileBusy } from "./commands.js";
36
+ import { SLASH_COMMANDS, closestCommand, onlyChangesScreen, runsWhileBusy } from "./commands.js";
35
37
  import { WHEEL_STEP } from "./scroll.js";
36
38
  import { Chat, chatTabZones } from "./Chat.js";
37
39
  import { copyToClipboard, openInBrowser } from "./clipboard.js";
38
- import { Header } from "./Header.js";
40
+ import { Header, headerZones } from "./Header.js";
39
41
  import { Tabs, layoutTabs } from "./Tabs.js";
40
42
  import { GAME_PANEL, GameGuard, GamePanel } from "./GamePanel.js";
41
43
  import { Terminal } from "./Terminal.js";
42
44
  import { forgetScreen } from "./redraw.js";
43
- import { showMePrompt, showStepsTool, stepsFromReply } from "../guide/index.js";
45
+ import { SHOW_PLACES_NOTE, explainPrompt, showMePrompt, showPlacesTool, showStepsTool, stepsFromReply, stopsFromReply } from "../guide/index.js";
44
46
  import { Shell } from "../terminal/index.js";
45
47
  import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
46
48
  import { Settings } from "./Settings.js";
@@ -86,7 +88,7 @@ Keys:
86
88
  Ctrl+click, Option+click or right-click a name: go to its definition (an import's path opens the file)
87
89
  p problems u undo Nou's edit s settings
88
90
  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
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
90
92
  In the editor:
91
93
  Shift+arrows / mouse drag select · Ctrl+A all · Ctrl+C/X/V copy/cut/paste · Esc clear selection or leave
92
94
  Ctrl+←/→ word · Ctrl+Backspace or Ctrl+W delete word · Ctrl+Home/End file start/end
@@ -110,13 +112,17 @@ export function App({ initialModel, permContext, resume }) {
110
112
  const { exit } = useApp();
111
113
  const { columns, rows } = useWindowSize();
112
114
  const [settings, setSettings] = useState(loadSettings);
113
- 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);
114
118
  const [view, setView] = useState("main");
115
119
  const [connectTo, setConnectTo] = useState();
116
120
  /** Why settings were opened for you (a request failed on the provider's setup). */
117
121
  const [setupReason, setSetupReason] = useState(null);
118
122
  /** The file finder / command palette: closed (null), or open with this query (">" for commands). */
119
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);
120
126
  const [focus, setFocus] = useState("chat");
121
127
  /** The Games panel: whether it's open, the pane it's being dragged over, and the pane to go back to. */
122
128
  const [gamesOpen, setGamesOpen] = useState(false);
@@ -136,7 +142,7 @@ export function App({ initialModel, permContext, resume }) {
136
142
  /** The tab strip, kept while nothing in it changes: a new one would redraw the whole code view on every scroll. */
137
143
  const tabStripCache = useRef(null);
138
144
  /**
139
- * "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,
140
146
  * `guideAsk` holds what you asked (and its edits are turned down: you're doing the typing).
141
147
  */
142
148
  const [guide, setGuide] = useState(null);
@@ -146,6 +152,12 @@ export function App({ initialModel, permContext, resume }) {
146
152
  /** /resume just listed the conversations: the next message, if it's a number, picks one. */
147
153
  const pickingResume = useRef(false);
148
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);
149
161
  const shellRef = useRef(null);
150
162
  const beforeTerm = useRef("code");
151
163
  const [follow, setFollow] = useState(settings.followMode);
@@ -545,7 +557,7 @@ export function App({ initialModel, permContext, resume }) {
545
557
  useEffect(() => {
546
558
  const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
547
559
  // 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 };
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 };
549
561
  agentRef.current.configure(engineConfig.current);
550
562
  }, [settings.gitContext, settings.effort, activePlugins, mcpVersion]);
551
563
  // Pick up changes made outside Nou (other editors, git commands). Not while Nou's window is in the
@@ -581,6 +593,24 @@ export function App({ initialModel, permContext, resume }) {
581
593
  if (openFile && diffMode === "off" && !editing)
582
594
  setContent(readText(openFile));
583
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]);
584
614
  const updateSettings = (patch) => setSettings(saveSettings(patch));
585
615
  const switchModel = (next) => {
586
616
  const problem = next.provider.unavailable();
@@ -642,19 +672,31 @@ export function App({ initialModel, permContext, resume }) {
642
672
  const list = resumable();
643
673
  if (!list.length)
644
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);
645
676
  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)})`);
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)})`);
647
678
  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")}`);
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;
649
684
  }
650
685
  const pick = arg === "last" ? list[0] : list[Number(arg) - 1] ?? list.find((c) => c.id === arg);
651
686
  if (!pick)
652
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");
653
695
  if ("file" in pick)
654
696
  return resumeExternal(pick);
655
697
  const saved = loadConversation(pick.id);
656
698
  if (!saved)
657
- return info(`Couldn't read conversation ${arg}.`, "error");
699
+ return info(`Couldn't read conversation "${pick.title}".`, "error");
658
700
  const engine = engineFor(isEngineId(saved.engine) ? saved.engine : null);
659
701
  const restored = saved.engine === engine.id && !!saved.state && !!engine.restore?.(saved.state);
660
702
  if (!restored) {
@@ -1020,6 +1062,14 @@ export function App({ initialModel, permContext, resume }) {
1020
1062
  }
1021
1063
  if (event.kind === "click" && !approval && event.y === 0 && event.x >= clickable.current.modelFrom)
1022
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
+ }
1023
1073
  if (event.kind === "click" && event.y === paneHeight + 1)
1024
1074
  return clickable.current.status.find((z) => event.x >= z.from && event.x < z.to)?.run();
1025
1075
  if (event.y < 1 || event.y > paneHeight)
@@ -1097,8 +1147,14 @@ export function App({ initialModel, permContext, resume }) {
1097
1147
  }
1098
1148
  // Two clicks in the same spot within half a second are a double-click.
1099
1149
  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() };
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");
1102
1158
  if (pane === "code" && event.y >= 3 && codePanel === "code") {
1103
1159
  // As the code view counts them (a last empty line after the final newline isn't shown).
1104
1160
  const digits = String(splitLines(content).length).length;
@@ -1632,6 +1688,21 @@ ${snippet}
1632
1688
  return info(`No file matches "${wanted}". To find where something is in the code, ask in the chat.`, "error");
1633
1689
  return openRef({ path: file, ...(m[2] ? { line: Number(m[2]) } : {}), ...(m[3] ? { to: Number(m[3]) } : {}) });
1634
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();
1635
1706
  case "games":
1636
1707
  return toggleGames();
1637
1708
  case "cancel":
@@ -1748,6 +1819,10 @@ ${snippet}
1748
1819
  return toggleTerminal();
1749
1820
  case "redraw":
1750
1821
  return redraw();
1822
+ case "workspace":
1823
+ case "workspaces":
1824
+ case "ws":
1825
+ return void workspaceCommand(arg);
1751
1826
  case "fold":
1752
1827
  case "unfold":
1753
1828
  if (!openFile)
@@ -1766,7 +1841,9 @@ ${snippet}
1766
1841
  return switchChatTab(chatTabs[n - 1]);
1767
1842
  }
1768
1843
  case "showme":
1844
+ return showPlaces(rest.join(" "));
1769
1845
  case "teach":
1846
+ case "teachme":
1770
1847
  return showMe(rest.join(" "));
1771
1848
  case "resume":
1772
1849
  return resumeConversation(rest[0] ?? "");
@@ -1791,6 +1868,13 @@ ${snippet}
1791
1868
  }
1792
1869
  return;
1793
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");
1794
1878
  return info(`Unknown command /${command}. Type /help.`, "error");
1795
1879
  }
1796
1880
  }
@@ -1872,6 +1956,57 @@ ${snippet}
1872
1956
  setQueued(queued.slice(take));
1873
1957
  send(queued.slice(0, take).join("\n\n"));
1874
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
+ };
1875
2010
  // When Nou has finished looking, the guide starts at its first step.
1876
2011
  useEffect(() => {
1877
2012
  if (busy || !guideAsk.current)
@@ -1897,8 +2032,8 @@ ${snippet}
1897
2032
  info("It edited files anyway, so those changes were put back: you're the one typing them.");
1898
2033
  }
1899
2034
  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.`);
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.`);
1902
2037
  setGuide({ steps, at: 0, task });
1903
2038
  }, [busy]);
1904
2039
  // Each step: open its file in the editor where the code goes, and say what to type and why.
@@ -1934,7 +2069,7 @@ ${snippet}
1934
2069
  setGuide(null);
1935
2070
  }
1936
2071
  });
1937
- /** /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. */
1938
2073
  const showMe = (arg) => {
1939
2074
  const words = arg.trim();
1940
2075
  if (words === "stop" || words === "end") {
@@ -1952,8 +2087,8 @@ ${snippet}
1952
2087
  }
1953
2088
  if (!words) {
1954
2089
  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");
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");
1957
2092
  }
1958
2093
  if (busy)
1959
2094
  return info("Wait for the current reply to finish (or press Esc) first.", "error");
@@ -1967,10 +2102,21 @@ ${snippet}
1967
2102
  if (busy)
1968
2103
  stopped.current = false;
1969
2104
  }, [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) => {
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) => {
1972
2117
  if (!busy || (text.startsWith("/") && runsWhileBusy(text)))
1973
2118
  return send(text);
2119
+ const now = settings.sendNowWhileBusy ? !alt : alt;
1974
2120
  if (now) {
1975
2121
  stop();
1976
2122
  stopped.current = false; // this one is meant to go
@@ -2129,6 +2275,51 @@ ${snippet}
2129
2275
  forgetScreen();
2130
2276
  setRepaint((n) => n + 1);
2131
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
+ }
2132
2323
  /** Opens the terminal panel (starting its shell the first time) and gives it the keyboard, or closes it. */
2133
2324
  function toggleTerminal(open = !termOpen) {
2134
2325
  shellRef.current ??= new Shell(() => setTermVersion((v) => v + 1));
@@ -2302,9 +2493,9 @@ ${snippet}
2302
2493
  }
2303
2494
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
2304
2495
  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
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
2306
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)` : ""] })
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) => {
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) => {
2308
2499
  setQuickOpen(null);
2309
2500
  clearFollow();
2310
2501
  open(file);
@@ -2312,6 +2503,10 @@ ${snippet}
2312
2503
  setFocus("code");
2313
2504
  }, onRun: (action) => {
2314
2505
  setQuickOpen(null);
2506
+ setResumePicker(null);
2315
2507
  action.run();
2316
- }, onClose: () => setQuickOpen(null) }))] }));
2508
+ }, onClose: () => {
2509
+ setQuickOpen(null);
2510
+ setResumePicker(null);
2511
+ } }))] }));
2317
2512
  }
@@ -100,8 +100,10 @@ export function chatTabZones(tabs) {
100
100
  return { zones, plus: x + 1, width: x + 3 };
101
101
  }
102
102
  const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
103
+ /** Characters of a word for a double-click: paths and web addresses come whole (src/a.ts, https://x.io/y). */
104
+ const WORD_CHAR = /[\p{L}\p{N}_.\-/@:~#%?=&+]/u;
103
105
  const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
104
- export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [] }) {
106
+ export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [], sendNowFirst = false, onSendNow }) {
105
107
  const [input, setInputText] = useState("");
106
108
  // Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
107
109
  // still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
@@ -192,9 +194,11 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
192
194
  else if (busy) {
193
195
  footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
194
196
  }
195
- for (const text of queued) {
196
- footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim });
197
- }
197
+ // Click what's queued (or /now) to stop Nou and send it right away.
198
+ for (const text of queued)
199
+ footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim, sendNow: true });
200
+ if (queued.length && busy)
201
+ footer.push({ text: " ↳ click to send now, or /now", color: C.accent2, sendNow: true });
198
202
  // Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
199
203
  const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
200
204
  const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
@@ -224,6 +228,12 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
224
228
  useEffect(() => {
225
229
  if (!click)
226
230
  return;
231
+ if (click.row >= rows) {
232
+ // Under the messages: a click on what's queued sends it now.
233
+ if (footer[click.row - rows]?.sendNow)
234
+ onSendNow?.();
235
+ return;
236
+ }
227
237
  const line = visible[click.row];
228
238
  const run = line?.toggle;
229
239
  if (run) {
@@ -252,6 +262,40 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
252
262
  if (!select)
253
263
  return;
254
264
  const at = { line: Math.max(0, Math.min(lines.length - 1, firstShown + select.row)), col: Math.max(0, select.col) };
265
+ if (select.kind === "word" || select.kind === "block") {
266
+ anchor.current = null; // the button coming up doesn't change it
267
+ let range;
268
+ if (select.kind === "word") {
269
+ const chars = [...lineText(lines[at.line] ?? { text: "" })];
270
+ if (!WORD_CHAR.test(chars[at.col] ?? ""))
271
+ return;
272
+ let start = at.col;
273
+ let end = at.col;
274
+ while (start > 0 && WORD_CHAR.test(chars[start - 1]))
275
+ start--;
276
+ while (end < chars.length - 1 && WORD_CHAR.test(chars[end + 1]))
277
+ end++;
278
+ // Punctuation that ends a sentence isn't part of the word.
279
+ while (end > start && /[.,:;?!]/.test(chars[end]))
280
+ end--;
281
+ range = { from: { line: at.line, col: start }, to: { line: at.line, col: end } };
282
+ }
283
+ else {
284
+ const blank = (i) => !lineText(lines[i] ?? { text: "" }).trim();
285
+ if (blank(at.line))
286
+ return;
287
+ let first = at.line;
288
+ let last = at.line;
289
+ while (first > 0 && !blank(first - 1))
290
+ first--;
291
+ while (last < lines.length - 1 && !blank(last + 1))
292
+ last++;
293
+ range = { from: { line: first, col: 0 }, to: { line: last, col: Infinity } };
294
+ }
295
+ setSelection(range);
296
+ copySelection(range);
297
+ return;
298
+ }
255
299
  if (select.kind === "press") {
256
300
  anchor.current = at;
257
301
  setSelection(null);
@@ -271,17 +315,22 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
271
315
  if (select.kind !== "release")
272
316
  return;
273
317
  anchor.current = null;
274
- if (!range)
275
- return;
318
+ if (range)
319
+ copySelection(range);
320
+ }, [select?.nonce]);
321
+ /** Copies the selected text (and says so for a moment). Columns count characters, as the screen shows them. */
322
+ const copySelection = (range) => {
276
323
  const text = lines.slice(range.from.line, range.to.line + 1).map((line, i, all) => {
277
- const t = lineText(line);
278
- return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 ? range.to.col + 1 : undefined).trimEnd();
324
+ const t = [...lineText(line)];
325
+ return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 && Number.isFinite(range.to.col) ? range.to.col + 1 : undefined).join("").trimEnd();
279
326
  }).join("\n");
327
+ if (!text)
328
+ return;
280
329
  void copyToClipboard(text).then((ok) => {
281
330
  setFlash(ok ? `Copied ${text.split("\n").length > 1 ? `${text.split("\n").length} lines` : "it"} ✓` : "Can't copy: press m, then select");
282
331
  setTimeout(() => setFlash(null), 2000);
283
332
  });
284
- }, [select?.nonce]);
333
+ };
285
334
  // Typing or new messages leave the selection be; a new conversation clears it.
286
335
  useEffect(() => {
287
336
  if (!entries.length)
@@ -360,5 +409,5 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
360
409
  return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: messages }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), showMenu ? (_jsx(Box, { flexDirection: "column", children: matches.slice(menuStart, menuStart + menuRows).map((c, i) => {
361
410
  const selected = menuStart + i === menuRow;
362
411
  return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsxs(Text, { color: selected ? C.accent : C.text, bold: selected, children: ["/", c.name] }), c.args && inner >= 56 ? _jsxs(Text, { color: C.dim, children: [" ", c.args] }) : null /* narrow: the description matters more */, _jsxs(Text, { color: C.dim, children: [" ", c.description] })] }, c.name));
363
- }) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " })), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? "Enter queues · Alt+Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
412
+ }) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " })), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? (sendNowFirst ? "Enter sends now · Alt+Enter queues · Esc stops" : "Enter queues · Alt+Enter sends now · Esc stops") : sendNowFirst ? "Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
364
413
  });
@@ -216,7 +216,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
216
216
  return (_jsx(Box, { width: w, children: _jsxs(Text, { wrap: "truncate-end", ...(color ? { color } : { color: C.dim }), children: [String(c.n).padStart(gutter - 1), changed ? side : " ", c.text.slice(left) || " "] }) }));
217
217
  };
218
218
  const { icon, color } = fileIcon(diff.path);
219
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: "black", children: pending ? (diff.isNew ? " NEW FILE " : " PROPOSED ") : ` ${diffLabel.toUpperCase()} ` }), " ", _jsxs(Text, { color: C.green, children: ["+", added] }), " ", _jsxs(Text, { color: C.red, children: ["-", removed] }), diffNote ? _jsxs(Text, { color: diffNote.startsWith("✓") ? C.green : C.red, children: [" ", diffNote] }) : null, _jsxs(Text, { color: C.dim, children: [pending ? " y accept · n reject" : " n/N next/prev change", scroll] })] }));
219
+ const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: C.onBadge, children: pending ? (diff.isNew ? " NEW FILE " : " PROPOSED ") : ` ${diffLabel.toUpperCase()} ` }), " ", _jsxs(Text, { color: C.green, children: ["+", added] }), " ", _jsxs(Text, { color: C.red, children: ["-", removed] }), diffNote ? _jsxs(Text, { color: diffNote.startsWith("✓") ? C.green : C.red, children: [" ", diffNote] }) : null, _jsxs(Text, { color: C.dim, children: [pending ? " y accept · n reject" : " n/N next/prev change", scroll] })] }));
220
220
  return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, ...(pending ? { accent: C.yellow } : {}), children: _jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: diffRows.slice(start, start + rows).map((row, i) => (_jsxs(Box, { flexDirection: "row", children: [cell(row.left, "-", row.changed, half), _jsx(Text, { color: C.dim, children: "\u2502" }), cell(row.right, "+", row.changed, inner - half - 1)] }, start + i))) }), _jsx(Scrollbar, { total: total, top: start, rows: rows, focused: focused })] }) }));
221
221
  }
222
222
  if (!filePath) {
@@ -635,7 +635,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
635
635
  const statusColor = guideStatus && !confirmExit && !prompt ? (progress < 0 ? C.red : C.accent2) : confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
636
636
  const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
637
637
  const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
638
- return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: "black", children: " \u270E EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
638
+ return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: C.onBadge, children: " \u270E EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
639
639
  const n = v.top + i;
640
640
  const isCursorLine = n === buf.row;
641
641
  const problem = byLine.get(n + 1)?.[0];
@@ -119,7 +119,7 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
119
119
  if (view.over && i === Math.floor(FIELD.height / 2)) {
120
120
  const msg = ` ${view.over}${newBest ? " New best!" : ""} `;
121
121
  const left = Math.max(0, Math.floor((FIELD.width - msg.length) / 2));
122
- return _jsxs(Text, { children: [line.slice(0, left), _jsx(Text, { backgroundColor: C.yellow, color: "black", bold: true, children: msg }), line.slice(left + msg.length)] }, i);
122
+ return _jsxs(Text, { children: [line.slice(0, left), _jsx(Text, { backgroundColor: C.yellow, color: C.onBadge, bold: true, children: msg }), line.slice(left + msg.length)] }, i);
123
123
  }
124
124
  return _jsx(Row, { line: line, colors: playing.game.colors }, i);
125
125
  }), _jsx(Text, { color: C.dim, children: "─".repeat(FIELD.width) }), _jsx(Text, { color: C.text, wrap: "truncate-end", children: view.status })] })) : (_jsx(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: games.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.text, children: "No games yet." }), _jsxs(Text, { color: C.dim, wrap: "wrap", children: ["Add some in settings \u2192 Games", focused ? " (press s)" : "", ": ", GAMES.map((g) => g.title).join(", "), "."] })] })) : (_jsxs(_Fragment, { children: [games.map((g, i) => (_jsxs(Text, { wrap: "truncate-end", ...(i === row && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: i === row ? C.accent2 : C.dim, children: i === row ? "▸ " : " " }), _jsx(Text, { color: fits(g) ? C.text : C.dim, bold: i === row, children: g.title }), best[g.id] ? _jsxs(Text, { color: C.yellow, children: [" best ", best[g.id]] }) : null, fits(g) ? null : _jsx(Text, { color: C.dim, children: " (needs more room)" })] }, g.id))), games[row] ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: C.dim, wrap: "wrap", children: games[row].blurb }), _jsx(Text, { color: C.accent2, wrap: "truncate-end", children: games[row].controls })] })) : null] })) })), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", hint] })] }));