nou-cli 0.5.3 → 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,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { readFileSync, writeFileSync } from "fs";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
4
  import path from "path";
5
5
  import { Box, Text, useApp, useInput, useWindowSize } from "ink";
6
6
  import { nouRulesOnly } from "../context/index.js";
@@ -30,14 +30,15 @@ import { C } from "./theme.js";
30
30
  import { FileTree } from "./FileTree.js";
31
31
  import { CodeView } from "./CodeView.js";
32
32
  import { Editor } from "./Editor.js";
33
- import { SLASH_COMMANDS } from "./commands.js";
33
+ import { SLASH_COMMANDS, runsWhileBusy } from "./commands.js";
34
34
  import { WHEEL_STEP } from "./scroll.js";
35
- import { Chat } from "./Chat.js";
36
- import { copyToClipboard } from "./clipboard.js";
35
+ import { Chat, chatTabZones } from "./Chat.js";
36
+ import { copyToClipboard, openInBrowser } from "./clipboard.js";
37
37
  import { Header } from "./Header.js";
38
38
  import { Tabs, layoutTabs } from "./Tabs.js";
39
39
  import { GAME_PANEL, GamePanel } from "./GamePanel.js";
40
40
  import { Terminal } from "./Terminal.js";
41
+ import { showMePrompt, showStepsTool, stepsFromReply } from "../guide/index.js";
41
42
  import { Shell } from "../terminal/index.js";
42
43
  import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
43
44
  import { Settings } from "./Settings.js";
@@ -124,6 +125,21 @@ export function App({ initialModel, permContext, resume }) {
124
125
  /** The terminal panel under the code (` or /terminal): its shell lives on while the panel is closed. */
125
126
  const [termOpen, setTermOpen] = useState(false);
126
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);
127
143
  const shellRef = useRef(null);
128
144
  const beforeTerm = useRef("code");
129
145
  const [follow, setFollow] = useState(settings.followMode);
@@ -287,34 +303,11 @@ export function App({ initialModel, permContext, resume }) {
287
303
  openAtRef.current(filePath, line ?? 1, false, to);
288
304
  },
289
305
  onRead: (filePath, line, to) => followStepRef.current({ file: filePath, ...(line ? { line } : {}), ...(to ? { to } : {}) }),
290
- onChange: (change, status, turn) => {
291
- if (status === "applied") {
292
- timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
293
- }
294
- const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
295
- setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
296
- setWorking((w) => {
297
- if (status === "rejected")
298
- return w.has(change.path) ? new Set([...w].filter((p) => p !== change.path)) : w;
299
- return w.has(change.path) ? w : new Set(w).add(change.path);
300
- });
301
- // Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
302
- if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
303
- clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
304
- setOpenFile(change.path);
305
- setContent(status === "applied" ? change.after : readText(change.path));
306
- setDiffMode("off");
307
- setReveal(change.path);
308
- }
309
- if (status === "applied") {
310
- setTreeVersion((v) => v + 1);
311
- setGit(gitStatus());
312
- if (draft.current?.path === change.path) {
313
- setEntries((prev) => [...prev, { kind: "info", text: `Heads up: you have unsaved edits in ${change.path}; saving them will overwrite Nou's change.` }]);
314
- }
315
- }
316
- },
306
+ onChange: (change, status, turn) => applyChange(change, status, turn, false),
317
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);
318
311
  const s = settingsRef.current;
319
312
  if ((request.kind === "change" && s.autoApproveEdits) || (request.kind !== "change" && s.autoApproveCommands)) {
320
313
  return Promise.resolve(true);
@@ -324,9 +317,154 @@ export function App({ initialModel, permContext, resume }) {
324
317
  checkChange: (change) => problemsIntroduced(diagnosticsRef.current, change),
325
318
  };
326
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
+ }
327
465
  if (!agentRef.current) {
328
466
  const saved = loadSettings().engine;
329
- 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));
330
468
  }
331
469
  useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
332
470
  useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
@@ -398,7 +536,7 @@ export function App({ initialModel, permContext, resume }) {
398
536
  useEffect(() => {
399
537
  const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
400
538
  // Other engines read AGENTS.md / CLAUDE.md themselves: they get only the NOU.md rules on top.
401
- engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, nouRulesOnly(permContext)].filter(Boolean), extraTools: [...extraTools, ...mcpRef.current.tools()], effort: settings.effort };
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 };
402
540
  agentRef.current.configure(engineConfig.current);
403
541
  }, [settings.gitContext, settings.effort, activePlugins, mcpVersion]);
404
542
  // Pick up changes made outside Nou (other editors, git commands).
@@ -466,7 +604,7 @@ export function App({ initialModel, permContext, resume }) {
466
604
  info(`Installed ${target.label} (${target.package}).`);
467
605
  }
468
606
  agentRef.current.dispose?.();
469
- 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));
470
608
  engine.configure(engineConfig.current);
471
609
  agentRef.current = engine;
472
610
  updateSettings({ engine: id });
