nou-cli 0.5.2 → 0.5.3

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.
@@ -6,7 +6,9 @@ import { Box, Text, useApp, useInput, useWindowSize } from "ink";
6
6
  import { nouRulesOnly } from "../context/index.js";
7
7
  import { MCP_PRESETS, McpManager, loadMcpServers, removeUserServer, saveUserServers, USER_MCP_FILE } from "../mcp/index.js";
8
8
  import { findOtherMcpServers } from "../mcp/import.js";
9
- import { buildSystemPrompt } from "../agent/index.js";
9
+ import { ago, listConversations, loadConversation, saveConversation } from "../conversations/index.js";
10
+ import { externalEntries, externalSessions } from "../conversations/external.js";
11
+ import { buildSystemPrompt, newSessionId } from "../agent/index.js";
10
12
  import { ENGINES, createEngine, engineInfo, installEngine, isEngineId, isInstalled } from "../engines/index.js";
11
13
  import { harness, problemsIntroduced } from "../agent/harness.js";
12
14
  import { describe, providers, resolveModel, saveModel, setupProblem } from "../providers/index.js";
@@ -35,6 +37,8 @@ import { copyToClipboard } from "./clipboard.js";
35
37
  import { Header } from "./Header.js";
36
38
  import { Tabs, layoutTabs } from "./Tabs.js";
37
39
  import { GAME_PANEL, GamePanel } from "./GamePanel.js";
40
+ import { Terminal } from "./Terminal.js";
41
+ import { Shell } from "../terminal/index.js";
38
42
  import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
39
43
  import { Settings } from "./Settings.js";
40
44
  const FOCUS_ORDER = ["tree", "code", "chat"];
@@ -42,13 +46,14 @@ const MIN_TREE = 14;
42
46
  const MIN_CODE = 20;
43
47
  const MIN_CHAT = 24;
44
48
  const NO_LINES = new Set();
49
+ const NO_FILES = new Set();
45
50
  const DOUBLE_CLICK_MS = 500;
46
51
  /** How long each follow step stays on screen before the next. */
47
52
  const FOLLOW_STEP_MS = { slow: 2500, normal: 1200, fast: 450, instant: 0 };
48
53
  /** Steps follow mode may fall behind before it skips ahead. */
49
54
  const MAX_FOLLOW_LAG = 12;
50
55
  /** Columns the tabs get in the code view's title, leaving room for the scroll position and badges. */
51
- const tabsWidth = (codeWidth) => Math.max(16, codeWidth - 16);
56
+ const tabsWidth = (codeWidth) => Math.max(16, codeWidth - 3);
52
57
  /** How much of the conversation a newly switched-to engine is given (the latest part when it's longer). */
53
58
  const HANDOFF_CHARS = 24_000;
54
59
  /** The conversation so far as text, for an engine taking over from another one; null when there's none. */
@@ -98,7 +103,7 @@ function readText(filePath) {
98
103
  return "";
99
104
  }
100
105
  }
