nou-cli 0.5.2 → 0.5.4

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,12 +1,14 @@
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";
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";
@@ -28,13 +30,16 @@ import { C } from "./theme.js";
28
30
  import { FileTree } from "./FileTree.js";
29
31
  import { CodeView } from "./CodeView.js";
30
32
  import { Editor } from "./Editor.js";
31
- import { SLASH_COMMANDS } from "./commands.js";
33
+ import { SLASH_COMMANDS, runsWhileBusy } from "./commands.js";
32
34
  import { WHEEL_STEP } from "./scroll.js";
33
- import { Chat } from "./Chat.js";
34
- import { copyToClipboard } from "./clipboard.js";
35
+ import { Chat, chatTabZones } from "./Chat.js";
36
+ import { copyToClipboard, openInBrowser } 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 { showMePrompt, showStepsTool, stepsFromReply } from "../guide/index.js";
42
+ import { Shell } from "../terminal/index.js";
38
43
  import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
39
44
  import { Settings } from "./Settings.js";
40
45
  const FOCUS_ORDER = ["tree", "code", "chat"];
@@ -42,13 +47,14 @@ const MIN_TREE = 14;
42
47
  const MIN_CODE = 20;
43
48
  const MIN_CHAT = 24;
44
49
  const NO_LINES = new Set();
50
+ const NO_FILES = new Set();
45
51
  const DOUBLE_CLICK_MS = 500;
46
52
  /** How long each follow step stays on screen before the next. */
47
53
  const FOLLOW_STEP_MS = { slow: 2500, normal: 1200, fast: 450, instant: 0 };
48
54
  /** Steps follow mode may fall behind before it skips ahead. */
49
55
  const MAX_FOLLOW_LAG = 12;
50
56
  /** 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);
57
+ const tabsWidth = (codeWidth) => Math.max(16, codeWidth - 3);
52
58
  /** How much of the conversation a newly switched-to engine is given (the latest part when it's longer). */
53
59
  const HANDOFF_CHARS = 24_000;
54
60
  /** The conversation so far as text, for an engine taking over from another one; null when there's none. */
@@ -98,7 +104,7 @@ function readText(filePath) {
98
104
  return "";
99
105
  }
100
106
  }
