nou-cli 0.5.3 → 0.5.6

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.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { readFileSync, writeFileSync } from "fs";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
4
  import path from "path";
5
5
  import { Box, Text, useApp, useInput, useWindowSize } from "ink";
6
6
  import { nouRulesOnly } from "../context/index.js";
@@ -22,22 +22,25 @@ 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 { useFocusReporting, useTerminalFocused } from "./focus.js";
28
29
  import { useMouse } from "./mouse.js";
29
30
  import { C } from "./theme.js";
30
31
  import { FileTree } from "./FileTree.js";
31
32
  import { CodeView } from "./CodeView.js";
32
33
  import { Editor } from "./Editor.js";
33
- import { SLASH_COMMANDS } from "./commands.js";
34
+ import { SLASH_COMMANDS, runsWhileBusy } from "./commands.js";
34
35
  import { WHEEL_STEP } from "./scroll.js";
35
- import { Chat } from "./Chat.js";
36
- import { copyToClipboard } from "./clipboard.js";
36
+ import { Chat, chatTabZones } from "./Chat.js";
37
+ import { copyToClipboard, openInBrowser } from "./clipboard.js";
37
38
  import { Header } from "./Header.js";
38
39
  import { Tabs, layoutTabs } from "./Tabs.js";
39
- import { GAME_PANEL, GamePanel } from "./GamePanel.js";
40
+ import { GAME_PANEL, GameGuard, GamePanel } from "./GamePanel.js";
40
41
  import { Terminal } from "./Terminal.js";
42
+ import { forgetScreen } from "./redraw.js";
43
+ import { showMePrompt, showStepsTool, stepsFromReply } from "../guide/index.js";
41
44
  import { Shell } from "../terminal/index.js";
42
45
  import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
43
46
  import { Settings } from "./Settings.js";