101
- export function App({ initialModel, permContext }) {
106
+ export function App({ initialModel, permContext, resume }) {
102
107
  const { exit } = useApp();
103
108
  const { columns, rows } = useWindowSize();
104
109
  const [settings, setSettings] = useState(loadSettings);
@@ -110,16 +115,17 @@ export function App({ initialModel, permContext }) {
110
115
  /** The file finder / command palette: closed (null), or open with this query (">" for commands). */
111
116
  const [quickOpen, setQuickOpen] = useState(null);
112
117
  const [focus, setFocus] = useState("chat");
113
- /** The Games panel: whether it's open, where it is while being dragged, and the pane to go back to. */
118
+ /** The Games panel: whether it's open, the pane it's being dragged over, and the pane to go back to. */
114
119
  const [gamesOpen, setGamesOpen] = useState(false);
115
120
  const [gameDrag, setGameDrag] = useState(null);
116
121
  const beforeGame = useRef("chat");
117
- /** Keeps the Games panel on screen (it's dragged by its title; the header row stays clear). */
118
- const placeGames = (x, y) => ({
119
- x: Math.max(0, Math.min(columns - GAME_PANEL.width, x)),
120
- y: Math.max(1, Math.min(rows - GAME_PANEL.height - 1, y)),
121
- });
122
- const gamePos = gameDrag ?? placeGames(settings.gamePanel?.x ?? columns - GAME_PANEL.width - 1, settings.gamePanel?.y ?? rows - GAME_PANEL.height - 2);
122
+ /** It docks under a pane, splitting that pane's column: under the one it's dragged over while moving. */
123
+ const gameDock = gameDrag ?? settings.gameDock ?? "chat";
124
+ /** The terminal panel under the code (` or /terminal): its shell lives on while the panel is closed. */
125
+ const [termOpen, setTermOpen] = useState(false);
126
+ const [termVersion, setTermVersion] = useState(0);
127
+ const shellRef = useRef(null);
128
+ const beforeTerm = useRef("code");
123
129
  const [follow, setFollow] = useState(settings.followMode);
124
130
  // Follow mode replays what the agent reads a step at a time (it reads far faster than you can watch):
125
131
  // the steps waiting, and the timer that shows the next one.
@@ -134,6 +140,8 @@ export function App({ initialModel, permContext }) {
134
140
  setFollowLag(0);
135
141
  };
136
142
  const [entries, setEntries] = useState([]);
143
+ /** This conversation, as saved for /resume: a new one after /clear, an old one once resumed. */
144
+ const [conversationId, setConversationId] = useState(newSessionId);
137
145
  const [streaming, setStreaming] = useState("");
138
146
  // Streamed text arrives a token at a time; redrawing the whole screen for each one is wasted work.
139
147
  const streamed = useRef({ text: "", timer: null });
@@ -154,6 +162,8 @@ export function App({ initialModel, permContext }) {
154
162
  }
155
163
  };
156
164
  const [busy, setBusy] = useState(null);
165
+ /** Files Nou has changed (or is asking to) since it started working: they shimmer in the tree until it's done. */
166
+ const [working, setWorking] = useState(NO_FILES);
157
167
  const [choice, setChoice] = useState(initialModel);
158
168
  const [approval, setApproval] = useState(null);
159
169
  const [openFile, setOpenFile] = useState(null);
@@ -217,8 +227,10 @@ export function App({ initialModel, permContext }) {
217
227
  };
218
228
  const [codeDefine, setCodeDefine] = useState(null);
219
229
  const [chatClick, setChatClick] = useState(null);
230
+ /** Dragging over the chat's messages selects them (copied when you let go). */
231
+ const [chatSelect, setChatSelect] = useState(null);
220
232
  // Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
221
- const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
233
+ const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0, heights: { tree: 0, code: 0, chat: 0 }, game: null, terminal: null });
222
234
  /** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
223
235
  const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
224
236
  const paneWidthsRef = useRef(paneWidths);
@@ -281,6 +293,11 @@ export function App({ initialModel, permContext }) {
281
293
  }
282
294
  const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
283
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
+ });
284
301
  // Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
285
302
  if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
286
303
  clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
@@ -318,6 +335,24 @@ export function App({ initialModel, permContext }) {
318
335
  useEffect(() => {
319
336
  void checkForUpdate().then(setUpdate);
320
337
  }, []);
338
+ useEffect(() => {
339
+ if (!busy)
340
+ setWorking(NO_FILES); // done: the files stop shimmering (● still marks them)
341
+ }, [!busy]);
342
+ // After each reply, the conversation is saved so /resume can pick it back up.
343
+ useEffect(() => {
344
+ const first = entries.find((e) => e.kind === "user");
345
+ if (busy || !first)
346
+ return;
347
+ const engine = agentRef.current;
348
+ saveConversation({ id: conversationId, title: first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: engine.id, entries, state: engine.snapshot?.() });
349
+ }, [!busy, entries.length, conversationId]);
350
+ /** Earlier conversations for the palette, read from disk when it opens. */
351
+ const recentChats = useMemo(() => (quickOpen !== null ? resumable() : []), [quickOpen !== null]);
352
+ useEffect(() => {
353
+ if (resume)
354
+ void resumeConversation(resume === "last" ? "last" : "");
355
+ }, []);
321
356
  // Discover plugins once and load the enabled ones.