101
- export function App({ initialModel, permContext }) {
107
+ export function App({ initialModel, permContext, resume }) {
102
108
  const { exit } = useApp();
103
109
  const { columns, rows } = useWindowSize();
104
110
  const [settings, setSettings] = useState(loadSettings);
@@ -110,16 +116,32 @@ export function App({ initialModel, permContext }) {
110
116
  /** The file finder / command palette: closed (null), or open with this query (">" for commands). */
111
117
  const [quickOpen, setQuickOpen] = useState(null);
112
118
  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. */
119
+ /** The Games panel: whether it's open, the pane it's being dragged over, and the pane to go back to. */
114
120
  const [gamesOpen, setGamesOpen] = useState(false);
115
121
  const [gameDrag, setGameDrag] = useState(null);
116
122
  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);
123
+ /** It docks under a pane, splitting that pane's column: under the one it's dragged over while moving. */
124
+ const gameDock = gameDrag ?? settings.gameDock ?? "chat";
125
+ /** The terminal panel under the code (` or /terminal): its shell lives on while the panel is closed. */
126
+ const [termOpen, setTermOpen] = useState(false);
127
+ const [termVersion, setTermVersion] = useState(0);
128
+ /** Its height while its top border is being dragged (saved when you let go). */
129
+ const [termDrag, setTermDrag] = useState(null);
130
+ /** The Games panel's height while its top border is being dragged (saved when you let go). */
131
+ const [gameSizeDrag, setGameSizeDrag] = useState(null);
132
+ /** The tab strip, kept while nothing in it changes: a new one would redraw the whole code view on every scroll. */
133
+ const tabStripCache = useRef(null);
134
+ /**
135
+ * "Show me how" (/showme): the steps Nou found, and the one you're on. While Nou looks for them,
136
+ * `guideAsk` holds what you asked (and its edits are turned down: you're doing the typing).
137
+ */
138
+ const [guide, setGuide] = useState(null);
139
+ const guideAsk = useRef(null);
140
+ /** Where the undo history stood when the steps were asked for: an engine that edits anyway is put back. */
141
+ const guideMark = useRef(0);
142
+ const guideFound = useRef(null);
143
+ const shellRef = useRef(null);
144
+ const beforeTerm = useRef("code");
123
145
  const [follow, setFollow] = useState(settings.followMode);
124
146
  // Follow mode replays what the agent reads a step at a time (it reads far faster than you can watch):
125
147
  // the steps waiting, and the timer that shows the next one.
@@ -134,6 +156,8 @@ export function App({ initialModel, permContext }) {
134
156
  setFollowLag(0);
135
157
  };
136
158
  const [entries, setEntries] = useState([]);
159
+ /** This conversation, as saved for /resume: a new one after /clear, an old one once resumed. */
160
+ const [conversationId, setConversationId] = useState(newSessionId);
137
161
  const [streaming, setStreaming] = useState("");
138
162
  // Streamed text arrives a token at a time; redrawing the whole screen for each one is wasted work.
139
163
  const streamed = useRef({ text: "", timer: null });
@@ -154,6 +178,8 @@ export function App({ initialModel, permContext }) {
154
178
  }
155
179
  };
156
180
  const [busy, setBusy] = useState(null);
181
+ /** Files Nou has changed (or is asking to) since it started working: they shimmer in the tree until it's done. */
182
+ const [working, setWorking] = useState(NO_FILES);
157
183
  const [choice, setChoice] = useState(initialModel);
158
184
  const [approval, setApproval] = useState(null);
159
185
  const [openFile, setOpenFile] = useState(null);
@@ -217,8 +243,10 @@ export function App({ initialModel, permContext }) {
217
243
  };
218
244
  const [codeDefine, setCodeDefine] = useState(null);
219
245
  const [chatClick, setChatClick] = useState(null);
246
+ /** Dragging over the chat's messages selects them (copied when you let go). */
247
+ const [chatSelect, setChatSelect] = useState(null);
220
248
  // 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 });
249
+ const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0, heights: { tree: 0, code: 0, chat: 0 }, game: null, terminal: null });
222
250
  /** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
223
251
  const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
224
252
  const paneWidthsRef = useRef(paneWidths);
@@ -275,29 +303,11 @@ export function App({ initialModel, permContext }) {
275
303
  openAtRef.current(filePath, line ?? 1, false, to);
276
304
  },
277
305
  onRead: (filePath, line, to) => followStepRef.current({ file: filePath, ...(line ? { line } : {}), ...(to ? { to } : {}) }),
278
- onChange: (change, status, turn) => {
279
- if (status === "applied") {
280
- timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
281
- }
282
- const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
283
- setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
284
- // Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
285
- if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
286
- clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
287
- setOpenFile(change.path);
288
- setContent(status === "applied" ? change.after : readText(change.path));
289
- setDiffMode("off");
290
- setReveal(change.path);
291
- }
292
- if (status === "applied") {
293
- setTreeVersion((v) => v + 1);
294
- setGit(gitStatus());
295
- if (draft.current?.path === change.path) {
296
- setEntries((prev) => [...prev, { kind: "info", text: `Heads up: you have unsaved edits in ${change.path}; saving them will overwrite Nou's change.` }]);
297
- }
298
- }
299
- },
306
+ onChange: (change, status, turn) => applyChange(change, status, turn, false),
300
307
  approve: (request) => {
308
+ // Asked to show how, not to do it: edits are turned down (the model is told to hand back steps).
309
+ if (guideAsk.current && request.kind === "change")
310
+ return Promise.resolve(false);
301
311
  const s = settingsRef.current;
302
312
  if ((request.kind === "change" && s.autoApproveEdits) || (request.kind !== "change" && s.autoApproveCommands)) {
303
313
  return Promise.resolve(true);
@@ -307,9 +317,154 @@ export function App({ initialModel, permContext }) {
307
317
  checkChange: (change) => problemsIntroduced(diagnosticsRef.current, change),
308
318
  };
309
319
  }
320
+ /** A change an engine made or proposes; one from a tab in the background doesn't take over the code view. */
321
+ function applyChange(change, status, turn, background) {
322
+ if (status === "applied") {
323
+ timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
324
+ }
325
+ const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
326
+ setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
327
+ setWorking((w) => {
328
+ if (status === "rejected")
329
+ return w.has(change.path) ? new Set([...w].filter((p) => p !== change.path)) : w;
330
+ return w.has(change.path) ? w : new Set(w).add(change.path);
331
+ });
332
+ // Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
333
+ if (!background && ((status === "pending" && !draftActiveRef.current) || followRef.current)) {
334
+ clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
335
+ setOpenFile(change.path);
336
+ setContent(status === "applied" ? change.after : readText(change.path));
337
+ setDiffMode("off");
338
+ setReveal(change.path);
339
+ }
340
+ if (status === "applied") {
341
+ setTreeVersion((v) => v + 1);
342
+ setGit(gitStatus());
343
+ if (draft.current?.path === change.path) {
344
+ setEntries((prev) => [...prev, { kind: "info", text: `Heads up: you have unsaved edits in ${change.path}; saving them will overwrite Nou's change.` }]);
345
+ }
346
+ }
347
+ }
348
+ // Conversation tabs: several agents at once. The one on screen uses the state above; the others keep
349
+ // theirs here, and each tab's engine reports through hooks that send it to the right place.
350
+ const activeTab = useRef("1");
351
+ const [activeTabId, setActiveTabId] = useState("1");
352
+ const [chatTabs, setChatTabs] = useState(["1"]);
353
+ const tabStore = useRef(new Map());
354
+ const [, setTabVersion] = useState(0);
355
+ const tabHooks = useRef(new Map());
356
+ /** The tab strip's data, kept while it reads the same, so the chat doesn't redraw on every app render. */
357
+ const chatTabsCache = useRef(null);
358
+ /** The hooks for tab `id`'s engine: on screen, they're the chat's own; in the background, its stored state. */
359
+ function hooksFor(id) {
360
+ const cached = tabHooks.current.get(id);
361
+ if (cached)
362
+ return cached;
363
+ const live = () => hooksRef.current;
364
+ const shown = () => activeTab.current === id;
365
+ const stored = (fn) => {
366
+ const t = tabStore.current.get(id);
367
+ if (!t)
368
+ return;
369
+ fn(t);
370
+ setTabVersion((v) => v + 1);
371
+ };
372
+ const hooks = {
373
+ onEntry: (entry) => (shown() ? live().onEntry(entry) : stored((t) => (t.entries = [...t.entries, entry]))),
374
+ onStream: (text) => (shown() ? live().onStream(text) : void (tabStore.current.get(id) && (tabStore.current.get(id).streaming = text))),
375
+ onBusy: (label) => {
376
+ if (shown())
377
+ return live().onBusy(label);
378
+ stored((t) => {
379
+ t.busy = label;
380
+ if (!label)
381
+ saveTab(t); // it finished in the background: keep it for /resume
382
+ });
383
+ },
384
+ onOpen: (...args) => (shown() ? live().onOpen?.(...args) : undefined),
385
+ onRead: (...args) => (shown() ? live().onRead(...args) : undefined),
386
+ onChange: (change, status, turn) => applyChange(change, status, turn, !shown()),
387
+ approve: (request) => {
388
+ if (shown())
389
+ return live().approve(request);
390
+ const s = settingsRef.current;
391
+ if ((request.kind === "change" && s.autoApproveEdits) || (request.kind !== "change" && s.autoApproveCommands))
392
+ return Promise.resolve(true);
393
+ // It waits for you: its tab says so, and the question is there when you switch to it.
394
+ return new Promise((resolve) => stored((t) => (t.approval = { request, resolve })));
395
+ },
396
+ checkChange: (change) => live().checkChange?.(change) ?? Promise.resolve(null),
397
+ };
398
+ tabHooks.current.set(id, hooks);
399
+ return hooks;
400
+ }
401
+ /** Puts tab `id` on screen, keeping the one there now (and whatever it's doing) in the background. */
402
+ function switchChatTab(id) {
403
+ if (id === activeTab.current || !tabStore.current.has(id))
404
+ return;
405
+ tabStore.current.set(activeTab.current, { entries, streaming: streamed.current.text, busy, approval, queued, conversationId, agent: agentRef.current });
406
+ const t = tabStore.current.get(id);
407
+ tabStore.current.delete(id);
408
+ activeTab.current = id;
409
+ setActiveTabId(id);
410
+ agentRef.current = t.agent;
411
+ setEntries(t.entries);
412
+ showStream(t.streaming);
413
+ setBusy(t.busy);
414
+ setApproval(t.approval);
415
+ setQueued(t.queued);
416
+ setConversationId(t.conversationId);
417
+ setFocus("chat");
418
+ }
419
+ /** A new conversation tab, with the same engine and model, next to the others. */
420
+ function newChatTab() {
421
+ const id = String(Date.now());
422
+ const agent = createEngine(agentRef.current.id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksFor(id));
423
+ agent.configure(engineConfig.current);
424
+ tabStore.current.set(id, { entries: [], streaming: "", busy: null, approval: null, queued: [], conversationId: newSessionId(), agent });
425
+ setChatTabs((tabs) => [...tabs, id]);
426
+ switchChatTab(id);
427
+ }
428
+ /** Closes the conversation tab on screen (stopping its engine); the conversation stays saved for /resume. */
429
+ function closeChatTab() {
430
+ if (chatTabs.length < 2)
431
+ return info("That's the only conversation tab. /clear starts a fresh conversation in it.");
432
+ const id = activeTab.current;
433
+ const at = chatTabs.indexOf(id);
434
+ const next = chatTabs[at + 1] ?? chatTabs[at - 1];
435
+ const agent = agentRef.current;
436
+ approval?.resolve(false);
437
+ switchChatTab(next);
438
+ agent.cancel();
439
+ agent.dispose?.();
440
+ tabStore.current.delete(id);
441
+ tabHooks.current.delete(id);
442
+ setChatTabs((tabs) => tabs.filter((t) => t !== id));
443
+ }
444
+ /** The tabs as the chat's title row shows them: named for what you asked first, marked when working or waiting. */
445
+ function chatTabItems() {
446
+ return chatTabs.map((id, i) => {
447
+ const t = id === activeTab.current ? { entries, busy, approval } : tabStore.current.get(id);
448
+ const first = t?.entries.find((e) => e.kind === "user")?.text.replace(/\s+/g, " ");
449
+ const label = first ? (first.length > 14 ? `${first.slice(0, 13)}…` : first) : `chat ${i + 1}`;
450
+ return { id, label, state: t?.approval ? "waiting" : t?.busy ? "busy" : "idle" };
451
+ });
452
+ }
453
+ function chatTabsProp() {
454
+ const items = chatTabItems();
455
+ const key = `${activeTabId}|${items.map((t) => `${t.id}:${t.label}:${t.state}`).join("|")}`;
456
+ if (chatTabsCache.current?.key !== key)
457
+ chatTabsCache.current = { key, tabs: { items, active: activeTabId } };
458
+ return chatTabsCache.current.tabs;
459
+ }
460
+ function saveTab(t) {
461
+ const first = t.entries.find((e) => e.kind === "user");
462
+ if (first)
463
+ 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?.() });
464
+ }
310
465
  if (!agentRef.current) {
311
466
  const saved = loadSettings().engine;
312
- agentRef.current = createEngine(isEngineId(saved) ? saved : "nou", initialModel, buildSystemPrompt(permContext), hooksRef.current);
467
+ agentRef.current = createEngine(isEngineId(saved) ? saved : "nou", initialModel, buildSystemPrompt(permContext), hooksFor(activeTab.current));
313
468
  }