@@ -124,6 +127,25 @@ export function App({ initialModel, permContext, resume }) {
124
127
  /** The terminal panel under the code (` or /terminal): its shell lives on while the panel is closed. */
125
128
  const [termOpen, setTermOpen] = useState(false);
126
129
  const [termVersion, setTermVersion] = useState(0);
130
+ /** Its height while its top border is being dragged (saved when you let go). */
131
+ const [termDrag, setTermDrag] = useState(null);
132
+ /** Bumped to draw every line again after the screen is cleared (/redraw, Ctrl+L). */
133
+ const [, setRepaint] = useState(0);
134
+ /** The Games panel's height while its top border is being dragged (saved when you let go). */
135
+ const [gameSizeDrag, setGameSizeDrag] = useState(null);
136
+ /** The tab strip, kept while nothing in it changes: a new one would redraw the whole code view on every scroll. */
137
+ const tabStripCache = useRef(null);
138
+ /**
139
+ * "Show me how" (/showme): the steps Nou found, and the one you're on. While Nou looks for them,
140
+ * `guideAsk` holds what you asked (and its edits are turned down: you're doing the typing).
141
+ */
142
+ const [guide, setGuide] = useState(null);
143
+ const guideAsk = useRef(null);
144
+ /** Where the undo history stood when the steps were asked for: an engine that edits anyway is put back. */
145
+ const guideMark = useRef(0);
146
+ /** /resume just listed the conversations: the next message, if it's a number, picks one. */
147
+ const pickingResume = useRef(false);
148
+ const guideFound = useRef(null);
127
149
  const shellRef = useRef(null);
128
150
  const beforeTerm = useRef("code");
129
151
  const [follow, setFollow] = useState(settings.followMode);
@@ -185,6 +207,9 @@ export function App({ initialModel, permContext, resume }) {
185
207
  const [plugins, setPlugins] = useState([]);
186
208
  const [starters, setStarters] = useState([]);
187
209
  const [viewport, setViewport] = useState(null);
210
+ /** Folding in the code view: a click on a row's fold marker, and /fold, /unfold for the whole file. */
211
+ const [foldClick, setFoldClick] = useState(null);
212
+ const [foldAll, setFoldAll] = useState(null);
188
213
  // The editor reports every keystroke; the app only re-renders when what it shows changes (line, unsaved, selection).
189
214
  const editorStateRef = useRef(null);
190
215
  const [editorState, setEditorState] = useState(null);
@@ -287,34 +312,11 @@ export function App({ initialModel, permContext, resume }) {
287
312
  openAtRef.current(filePath, line ?? 1, false, to);
288
313
  },
289
314
  onRead: (filePath, line, to) => followStepRef.current({ file: filePath, ...(line ? { line } : {}), ...(to ? { to } : {}) }),
290
- onChange: (change, status, turn) => {
291
- if (status === "applied") {
292
- timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
293
- }
294
- const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
295
- setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
296
- setWorking((w) => {
297
- if (status === "rejected")
298
- return w.has(change.path) ? new Set([...w].filter((p) => p !== change.path)) : w;
299
- return w.has(change.path) ? w : new Set(w).add(change.path);
300
- });
301
- // Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
302
- if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
303
- clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
304
- setOpenFile(change.path);
305
- setContent(status === "applied" ? change.after : readText(change.path));
306
- setDiffMode("off");
307
- setReveal(change.path);
308
- }
309
- if (status === "applied") {
310
- setTreeVersion((v) => v + 1);
311
- setGit(gitStatus());
312
- if (draft.current?.path === change.path) {
313
- setEntries((prev) => [...prev, { kind: "info", text: `Heads up: you have unsaved edits in ${change.path}; saving them will overwrite Nou's change.` }]);
314
- }
315
- }
316
- },
315
+ onChange: (change, status, turn) => applyChange(change, status, turn, false),
317
316
  approve: (request) => {
317
+ // Asked to show how, not to do it: edits are turned down (the model is told to hand back steps).
318
+ if (guideAsk.current && request.kind === "change")
319
+ return Promise.resolve(false);
318
320
  const s = settingsRef.current;
319
321
  if ((request.kind === "change" && s.autoApproveEdits) || (request.kind !== "change" && s.autoApproveCommands)) {
320
322
  return Promise.resolve(true);
@@ -324,9 +326,154 @@ export function App({ initialModel, permContext, resume }) {
324
326
  checkChange: (change) => problemsIntroduced(diagnosticsRef.current, change),
325
327
  };
326
328
  }
329
+ /** A change an engine made or proposes; one from a tab in the background doesn't take over the code view. */
330
+ function applyChange(change, status, turn, background) {
331
+ if (status === "applied") {
332
+ timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
333
+ }
334
+ const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
335
+ setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
336
+ setWorking((w) => {
337
+ if (status === "rejected")
338
+ return w.has(change.path) ? new Set([...w].filter((p) => p !== change.path)) : w;
339
+ return w.has(change.path) ? w : new Set(w).add(change.path);
340
+ });
341
+ // Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
342
+ if (!background && ((status === "pending" && !draftActiveRef.current) || followRef.current)) {
343
+ clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
344
+ setOpenFile(change.path);
345
+ setContent(status === "applied" ? change.after : readText(change.path));
346
+ setDiffMode("off");
347
+ setReveal(change.path);
348
+ }
349
+ if (status === "applied") {
350
+ setTreeVersion((v) => v + 1);
351
+ setGit(gitStatus());
352
+ if (draft.current?.path === change.path) {
353
+ setEntries((prev) => [...prev, { kind: "info", text: `Heads up: you have unsaved edits in ${change.path}; saving them will overwrite Nou's change.` }]);
354
+ }
355
+ }
356
+ }
357
+ // Conversation tabs: several agents at once. The one on screen uses the state above; the others keep
358
+ // theirs here, and each tab's engine reports through hooks that send it to the right place.
359
+ const activeTab = useRef("1");
360
+ const [activeTabId, setActiveTabId] = useState("1");
361
+ const [chatTabs, setChatTabs] = useState(["1"]);
362
+ const tabStore = useRef(new Map());
363
+ const [, setTabVersion] = useState(0);
364
+ const tabHooks = useRef(new Map());
365
+ /** The tab strip's data, kept while it reads the same, so the chat doesn't redraw on every app render. */
366
+ const chatTabsCache = useRef(null);
367
+ /** The hooks for tab `id`'s engine: on screen, they're the chat's own; in the background, its stored state. */
368
+ function hooksFor(id) {
369
+ const cached = tabHooks.current.get(id);
370
+ if (cached)
371
+ return cached;
372
+ const live = () => hooksRef.current;
373
+ const shown = () => activeTab.current === id;
374
+ const stored = (fn) => {
375
+ const t = tabStore.current.get(id);
376
+ if (!t)
377
+ return;
378
+ fn(t);
379
+ setTabVersion((v) => v + 1);
380
+ };
381
+ const hooks = {
382
+ onEntry: (entry) => (shown() ? live().onEntry(entry) : stored((t) => (t.entries = [...t.entries, entry]))),
383
+ onStream: (text) => (shown() ? live().onStream(text) : void (tabStore.current.get(id) && (tabStore.current.get(id).streaming = text))),
384
+ onBusy: (label) => {
385
+ if (shown())
386
+ return live().onBusy(label);
387
+ stored((t) => {
388
+ t.busy = label;
389
+ if (!label)
390
+ saveTab(t); // it finished in the background: keep it for /resume
391
+ });
392
+ },
393
+ onOpen: (...args) => (shown() ? live().onOpen?.(...args) : undefined),
394
+ onRead: (...args) => (shown() ? live().onRead(...args) : undefined),
395
+ onChange: (change, status, turn) => applyChange(change, status, turn, !shown()),
396
+ approve: (request) => {
397
+ if (shown())
398
+ return live().approve(request);
399
+ const s = settingsRef.current;
400
+ if ((request.kind === "change" && s.autoApproveEdits) || (request.kind !== "change" && s.autoApproveCommands))
401
+ return Promise.resolve(true);
402
+ // It waits for you: its tab says so, and the question is there when you switch to it.
403
+ return new Promise((resolve) => stored((t) => (t.approval = { request, resolve })));
404
+ },
405
+ checkChange: (change) => live().checkChange?.(change) ?? Promise.resolve(null),
406
+ };
407
+ tabHooks.current.set(id, hooks);
408
+ return hooks;
409
+ }
410
+ /** Puts tab `id` on screen, keeping the one there now (and whatever it's doing) in the background. */
411
+ function switchChatTab(id) {
412
+ if (id === activeTab.current || !tabStore.current.has(id))
413
+ return;
414
+ tabStore.current.set(activeTab.current, { entries, streaming: streamed.current.text, busy, approval, queued, conversationId, agent: agentRef.current });
415
+ const t = tabStore.current.get(id);
416
+ tabStore.current.delete(id);
417
+ activeTab.current = id;
418
+ setActiveTabId(id);
419
+ agentRef.current = t.agent;
420
+ setEntries(t.entries);
421
+ showStream(t.streaming);
422
+ setBusy(t.busy);
423
+ setApproval(t.approval);
424
+ setQueued(t.queued);
425
+ setConversationId(t.conversationId);
426
+ setFocus("chat");
427
+ }
428
+ /** A new conversation tab, with the same engine and model, next to the others. */
429
+ function newChatTab() {
430
+ const id = String(Date.now());
431
+ const agent = createEngine(agentRef.current.id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksFor(id));
432
+ agent.configure(engineConfig.current);
433
+ tabStore.current.set(id, { entries: [], streaming: "", busy: null, approval: null, queued: [], conversationId: newSessionId(), agent });
434
+ setChatTabs((tabs) => [...tabs, id]);
435
+ switchChatTab(id);
436
+ }
437
+ /** Closes the conversation tab on screen (stopping its engine); the conversation stays saved for /resume. */
438
+ function closeChatTab() {
439
+ if (chatTabs.length < 2)
440
+ return info("That's the only conversation tab. /clear starts a fresh conversation in it.");
441
+ const id = activeTab.current;
442
+ const at = chatTabs.indexOf(id);
443
+ const next = chatTabs[at + 1] ?? chatTabs[at - 1];
444
+ const agent = agentRef.current;
445
+ approval?.resolve(false);
446
+ switchChatTab(next);
447
+ agent.cancel();
448
+ agent.dispose?.();
449
+ tabStore.current.delete(id);
450
+ tabHooks.current.delete(id);
451
+ setChatTabs((tabs) => tabs.filter((t) => t !== id));
452
+ }
453
+ /** The tabs as the chat's title row shows them: named for what you asked first, marked when working or waiting. */
454
+ function chatTabItems() {
455
+ return chatTabs.map((id, i) => {
456
+ const t = id === activeTab.current ? { entries, busy, approval } : tabStore.current.get(id);
457
+ const first = t?.entries.find((e) => e.kind === "user")?.text.replace(/\s+/g, " ");
458
+ const label = first ? (first.length > 14 ? `${first.slice(0, 13)}…` : first) : `chat ${i + 1}`;
459
+ return { id, label, state: t?.approval ? "waiting" : t?.busy ? "busy" : "idle" };
460
+ });
461
+ }
462
+ function chatTabsProp() {
463
+ const items = chatTabItems();
464
+ const key = `${activeTabId}|${items.map((t) => `${t.id}:${t.label}:${t.state}`).join("|")}`;
465
+ if (chatTabsCache.current?.key !== key)
466
+ chatTabsCache.current = { key, tabs: { items, active: activeTabId } };
467
+ return chatTabsCache.current.tabs;
468
+ }
469
+ function saveTab(t) {
470
+ const first = t.entries.find((e) => e.kind === "user");
471
+ if (first)
472
+ saveConversation({ id: t.conversationId, title: first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: t.agent.id, entries: t.entries, state: t.agent.snapshot?.() });
473
+ }
327
474
  if (!agentRef.current) {
328
475
  const saved = loadSettings().engine;
329
- agentRef.current = createEngine(isEngineId(saved) ? saved : "nou", initialModel, buildSystemPrompt(permContext), hooksRef.current);
476
+ agentRef.current = createEngine(isEngineId(saved) ? saved : "nou", initialModel, buildSystemPrompt(permContext), hooksFor(activeTab.current));
330
477
  }
331
478
  useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
332
479
  useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
@@ -398,15 +545,21 @@ export function App({ initialModel, permContext, resume }) {
398
545
  useEffect(() => {
399
546
  const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
400
547
  // Other engines read AGENTS.md / CLAUDE.md themselves: they get only the NOU.md rules on top.
401
- engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, nouRulesOnly(permContext)].filter(Boolean), extraTools: [...extraTools, ...mcpRef.current.tools()], effort: settings.effort };
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 };
402
549
  agentRef.current.configure(engineConfig.current);
403
550
  }, [settings.gitContext, settings.effort, activePlugins, mcpVersion]);