@@ -522,7 +660,7 @@ export function App({ initialModel, permContext, resume }) {
522
660
  let engine = agentRef.current;
523
661
  if (id && id !== engine.id && isInstalled(engineInfo(id))) {
524
662
  engine.dispose?.();
525
- engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
663
+ engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksFor(activeTab.current));
526
664
  engine.configure(engineConfig.current);
527
665
  agentRef.current = engine;
528
666
  updateSettings({ engine: id });
@@ -752,6 +890,18 @@ export function App({ initialModel, permContext, resume }) {
752
890
  // The Games panel sits under a pane: it takes the clicks that land on it, × on its title closes it,
753
891
  // and dragging its title docks it under the pane you let go over.
754
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
+ }
755
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) {
756
906
  if (event.kind === "wheel")
757
907
  return queueWheel("terminal", event.dir, false);
@@ -759,6 +909,10 @@ export function App({ initialModel, permContext, resume }) {
759
909
  return;
760
910
  if (event.y === term.y + 1 && event.x >= term.x + term.width - 5)
761
911
  return toggleTerminal(false); // ×
912
+ if (event.y === term.y) {
913
+ dragging.current = "terminal"; // its top border: drag to resize
914
+ return;
915
+ }
762
916
  if (focus !== "terminal")
763
917
  beforeTerm.current = focus;
764
918
  setFocus("terminal");
@@ -767,6 +921,18 @@ export function App({ initialModel, permContext, resume }) {
767
921
  const game = layout.current.game;
768
922
  if (gamesOpen && game) {
769
923
  const drag = dragging.current;
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
+ }
770
936
  if (drag === "game") {
771
937
  const { treeWidth, codeWidth } = layout.current;
772
938
  const over = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
@@ -793,7 +959,9 @@ export function App({ initialModel, permContext, resume }) {
793
959
  if (focus !== "game")
794
960
  beforeGame.current = focus;
795
961
  setFocus("game");
796
- if (event.y <= game.y + 1)
962
+ if (event.y === game.y)
963
+ dragging.current = "gameSize";
964
+ else if (event.y === game.y + 1)
797
965
  dragging.current = "game";
798
966
  return;
799
967
  }
@@ -858,6 +1026,16 @@ export function App({ initialModel, permContext, resume }) {
858
1026
  scrollPane(pane, event.y);
859
1027
  return;
860
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
+ }
861
1039
  // The code view's title row holds the tabs: a click switches, the × closes.
862
1040
  if (event.kind === "click" && pane === "code" && event.y === 2 && tabs.length && !diff) {
863
1041
  const at = event.x - codeLeft - 1;
@@ -1161,6 +1339,8 @@ export function App({ initialModel, permContext, resume }) {
1161
1339
  openAtRef.current = openAt;
1162
1340
  /** A file named in the chat (clicked, or shown by Nou): open it there, highlighting the lines it names. */
1163
1341
  const openRef = useEvent((ref) => {
1342
+ if (ref.url)
1343
+ return openInBrowser(ref.url);
1164
1344
  clearFollow();
1165
1345
  const file = path.relative(process.cwd(), path.resolve(ref.path)).replace(/\\/g, "/");
1166
1346
  openAt(file, ref.line ?? 1, false, ref.line ? ref.to ?? ref.line : undefined);
@@ -1536,6 +1716,19 @@ ${snippet}
1536
1716
  return;
1537
1717
  case "terminal":
1538
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(" "));
1539
1732
  case "resume":
1540
1733
  return resumeConversation(rest[0] ?? "");
1541
1734
  case "plugins":
@@ -1640,13 +1833,104 @@ ${snippet}
1640
1833
  setQueued(queued.slice(take));
1641
1834
  send(queued.slice(0, take).join("\n\n"));
1642
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
+ };
1643
1927
  useEffect(() => {
1644
1928
  if (busy)
1645
1929
  stopped.current = false;
1646
1930
  }, [busy]);
1647
1931
  /** From the chat: while Nou works, a message waits its turn, or (`now`) stops Nou and goes right away. */
1648
1932
  const submit = (text, now = false) => {
1649
- if (!busy)
1933
+ if (!busy || (text.startsWith("/") && runsWhileBusy(text)))
1650
1934
  return send(text);
1651
1935
  if (now) {
1652
1936
  stop();
@@ -1768,13 +2052,17 @@ ${snippet}
1768
2052
  const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
1769
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));
1770
2054
  const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
1771
- const tabStrip = tabs.length && openFile ? _jsx(Tabs, { tabs: tabs, active: openFile, width: tabsWidth(codeWidth), dirty: showEditor && !!editorState?.dirty }) : undefined;
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;
1772
2060
  // 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;
2061
+ const gameHeight = gamesOpen ? Math.max(GAME_PANEL.minHeight, Math.min(gameSizeDrag ?? settings.gameRows ?? GAME_PANEL.height, height - 6)) : 0;
1774
2062
  const heights = { tree: height, code: height, chat: height };
1775
2063
  heights[gameDock] -= gameHeight;
1776
2064
  // 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;
2065
+ const termHeight = termOpen ? Math.max(4, Math.min(termDrag ?? settings.terminalRows ?? Math.floor(height / 3), heights.code - 4)) : 0;
1778
2066
  heights.code -= termHeight;
1779
2067
  const dockLeft = gameDock === "tree" ? 0 : gameDock === "code" ? treeWidth : treeWidth + codeWidth;
1780
2068
  const dockWidth = gameDock === "tree" ? treeWidth : gameDock === "code" ? codeWidth : chatWidth;
@@ -1804,6 +2092,12 @@ ${snippet}
1804
2092
  else if (focus === "terminal")
1805
2093
  setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
1806
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
+ }
1807
2101
  function leaveTerminal() {
1808
2102
  setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
1809
2103
  }
@@ -1958,9 +2252,9 @@ ${snippet}
1958
2252
  }
1959
2253
  const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
1960
2254
  clickable.current = { status: zones, modelFrom: columns - (" ⌂ LOCAL ".length + ` ${modelLabel} `.length + 1) };
1961
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, git: git, problemCounts: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest })), _jsx(Chat, { width: chatWidth, height: heights.chat, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, onAnswer: onAnswer, click: chatClick, select: chatSelect, onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: followChip }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
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
1962
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)` : ""] })
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) => {
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) => {
1964
2258
  setQuickOpen(null);
1965
2259
  clearFollow();
1966
2260
  open(file);