314
469
  useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
315
470
  useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
@@ -318,6 +473,24 @@ export function App({ initialModel, permContext }) {
318
473
  useEffect(() => {
319
474
  void checkForUpdate().then(setUpdate);
320
475
  }, []);
476
+ useEffect(() => {
477
+ if (!busy)
478
+ setWorking(NO_FILES); // done: the files stop shimmering (● still marks them)
479
+ }, [!busy]);
480
+ // After each reply, the conversation is saved so /resume can pick it back up.
481
+ useEffect(() => {
482
+ const first = entries.find((e) => e.kind === "user");
483
+ if (busy || !first)
484
+ return;
485
+ const engine = agentRef.current;
486
+ saveConversation({ id: conversationId, title: first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: engine.id, entries, state: engine.snapshot?.() });
487
+ }, [!busy, entries.length, conversationId]);
488
+ /** Earlier conversations for the palette, read from disk when it opens. */
489
+ const recentChats = useMemo(() => (quickOpen !== null ? resumable() : []), [quickOpen !== null]);
490
+ useEffect(() => {
491
+ if (resume)
492
+ void resumeConversation(resume === "last" ? "last" : "");
493
+ }, []);
321
494
  // Discover plugins once and load the enabled ones.
322
495
  useEffect(() => {
323
496
  void (async () => {
@@ -363,7 +536,7 @@ export function App({ initialModel, permContext }) {
363
536
  useEffect(() => {
364
537
  const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
365
538
  // Other engines read AGENTS.md / CLAUDE.md themselves: they get only the NOU.md rules on top.
366
- engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, nouRulesOnly(permContext)].filter(Boolean), extraTools: [...extraTools, ...mcpRef.current.tools()], effort: settings.effort };
539
+ engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, nouRulesOnly(permContext)].filter(Boolean), extraTools: [...extraTools, showStepsTool((steps) => (guideFound.current = steps)), ...mcpRef.current.tools()], effort: settings.effort };
367
540
  agentRef.current.configure(engineConfig.current);
368
541
  }, [settings.gitContext, settings.effort, activePlugins, mcpVersion]);
369
542
  // Pick up changes made outside Nou (other editors, git commands).
@@ -431,7 +604,7 @@ export function App({ initialModel, permContext }) {
431
604
  info(`Installed ${target.label} (${target.package}).`);
432
605
  }
433
606
  agentRef.current.dispose?.();
434
- const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
607
+ const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksFor(activeTab.current));
435
608
  engine.configure(engineConfig.current);
436
609
  agentRef.current = engine;
437
610
  updateSettings({ engine: id });
@@ -440,6 +613,80 @@ export function App({ initialModel, permContext }) {
440
613
  engine.note(handoff);
441
614
  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
615
  };
616
+ /**
617
+ * /resume: lists this folder's earlier conversations, or picks one back up (by number, or "last"). The
618
+ * engine it was with carries on from its own saved state where it can (Nou's messages, Claude Code's
619
+ * session, Codex's thread); otherwise it's handed the conversation, like switching engines.
620
+ */
621
+ const resumeConversation = async (arg) => {
622
+ if (busy)
623
+ return info("Wait for the current reply to finish (or press Esc) before resuming another conversation.", "error");
624
+ const list = resumable();
625
+ if (!list.length)
626
+ 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.");
627
+ if (!arg) {
628
+ const rows = list.slice(0, 10).map((c, i) => ` ${String(i + 1).padStart(2)}. ${c.title} (${c.messages} message${c.messages === 1 ? "" : "s"} · ${"file" in c ? `${engineInfo(c.engine).label} CLI` : engineInfo(isEngineId(c.engine) ? c.engine : "nou").label} · ${ago(c.updated)})`);
629
+ return info(`Earlier conversations here. /resume <number> picks one up (or /resume last):\n${rows.join("\n")}`);
630
+ }
631
+ const pick = arg === "last" ? list[0] : list[Number(arg) - 1] ?? list.find((c) => c.id === arg);
632
+ if (!pick)
633
+ return info(`There's no conversation ${arg}: /resume lists them.`, "error");
634
+ if ("file" in pick)
635
+ return resumeExternal(pick);
636
+ const saved = loadConversation(pick.id);
637
+ if (!saved)
638
+ return info(`Couldn't read conversation ${arg}.`, "error");
639
+ const engine = engineFor(isEngineId(saved.engine) ? saved.engine : null);
640
+ const restored = saved.engine === engine.id && !!saved.state && !!engine.restore?.(saved.state);
641
+ if (!restored) {
642
+ const handoff = conversationSoFar(saved.entries);
643
+ if (handoff)
644
+ engine.note(handoff);
645
+ }
646
+ setConversationId(saved.id);
647
+ setQueued([]);
648
+ 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."}` }]);
649
+ setFocus("chat");
650
+ };
651
+ /** Nou's saved conversations and Claude Code's and Codex's own sessions here, latest first, without doubles. */
652
+ function resumable() {
653
+ const own = listConversations().filter((c) => c.id !== conversationId);
654
+ const current = agentRef.current.snapshot?.();
655
+ const known = new Set([...own.map((c) => c.ref), current?.claudeSession, current?.codexThread].filter(Boolean));
656
+ return [...own, ...externalSessions().filter((s) => !known.has(s.id))].sort((a, b) => b.updated - a.updated);
657
+ }
658
+ /** The engine to resume with: back to `id` when it's installed (it picks up best), else the current one, cleared. */
659
+ const engineFor = (id) => {
660
+ let engine = agentRef.current;
661
+ if (id && id !== engine.id && isInstalled(engineInfo(id))) {
662
+ engine.dispose?.();
663
+ engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksFor(activeTab.current));
664
+ engine.configure(engineConfig.current);
665
+ agentRef.current = engine;
666
+ updateSettings({ engine: id });
667
+ }
668
+ engine.clear();
669
+ return engine;
670
+ };
671
+ /** A session from Claude Code's or Codex's own CLI: that engine resumes it, so it carries on with all of it. */
672
+ const resumeExternal = (session) => {
673
+ const label = engineInfo(session.engine).label;
674
+ if (!isInstalled(engineInfo(session.engine))) {
675
+ return info(`That's a ${label} session: switch to its engine first (/engine ${session.engine}, which installs it), then /resume it again.`, "error");
676
+ }
677
+ const engine = engineFor(session.engine);
678
+ const restored = !!engine.restore?.(session.engine === "claude-code" ? { claudeSession: session.id } : { codexThread: session.id });
679
+ const entries = externalEntries(session);
680
+ if (!restored) {
681
+ const handoff = conversationSoFar(entries);
682
+ if (handoff)
683
+ engine.note(handoff);
684
+ }
685
+ setConversationId(newSessionId());
686
+ setQueued([]);
687
+ 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.` }]);
688
+ setFocus("chat");
689
+ };
443
690
  /** Downloads an Ollama model, showing progress in the status line. Esc stops it. */
444
691
  const pullModel = async (model) => {
445
692
  const ollama = providers.find((p) => p.id === "ollama");
@@ -629,7 +876,7 @@ export function App({ initialModel, permContext }) {
629
876
  };
630
877
  /** Scrolls a pane to where its scrollbar was clicked or dragged (rows start at y=3, under the border and title). */
631
878
  const scrollPane = (pane, y) => {
632
- const { paneHeight } = layout.current;
879
+ const paneHeight = pane === "game" || pane === "terminal" ? 0 : layout.current.heights[pane];
633
880
  const fraction = (y - 3) / Math.max(1, paneHeight - 4);
634
881
  setScrollTo((s) => ({ pane, fraction, nonce: (s?.nonce ?? 0) + 1 }));
635
882
  };
@@ -640,35 +887,88 @@ export function App({ initialModel, permContext }) {
640
887
  useMouse((event) => {
641
888
  if (view !== "main" || quickOpen !== null)
642
889
  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) {
890
+ // The Games panel sits under a pane: it takes the clicks that land on it, × on its title closes it,
891
+ // and dragging its title docks it under the pane you let go over.
892
+ const term = layout.current.terminal;
893
+ // Dragging the terminal's top border makes it taller or shorter; its bottom stays put.
894
+ if (term && dragging.current === "terminal") {
895
+ const rows = Math.max(4, term.y + term.height - event.y);
896
+ if (event.kind === "release") {
897
+ dragging.current = null;
898
+ setTermDrag(null);
899
+ updateSettings({ terminalRows: rows });
900
+ }
901
+ else if (event.kind === "drag")
902
+ setTermDrag(rows);
903
+ return;
904
+ }
905
+ if (term && event.x >= term.x && event.x < term.x + term.width && event.y >= term.y && event.y < term.y + term.height && !dragging.current) {
906
+ if (event.kind === "wheel")
907
+ return queueWheel("terminal", event.dir, false);
908
+ if (event.kind !== "click")
909
+ return;
910
+ if (event.y === term.y + 1 && event.x >= term.x + term.width - 5)
911
+ return toggleTerminal(false); // ×
912
+ if (event.y === term.y) {
913
+ dragging.current = "terminal"; // its top border: drag to resize
914
+ return;
915
+ }
916
+ if (focus !== "terminal")
917
+ beforeTerm.current = focus;
918
+ setFocus("terminal");
919
+ return;
920
+ }
921
+ const game = layout.current.game;
922
+ if (gamesOpen && game) {
645
923
  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);
924
+ // Its top border makes it taller or shorter (its bottom stays put); its title moves it.
925
+ if (drag === "gameSize") {
926
+ const rows = Math.max(GAME_PANEL.minHeight, game.y + game.height - event.y);
927
+ if (event.kind === "release") {
928
+ dragging.current = null;
929
+ setGameSizeDrag(null);
930
+ updateSettings({ gameRows: rows });
931
+ }
932
+ else if (event.kind === "drag")
933
+ setGameSizeDrag(rows);
934
+ return;
935
+ }
936
+ if (drag === "game") {
937
+ const { treeWidth, codeWidth } = layout.current;
938
+ const over = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
648
939
  if (event.kind === "release") {
649
940
  dragging.current = null;
650
941
  setGameDrag(null);
651
- updateSettings({ gamePanel: at });
942
+ updateSettings({ gameDock: over });
652
943
  }
653
944
  else
654
- setGameDrag(at);
945
+ setGameDrag(over);
655
946
  return;
656
947
  }
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;
948
+ const inside = event.x >= game.x && event.x < game.x + game.width && event.y >= game.y && event.y < game.y + game.height;
659
949
  if (inside) {
660
950
  if (event.kind !== "click")
661
951
  return;
952
+ if (event.y === game.y + 1 && event.x >= game.x + game.width - 5) {
953
+ // × closes it
954
+ setGamesOpen(false);
955
+ if (focus === "game")
956
+ setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
957
+ return;
958
+ }
662
959
  if (focus !== "game")
663
960
  beforeGame.current = focus;
664
961
  setFocus("game");
665
- if (event.y <= g.y + 1)
666
- dragging.current = { game: { dx: event.x - g.x, dy: event.y - g.y } };
962
+ if (event.y === game.y)
963
+ dragging.current = "gameSize";
964
+ else if (event.y === game.y + 1)
965
+ dragging.current = "game";
667
966
  return;
668
967
  }
669
968
  }
670
969
  const { treeWidth, codeWidth, paneHeight } = layout.current;
671
970
  const codeLeft = treeWidth;
971
+ const chatAt = (x, y, kind) => setChatSelect((c) => ({ row: y - 3, col: x - treeWidth - codeWidth - 3, kind, nonce: (c?.nonce ?? 0) + 1 }));
672
972
  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
973
  if (event.kind === "drag") {
674
974
  lastClick.current = null; // a press that became a drag doesn't start a double-click
@@ -677,7 +977,9 @@ export function App({ initialModel, permContext }) {
677
977
  moveBorder(drag, event.x);
678
978
  else if (drag === "editor")
679
979
  editorAt(event.x, event.y, "drag");
680
- else if (drag && "scroll" in drag)
980
+ else if (drag === "chatText")
981
+ chatAt(event.x, event.y, "drag");
982
+ else if (drag && typeof drag === "object")
681
983
  scrollPane(drag.scroll, event.y);
682
984
  return;
683
985
  }
@@ -692,6 +994,8 @@ export function App({ initialModel, permContext }) {
692
994
  scrollPane(dragging.current.scroll, event.y);
693
995
  if (dragging.current === "editor")
694
996
  editorAt(event.x, event.y, "release");
997
+ if (dragging.current === "chatText")
998
+ chatAt(event.x, event.y, "release");
695
999
  dragging.current = null;
696
1000
  return;
697
1001
  }
@@ -722,12 +1026,29 @@ export function App({ initialModel, permContext }) {
722
1026
  scrollPane(pane, event.y);
723
1027
  return;
724
1028
  }
1029
+ // The chat's title row holds the conversation tabs: a click switches, + opens a new one.
1030
+ if (event.kind === "click" && pane === "chat" && event.y === 2) {
1031
+ const at = event.x - treeWidth - codeWidth - 1;
1032
+ const { zones, plus } = chatTabZones(chatTabItems());
1033
+ if (Math.abs(at - plus) <= 1)
1034
+ return newChatTab();
1035
+ const zone = zones.find((z) => at >= z.from && at < z.to);
1036
+ if (zone)
1037
+ return switchChatTab(zone.id);
1038
+ }
725
1039
  // 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);
1040
+ if (event.kind === "click" && pane === "code" && event.y === 2 && tabs.length && !diff) {
1041
+ const at = event.x - codeLeft - 1;
1042
+ const layout = layoutTabs(tabs, openFile, tabsWidth(codeWidth));
1043
+ setFocus("code"); // so x, Ctrl+W and [ ] work next
1044
+ if (layout.prev !== null && at === layout.prev)
1045
+ return cycleTab(-1);
1046
+ if (layout.next !== null && at === layout.next)
1047
+ return cycleTab(1);
1048
+ const zone = layout.zones.find((z) => at >= z.from && at < z.to);
728
1049
  if (!zone)
729
1050
  return;
730
- if (zone.close !== null && Math.abs(event.x - codeLeft - 1 - zone.close) <= 1)
1051
+ if (Math.abs(at - zone.close) <= 1)
731
1052
  closeTab(zone.file);
732
1053
  else
733
1054
  switchTab(zone.file);
@@ -750,8 +1071,11 @@ export function App({ initialModel, permContext }) {
750
1071
  if (pane === "tree" && event.y >= 3)
751
1072
  setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
752
1073
  // Messages start after the chat's border and the two-column bar in front of each line.
753
- if (pane === "chat" && event.y >= 3)
1074
+ if (pane === "chat" && event.y >= 3) {
754
1075
  setChatClick((c) => ({ row: event.y - 3, col: event.x - treeWidth - codeWidth - 3, nonce: (c?.nonce ?? 0) + 1 }));
1076
+ dragging.current = "chatText";
1077
+ chatAt(event.x, event.y, "press");
1078
+ }
755
1079
  // Two clicks in the same spot within half a second are a double-click.
756
1080
  const last = lastClick.current;
757
1081
  const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
@@ -776,6 +1100,8 @@ export function App({ initialModel, permContext }) {
776
1100
  return;
777
1101
  if (showEditor && focus === "code" && editorStateRef.current?.selection)
778
1102
  return; // the editor copies it
1103
+ if (focus === "terminal")
1104
+ return; // the terminal stops its command
779
1105
  if (busy || approval) {
780
1106
  stop();
781
1107
  approval?.resolve(false);
@@ -1013,6 +1339,8 @@ export function App({ initialModel, permContext }) {
1013
1339
  openAtRef.current = openAt;
1014
1340
  /** A file named in the chat (clicked, or shown by Nou): open it there, highlighting the lines it names. */
1015
1341
  const openRef = useEvent((ref) => {
1342
+ if (ref.url)
1343
+ return openInBrowser(ref.url);
1016
1344
  clearFollow();
1017
1345
  const file = path.relative(process.cwd(), path.resolve(ref.path)).replace(/\\/g, "/");
1018
1346
  openAt(file, ref.line ?? 1, false, ref.line ? ref.to ?? ref.line : undefined);
@@ -1052,7 +1380,7 @@ export function App({ initialModel, permContext }) {
1052
1380
  stop();
1053
1381
  return answer(false);
1054
1382
  }
1055
- if (focus !== "chat" && /^[yYnN]$/.test(input))
1383
+ if (focus !== "chat" && focus !== "terminal" && /^[yYnN]$/.test(input))
1056
1384
  return answer(/[yY]/.test(input));
1057
1385
  if (focus !== "chat" && input === "o" && pendingChange)
1058
1386
  return openPending();
@@ -1065,8 +1393,8 @@ export function App({ initialModel, permContext }) {
1065
1393
  return; // the editor owns the keyboard
1066
1394
  if (focus === "code" && codeFinding.current)
1067
1395
  return; // so does the find bar
1068
- if (focus === "game")
1069
- return; // and the Games panel
1396
+ if (focus === "game" || focus === "terminal")
1397
+ return; // and the Games panel and the terminal
1070
1398
  if (key.tab && key.shift)
1071
1399
  return cycleAutoMode();
1072
1400
  if (key.tab && focus === "chat" && chatMenuOpen.current)
@@ -1099,6 +1427,8 @@ export function App({ initialModel, permContext }) {
1099
1427
  setFollow((f) => !f);
1100
1428
  else if (input === "m")
1101
1429
  toggleMouse();
1430
+ else if (input === "`")
1431
+ toggleTerminal();
1102
1432
  else if (input === "p")
1103
1433
  toggleProblems();
1104
1434
  else if (input === "u")
@@ -1119,7 +1449,7 @@ export function App({ initialModel, permContext }) {
1119
1449
  stepSessionFile(input === "]" ? 1 : -1);
1120
1450
  else if (input === "]" || input === "[")
1121
1451
  cycleTab(input === "]" ? 1 : -1);
1122
- else if (input === "x")
1452
+ else if (input === "x" || (key.ctrl && input === "w"))
1123
1453
  closeTab();
1124
1454
  else if (input === "e")
1125
1455
  startEditing();
@@ -1274,6 +1604,8 @@ ${snippet}
1274
1604
  }
1275
1605
  case "games":
1276
1606
  return toggleGames();
1607
+ case "cancel":
1608
+ return cancelLast();
1277
1609
  case "mcp":
1278
1610
  return mcpCommand(rest);
1279
1611
  case "problems":
@@ -1380,7 +1712,25 @@ ${snippet}
1380
1712
  case "clear":
1381
1713
  agentRef.current.clear();
1382
1714
  setEntries([]);
1715
+ setConversationId(newSessionId()); // the last one stays saved for /resume
1383
1716
  return;
1717
+ case "terminal":
1718
+ return toggleTerminal();
1719
+ case "new":
1720
+ return newChatTab();
1721
+ case "close":
1722
+ return closeChatTab();
1723
+ case "tab": {
1724
+ const n = Number(arg);
1725
+ if (!n || !chatTabs[n - 1])
1726
+ 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.`);
1727
+ return switchChatTab(chatTabs[n - 1]);
1728
+ }
1729
+ case "showme":
1730
+ case "teach":
1731
+ return showMe(rest.join(" "));
1732
+ case "resume":
1733
+ return resumeConversation(rest[0] ?? "");
1384
1734
  case "plugins":
1385
1735
  return pluginsCommand(rest);
1386
1736
  case "version":
@@ -1427,7 +1777,38 @@ ${snippet}
1427
1777
  };
1428
1778
  const setupCheckRef = useRef(setupCheck);
1429
1779
  setupCheckRef.current = setupCheck;
1430
- /** Messages sent while Nou works: each goes when it finishes, in order. */
1780
+ /**
1781
+ * Takes back your last message: stops Nou if it's still on it, removes the message and its reply from the
1782
+ * conversation (yours on screen and the model's), and puts the text back in the input to edit and resend.
1783
+ * Files it changed stay changed: /rewind undoes those.
1784
+ */
1785
+ // What was queued behind it comes back into the input too, rather than being sent.
1786
+ const [retract, setRetract] = useState(null);
1787
+ const cancelLast = () => {
1788
+ setRetract({ queued });
1789
+ setQueued([]);
1790
+ if (busy)
1791
+ stop();
1792
+ };
1793
+ useEffect(() => {
1794
+ if (!retract || busy)
1795
+ return;
1796
+ const later = retract.queued;
1797
+ setRetract(null);
1798
+ const engine = agentRef.current;
1799
+ const at = entries.findLastIndex((e) => e.kind === "user");
1800
+ if (at < 0)
1801
+ return info("There's no message to take back.");
1802
+ if (!engine.retractLast)
1803
+ return info(`${engineInfo(engine.id).label} keeps its own history, so a message can't be taken back there: /clear starts over.`, "error");
1804
+ engine.retractLast();
1805
+ const text = [entries[at].text, ...later].join("\n");
1806
+ stopped.current = false;
1807
+ 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.)" }]);
1808
+ setChatPrefill((p) => ({ text, nonce: (p?.nonce ?? 0) + 1 }));
1809
+ setFocus("chat");
1810
+ }, [retract, busy]);
1811
+ /** Messages sent while Nou works: they go together when it finishes (commands each in turn). */
1431
1812
  const [queued, setQueued] = useState([]);