404
- // Pick up changes made outside Nou (other editors, git commands).
551
+ // Pick up changes made outside Nou (other editors, git commands). Not while Nou's window is in the
552
+ // background (in a big repo each `git status` costs real CPU); coming back checks right away.
405
553
  const polling = useRef(false);
554
+ const wasFocused = useRef(true);
555
+ const windowFocused = useTerminalFocused();
556
+ useFocusReporting(true);
406
557
  useEffect(() => {
407
- if (!repo)
558
+ const cameBack = windowFocused && !wasFocused.current;
559
+ wasFocused.current = windowFocused;
560
+ if (!repo || !windowFocused)
408
561
  return;
409
- const timer = setInterval(() => {
562
+ const poll = () => {
410
563
  if (busy || polling.current)
411
564
  return;
412
565
  polling.current = true;
@@ -415,9 +568,12 @@ export function App({ initialModel, permContext, resume }) {
415
568
  // Only redraw when something changed.
416
569
  setGit((prev) => (sameStatus(prev, next) ? prev : next));
417
570
  });
418
- }, 4000);
571
+ };
572
+ if (cameBack)
573
+ poll();
574
+ const timer = setInterval(poll, 4000);
419
575
  return () => clearInterval(timer);
420
- }, [repo, busy]);
576
+ }, [repo, busy, windowFocused]);
421
577
  useEffect(() => {
422
578
  if (busy)
423
579
  return;
@@ -466,7 +622,7 @@ export function App({ initialModel, permContext, resume }) {
466
622
  info(`Installed ${target.label} (${target.package}).`);
467
623
  }
468
624
  agentRef.current.dispose?.();
469
- const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
625
+ const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksFor(activeTab.current));
470
626
  engine.configure(engineConfig.current);
471
627
  agentRef.current = engine;
472
628
  updateSettings({ engine: id });
@@ -488,7 +644,8 @@ export function App({ initialModel, permContext, resume }) {
488
644
  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.");
489
645
  if (!arg) {
490
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)})`);
491
- return info(`Earlier conversations here. /resume <number> picks one up (or /resume last):\n${rows.join("\n")}`);
647
+ 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")}`);
492
649
  }