322
357
  useEffect(() => {
323
358
  void (async () => {
@@ -440,6 +475,80 @@ export function App({ initialModel, permContext }) {
440
475
  engine.note(handoff);
441
476
  info(`Now using the ${target.label} engine (model: ${target.model(choice)}). ${handoff ? "It has the conversation so far." : "This starts a new conversation."}${target.approvals === "after" ? " It edits without asking first; review its changes in the diff view and press u to undo." : ""}`);
442
477
  };
478
+ /**
479
+ * /resume: lists this folder's earlier conversations, or picks one back up (by number, or "last"). The
480
+ * engine it was with carries on from its own saved state where it can (Nou's messages, Claude Code's
481
+ * session, Codex's thread); otherwise it's handed the conversation, like switching engines.
482
+ */
483
+ const resumeConversation = async (arg) => {
484
+ if (busy)
485
+ return info("Wait for the current reply to finish (or press Esc) before resuming another conversation.", "error");
486
+ const list = resumable();
487
+ if (!list.length)
488
+ 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
+ if (!arg) {
490
+ 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")}`);
492
+ }
493
+ const pick = arg === "last" ? list[0] : list[Number(arg) - 1] ?? list.find((c) => c.id === arg);
494
+ if (!pick)
495
+ return info(`There's no conversation ${arg}: /resume lists them.`, "error");
496
+ if ("file" in pick)
497
+ return resumeExternal(pick);
498
+ const saved = loadConversation(pick.id);
499
+ if (!saved)
500
+ return info(`Couldn't read conversation ${arg}.`, "error");
501
+ const engine = engineFor(isEngineId(saved.engine) ? saved.engine : null);
502
+ const restored = saved.engine === engine.id && !!saved.state && !!engine.restore?.(saved.state);
503
+ if (!restored) {
504
+ const handoff = conversationSoFar(saved.entries);
505
+ if (handoff)
506
+ engine.note(handoff);
507
+ }
508
+ setConversationId(saved.id);
509
+ setQueued([]);
510
+ setEntries([...saved.entries, { kind: "info", text: `Resumed "${saved.title}" from ${ago(saved.updated)} with ${engineInfo(engine.id).label}. ${restored ? "It picks up right where it left off." : "It has the conversation so far."}` }]);
511
+ setFocus("chat");
512
+ };
513
+ /** Nou's saved conversations and Claude Code's and Codex's own sessions here, latest first, without doubles. */
514
+ function resumable() {
515
+ const own = listConversations().filter((c) => c.id !== conversationId);
516
+ const current = agentRef.current.snapshot?.();
517
+ const known = new Set([...own.map((c) => c.ref), current?.claudeSession, current?.codexThread].filter(Boolean));
518
+ return [...own, ...externalSessions().filter((s) => !known.has(s.id))].sort((a, b) => b.updated - a.updated);
519
+ }
520
+ /** The engine to resume with: back to `id` when it's installed (it picks up best), else the current one, cleared. */
521
+ const engineFor = (id) => {
522
+ let engine = agentRef.current;
523
+ if (id && id !== engine.id && isInstalled(engineInfo(id))) {
524
+ engine.dispose?.();
525
+ engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
526
+ engine.configure(engineConfig.current);
527
+ agentRef.current = engine;
528
+ updateSettings({ engine: id });
529
+ }
530
+ engine.clear();
531
+ return engine;
532
+ };
533
+ /** A session from Claude Code's or Codex's own CLI: that engine resumes it, so it carries on with all of it. */
534
+ const resumeExternal = (session) => {
535
+ const label = engineInfo(session.engine).label;
536
+ if (!isInstalled(engineInfo(session.engine))) {
537
+ return info(`That's a ${label} session: switch to its engine first (/engine ${session.engine}, which installs it), then /resume it again.`, "error");
538
+ }
539
+ const engine = engineFor(session.engine);
540
+ const restored = !!engine.restore?.(session.engine === "claude-code" ? { claudeSession: session.id } : { codexThread: session.id });
541
+ const entries = externalEntries(session);
542
+ if (!restored) {
543
+ const handoff = conversationSoFar(entries);
544
+ if (handoff)
545
+ engine.note(handoff);
546
+ }
547
+ setConversationId(newSessionId());
548
+ setQueued([]);
549
+ setEntries([...entries, { kind: "info", text: `Resumed "${session.title}" from ${label}'s CLI (${ago(session.updated)}). ${label} picks up right where it left off, with Nou's diffs and undo from here on.` }]);
550
+ setFocus("chat");
551
+ };
443
552
  /** Downloads an Ollama model, showing progress in the status line. Esc stops it. */
444
553
  const pullModel = async (model) => {
445
554
  const ollama = providers.find((p) => p.id === "ollama");
@@ -629,7 +738,7 @@ export function App({ initialModel, permContext }) {
629
738
  };
630
739
  /** Scrolls a pane to where its scrollbar was clicked or dragged (rows start at y=3, under the border and title). */
631
740
  const scrollPane = (pane, y) => {
632
- const { paneHeight } = layout.current;
741
+ const paneHeight = pane === "game" || pane === "terminal" ? 0 : layout.current.heights[pane];
633
742
  const fraction = (y - 3) / Math.max(1, paneHeight - 4);
634
743
  setScrollTo((s) => ({ pane, fraction, nonce: (s?.nonce ?? 0) + 1 }));
635
744
  };
@@ -640,35 +749,58 @@ export function App({ initialModel, permContext }) {
640
749
  useMouse((event) => {
641
750
  if (view !== "main" || quickOpen !== null)
642
751
  return;
643
- // The Games panel floats over the panes: it takes the clicks that land on it, and its title bar drags it.
644
- if (gamesOpen) {
752
+ // The Games panel sits under a pane: it takes the clicks that land on it, × on its title closes it,
753
+ // and dragging its title docks it under the pane you let go over.
754
+ const term = layout.current.terminal;
755
+ 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
+ if (event.kind === "wheel")
757
+ return queueWheel("terminal", event.dir, false);
758
+ if (event.kind !== "click")
759
+ return;
760
+ if (event.y === term.y + 1 && event.x >= term.x + term.width - 5)
761
+ return toggleTerminal(false); // ×
762
+ if (focus !== "terminal")
763
+ beforeTerm.current = focus;
764
+ setFocus("terminal");
765
+ return;
766
+ }
767
+ const game = layout.current.game;
768
+ if (gamesOpen && game) {
645
769
  const drag = dragging.current;
646
- if (drag && typeof drag === "object" && "game" in drag) {
647
- const at = placeGames(event.x - drag.game.dx, event.y - drag.game.dy);
770
+ if (drag === "game") {
771
+ const { treeWidth, codeWidth } = layout.current;
772
+ const over = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
648
773
  if (event.kind === "release") {
649
774
  dragging.current = null;
650
775
  setGameDrag(null);
651
- updateSettings({ gamePanel: at });
776
+ updateSettings({ gameDock: over });
652
777
  }
653
778
  else
654
- setGameDrag(at);
779
+ setGameDrag(over);
655
780
  return;
656
781
  }
657
- const g = gamePos;
658
- const inside = event.x >= g.x && event.x < g.x + GAME_PANEL.width && event.y >= g.y && event.y < g.y + GAME_PANEL.height;
782
+ const inside = event.x >= game.x && event.x < game.x + game.width && event.y >= game.y && event.y < game.y + game.height;
659
783
  if (inside) {
660
784
  if (event.kind !== "click")
661
785
  return;
786
+ if (event.y === game.y + 1 && event.x >= game.x + game.width - 5) {
787
+ // × closes it
788
+ setGamesOpen(false);
789
+ if (focus === "game")
790
+ setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
791
+ return;
792
+ }
662
793
  if (focus !== "game")
663
794
  beforeGame.current = focus;
664
795
  setFocus("game");
665
- if (event.y <= g.y + 1)
666
- dragging.current = { game: { dx: event.x - g.x, dy: event.y - g.y } };
796
+ if (event.y <= game.y + 1)
797
+ dragging.current = "game";
667
798
  return;
668
799
  }
669
800
  }
670
801
  const { treeWidth, codeWidth, paneHeight } = layout.current;
671
802
  const codeLeft = treeWidth;
803
+ const chatAt = (x, y, kind) => setChatSelect((c) => ({ row: y - 3, col: x - treeWidth - codeWidth - 3, kind, nonce: (c?.nonce ?? 0) + 1 }));
672
804
  const editorAt = (x, y, kind) => setEditorMouse((m) => [...m.slice(-15), { x: x - codeLeft - 1, y: y - 3, kind, seq: (m[m.length - 1]?.seq ?? 0) + 1 }]);
673
805
  if (event.kind === "drag") {
674
806
  lastClick.current = null; // a press that became a drag doesn't start a double-click
@@ -677,7 +809,9 @@ export function App({ initialModel, permContext }) {
677
809
  moveBorder(drag, event.x);
678
810
  else if (drag === "editor")
679
811
  editorAt(event.x, event.y, "drag");
680
- else if (drag && "scroll" in drag)
812
+ else if (drag === "chatText")
813
+ chatAt(event.x, event.y, "drag");
814
+ else if (drag && typeof drag === "object")
681
815
  scrollPane(drag.scroll, event.y);
682
816
  return;
683
817
  }
@@ -692,6 +826,8 @@ export function App({ initialModel, permContext }) {
692
826
  scrollPane(dragging.current.scroll, event.y);
693
827
  if (dragging.current === "editor")
694
828
  editorAt(event.x, event.y, "release");
829
+ if (dragging.current === "chatText")
830
+ chatAt(event.x, event.y, "release");
695
831
  dragging.current = null;
696
832
  return;
697
833
  }
@@ -723,11 +859,18 @@ export function App({ initialModel, permContext }) {
723
859
  return;
724
860
  }
725
861
  // The code view's title row holds the tabs: a click switches, the × closes.
726
- if (event.kind === "click" && pane === "code" && event.y === 2 && tabs.length >= 2 && !diff) {
727
- const zone = layoutTabs(tabs, openFile, tabsWidth(codeWidth)).zones.find((z) => event.x - codeLeft - 1 >= z.from && event.x - codeLeft - 1 < z.to);
862
+ if (event.kind === "click" && pane === "code" && event.y === 2 && tabs.length && !diff) {
863
+ const at = event.x - codeLeft - 1;
864
+ const layout = layoutTabs(tabs, openFile, tabsWidth(codeWidth));
865
+ setFocus("code"); // so x, Ctrl+W and [ ] work next
866
+ if (layout.prev !== null && at === layout.prev)
867
+ return cycleTab(-1);
868
+ if (layout.next !== null && at === layout.next)
869
+ return cycleTab(1);
870
+ const zone = layout.zones.find((z) => at >= z.from && at < z.to);
728
871
  if (!zone)
729
872
  return;
730
- if (zone.close !== null && Math.abs(event.x - codeLeft - 1 - zone.close) <= 1)
873
+ if (Math.abs(at - zone.close) <= 1)
731
874
  closeTab(zone.file);
732
875
  else
733
876
  switchTab(zone.file);
@@ -750,8 +893,11 @@ export function App({ initialModel, permContext }) {
750
893
  if (pane === "tree" && event.y >= 3)
751
894
  setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
752
895
  // Messages start after the chat's border and the two-column bar in front of each line.
753
- if (pane === "chat" && event.y >= 3)
896
+ if (pane === "chat" && event.y >= 3) {
754
897
  setChatClick((c) => ({ row: event.y - 3, col: event.x - treeWidth - codeWidth - 3, nonce: (c?.nonce ?? 0) + 1 }));
898
+ dragging.current = "chatText";
899
+ chatAt(event.x, event.y, "press");
900
+ }
755
901
  // Two clicks in the same spot within half a second are a double-click.
756
902
  const last = lastClick.current;
757
903
  const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
@@ -776,6 +922,8 @@ export function App({ initialModel, permContext }) {
776
922
  return;
777
923
  if (showEditor && focus === "code" && editorStateRef.current?.selection)
778
924
  return; // the editor copies it
925
+ if (focus === "terminal")
926
+ return; // the terminal stops its command
779
927
  if (busy || approval) {
780
928
  stop();
781
929
  approval?.resolve(false);
@@ -1052,7 +1200,7 @@ export function App({ initialModel, permContext }) {
1052
1200
  stop();
1053
1201
  return answer(false);
1054
1202
  }
1055
- if (focus !== "chat" && /^[yYnN]$/.test(input))
1203
+ if (focus !== "chat" && focus !== "terminal" && /^[yYnN]$/.test(input))
1056
1204
  return answer(/[yY]/.test(input));
1057
1205
  if (focus !== "chat" && input === "o" && pendingChange)
1058
1206
  return openPending();
@@ -1065,8 +1213,8 @@ export function App({ initialModel, permContext }) {
1065
1213
  return; // the editor owns the keyboard
1066
1214
  if (focus === "code" && codeFinding.current)
1067
1215
  return; // so does the find bar
1068
- if (focus === "game")
1069
- return; // and the Games panel
1216
+ if (focus === "game" || focus === "terminal")
1217
+ return; // and the Games panel and the terminal
1070
1218
  if (key.tab && key.shift)
1071
1219
  return cycleAutoMode();
1072
1220
  if (key.tab && focus === "chat" && chatMenuOpen.current)
@@ -1099,6 +1247,8 @@ export function App({ initialModel, permContext }) {
1099
1247
  setFollow((f) => !f);
1100
1248
  else if (input === "m")
1101
1249
  toggleMouse();
1250
+ else if (input === "`")
1251
+ toggleTerminal();
1102
1252
  else if (input === "p")
1103
1253
  toggleProblems();
1104
1254
  else if (input === "u")
@@ -1119,7 +1269,7 @@ export function App({ initialModel, permContext }) {
1119
1269
  stepSessionFile(input === "]" ? 1 : -1);
1120
1270
  else if (input === "]" || input === "[")
1121
1271
  cycleTab(input === "]" ? 1 : -1);
1122
- else if (input === "x")
1272
+ else if (input === "x" || (key.ctrl && input === "w"))
1123
1273
  closeTab();
1124
1274
  else if (input === "e")
1125
1275
  startEditing();
@@ -1274,6 +1424,8 @@ ${snippet}
1274
1424
  }
1275
1425
  case "games":
1276
1426
  return toggleGames();
1427
+ case "cancel":
1428
+ return cancelLast();
1277
1429
  case "mcp":
1278
1430
  return mcpCommand(rest);
1279
1431
  case "problems":
@@ -1380,7 +1532,12 @@ ${snippet}
1380
1532
  case "clear":
1381
1533
  agentRef.current.clear();
1382
1534
  setEntries([]);
1535
+ setConversationId(newSessionId()); // the last one stays saved for /resume
1383
1536
  return;
1537
+ case "terminal":
1538
+ return toggleTerminal();
1539
+ case "resume":
1540
+ return resumeConversation(rest[0] ?? "");
1384
1541
  case "plugins":
1385
1542
  return pluginsCommand(rest);
1386
1543
  case "version":
@@ -1427,7 +1584,38 @@ ${snippet}
1427
1584
  };
1428
1585
  const setupCheckRef = useRef(setupCheck);
1429
1586
  setupCheckRef.current = setupCheck;
1430
- /** Messages sent while Nou works: each goes when it finishes, in order. */
1587
+ /**
1588
+ * Takes back your last message: stops Nou if it's still on it, removes the message and its reply from the
1589
+ * conversation (yours on screen and the model's), and puts the text back in the input to edit and resend.
1590
+ * Files it changed stay changed: /rewind undoes those.
1591
+ */
1592
+ // What was queued behind it comes back into the input too, rather than being sent.
1593
+ const [retract, setRetract] = useState(null);
1594
+ const cancelLast = () => {
1595
+ setRetract({ queued });
1596
+ setQueued([]);
1597
+ if (busy)
1598
+ stop();
1599
+ };
1600
+ useEffect(() => {
1601
+ if (!retract || busy)
1602
+ return;
1603
+ const later = retract.queued;
1604
+ setRetract(null);
1605
+ const engine = agentRef.current;
1606
+ const at = entries.findLastIndex((e) => e.kind === "user");
1607
+ if (at < 0)
1608
+ return info("There's no message to take back.");
1609
+ if (!engine.retractLast)
1610
+ return info(`${engineInfo(engine.id).label} keeps its own history, so a message can't be taken back there: /clear starts over.`, "error");
1611
+ engine.retractLast();
1612
+ const text = [entries[at].text, ...later].join("\n");
1613
+ stopped.current = false;
1614
+ setEntries((prev) => [...prev.slice(0, at), { kind: "info", text: "Took back your last message; it's in the input to edit and resend. (Any files it changed stay changed: /rewind undoes them.)" }]);
1615
+ setChatPrefill((p) => ({ text, nonce: (p?.nonce ?? 0) + 1 }));
1616
+ setFocus("chat");
1617
+ }, [retract, busy]);
1618
+ /** Messages sent while Nou works: they go together when it finishes (commands each in turn). */
1431
1619
  const [queued, setQueued] = useState([]);
1432
1620
  /** Set when you stop Nou: what was queued goes back into the input instead of being sent. */
1433
1621
  const stopped = useRef(false);
@@ -1445,9 +1633,12 @@ ${snippet}
1445
1633
  setQueued([]);
1446
1634
  return;
1447
1635
  }
1448
- const [next, ...rest] = queued;
1449
- setQueued(rest);
1450
- send(next);
1636
+ // Messages queued together go together, as one message, so they're answered at once rather than one
1637
+ // after another. A command runs on its own, in its turn.
1638
+ const isCommand = (text) => text.startsWith("/");
1639
+ const take = isCommand(queued[0]) ? 1 : Math.max(1, queued.findIndex(isCommand) === -1 ? queued.length : queued.findIndex(isCommand));
1640
+ setQueued(queued.slice(take));
1641
+ send(queued.slice(0, take).join("\n\n"));
1451
1642
  }, [busy, approval, queued]);
1452
1643
  useEffect(() => {
1453
1644
  if (busy)
@@ -1577,8 +1768,17 @@ ${snippet}
1577
1768
  const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
1578
1769
  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));
1579
1770
  const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
1580
- const tabStrip = tabs.length >= 2 && openFile ? _jsx(Tabs, { tabs: tabs, active: openFile, width: tabsWidth(codeWidth), dirty: showEditor && !!editorState?.dirty }) : undefined;
1581
- layout.current = { treeWidth, codeWidth, paneHeight: height };
1771
+ const tabStrip = tabs.length && openFile ? _jsx(Tabs, { tabs: tabs, active: openFile, width: tabsWidth(codeWidth), dirty: showEditor && !!editorState?.dirty }) : undefined;
1772
+ // 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;
1774
+ const heights = { tree: height, code: height, chat: height };
1775
+ heights[gameDock] -= gameHeight;
1776
+ // 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;
1778
+ heights.code -= termHeight;
1779
+ const dockLeft = gameDock === "tree" ? 0 : gameDock === "code" ? treeWidth : treeWidth + codeWidth;
1780
+ const dockWidth = gameDock === "tree" ? treeWidth : gameDock === "code" ? codeWidth : chatWidth;
1781
+ layout.current = { treeWidth, codeWidth, paneHeight: height, heights, game: gamesOpen ? { x: dockLeft, y: 1 + heights[gameDock] + (gameDock === "code" ? termHeight : 0), width: dockWidth, height: gameHeight } : null, terminal: termOpen ? { x: treeWidth, y: 1 + heights.code, width: codeWidth, height: termHeight } : null };
1582
1782
  const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
1583
1783
  const scrollFor = (pane) => (scrollTo?.pane === pane ? scrollTo : null);
1584
1784
  const totals = diagnostics.counts();
@@ -1592,6 +1792,21 @@ ${snippet}
1592
1792
  updateSettings({ mouse: !settings.mouse });
1593
1793
  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.");
1594
1794
  };
1795
+ /** Opens the terminal panel (starting its shell the first time) and gives it the keyboard, or closes it. */
1796
+ function toggleTerminal(open = !termOpen) {
1797
+ shellRef.current ??= new Shell(() => setTermVersion((v) => v + 1));
1798
+ setTermOpen(open);
1799
+ if (open) {
1800
+ if (focus !== "terminal")
1801
+ beforeTerm.current = focus;
1802
+ setFocus("terminal");
1803
+ }
1804
+ else if (focus === "terminal")
1805
+ setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
1806
+ }
1807
+ function leaveTerminal() {
1808
+ setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
1809
+ }
1595
1810
  /** Opens the Games panel and gives it the keyboard, or closes it. */
1596
1811
  const toggleGames = (open = !gamesOpen) => {
1597
1812
  setGamesOpen(open);
@@ -1629,6 +1844,10 @@ ${snippet}
1629
1844
  { title: "Find in the open file", keys: "Ctrl+F", run: startFind },
1630
1845
  { title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
1631
1846
  { title: "Go back to the previous file", keys: "b", run: goBack },
1847
+ { title: "Cancel your last message (edit it and resend)", keys: "/cancel", run: cancelLast },
1848
+ { title: "Resume an earlier conversation", keys: "/resume", run: () => void resumeConversation("") },
1849
+ { title: termOpen ? "Close the terminal" : "Open a terminal under the code", keys: "`", run: () => toggleTerminal() },
1850
+ ...recentChats.slice(0, 5).map((c) => ({ title: `Resume: ${c.title}${"file" in c ? ` (${engineInfo(c.engine).label} CLI)` : ""}`, keys: ago(c.updated), run: () => void resumeConversation(c.id) })),
1632
1851
  { title: "MCP servers: tools from other programs", keys: "/mcp", run: () => void runCommand("/mcp") },
1633
1852
  { title: "Browser control (Playwright MCP)", keys: "/mcp add browser", run: () => void runCommand("/mcp add browser") },
1634
1853
  { title: "Import MCP servers from Claude Code, Cursor, Codex...", keys: "/mcp import", run: () => void runCommand("/mcp import") },
@@ -1739,9 +1958,9 @@ ${snippet}
1739
1958
  }
1740
1959
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
1741
1960
  clickable.current = { status: zones, modelFrom: columns - (" ⌂ LOCAL ".length + ` ${modelLabel} `.length + 1) };
1742
- 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: height, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: height, 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: height, 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: height, 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, 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
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
1743
1962
  ? _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)` : ""] })
1744
- : 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)))] }) }), gamesOpen && (_jsx(GamePanel, { x: gamePos.x, y: gamePos.y, focused: focus === "game", 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) => {
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) => {
1745
1964
  setQuickOpen(null);
1746
1965
  clearFollow();
1747
1966
  open(file);