1432
1813
  /** Set when you stop Nou: what was queued goes back into the input instead of being sent. */
1433
1814
  const stopped = useRef(false);
@@ -1445,17 +1826,111 @@ ${snippet}
1445
1826
  setQueued([]);
1446
1827
  return;
1447
1828
  }
1448
- const [next, ...rest] = queued;
1449
- setQueued(rest);
1450
- send(next);
1829
+ // Messages queued together go together, as one message, so they're answered at once rather than one
1830
+ // after another. A command runs on its own, in its turn.
1831
+ const isCommand = (text) => text.startsWith("/");
1832
+ const take = isCommand(queued[0]) ? 1 : Math.max(1, queued.findIndex(isCommand) === -1 ? queued.length : queued.findIndex(isCommand));
1833
+ setQueued(queued.slice(take));
1834
+ send(queued.slice(0, take).join("\n\n"));
1451
1835
  }, [busy, approval, queued]);
1836
+ // When Nou has finished looking, the guide starts at its first step.
1837
+ useEffect(() => {
1838
+ if (busy || !guideAsk.current)
1839
+ return;
1840
+ const task = guideAsk.current;
1841
+ let steps = guideFound.current;
1842
+ guideAsk.current = null;
1843
+ guideFound.current = null;
1844
+ // Other engines hand their steps back in a ```nou-steps block at the end of the reply: read it, and
1845
+ // show the reply without it.
1846
+ if (!steps?.length) {
1847
+ const reply = [...entries].reverse().find((e) => e.kind === "assistant");
1848
+ const found = reply && stepsFromReply(reply.text);
1849
+ if (found) {
1850
+ steps = found.steps;
1851
+ setEntries((prev) => prev.map((e) => (e === reply ? { ...e, text: found.rest || "Here's how." } : e)));
1852
+ }
1853
+ }
1854
+ // Asked not to, an engine that edited anyway (Codex can't be stopped before it does) is put back.
1855
+ const edited = timeline.all.slice(guideMark.current).filter((c) => c.source === "nou" && !c.undone);
1856
+ if (edited.length) {
1857
+ undoCheckpoints([...edited].reverse());
1858
+ info("It edited files anyway, so those changes were put back: you're the one typing them.");
1859
+ }
1860
+ if (!steps?.length)
1861
+ return info("Nou didn't come back with steps to show. Try asking more specifically, e.g. /showme add a --verbose flag to the CLI", "error");
1862
+ info(`Here's how, in ${steps.length} step${steps.length > 1 ? "s" : ""}. You type each one in the editor: the dim text ahead of the cursor is what goes next, and Tab fills in the rest of a line. /showme next skips a step, /showme stop ends it.`);
1863
+ setGuide({ steps, at: 0, task });
1864
+ }, [busy]);
1865
+ // Each step: open its file in the editor where the code goes, and say what to type and why.
1866
+ useEffect(() => {
1867
+ if (!guide)
1868
+ return;
1869
+ const step = guide.steps[guide.at];
1870
+ if (!existsSync(step.file)) {
1871
+ // A step that starts a new file: it's created empty, for you to type into.
1872
+ mkdirSync(path.dirname(path.resolve(step.file)), { recursive: true });
1873
+ writeFileSync(step.file, "");
1874
+ setTreeVersion((v) => v + 1);
1875
+ }
1876
+ info(`👉 Step ${guide.at + 1} of ${guide.steps.length}: ${step.file}:${step.line}\n${step.why}\n\n\`\`\`\n${step.code}\n\`\`\``);
1877
+ openAt(step.file, step.line, true);
1878
+ }, [guide?.at, guide?.steps]);
1879
+ const guideStep = guide?.steps[guide.at] ?? null;
1880
+ /** The step's code is all typed: save it, and go on to the next one. */
1881
+ const onGuideDone = useEvent(() => {
1882
+ if (!guide || !guideStep)
1883
+ return;
1884
+ if (draft.current?.path === guideStep.file)
1885
+ saveFromEditor(draft.current.text, guideStep.file);
1886
+ // Later steps in the same file further down move down by the lines just added.
1887
+ const added = guideStep.code.split("\n").length;
1888
+ const steps = guide.steps.map((s, i) => (i > guide.at && s.file === guideStep.file && s.line >= guideStep.line ? { ...s, line: s.line + added } : s));
1889
+ if (guide.at + 1 < steps.length) {
1890
+ info(`✓ Step ${guide.at + 1} done.`);
1891
+ setGuide({ ...guide, steps, at: guide.at + 1 });
1892
+ }
1893
+ else {
1894
+ info(`🎉 That's it: all ${steps.length} step${steps.length > 1 ? "s" : ""} done and saved. Ask Nou to check it over if you like.`);
1895
+ setGuide(null);
1896
+ }
1897
+ });
1898
+ /** /showme <what you want>: Nou finds where the change goes and walks you through typing it. */
1899
+ const showMe = (arg) => {
1900
+ const words = arg.trim();
1901
+ if (words === "stop" || words === "end") {
1902
+ setGuide(null);
1903
+ return info(guide ? "Stopped the guide. What you typed so far stays." : "No guide is running.");
1904
+ }
1905
+ if (words === "next" || words === "skip") {
1906
+ if (!guide)
1907
+ return info("No guide is running.");
1908
+ if (guide.at + 1 >= guide.steps.length) {
1909
+ setGuide(null);
1910
+ return info("That was the last step.");
1911
+ }
1912
+ return setGuide({ ...guide, at: guide.at + 1 });
1913
+ }
1914
+ if (!words) {
1915
+ if (guide)
1916
+ return info(`On step ${guide.at + 1} of ${guide.steps.length}: ${guideStep.file}:${guideStep.line}. /showme next skips it, /showme stop ends it.`);
1917
+ return info("Tell Nou what you want to do and it shows you how, step by step, for you to type: /showme add a --verbose flag to the CLI");
1918
+ }
1919
+ if (busy)
1920
+ return info("Wait for the current reply to finish (or press Esc) first.", "error");
1921
+ setGuide(null);
1922
+ guideAsk.current = words;
1923
+ guideFound.current = null;
1924
+ guideMark.current = timeline.all.length;
1925
+ send(showMePrompt(words, agentRef.current.id === "nou"));
1926
+ };
1452
1927
  useEffect(() => {
1453
1928
  if (busy)
1454
1929
  stopped.current = false;
1455
1930
  }, [busy]);