493
650
  const pick = arg === "last" ? list[0] : list[Number(arg) - 1] ?? list.find((c) => c.id === arg);
494
651
  if (!pick)
@@ -522,7 +679,7 @@ export function App({ initialModel, permContext, resume }) {
522
679
  let engine = agentRef.current;
523
680
  if (id && id !== engine.id && isInstalled(engineInfo(id))) {
524
681
  engine.dispose?.();
525
- engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
682
+ engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksFor(activeTab.current));
526
683
  engine.configure(engineConfig.current);
527
684
  agentRef.current = engine;
528
685
  updateSettings({ engine: id });
@@ -752,6 +909,18 @@ export function App({ initialModel, permContext, resume }) {
752
909
  // The Games panel sits under a pane: it takes the clicks that land on it, × on its title closes it,
753
910
  // and dragging its title docks it under the pane you let go over.
754
911
  const term = layout.current.terminal;
912
+ // Dragging the terminal's top border makes it taller or shorter; its bottom stays put.
913
+ if (term && dragging.current === "terminal") {
914
+ const rows = Math.max(4, term.y + term.height - event.y);
915
+ if (event.kind === "release") {
916
+ dragging.current = null;
917
+ setTermDrag(null);
918
+ updateSettings({ terminalRows: rows });
919
+ }
920
+ else if (event.kind === "drag")
921
+ setTermDrag(rows);
922
+ return;
923
+ }
755
924
  if (term && event.x >= term.x && event.x < term.x + term.width && event.y >= term.y && event.y < term.y + term.height && !dragging.current) {
756
925
  if (event.kind === "wheel")
757
926
  return queueWheel("terminal", event.dir, false);
@@ -759,6 +928,10 @@ export function App({ initialModel, permContext, resume }) {
759
928
  return;
760
929
  if (event.y === term.y + 1 && event.x >= term.x + term.width - 5)
761
930
  return toggleTerminal(false); // ×
931
+ if (event.y === term.y) {
932
+ dragging.current = "terminal"; // its top border: drag to resize
933
+ return;
934
+ }
762
935
  if (focus !== "terminal")
763
936
  beforeTerm.current = focus;
764
937
  setFocus("terminal");
@@ -767,6 +940,18 @@ export function App({ initialModel, permContext, resume }) {
767
940
  const game = layout.current.game;
768
941
  if (gamesOpen && game) {
769
942
  const drag = dragging.current;
943
+ // Its top border makes it taller or shorter (its bottom stays put); its title moves it.
944
+ if (drag === "gameSize") {
945
+ const rows = Math.max(GAME_PANEL.minHeight, game.y + game.height - event.y);
946
+ if (event.kind === "release") {
947
+ dragging.current = null;
948
+ setGameSizeDrag(null);
949
+ updateSettings({ gameRows: rows });
950
+ }
951
+ else if (event.kind === "drag")
952
+ setGameSizeDrag(rows);
953
+ return;
954
+ }
770
955
  if (drag === "game") {
771
956
  const { treeWidth, codeWidth } = layout.current;
772
957
  const over = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
@@ -793,7 +978,9 @@ export function App({ initialModel, permContext, resume }) {
793
978
  if (focus !== "game")
794
979
  beforeGame.current = focus;
795
980
  setFocus("game");
796
- if (event.y <= game.y + 1)
981
+ if (event.y === game.y)
982
+ dragging.current = "gameSize";
983
+ else if (event.y === game.y + 1)
797
984
  dragging.current = "game";
798
985
  return;
799
986
  }
@@ -858,6 +1045,16 @@ export function App({ initialModel, permContext, resume }) {
858
1045
  scrollPane(pane, event.y);
859
1046
  return;
860
1047
  }
1048
+ // The chat's title row holds the conversation tabs: a click switches, + opens a new one.
1049
+ if (event.kind === "click" && pane === "chat" && event.y === 2) {
1050
+ const at = event.x - treeWidth - codeWidth - 1;
1051
+ const { zones, plus } = chatTabZones(chatTabItems());
1052
+ if (Math.abs(at - plus) <= 1)
1053
+ return newChatTab();
1054
+ const zone = zones.find((z) => at >= z.from && at < z.to);
1055
+ if (zone)
1056
+ return switchChatTab(zone.id);
1057
+ }
861
1058
  // The code view's title row holds the tabs: a click switches, the × closes.
862
1059
  if (event.kind === "click" && pane === "code" && event.y === 2 && tabs.length && !diff) {
863
1060
  const at = event.x - codeLeft - 1;
@@ -903,14 +1100,23 @@ export function App({ initialModel, permContext, resume }) {
903
1100
  const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
904
1101
  lastClick.current = double ? null : { x: event.x, y: event.y, time: Date.now() };
905
1102
  if (pane === "code" && event.y >= 3 && codePanel === "code") {
1103
+ // As the code view counts them (a last empty line after the final newline isn't shown).
1104
+ const digits = String(splitLines(content).length).length;
906
1105
  if (showEditor) {
907
1106
  dragging.current = "editor";
908
1107
  editorAt(event.x, event.y, double ? "double" : "press");
909
1108
  }
1109
+ else if (openFile && !diff && event.x - codeLeft - 1 === digits + 1) {
1110
+ // The fold marker (▾/▸) beside the line number: fold or unfold that block.
1111
+ lastClick.current = null;
1112
+ setFoldClick((f) => ({ row: event.y - 3, nonce: (f?.nonce ?? 0) + 1 }));
1113
+ }
910
1114
  else if (double && openFile && !diff && settleDraft(openFile)) {
911
1115
  // Double-click in the code: edit right there.
912
- const gutter = String(content.split("\n").length).length + 1;
913
- setEditorStart((s) => ({ line: (viewport?.top ?? 1) + event.y - 3, col: Math.max(1, event.x - codeLeft - gutter), nonce: s.nonce + 1 }));
1116
+ // The code view's gutter: the line number, a marker and the fold arrow; folds hide lines between rows.
1117
+ const gutter = digits + 2;
1118
+ const line = viewport?.lines?.[event.y - 3] ?? (viewport?.top ?? 1) + event.y - 3;
1119
+ setEditorStart((s) => ({ line, col: Math.max(1, event.x - codeLeft - gutter), nonce: s.nonce + 1 }));
914
1120
  setDiffMode("off");
915
1121
  setEditing(true);
916
1122
  }
@@ -1161,6 +1367,8 @@ export function App({ initialModel, permContext, resume }) {
1161
1367
  openAtRef.current = openAt;
1162
1368
  /** A file named in the chat (clicked, or shown by Nou): open it there, highlighting the lines it names. */
1163
1369
  const openRef = useEvent((ref) => {
1370
+ if (ref.url)
1371
+ return openInBrowser(ref.url);
1164
1372
  clearFollow();
1165
1373
  const file = path.relative(process.cwd(), path.resolve(ref.path)).replace(/\\/g, "/");
1166
1374
  openAt(file, ref.line ?? 1, false, ref.line ? ref.to ?? ref.line : undefined);
@@ -1234,6 +1442,8 @@ export function App({ initialModel, permContext, resume }) {
1234
1442
  setFocus("chat");
1235
1443
  return;
1236
1444
  }
1445
+ if (key.ctrl && input === "l")
1446
+ return redraw(); // (the editor and terminal use Ctrl+L themselves)
1237
1447
  if (focus === "chat")
1238
1448
  return; // letters belong to the text box
1239
1449
  if (input === "?")
@@ -1536,6 +1746,28 @@ ${snippet}
1536
1746
  return;
1537
1747
  case "terminal":
1538
1748
  return toggleTerminal();
1749
+ case "redraw":
1750
+ return redraw();
1751
+ case "fold":
1752
+ case "unfold":
1753
+ if (!openFile)
1754
+ return info("Open a file first: /fold folds every function and block in it, /unfold opens them.", "error");
1755
+ setEditing(false);
1756
+ setFocus("code");
1757
+ return setFoldAll((f) => ({ fold: command === "fold", nonce: (f?.nonce ?? 0) + 1 }));
1758
+ case "new":
1759
+ return newChatTab();
1760
+ case "close":
1761
+ return closeChatTab();
1762
+ case "tab": {
1763
+ const n = Number(arg);
1764
+ if (!n || !chatTabs[n - 1])
1765
+ return info(`Conversation tabs: ${chatTabItems().map((t, i) => `${i + 1}. ${t.label}${t.state !== "idle" ? ` (${t.state === "busy" ? "working" : "waiting on you"})` : ""}`).join(" ")}. /tab <n> switches, /new opens one, /close closes this one.`);
1766
+ return switchChatTab(chatTabs[n - 1]);
1767
+ }
1768
+ case "showme":
1769
+ case "teach":
1770
+ return showMe(rest.join(" "));
1539
1771
  case "resume":
1540
1772
  return resumeConversation(rest[0] ?? "");
1541
1773
  case "plugins":
@@ -1640,13 +1872,104 @@ ${snippet}
1640
1872
  setQueued(queued.slice(take));
1641
1873
  send(queued.slice(0, take).join("\n\n"));
1642
1874
  }, [busy, approval, queued]);
1875
+ // When Nou has finished looking, the guide starts at its first step.
1876
+ useEffect(() => {
1877
+ if (busy || !guideAsk.current)
1878
+ return;
1879
+ const task = guideAsk.current;
1880
+ let steps = guideFound.current;
1881
+ guideAsk.current = null;
1882
+ guideFound.current = null;
1883
+ // Other engines hand their steps back in a ```nou-steps block at the end of the reply: read it, and
1884
+ // show the reply without it.
1885
+ if (!steps?.length) {
1886
+ const reply = [...entries].reverse().find((e) => e.kind === "assistant");
1887
+ const found = reply && stepsFromReply(reply.text);
1888
+ if (found) {
1889
+ steps = found.steps;
1890
+ setEntries((prev) => prev.map((e) => (e === reply ? { ...e, text: found.rest || "Here's how." } : e)));
1891
+ }
1892
+ }
1893
+ // Asked not to, an engine that edited anyway (Codex can't be stopped before it does) is put back.
1894
+ const edited = timeline.all.slice(guideMark.current).filter((c) => c.source === "nou" && !c.undone);
1895
+ if (edited.length) {
1896
+ undoCheckpoints([...edited].reverse());
1897
+ info("It edited files anyway, so those changes were put back: you're the one typing them.");
1898
+ }
1899
+ 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.`);
1902
+ setGuide({ steps, at: 0, task });
1903
+ }, [busy]);
1904
+ // Each step: open its file in the editor where the code goes, and say what to type and why.
1905
+ useEffect(() => {
1906
+ if (!guide)
1907
+ return;
1908
+ const step = guide.steps[guide.at];
1909
+ if (!existsSync(step.file)) {
1910
+ // A step that starts a new file: it's created empty, for you to type into.
1911
+ mkdirSync(path.dirname(path.resolve(step.file)), { recursive: true });
1912
+ writeFileSync(step.file, "");
1913
+ setTreeVersion((v) => v + 1);
1914
+ }
1915
+ info(`👉 Step ${guide.at + 1} of ${guide.steps.length}: ${step.file}:${step.line}\n${step.why}\n\n\`\`\`\n${step.code}\n\`\`\``);
1916
+ openAt(step.file, step.line, true);
1917
+ }, [guide?.at, guide?.steps]);
1918
+ const guideStep = guide?.steps[guide.at] ?? null;
1919
+ /** The step's code is all typed: save it, and go on to the next one. */
1920
+ const onGuideDone = useEvent(() => {
1921
+ if (!guide || !guideStep)
1922
+ return;
1923
+ if (draft.current?.path === guideStep.file)
1924
+ saveFromEditor(draft.current.text, guideStep.file);
1925
+ // Later steps in the same file further down move down by the lines just added.
1926
+ const added = guideStep.code.split("\n").length;
1927
+ const steps = guide.steps.map((s, i) => (i > guide.at && s.file === guideStep.file && s.line >= guideStep.line ? { ...s, line: s.line + added } : s));
1928
+ if (guide.at + 1 < steps.length) {
1929
+ info(`✓ Step ${guide.at + 1} done.`);
1930
+ setGuide({ ...guide, steps, at: guide.at + 1 });
1931
+ }
1932
+ else {
1933
+ info(`🎉 That's it: all ${steps.length} step${steps.length > 1 ? "s" : ""} done and saved. Ask Nou to check it over if you like.`);
1934
+ setGuide(null);
1935
+ }
1936
+ });
1937
+ /** /showme <what you want>: Nou finds where the change goes and walks you through typing it. */
1938
+ const showMe = (arg) => {
1939
+ const words = arg.trim();
1940
+ if (words === "stop" || words === "end") {
1941
+ setGuide(null);
1942
+ return info(guide ? "Stopped the guide. What you typed so far stays." : "No guide is running.");
1943
+ }
1944
+ if (words === "next" || words === "skip") {
1945
+ if (!guide)
1946
+ return info("No guide is running.");
1947
+ if (guide.at + 1 >= guide.steps.length) {
1948
+ setGuide(null);
1949
+ return info("That was the last step.");
1950
+ }
1951
+ return setGuide({ ...guide, at: guide.at + 1 });
1952
+ }
1953
+ if (!words) {
1954
+ 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");
1957
+ }
1958
+ if (busy)
1959
+ return info("Wait for the current reply to finish (or press Esc) first.", "error");
1960
+ setGuide(null);
1961
+ guideAsk.current = words;
1962
+ guideFound.current = null;
1963
+ guideMark.current = timeline.all.length;
1964
+ send(showMePrompt(words, agentRef.current.id === "nou"));
1965
+ };
1643
1966
  useEffect(() => {
1644
1967
  if (busy)
1645
1968
  stopped.current = false;
1646
1969
  }, [busy]);
1647
1970
  /** From the chat: while Nou works, a message waits its turn, or (`now`) stops Nou and goes right away. */
1648
1971
  const submit = (text, now = false) => {
1649
- if (!busy)
1972
+ if (!busy || (text.startsWith("/") && runsWhileBusy(text)))
1650
1973
  return send(text);
1651
1974
  if (now) {
1652
1975
  stop();
@@ -1657,6 +1980,11 @@ ${snippet}
1657
1980
  setQueued((q) => [...q, text]);
1658
1981
  };
1659
1982
  const send = (text) => {
1983
+ // Right after /resume lists them, a number picks a conversation instead of being sent to the agent.
1984
+ const picking = pickingResume.current;
1985
+ pickingResume.current = false;
1986
+ if (picking && /^\s*\d+\s*$/.test(text))
1987
+ return void resumeConversation(text.trim());
1660
1988
  if (text.startsWith("/")) {
1661
1989
  void runCommand(text);
1662
1990
  return;
@@ -1700,7 +2028,7 @@ ${snippet}
1700
2028
  const onTreeHover = useEvent(() => { });
1701
2029
  const onJump = useEvent((file, line) => openAt(file, line));
1702
2030
  const onCloseProblems = useEvent(() => setCodePanel("code"));
1703
- const onViewport = useEvent((top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })));
2031
+ const onViewport = useEvent((top, bottom, lines) => setViewport((v) => (v?.top === top && v.bottom === bottom && v.lines?.join() === lines?.join() ? v : { top, bottom, ...(lines ? { lines } : {}) })));
1704
2032
  const onSend = useEvent(submit);
1705
2033
  const onChatMenu = useEvent((open) => {
1706
2034
  chatMenuOpen.current = open;
@@ -1768,13 +2096,17 @@ ${snippet}
1768
2096
  const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
1769
2097
  const chatWidth = paneWidths ? Math.max(MIN_CHAT, Math.min(Math.round(columns * paneWidths.chat), columns - treeWidth - MIN_CODE)) : Math.max(34, Math.floor(columns * 0.3));
1770
2098
  const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
1771
- const tabStrip = tabs.length && openFile ? _jsx(Tabs, { tabs: tabs, active: openFile, width: tabsWidth(codeWidth), dirty: showEditor && !!editorState?.dirty }) : undefined;
2099
+ const tabKey = tabs.length && openFile ? `${tabs.join("\0")}|${openFile}|${tabsWidth(codeWidth)}|${showEditor && !!editorState?.dirty}` : "";
2100
+ if (tabStripCache.current?.key !== tabKey) {
2101
+ tabStripCache.current = { key: tabKey, strip: tabKey ? _jsx(Tabs, { tabs: tabs, active: openFile, width: tabsWidth(codeWidth), dirty: showEditor && !!editorState?.dirty }) : undefined };
2102
+ }
2103
+ const tabStrip = tabStripCache.current.strip;
1772
2104
  // The Games panel takes the bottom of its pane's column: up to 19 rows, and never more than half.
1773
- const gameHeight = gamesOpen ? Math.min(GAME_PANEL.height, Math.floor(height / 2)) : 0;
2105
+ const gameHeight = gamesOpen ? Math.max(GAME_PANEL.minHeight, Math.min(gameSizeDrag ?? settings.gameRows ?? GAME_PANEL.height, height - 6)) : 0;
1774
2106
  const heights = { tree: height, code: height, chat: height };
1775
2107
  heights[gameDock] -= gameHeight;
1776
2108
  // The terminal takes the bottom third of the code's column (above the Games panel when that's there too).
1777
- const termHeight = termOpen ? Math.max(6, Math.min(Math.floor(height / 3), heights.code - 6)) : 0;
2109
+ const termHeight = termOpen ? Math.max(4, Math.min(termDrag ?? settings.terminalRows ?? Math.floor(height / 3), heights.code - 4)) : 0;
1778
2110
  heights.code -= termHeight;
1779
2111
  const dockLeft = gameDock === "tree" ? 0 : gameDock === "code" ? treeWidth : treeWidth + codeWidth;
1780
2112
  const dockWidth = gameDock === "tree" ? treeWidth : gameDock === "code" ? codeWidth : chatWidth;
@@ -1792,6 +2124,11 @@ ${snippet}
1792
2124
  updateSettings({ mouse: !settings.mouse });
1793
2125
  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.");
1794
2126
  };
2127
+ /** /redraw, Ctrl+L: draws the whole screen again, in case anything else wrote to the terminal and shifted it. */
2128
+ function redraw() {
2129
+ forgetScreen();
2130
+ setRepaint((n) => n + 1);
2131
+ }
1795
2132
  /** Opens the terminal panel (starting its shell the first time) and gives it the keyboard, or closes it. */
1796
2133
  function toggleTerminal(open = !termOpen) {
1797
2134
  shellRef.current ??= new Shell(() => setTermVersion((v) => v + 1));
@@ -1804,6 +2141,12 @@ ${snippet}
1804
2141
  else if (focus === "terminal")
1805
2142
  setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
1806
2143
  }
2144
+ /** Alt+↑/↓ in the terminal: taller or shorter by a couple of rows. */
2145
+ function resizeTerminal(by) {
2146
+ const now = layout.current.terminal?.height ?? 0;
2147
+ if (now)
2148
+ updateSettings({ terminalRows: Math.max(4, now + by) });
2149
+ }
1807
2150
  function leaveTerminal() {
1808
2151
  setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
1809
2152
  }
@@ -1819,6 +2162,7 @@ ${snippet}
1819
2162
  setFocus(beforeGame.current);
1820
2163
  };
1821
2164
  // Plain functions, not hooks: this runs after the settings screen's early return.
2165
+ const onGameScore = (game, score) => updateSettings({ gameBest: { ...(settings.gameBest ?? {}), [game]: score } });
1822
2166
  const leaveGames = () => setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
1823
2167
  const gamesSettings = () => {
1824
2168
  setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
@@ -1851,7 +2195,7 @@ ${snippet}
1851
2195
  { title: "MCP servers: tools from other programs", keys: "/mcp", run: () => void runCommand("/mcp") },
1852
2196
  { title: "Browser control (Playwright MCP)", keys: "/mcp add browser", run: () => void runCommand("/mcp add browser") },
1853
2197
  { title: "Import MCP servers from Claude Code, Cursor, Codex...", keys: "/mcp import", run: () => void runCommand("/mcp import") },
1854
- { title: gamesOpen ? "Close the Games panel" : "Games: Pong, Snake, Breakout", keys: "/games", run: () => toggleGames() },
2198
+ { title: gamesOpen ? "Close the Games panel" : "Games: Pong, Snake, Breakout, Dodge, Hop, Pairs", keys: "/games", run: () => toggleGames() },
1855
2199
  { title: "Next tab", keys: "]", run: () => cycleTab(1) },
1856
2200
  { title: "Previous tab", keys: "[", run: () => cycleTab(-1) },
1857
2201
  { title: "Close the tab", keys: "x", run: () => closeTab() },
@@ -1958,9 +2302,9 @@ ${snippet}
1958
2302
  }
1959
2303
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
1960
2304
  clickable.current = { status: zones, modelFrom: columns - (" ⌂ LOCAL ".length + ` ${modelLabel} `.length + 1) };
1961
- 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 }, `${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, 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
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
1962
2306
  ? _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)` : ""] })
1963
- : 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 }) })), 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) => {
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) => {
1964
2308
  setQuickOpen(null);
1965
2309
  clearFollow();
1966
2310
  open(file);