1456
1931
  /** From the chat: while Nou works, a message waits its turn, or (`now`) stops Nou and goes right away. */
1457
1932
  const submit = (text, now = false) => {
1458
- if (!busy)
1933
+ if (!busy || (text.startsWith("/") && runsWhileBusy(text)))
1459
1934
  return send(text);
1460
1935
  if (now) {
1461
1936
  stop();
@@ -1577,8 +2052,21 @@ ${snippet}
1577
2052
  const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
1578
2053
  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
2054
  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 };
2055
+ const tabKey = tabs.length && openFile ? `${tabs.join("\0")}|${openFile}|${tabsWidth(codeWidth)}|${showEditor && !!editorState?.dirty}` : "";
2056
+ if (tabStripCache.current?.key !== tabKey) {
2057
+ tabStripCache.current = { key: tabKey, strip: tabKey ? _jsx(Tabs, { tabs: tabs, active: openFile, width: tabsWidth(codeWidth), dirty: showEditor && !!editorState?.dirty }) : undefined };
2058
+ }
2059
+ const tabStrip = tabStripCache.current.strip;
2060
+ // The Games panel takes the bottom of its pane's column: up to 19 rows, and never more than half.
2061
+ const gameHeight = gamesOpen ? Math.max(GAME_PANEL.minHeight, Math.min(gameSizeDrag ?? settings.gameRows ?? GAME_PANEL.height, height - 6)) : 0;
2062
+ const heights = { tree: height, code: height, chat: height };
2063
+ heights[gameDock] -= gameHeight;
2064
+ // The terminal takes the bottom third of the code's column (above the Games panel when that's there too).
2065
+ const termHeight = termOpen ? Math.max(4, Math.min(termDrag ?? settings.terminalRows ?? Math.floor(height / 3), heights.code - 4)) : 0;
2066
+ heights.code -= termHeight;
2067
+ const dockLeft = gameDock === "tree" ? 0 : gameDock === "code" ? treeWidth : treeWidth + codeWidth;
2068
+ const dockWidth = gameDock === "tree" ? treeWidth : gameDock === "code" ? codeWidth : chatWidth;
2069
+ 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
2070
  const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
1583
2071
  const scrollFor = (pane) => (scrollTo?.pane === pane ? scrollTo : null);
1584
2072
  const totals = diagnostics.counts();
@@ -1592,6 +2080,27 @@ ${snippet}
1592
2080
  updateSettings({ mouse: !settings.mouse });
1593
2081
  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
2082
  };
2083
+ /** Opens the terminal panel (starting its shell the first time) and gives it the keyboard, or closes it. */
2084
+ function toggleTerminal(open = !termOpen) {
2085
+ shellRef.current ??= new Shell(() => setTermVersion((v) => v + 1));
2086
+ setTermOpen(open);
2087
+ if (open) {
2088
+ if (focus !== "terminal")
2089
+ beforeTerm.current = focus;
2090
+ setFocus("terminal");
2091
+ }
2092
+ else if (focus === "terminal")
2093
+ setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
2094
+ }
2095
+ /** Alt+↑/↓ in the terminal: taller or shorter by a couple of rows. */
2096
+ function resizeTerminal(by) {
2097
+ const now = layout.current.terminal?.height ?? 0;
2098
+ if (now)
2099
+ updateSettings({ terminalRows: Math.max(4, now + by) });
2100
+ }
2101
+ function leaveTerminal() {
2102
+ setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
2103
+ }
1595
2104
  /** Opens the Games panel and gives it the keyboard, or closes it. */
1596
2105
  const toggleGames = (open = !gamesOpen) => {
1597
2106
  setGamesOpen(open);
@@ -1629,6 +2138,10 @@ ${snippet}
1629
2138
  { title: "Find in the open file", keys: "Ctrl+F", run: startFind },
1630
2139
  { title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
1631
2140
  { title: "Go back to the previous file", keys: "b", run: goBack },
2141
+ { title: "Cancel your last message (edit it and resend)", keys: "/cancel", run: cancelLast },
2142
+ { title: "Resume an earlier conversation", keys: "/resume", run: () => void resumeConversation("") },
2143
+ { title: termOpen ? "Close the terminal" : "Open a terminal under the code", keys: "`", run: () => toggleTerminal() },
2144
+ ...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
2145
  { title: "MCP servers: tools from other programs", keys: "/mcp", run: () => void runCommand("/mcp") },
1633
2146
  { title: "Browser control (Playwright MCP)", keys: "/mcp add browser", run: () => void runCommand("/mcp add browser") },
1634
2147
  { title: "Import MCP servers from Claude Code, Cursor, Codex...", keys: "/mcp import", run: () => void runCommand("/mcp import") },
@@ -1739,9 +2252,9 @@ ${snippet}
1739
2252
  }
1740
2253
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
1741
2254
  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
2255
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, git: git, problemCounts: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest })), _jsx(Chat, { width: chatWidth, height: heights.chat, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, onAnswer: onAnswer, click: chatClick, select: chatSelect, tabs: chatTabsProp(), onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: followChip }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
1743
2256
  ? _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) => {
2257
+ : shownHints.map(([key, action], i) => (_jsxs(Text, { children: [i === 0 ? " " : _jsx(Text, { color: C.dim, children: " \u00B7 " }), _jsx(Text, { color: C.text, children: key }), _jsxs(Text, { color: C.dim, children: [" ", action] })] }, key)))] }) }), layout.current.terminal && shellRef.current && (_jsx(Box, { position: "absolute", marginLeft: layout.current.terminal.x, marginTop: layout.current.terminal.y, children: _jsx(Terminal, { shell: shellRef.current, version: termVersion, width: layout.current.terminal.width, height: layout.current.terminal.height, focused: focus === "terminal", wheel: wheelFor("terminal"), onLeave: leaveTerminal, onResize: resizeTerminal }) })), layout.current.game && (_jsx(Box, { position: "absolute", marginLeft: layout.current.game.x, marginTop: layout.current.game.y, children: _jsx(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, onLeave: leaveGames, onOpenSettings: gamesSettings }) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: paletteActions, initialQuery: quickOpen, onPick: (file) => {
1745
2258
  setQuickOpen(null);
1746
2259
  clearFollow();
1747
2260
  open(file);