nou-cli 0.5.1 → 0.5.2

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.
@@ -3,31 +3,39 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { readFileSync, writeFileSync } from "fs";
4
4
  import path from "path";
5
5
  import { Box, Text, useApp, useInput, useWindowSize } from "ink";
6
+ import { nouRulesOnly } from "../context/index.js";
7
+ import { MCP_PRESETS, McpManager, loadMcpServers, removeUserServer, saveUserServers, USER_MCP_FILE } from "../mcp/index.js";
8
+ import { findOtherMcpServers } from "../mcp/import.js";
6
9
  import { buildSystemPrompt } from "../agent/index.js";
7
10
  import { ENGINES, createEngine, engineInfo, installEngine, isEngineId, isInstalled } from "../engines/index.js";
8
11
  import { harness, problemsIntroduced } from "../agent/harness.js";
9
- import { describe, providers, resolveModel, saveModel } from "../providers/index.js";
12
+ import { describe, providers, resolveModel, saveModel, setupProblem } from "../providers/index.js";
10
13
  import { sameModel } from "../providers/ollama.js";
11
- import { loadSettings, saveSettings } from "../config/index.js";
14
+ import { FOLLOW_SPEEDS, isTrustedFolder, loadSettings, saveSettings } from "../config/index.js";
12
15
  import { branch as gitBranch, diffAgainstHead, isRepo, sameStatus, status as gitStatus, statusAsync as gitStatusAsync } from "../git/index.js";
13
16
  import { BUILTIN_PLUGINS, addStarter, discoverPlugins, isEnabled, loadPlugin, scaffoldPlugin, starterPlugins, trustPatch, trustState } from "../plugins/index.js";
14
17
  import { DiagnosticsManager, formatDiagnostic, newProblems } from "../diagnostics/index.js";
15
18
  import { Timeline } from "../history/index.js";
16
19
  import { lsp } from "../lsp/index.js";
17
20
  import { onGrammarLoaded, preload } from "../syntax/index.js";
21
+ import { setCodeTheme } from "../syntax/themes.js";
18
22
  import { checkForUpdate, currentVersion } from "../update/index.js";
19
- import { changedLines, lineCounts } from "./text.js";
23
+ import { changedLines, lineCounts, loadFallbackHighlighter } from "./text.js";
24
+ import { findDefinition, wordAt } from "./definition.js";
20
25
  import { ProblemsView } from "./ProblemsView.js";
21
26
  import { useMouse } from "./mouse.js";
22
27
  import { C } from "./theme.js";
23
28
  import { FileTree } from "./FileTree.js";
24
29
  import { CodeView } from "./CodeView.js";
25
30
  import { Editor } from "./Editor.js";
31
+ import { SLASH_COMMANDS } from "./commands.js";
26
32
  import { WHEEL_STEP } from "./scroll.js";
27
33
  import { Chat } from "./Chat.js";
28
34
  import { copyToClipboard } from "./clipboard.js";
29
35
  import { Header } from "./Header.js";
30
- import { QuickOpen, listAllFiles } from "./QuickOpen.js";
36
+ import { Tabs, layoutTabs } from "./Tabs.js";
37
+ import { GAME_PANEL, GamePanel } from "./GamePanel.js";
38
+ import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
31
39
  import { Settings } from "./Settings.js";
32
40
  const FOCUS_ORDER = ["tree", "code", "chat"];
33
41
  const MIN_TREE = 14;
@@ -35,33 +43,39 @@ const MIN_CODE = 20;
35
43
  const MIN_CHAT = 24;
36
44
  const NO_LINES = new Set();
37
45
  const DOUBLE_CLICK_MS = 500;
38
- const HELP = `Commands:
39
- /settings models, preferences and plugins
40
- /model show providers and the current model
41
- /model <provider[:model]> switch, e.g. /model anthropic /model ollama:qwen3:8b
42
- /models [provider] list models a provider offers
43
- /connect <provider> enter an API key (and URL), e.g. /connect fireworks /connect litellm
44
- /pull <model> download an Ollama model
45
- /engine [name] which agent answers: nou, claude-code, codex or pi
46
- /auto [ask|auto-edit|auto] how much Nou does without asking (also: Shift+Tab)
47
- /copy [code|file] copy the last reply, its last code block, or the open file
48
- /plugins your plugins and the starter ones you can add
49
- /plugins add <name> add a starter plugin (copied to ~/.nou/plugins to edit)
50
- /plugins new <name> start your own plugin in .nou/plugins/
51
- /clear start a fresh conversation
52
- /compact summarize the conversation so far, to free up context
53
- /undo undo Nou's last change (also: u)
54
- /rewind undo everything from Nou's last turn
55
- /history recent changes
56
- /diff [file] everything changed this session ([ ] step through files)
57
- /sessions earlier sessions; /diff <n> shows one of them
58
- /version which version of Nou this is
46
+ /** How long each follow step stays on screen before the next. */
47
+ const FOLLOW_STEP_MS = { slow: 2500, normal: 1200, fast: 450, instant: 0 };
48
+ /** Steps follow mode may fall behind before it skips ahead. */
49
+ const MAX_FOLLOW_LAG = 12;
50
+ /** 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);
52
+ /** How much of the conversation a newly switched-to engine is given (the latest part when it's longer). */
53
+ const HANDOFF_CHARS = 24_000;
54
+ /** The conversation so far as text, for an engine taking over from another one; null when there's none. */
55
+ export function conversationSoFar(entries) {
56
+ const said = entries
57
+ .filter((e) => e.kind === "user" || e.kind === "assistant" || e.kind === "tool")
58
+ .map((e) => (e.kind === "user" ? `User: ${e.text}` : e.kind === "assistant" ? `Assistant: ${e.text}` : `(${e.text})`));
59
+ if (!said.some((line) => line.startsWith("User:")))
60
+ return null;
61
+ let text = said.join("\n\n");
62
+ if (text.length > HANDOFF_CHARS)
63
+ text = `[earlier messages left out]\n${text.slice(-HANDOFF_CHARS)}`;
64
+ return `You are taking over a conversation the user was having with another assistant in this project. Here it is, so you can carry on from where it left off (the files may have changed since; check before relying on them):\n<conversation>\n${text}\n</conversation>`;
65
+ }
66
+ /** /help: every command from the shared list, then the keys. */
67
+ const HELP = `Commands (type / in the chat for a menu, or press ? for every action):
68
+ ${SLASH_COMMANDS.map((c) => ` /${`${c.name}${c.args ? ` ${c.args}` : ""}`.padEnd(30)} ${c.description}`).join("\n")}
59
69
  Keys:
60
- Ctrl+P find a file Tab switch pane Esc stop / back
70
+ ? every action, searchable (also Ctrl+P then >) Ctrl+P find a file
71
+ Tab switch pane Esc stop / back
61
72
  Shift+Tab ask / auto-edit / auto < > resize pane = reset
62
73
  m mouse on/off (off: select text to copy; Shift+drag also works in most terminals)
63
74
  / start a command from any pane
64
75
  e edit open file (or double-click in the code) v diff (git / Nou / session) f follow mode
76
+ Ctrl+F find in the open file (Enter/↓ next, ↑ previous)
77
+ b back to the previous file (Ctrl+P lists recent files first) [ ] previous/next tab · x close tab
78
+ Ctrl+click, Option+click or right-click a name: go to its definition (an import's path opens the file)
65
79
  p problems u undo Nou's edit s settings
66
80
  c (in files) show only changed files Ctrl+C twice quit
67
81
  Mouse: wheel scrolls (Shift+wheel sideways) · click/drag a scrollbar · double-click in the code to edit there (in the editor: select a word), drag to select
@@ -88,11 +102,37 @@ export function App({ initialModel, permContext }) {
88
102
  const { exit } = useApp();
89
103
  const { columns, rows } = useWindowSize();
90
104
  const [settings, setSettings] = useState(loadSettings);
105
+ setCodeTheme(settings.codeTheme); // before anything below highlights code
91
106
  const [view, setView] = useState("main");
92
107
  const [connectTo, setConnectTo] = useState();
93
- const [quickOpen, setQuickOpen] = useState(false);
108
+ /** Why settings were opened for you (a request failed on the provider's setup). */
109
+ const [setupReason, setSetupReason] = useState(null);
110
+ /** The file finder / command palette: closed (null), or open with this query (">" for commands). */
111
+ const [quickOpen, setQuickOpen] = useState(null);
94
112
  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. */
114
+ const [gamesOpen, setGamesOpen] = useState(false);
115
+ const [gameDrag, setGameDrag] = useState(null);
116
+ 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);
95
123
  const [follow, setFollow] = useState(settings.followMode);
124
+ // Follow mode replays what the agent reads a step at a time (it reads far faster than you can watch):
125
+ // the steps waiting, and the timer that shows the next one.
126
+ const followQueue = useRef([]);
127
+ const followTimer = useRef(null);
128
+ const [followLag, setFollowLag] = useState(0);
129
+ const clearFollow = () => {
130
+ followQueue.current = [];
131
+ if (followTimer.current)
132
+ clearTimeout(followTimer.current);
133
+ followTimer.current = null;
134
+ setFollowLag(0);
135
+ };
96
136
  const [entries, setEntries] = useState([]);
97
137
  const [streaming, setStreaming] = useState("");
98
138
  // Streamed text arrives a token at a time; redrawing the whole screen for each one is wasted work.
@@ -119,6 +159,10 @@ export function App({ initialModel, permContext }) {
119
159
  const [openFile, setOpenFile] = useState(null);
120
160
  const [content, setContent] = useState("");
121
161
  const [reveal, setReveal] = useState(null);
162
+ /** The open files, as tabs over the code view, in the order they were opened. */
163
+ const [tabs, setTabs] = useState([]);
164
+ /** Files opened this session, latest first: Ctrl+P lists them first, and b goes back to the previous one. */
165
+ const [recent, setRecent] = useState([]);
122
166
  const [treeVersion, setTreeVersion] = useState(0);
123
167
  // The last change Nou made per file, so we can flag edited lines and re-open the diff.
124
168
  const [changes, setChanges] = useState(new Map());
@@ -150,8 +194,29 @@ export function App({ initialModel, permContext }) {
150
194
  const [editorMouse, setEditorMouse] = useState([]);
151
195
  /** Wheel events waiting for the next frame, added up per pane. */
152
196
  const pendingWheel = useRef({ pane: "chat", lines: 0, columns: 0, timer: null });
197
+ /** The chat's command menu is open (Tab completes there instead of switching panes). */
198
+ const chatMenuOpen = useRef(false);
199
+ const [chatMenu, setChatMenu] = useState(false);
153
200
  const [chatPrefill, setChatPrefill] = useState(null);
154
201
  const [treeClick, setTreeClick] = useState(null);
202
+ /** Whether the code view's find bar is open (it takes the keyboard). */
203
+ const codeFinding = useRef(false);
204
+ const onCodeFinding = useCallback((open) => {
205
+ codeFinding.current = open;
206
+ }, []);
207
+ /** Bumped to open the code view's find bar from outside it (the palette, the status bar). */
208
+ const [findRequest, setFindRequest] = useState(0);
209
+ const startFind = () => {
210
+ if (!openFile)
211
+ return info("Open a file first (Ctrl+P), then Ctrl+F searches it.");
212
+ setDiffMode("off");
213
+ setEditing(false);
214
+ setCodePanel("code");
215
+ setFocus("code");
216
+ setFindRequest((n) => n + 1);
217
+ };
218
+ const [codeDefine, setCodeDefine] = useState(null);
219
+ const [chatClick, setChatClick] = useState(null);
155
220
  // Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
156
221
  const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
157
222
  /** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
@@ -159,8 +224,12 @@ export function App({ initialModel, permContext }) {
159
224
  const paneWidthsRef = useRef(paneWidths);
160
225
  paneWidthsRef.current = paneWidths;
161
226
  /** What a mouse drag is moving: a pane border, a pane's scrollbar, or the editor's selection. */
227
+ /** Where the status bar's parts and the header's model badge are, from the last render. */
228
+ const clickable = useRef({ status: [], modelFrom: Infinity });
162
229
  const lastClick = useRef(null);
163
230
  const dragging = useRef(null);
231
+ /** Which border is being dragged, for the status bar. */
232
+ const [resizing, setResizing] = useState(null);
164
233
  const pullAbort = useRef(null);
165
234
  const diagnosticsRef = useRef(null);
166
235
  diagnosticsRef.current ??= new DiagnosticsManager();
@@ -175,11 +244,12 @@ export function App({ initialModel, permContext }) {
175
244
  const settingsRef = useRef(settings);
176
245
  settingsRef.current = settings;
177
246
  const refreshGit = useCallback(() => setGit(gitStatus()), []);
178
- const allFiles = useMemo(() => (quickOpen ? listAllFiles() : []), [quickOpen]);
247
+ const allFiles = useMemo(() => (quickOpen !== null ? listAllFiles() : []), [quickOpen !== null]);
179
248
  const repo = useMemo(() => isRepo(), []);
180
249
  const branch = useMemo(() => (repo ? gitBranch() : null), [repo, git]);
181
250
  const open = useCallback((filePath) => {
182
251
  setOpenFile(filePath);
252
+ setRecent((r) => [filePath, ...r.filter((f) => f !== filePath)].slice(0, 30));
183
253
  setContent(readText(filePath));
184
254
  setDiffMode("off");
185
255
  setEditing(false);
@@ -193,15 +263,18 @@ export function App({ initialModel, permContext }) {
193
263
  const engineConfig = useRef({});
194
264
  if (!hooksRef.current) {
195
265
  hooksRef.current = {
196
- onEntry: (entry) => setEntries((prev) => [...prev, entry]),
266
+ onEntry: (entry) => {
267
+ setEntries((prev) => [...prev, entry]);
268
+ if (entry.kind === "error")
269
+ setupCheckRef.current(entry.text);
270
+ },
197
271
  onStream: showStream,
198
272
  onBusy: setBusy,
199
- onRead: (filePath) => {
200
- if (!followRef.current)
201
- return;
202
- open(filePath);
203
- setReveal(filePath);
273
+ onOpen: (filePath, line, to) => {
274
+ clearFollow(); // what was asked for wins over reads still waiting to be shown
275
+ openAtRef.current(filePath, line ?? 1, false, to);
204
276
  },
277
+ onRead: (filePath, line, to) => followStepRef.current({ file: filePath, ...(line ? { line } : {}), ...(to ? { to } : {}) }),
205
278
  onChange: (change, status, turn) => {
206
279
  if (status === "applied") {
207
280
  timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
@@ -210,6 +283,7 @@ export function App({ initialModel, permContext }) {
210
283
  setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
211
284
  // Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
212
285
  if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
286
+ clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
213
287
  setOpenFile(change.path);
214
288
  setContent(status === "applied" ? change.after : readText(change.path));
215
289
  setDiffMode("off");
@@ -239,6 +313,8 @@ export function App({ initialModel, permContext }) {
239
313
  }
240
314
  useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
241
315
  useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
316
+ useEffect(() => setSyntaxVersion((v) => v + 1), [settings.codeTheme]); // re-color the code
317
+ useEffect(() => void loadFallbackHighlighter(), []); // once Nou is on screen (it's slow to load)
242
318
  useEffect(() => {
243
319
  void checkForUpdate().then(setUpdate);
244
320
  }, []);
@@ -254,6 +330,7 @@ export function App({ initialModel, permContext }) {
254
330
  // Starter plugins are listed until you add them (or have your own plugin with the same name).
255
331
  const allPlugins = useMemo(() => [...BUILTIN_PLUGINS, ...plugins, ...starters.filter((s) => !plugins.some((p) => p.id === s.id))], [plugins, starters]);
256
332
  const activePlugins = useMemo(() => allPlugins.filter((p) => isEnabled(p, settings) && p.plugin), [allPlugins, settings.plugins, settings.trustedPlugins]);
333
+ const pluginCommands = useMemo(() => activePlugins.flatMap((p) => (p.plugin.commands ?? []).map((c) => ({ name: c.name, args: "[args]", description: `${c.description} (${p.plugin.name})` }))), [activePlugins]);
257
334
  // Checkers come from enabled plugins (built-in ones included).
258
335
  const firstCheck = useRef(true);
259
336
  useEffect(() => {
@@ -263,12 +340,32 @@ export function App({ initialModel, permContext }) {
263
340
  setTimeout(() => void diagnostics.checkProject(), 1500);
264
341
  }
265
342
  }, [activePlugins]);
266
- // Keep the agent's system prompt and tools in sync with settings and plugins.
343
+ // MCP servers (yours, and in a trusted folder the project's): connected in the background; their tools
344
+ // join the engine's as they come up.
345
+ const mcpRef = useRef(null);
346
+ const [mcpVersion, setMcpVersion] = useState(0);
347
+ mcpRef.current ??= new McpManager(() => setMcpVersion((v) => v + 1));
348
+ const connectMcp = () => mcpRef.current.connect(loadMcpServers(isTrustedFolder()));
349
+ useEffect(() => {
350
+ void connectMcp().then(() => {
351
+ // Servers you use in other tools, offered once there's nothing set up here yet.
352
+ if (loadMcpServers(false).length)
353
+ return;
354
+ const found = findOtherMcpServers();
355
+ if (found.length) {
356
+ const from = [...new Set(found.map((f) => f.from.replace(/ \(this project\)$/, "")))].join(", ");
357
+ info(`Found ${found.length} MCP server${found.length > 1 ? "s" : ""} you use in ${from} (${found.map((f) => f.name).join(", ")}). Type /mcp import to use them here too.`);
358
+ }
359
+ });
360
+ return () => mcpRef.current?.close();
361
+ }, []);
362
+ // Keep the agent's system prompt and tools in sync with settings, plugins and MCP servers.
267
363
  useEffect(() => {
268
364
  const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
269
- engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, permContext].filter(Boolean), extraTools, effort: settings.effort };
365
+ // 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 };
270
367
  agentRef.current.configure(engineConfig.current);
271
- }, [settings.gitContext, settings.effort, activePlugins]);
368
+ }, [settings.gitContext, settings.effort, activePlugins, mcpVersion]);
272
369
  // Pick up changes made outside Nou (other editors, git commands).
273
370
  const polling = useRef(false);
274
371
  useEffect(() => {
@@ -338,7 +435,10 @@ export function App({ initialModel, permContext }) {
338
435
  engine.configure(engineConfig.current);
339
436
  agentRef.current = engine;
340
437
  updateSettings({ engine: id });
341
- info(`Now using the ${target.label} engine (model: ${target.model(choice)}). 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." : ""}`);
438
+ const handoff = conversationSoFar(entries);
439
+ if (handoff)
440
+ engine.note(handoff);
441
+ 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." : ""}`);
342
442
  };
343
443
  /** Downloads an Ollama model, showing progress in the status line. Esc stops it. */
344
444
  const pullModel = async (model) => {
@@ -488,10 +588,16 @@ export function App({ initialModel, permContext }) {
488
588
  return next;
489
589
  };
490
590
  /** Adds a wheel event to its pane's total; the panes get one update per frame however many events arrive. */
591
+ const lastVerticalWheel = useRef(0);
491
592
  const queueWheel = (pane, dir, horizontal) => {
593
+ // A trackpad scrolling down also sends stray sideways steps: while scrolling up or down, ignore them.
594
+ if (!horizontal)
595
+ lastVerticalWheel.current = Date.now();
596
+ else if (Date.now() - lastVerticalWheel.current < 300)
597
+ return;
492
598
  const q = pendingWheel.current;
493
599
  if (q.timer && q.pane !== pane)
494
- flushWheel();
600
+ flushWheel(true);
495
601
  q.pane = pane;
496
602
  if (horizontal)
497
603
  q.columns += dir * WHEEL_STEP;
@@ -499,17 +605,27 @@ export function App({ initialModel, permContext }) {
499
605
  q.lines += dir * WHEEL_STEP;
500
606
  q.timer ??= setTimeout(flushWheel, 16);
501
607
  };
502
- const flushWheel = () => {
608
+ /**
609
+ * Scrolls by what the wheel added up, spread over a few frames: half of what's left each frame, so a
610
+ * notch glides over 2-3 frames and a fast flick eases out instead of jumping dozens of lines at once.
611
+ * `all` sends the rest at once (the pointer moved to another pane).
612
+ */
613
+ const flushWheel = (all = false) => {
503
614
  const q = pendingWheel.current;
504
615
  if (q.timer)
505
616
  clearTimeout(q.timer);
506
617
  q.timer = null;
507
618
  if (!q.lines && !q.columns)
508
619
  return;
509
- const { pane, lines, columns } = q;
510
- q.lines = 0;
511
- q.columns = 0;
620
+ const part = (n) => (all || Math.abs(n) <= 1 ? n : Math.sign(n) * Math.ceil(Math.abs(n) / 2));
621
+ const lines = part(q.lines);
622
+ const columns = part(q.columns);
623
+ const { pane } = q;
624
+ q.lines -= lines;
625
+ q.columns -= columns;
512
626
  setWheel((w) => ({ pane, lines, columns, nonce: (w?.nonce ?? 0) + 1 }));
627
+ if (q.lines || q.columns)
628
+ q.timer = setTimeout(flushWheel, 16);
513
629
  };
514
630
  /** Scrolls a pane to where its scrollbar was clicked or dragged (rows start at y=3, under the border and title). */
515
631
  const scrollPane = (pane, y) => {
@@ -522,8 +638,35 @@ export function App({ initialModel, permContext }) {
522
638
  // (starting the editor). Drag to select in the editor, drag a scrollbar to scroll, drag the border
523
639
  // between panes to resize them.
524
640
  useMouse((event) => {
525
- if (view !== "main" || quickOpen)
641
+ if (view !== "main" || quickOpen !== null)
526
642
  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) {
645
+ 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);
648
+ if (event.kind === "release") {
649
+ dragging.current = null;
650
+ setGameDrag(null);
651
+ updateSettings({ gamePanel: at });
652
+ }
653
+ else
654
+ setGameDrag(at);
655
+ return;
656
+ }
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;
659
+ if (inside) {
660
+ if (event.kind !== "click")
661
+ return;
662
+ if (focus !== "game")
663
+ beforeGame.current = focus;
664
+ setFocus("game");
665
+ if (event.y <= g.y + 1)
666
+ dragging.current = { game: { dx: event.x - g.x, dy: event.y - g.y } };
667
+ return;
668
+ }
669
+ }
527
670
  const { treeWidth, codeWidth, paneHeight } = layout.current;
528
671
  const codeLeft = treeWidth;
529
672
  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 }]);
@@ -534,32 +677,44 @@ export function App({ initialModel, permContext }) {
534
677
  moveBorder(drag, event.x);
535
678
  else if (drag === "editor")
536
679
  editorAt(event.x, event.y, "drag");
537
- else if (drag)
680
+ else if (drag && "scroll" in drag)
538
681
  scrollPane(drag.scroll, event.y);
539
682
  return;
540
683
  }
541
684
  if (event.kind === "release") {
542
685
  // The release says where the button let go, so a drag lands there even in a terminal that
543
686
  // doesn't report the movement in between.
544
- if (dragging.current === "tree" || dragging.current === "chat")
687
+ if (dragging.current === "tree" || dragging.current === "chat") {
545
688
  updateSettings({ paneWidths: moveBorder(dragging.current, event.x) });
546
- else if (dragging.current && typeof dragging.current === "object")
689
+ setResizing(null);
690
+ }
691
+ else if (dragging.current && typeof dragging.current === "object" && "scroll" in dragging.current)
547
692
  scrollPane(dragging.current.scroll, event.y);
548
693
  if (dragging.current === "editor")
549
694
  editorAt(event.x, event.y, "release");
550
695
  dragging.current = null;
551
696
  return;
552
697
  }
698
+ if (event.kind === "click" && !approval && event.y === 0 && event.x >= clickable.current.modelFrom)
699
+ return setView("settings");
700
+ if (event.kind === "click" && event.y === paneHeight + 1)
701
+ return clickable.current.status.find((z) => event.x >= z.from && event.x < z.to)?.run();
553
702
  if (event.y < 1 || event.y > paneHeight)
554
703
  return; // header or status bar
555
704
  const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
556
705
  if (event.kind === "wheel")
557
706
  return queueWheel(pane, event.dir, event.horizontal);
558
707
  // Each border is two columns: the left pane's edge and the right pane's.
559
- if (Math.abs(event.x - (treeWidth - 0.5)) <= 1)
560
- return void (dragging.current = "tree");
561
- if (Math.abs(event.x - (treeWidth + codeWidth - 0.5)) <= 1)
562
- return void (dragging.current = "chat");
708
+ // On the panes' top border row, a little either side of a border grabs it too: an easier target.
709
+ const reach = event.y === 1 ? 2 : 1;
710
+ for (const border of ["tree", "chat"]) {
711
+ const edge = border === "tree" ? treeWidth - 0.5 : treeWidth + codeWidth - 0.5;
712
+ if (Math.abs(event.x - edge) <= reach) {
713
+ dragging.current = border;
714
+ setResizing(border);
715
+ return;
716
+ }
717
+ }
563
718
  // Each pane's scrollbar is the column inside its right border.
564
719
  const scrollbarX = pane === "tree" ? treeWidth - 2 : pane === "code" ? treeWidth + codeWidth - 2 : columns - 2;
565
720
  if (event.x === scrollbarX && event.y >= 3) {
@@ -567,12 +722,36 @@ export function App({ initialModel, permContext }) {
567
722
  scrollPane(pane, event.y);
568
723
  return;
569
724
  }
725
+ // 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);
728
+ if (!zone)
729
+ return;
730
+ if (zone.close !== null && Math.abs(event.x - codeLeft - 1 - zone.close) <= 1)
731
+ closeTab(zone.file);
732
+ else
733
+ switchTab(zone.file);
734
+ return;
735
+ }
736
+ // Ctrl/Alt+click or right-click on code: go to the definition of what's under the pointer.
737
+ if (event.kind === "click" && event.define && pane === "code" && event.y >= 3 && codePanel === "code") {
738
+ lastClick.current = null;
739
+ setFocus("code");
740
+ if (showEditor)
741
+ editorAt(event.x, event.y, "define");
742
+ else if (openFile && !diff)
743
+ setCodeDefine((d) => ({ row: event.y - 3, x: event.x - codeLeft - 1, nonce: (d?.nonce ?? 0) + 1 }));
744
+ return;
745
+ }
570
746
  if (approval)
571
747
  return;
572
748
  setFocus(pane);
573
749
  // Rows start under the pane's top border (y=1) and title (y=2).
574
750
  if (pane === "tree" && event.y >= 3)
575
751
  setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
752
+ // Messages start after the chat's border and the two-column bar in front of each line.
753
+ if (pane === "chat" && event.y >= 3)
754
+ setChatClick((c) => ({ row: event.y - 3, col: event.x - treeWidth - codeWidth - 3, nonce: (c?.nonce ?? 0) + 1 }));
576
755
  // Two clicks in the same spot within half a second are a double-click.
577
756
  const last = lastClick.current;
578
757
  const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
@@ -598,8 +777,7 @@ export function App({ initialModel, permContext }) {
598
777
  if (showEditor && focus === "code" && editorStateRef.current?.selection)
599
778
  return; // the editor copies it
600
779
  if (busy || approval) {
601
- pullAbort.current?.abort();
602
- agentRef.current?.cancel();
780
+ stop();
603
781
  approval?.resolve(false);
604
782
  setApproval(null);
605
783
  return;
@@ -725,8 +903,11 @@ export function App({ initialModel, permContext }) {
725
903
  info(`You have unsaved edits in ${draft.current.path}. Open it and save (Ctrl+S) first, or turn on auto-save in settings.`, "error");
726
904
  return false;
727
905
  };
728
- /** Opens a file at a line, in the editor if `edit` (used by the Problems list and go-to-definition). */
729
- const openAt = (file, line, edit = false) => {
906
+ /**
907
+ * Opens a file at a line, in the editor if `edit` (used by the Problems list and go-to-definition).
908
+ * With `to`, lines `line` to `to` are highlighted: something the user asked Nou to find.
909
+ */
910
+ const openAt = (file, line, edit = false, to) => {
730
911
  if (!settleDraft(file))
731
912
  return;
732
913
  if (file !== openFile)
@@ -739,12 +920,107 @@ export function App({ initialModel, permContext }) {
739
920
  setFocus("code");
740
921
  }
741
922
  else {
742
- setFocusLine((f) => ({ line, nonce: (f?.nonce ?? 0) + 1 }));
923
+ setFocusLine((f) => ({ line, ...(to !== undefined ? { to, path: file } : {}), nonce: (f?.nonce ?? 0) + 1 }));
924
+ }
925
+ };
926
+ // Every file that's shown gets a tab (however it was opened: the tree, Nou, a link...).
927
+ useEffect(() => {
928
+ if (openFile)
929
+ setTabs((t) => (t.includes(openFile) ? t : [...t, openFile].slice(-20)));
930
+ }, [openFile]);
931
+ /** Switches to another open file's tab. */
932
+ const switchTab = (file) => {
933
+ if (file === openFile || !settleDraft(file))
934
+ return;
935
+ clearFollow();
936
+ open(file);
937
+ setReveal(file);
938
+ };
939
+ const cycleTab = (step) => {
940
+ if (tabs.length < 2 || !openFile)
941
+ return;
942
+ const i = tabs.indexOf(openFile);
943
+ switchTab(tabs[(i + step + tabs.length) % tabs.length]);
944
+ };
945
+ /** Closes a tab (the open file's by default), moving to its neighbour. */
946
+ const closeTab = (file = openFile) => {
947
+ if (!file)
948
+ return;
949
+ const i = tabs.indexOf(file);
950
+ const rest = tabs.filter((f) => f !== file);
951
+ if (file !== openFile)
952
+ return setTabs(rest);
953
+ const next = rest[Math.min(Math.max(0, i), rest.length - 1)];
954
+ if (!settleDraft(next ?? ""))
955
+ return;
956
+ if (draft.current?.path === file)
957
+ draft.current = null;
958
+ setTabs(rest);
959
+ if (next) {
960
+ open(next);
961
+ setReveal(next);
962
+ }
963
+ else {
964
+ setOpenFile(null);
965
+ setContent("");
966
+ setEditing(false);
967
+ setDiffMode("off");
968
+ }
969
+ };
970
+ const showFollowStep = (step) => {
971
+ if (step.line)
972
+ openAt(step.file, step.line, false, step.to ?? step.line);
973
+ else if (step.file !== openFile) {
974
+ if (!settleDraft(step.file))
975
+ return;
976
+ open(step.file);
977
+ setReveal(step.file);
743
978
  }
744
979
  };
980
+ const showFollowRef = useRef(showFollowStep);
981
+ showFollowRef.current = showFollowStep;
982
+ /** Shows the next waiting step, then waits as long as the follow speed says before the one after. */
983
+ const playFollow = () => {
984
+ const step = followQueue.current.shift();
985
+ setFollowLag(followQueue.current.length);
986
+ if (!step) {
987
+ followTimer.current = null;
988
+ return;
989
+ }
990
+ showFollowRef.current(step);
991
+ followTimer.current = setTimeout(playFollow, FOLLOW_STEP_MS[settingsRef.current.followSpeed] ?? FOLLOW_STEP_MS.normal);
992
+ };
993
+ const followStep = (step) => {
994
+ if (!followRef.current)
995
+ return;
996
+ if (!FOLLOW_STEP_MS[settings.followSpeed])
997
+ return showFollowStep(step);
998
+ followQueue.current.push(step);
999
+ // Never fall too far behind: past that, the oldest steps are skipped.
1000
+ if (followQueue.current.length > MAX_FOLLOW_LAG)
1001
+ followQueue.current.splice(0, followQueue.current.length - MAX_FOLLOW_LAG);
1002
+ setFollowLag(followQueue.current.length);
1003
+ if (!followTimer.current)
1004
+ playFollow();
1005
+ };
1006
+ const followStepRef = useRef(followStep);
1007
+ followStepRef.current = followStep;
1008
+ useEffect(() => {
1009
+ if (!follow)
1010
+ clearFollow();
1011
+ }, [follow]);
1012
+ const openAtRef = useRef(openAt);
1013
+ openAtRef.current = openAt;
1014
+ /** A file named in the chat (clicked, or shown by Nou): open it there, highlighting the lines it names. */
1015
+ const openRef = useEvent((ref) => {
1016
+ clearFollow();
1017
+ const file = path.relative(process.cwd(), path.resolve(ref.path)).replace(/\\/g, "/");
1018
+ openAt(file, ref.line ?? 1, false, ref.line ? ref.to ?? ref.line : undefined);
1019
+ });
745
1020
  const startEditing = () => {
746
1021
  if (!openFile || !settleDraft(openFile))
747
1022
  return;
1023
+ clearFollow();
748
1024
  setDiffMode("off");
749
1025
  setCodePanel("code");
750
1026
  setEditorStart((s) => ({ line: viewport?.top ?? 1, nonce: s.nonce + 1 }));
@@ -773,7 +1049,7 @@ export function App({ initialModel, permContext }) {
773
1049
  // handles them itself when its input is empty), Esc stops the turn, and every other key works as usual.
774
1050
  if (approval && !(showEditor && focus === "code")) {
775
1051
  if (key.escape) {
776
- agentRef.current?.cancel();
1052
+ stop();
777
1053
  return answer(false);
778
1054
  }
779
1055
  if (focus !== "chat" && /^[yYnN]$/.test(input))
@@ -782,22 +1058,26 @@ export function App({ initialModel, permContext }) {
782
1058
  return openPending();
783
1059
  }
784
1060
  if (key.ctrl && input === "p") {
785
- setQuickOpen(true);
1061
+ setQuickOpen("");
786
1062
  return;
787
1063
  }
788
1064
  if (showEditor && focus === "code")
789
1065
  return; // the editor owns the keyboard
1066
+ if (focus === "code" && codeFinding.current)
1067
+ return; // so does the find bar
1068
+ if (focus === "game")
1069
+ return; // and the Games panel
790
1070
  if (key.tab && key.shift)
791
1071
  return cycleAutoMode();
1072
+ if (key.tab && focus === "chat" && chatMenuOpen.current)
1073
+ return; // Tab completes the command
792
1074
  if (key.tab) {
793
1075
  setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]);
794
1076
  return;
795
1077
  }
796
1078
  if (key.escape) {
797
- if (busy) {
798
- pullAbort.current?.abort();
799
- agentRef.current?.cancel();
800
- }
1079
+ if (busy)
1080
+ stop();
801
1081
  else if (diffMode !== "off") {
802
1082
  setDiffMode("off");
803
1083
  setSessionView(null);
@@ -808,22 +1088,19 @@ export function App({ initialModel, permContext }) {
808
1088
  }
809
1089
  if (focus === "chat")
810
1090
  return; // letters belong to the text box
811
- if (input === "/") {
1091
+ if (input === "?")
1092
+ setQuickOpen(">"); // every action, searchable
1093
+ else if (input === "/") {
812
1094
  // Commands work from anywhere: / jumps to the chat's command box.
813
1095
  setFocus("chat");
814
1096
  setChatPrefill((p) => ({ text: "/", nonce: (p?.nonce ?? 0) + 1 }));
815
1097
  }
816
1098
  else if (input === "f")
817
1099
  setFollow((f) => !f);
818
- else if (input === "m") {
819
- updateSettings({ mouse: !settings.mouse });
820
- 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.");
821
- }
822
- else if (input === "p") {
823
- setCodePanel((p) => (p === "problems" ? "code" : "problems"));
824
- setEditing(false);
825
- setFocus("code");
826
- }
1100
+ else if (input === "m")
1101
+ toggleMouse();
1102
+ else if (input === "p")
1103
+ toggleProblems();
827
1104
  else if (input === "u")
828
1105
  undoLast();
829
1106
  else if (input === "<" || input === ">") {
@@ -840,8 +1117,14 @@ export function App({ initialModel, permContext }) {
840
1117
  cycleDiff();
841
1118
  else if ((input === "]" || input === "[") && diffMode === "session")
842
1119
  stepSessionFile(input === "]" ? 1 : -1);
1120
+ else if (input === "]" || input === "[")
1121
+ cycleTab(input === "]" ? 1 : -1);
1122
+ else if (input === "x")
1123
+ closeTab();
843
1124
  else if (input === "e")
844
1125
  startEditing();
1126
+ else if (input === "b")
1127
+ goBack();
845
1128
  else if (input === "s" || input === ",")
846
1129
  setView("settings");
847
1130
  else if (input === "r") {
@@ -850,7 +1133,7 @@ export function App({ initialModel, permContext }) {
850
1133
  }
851
1134
  else if (input === "q")
852
1135
  requestQuit();
853
- }, { isActive: view === "main" && !quickOpen && !(codePanel === "problems" && focus === "code") });
1136
+ }, { isActive: view === "main" && quickOpen === null && !(codePanel === "problems" && focus === "code") });
854
1137
  /** What the user is looking at, attached to each message so "this file" / "here" make sense. */
855
1138
  const editorContext = () => {
856
1139
  if (!openFile)
@@ -880,6 +1163,56 @@ ${snippet}
880
1163
  const contextChip = openFile
881
1164
  ? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ✎ unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
882
1165
  : null;
1166
+ /** /mcp: list servers; add (a preset, a command, or a URL), remove, or import the ones from other tools. */
1167
+ const mcpCommand = async ([action, name, ...more]) => {
1168
+ if (action === "add") {
1169
+ if (!name)
1170
+ return info(`Usage: /mcp add <name> <command and arguments | url>, or a ready-made one: ${Object.keys(MCP_PRESETS).join(", ")}`, "error");
1171
+ const preset = MCP_PRESETS[name];
1172
+ const target = more.join(" ").trim();
1173
+ if (!preset && !target)
1174
+ return info(`Usage: /mcp add ${name} <command and arguments | url>`, "error");
1175
+ const config = preset && !target ? preset.config : /^https?:\/\//.test(target) ? { url: target } : { command: more[0], args: more.slice(1) };
1176
+ saveUserServers({ [name]: config });
1177
+ info(`Added ${name} to ${USER_MCP_FILE}. Connecting...`);
1178
+ await connectMcp();
1179
+ const st = mcpRef.current.status().find((x) => x.name === name);
1180
+ return info(st?.state === "ready" ? `${name} is connected: ${st.tools} tools. Just ask; tools that change things ask you first.` : `${name} didn't connect: ${st?.error ?? "unknown error"}`, st?.state === "ready" ? "info" : "error");
1181
+ }
1182
+ if (action === "remove") {
1183
+ if (!name || !removeUserServer(name))
1184
+ return info(`No server called "${name ?? ""}" in ${USER_MCP_FILE}.`, "error");
1185
+ await connectMcp();
1186
+ return info(`Removed ${name}.`);
1187
+ }
1188
+ if (action === "import") {
1189
+ const have = new Set(loadMcpServers(true).map((e) => e.name));
1190
+ const found = findOtherMcpServers().filter((f) => !have.has(f.name));
1191
+ if (!found.length)
1192
+ return info("No MCP servers to import: none set up in Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Gemini CLI or Codex that Nou doesn't have.");
1193
+ saveUserServers(Object.fromEntries(found.map((f) => [f.name, f.config])));
1194
+ info(`Imported ${found.map((f) => `${f.name} (from ${f.from})`).join(", ")} into ${USER_MCP_FILE}. Connecting...`);
1195
+ await connectMcp();
1196
+ return mcpCommand([]);
1197
+ }
1198
+ if (action === "reconnect") {
1199
+ mcpRef.current.close();
1200
+ await connectMcp();
1201
+ return mcpCommand([]);
1202
+ }
1203
+ if (action)
1204
+ return info("Usage: /mcp, /mcp add <name> <command|url>, /mcp add browser, /mcp remove <name>, /mcp import, /mcp reconnect", "error");
1205
+ const list = mcpRef.current.status();
1206
+ const mark = { ready: "●", connecting: "◌", failed: "✖", off: "○" };
1207
+ const lines = list.map((x) => ` ${mark[x.state]} ${x.name.padEnd(16)} ${x.state === "ready" ? `${x.tools} tools` : x.state}${x.scope === "project" ? " · this project" : ""}${x.error ? ` · ${x.error}` : ""}`);
1208
+ const others = findOtherMcpServers().filter((f) => !list.some((x) => x.name === f.name));
1209
+ return info([
1210
+ list.length ? `MCP servers:\n${lines.join("\n")}` : "No MCP servers yet.",
1211
+ `Ready-made: ${Object.entries(MCP_PRESETS).map(([k, v]) => `/mcp add ${k} (${v.about})`).join(" · ")}`,
1212
+ others.length ? `In your other tools: ${others.map((f) => `${f.name} (${f.from})`).join(", ")}. /mcp import adds them.` : "",
1213
+ `Yours are in ${USER_MCP_FILE}; a project's in .mcp.json (used in trusted folders).`,
1214
+ ].filter(Boolean).join("\n"));
1215
+ };
883
1216
  const pluginsCommand = async ([action, name]) => {
884
1217
  if (action === "add") {
885
1218
  const starter = starters.find((s) => s.id === name);
@@ -924,6 +1257,25 @@ ${snippet}
924
1257
  return showSessionDiff(arg);
925
1258
  case "sessions":
926
1259
  return showSessions();
1260
+ case "open": {
1261
+ // A path (with :line or :line-line), or part of a file's name.
1262
+ const m = arg.match(/^(.*?)(?::(\d+)(?:-(\d+))?)?$/);
1263
+ const wanted = m[1].replace(/^\.\//, "");
1264
+ if (!wanted)
1265
+ return info("Usage: /open <file>[:line] (part of the name is enough), or ask in the chat where something is.", "error");
1266
+ const files = listAllFiles();
1267
+ const file = files.includes(wanted) ? wanted : files
1268
+ .map((f) => ({ f, m: score(f, wanted) }))
1269
+ .filter((r) => r.m)
1270
+ .sort((a, b) => b.m.score - a.m.score)[0]?.f;
1271
+ if (!file)
1272
+ return info(`No file matches "${wanted}". To find where something is in the code, ask in the chat.`, "error");
1273
+ return openRef({ path: file, ...(m[2] ? { line: Number(m[2]) } : {}), ...(m[3] ? { to: Number(m[3]) } : {}) });
1274
+ }
1275
+ case "games":
1276
+ return toggleGames();
1277
+ case "mcp":
1278
+ return mcpCommand(rest);
927
1279
  case "problems":
928
1280
  setCodePanel("problems");
929
1281
  setFocus("code");
@@ -1054,11 +1406,76 @@ ${snippet}
1054
1406
  }
1055
1407
  }
1056
1408
  };
1409
+ /** Takes you to where the provider's setup is fixed: its key and URL form, or its list of models. */
1410
+ const fixSetup = (provider, reason, form) => {
1411
+ setConnectTo(form && provider.connect ? provider : undefined);
1412
+ setSetupReason(reason);
1413
+ setView("settings");
1414
+ };
1415
+ // A request that failed because of the provider's setup (not the request) opens the fix for it.
1416
+ const setupCheck = (error) => {
1417
+ // Only a failed request to the model ("provider:model: ..."), never a tool's error or a problem report.
1418
+ if (agentRef.current?.id !== "nou" || !error.startsWith(`${describe(choice)}: `))
1419
+ return;
1420
+ const provider = choice.provider;
1421
+ const problem = setupProblem(provider, error);
1422
+ if (!problem)
1423
+ return;
1424
+ const what = problem === "key" ? "the key was rejected" : problem === "url" ? "its URL didn't answer" : "it doesn't have that model";
1425
+ info(`${provider.label}: ${what}. Opened its settings to fix it (Esc to come back).`);
1426
+ fixSetup(provider, error.split("\n")[0], problem !== "model");
1427
+ };
1428
+ const setupCheckRef = useRef(setupCheck);
1429
+ setupCheckRef.current = setupCheck;
1430
+ /** Messages sent while Nou works: each goes when it finishes, in order. */
1431
+ const [queued, setQueued] = useState([]);
1432
+ /** Set when you stop Nou: what was queued goes back into the input instead of being sent. */
1433
+ const stopped = useRef(false);
1434
+ const stop = () => {
1435
+ pullAbort.current?.abort();
1436
+ agentRef.current?.cancel();
1437
+ stopped.current = true;
1438
+ };
1439
+ useEffect(() => {
1440
+ if (busy || approval || !queued.length)
1441
+ return;
1442
+ if (stopped.current) {
1443
+ stopped.current = false;
1444
+ setChatPrefill((p) => ({ text: queued.join("\n"), nonce: (p?.nonce ?? 0) + 1 }));
1445
+ setQueued([]);
1446
+ return;
1447
+ }
1448
+ const [next, ...rest] = queued;
1449
+ setQueued(rest);
1450
+ send(next);
1451
+ }, [busy, approval, queued]);
1452
+ useEffect(() => {
1453
+ if (busy)
1454
+ stopped.current = false;
1455
+ }, [busy]);
1456
+ /** From the chat: while Nou works, a message waits its turn, or (`now`) stops Nou and goes right away. */
1457
+ const submit = (text, now = false) => {
1458
+ if (!busy)
1459
+ return send(text);
1460
+ if (now) {
1461
+ stop();
1462
+ stopped.current = false; // this one is meant to go
1463
+ setQueued((q) => [text, ...q]);
1464
+ }
1465
+ else
1466
+ setQueued((q) => [...q, text]);
1467
+ };
1057
1468
  const send = (text) => {
1058
1469
  if (text.startsWith("/")) {
1059
1470
  void runCommand(text);
1060
1471
  return;
1061
1472
  }
1473
+ const missing = agentRef.current?.id === "nou" ? choice.provider.unavailable() : null;
1474
+ if (missing) {
1475
+ info(`${choice.provider.label} isn't set up yet (${missing}). Opened its settings: enter what it needs, then send again.`, "error");
1476
+ fixSetup(choice.provider, `${choice.provider.label} isn't set up: ${missing}`, true);
1477
+ return;
1478
+ }
1062
1479
  setEntries((prev) => [...prev, { kind: "user", text }]);
1063
1480
  void agentRef.current.send(text, editorContext() ?? undefined);
1064
1481
  };
@@ -1085,6 +1502,7 @@ ${snippet}
1085
1502
  }
1086
1503
  // Stable callbacks for the memoized panes.
1087
1504
  const onTreeOpen = useEvent((filePath) => {
1505
+ clearFollow(); // you went somewhere yourself: follow mode stops replaying what Nou read
1088
1506
  open(filePath);
1089
1507
  setFocus("code");
1090
1508
  });
@@ -1092,23 +1510,36 @@ ${snippet}
1092
1510
  const onJump = useEvent((file, line) => openAt(file, line));
1093
1511
  const onCloseProblems = useEvent(() => setCodePanel("code"));
1094
1512
  const onViewport = useEvent((top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })));
1095
- const onSend = useEvent(send);
1513
+ const onSend = useEvent(submit);
1514
+ const onChatMenu = useEvent((open) => {
1515
+ chatMenuOpen.current = open;
1516
+ setChatMenu(open);
1517
+ });
1096
1518
  const onAnswer = useEvent(answer);
1097
1519
  const onEditorHover = useEvent((text, line, col) => (lspEnabled && openFile ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)));
1098
- const onEditorDefinition = useEvent((text, line, col) => {
1520
+ /**
1521
+ * Go to definition (Ctrl+G, Ctrl/Alt+click, right-click): the language server's answer when there is one,
1522
+ * otherwise Nou's own (an imported name goes to its import line; a click on an import's path opens it).
1523
+ */
1524
+ const goToDefinition = async (text, line, col, inEditor) => {
1099
1525
  if (!openFile)
1100
1526
  return;
1101
- if (!lspEnabled)
1102
- return info("Go to definition needs a language server (see /settings → Plugins).", "error");
1103
- void lsp.definition(openFile, text, line, col).then((target) => {
1104
- if (!target)
1105
- return info("No definition found here.");
1106
- // Keep unsaved text when jumping within the same file.
1107
- if (target.file === openFile && draft.current)
1108
- draft.current = { ...draft.current, text };
1527
+ const file = openFile;
1528
+ const target = (lspEnabled ? await lsp.definition(file, text, line, col).catch(() => null) : null) ?? findDefinition(file, text, line, col);
1529
+ if (!target) {
1530
+ const word = wordAt(text.split(/\r?\n/)[line - 1] ?? "", col)?.word;
1531
+ return info(word ? `No definition found for ${word}.` : "Ctrl+click (or Option+click, or right-click) a name to go to its definition.");
1532
+ }
1533
+ // Keep unsaved text when jumping within the same file.
1534
+ if (inEditor && target.file === file && draft.current)
1535
+ draft.current = { ...draft.current, text };
1536
+ if (inEditor)
1109
1537
  openAt(target.file, target.line, true);
1110
- });
1111
- });
1538
+ else
1539
+ openAt(target.file, target.line, false, target.line);
1540
+ };
1541
+ const onEditorDefinition = useEvent((text, line, col) => void goToDefinition(text, line, col, true));
1542
+ const onCodeDefine = useEvent((line, col) => void goToDefinition(content, line, col, false));
1112
1543
  const onEditorSave = useEvent((text) => saveFromEditor(text));
1113
1544
  const onEditorExit = useEvent(() => {
1114
1545
  draft.current = null;
@@ -1136,8 +1567,9 @@ ${snippet}
1136
1567
  });
1137
1568
  const lspEnabled = activePlugins.some((p) => p.id === "lsp");
1138
1569
  if (view === "settings") {
1139
- return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, ...(connectTo ? { connectTo } : {}), onModel: (next) => void chooseModel(next), onEngine: (id) => void switchEngine(id), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: rescanPlugins, onClose: () => {
1570
+ return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, ...(connectTo ? { connectTo } : {}), reason: setupReason, onModel: (next) => void chooseModel(next), onEngine: (id) => void switchEngine(id), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: rescanPlugins, onClose: () => {
1140
1571
  setConnectTo(undefined);
1572
+ setSetupReason(null);
1141
1573
  setView("main");
1142
1574
  } }));
1143
1575
  }
@@ -1145,34 +1577,130 @@ ${snippet}
1145
1577
  const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
1146
1578
  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));
1147
1579
  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;
1148
1581
  layout.current = { treeWidth, codeWidth, paneHeight: height };
1149
1582
  const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
1150
1583
  const scrollFor = (pane) => (scrollTo?.pane === pane ? scrollTo : null);
1151
1584
  const totals = diagnostics.counts();
1152
1585
  const checking = diagnostics.busy;
1153
- const mode = approval
1154
- ? { label: "APPROVE", color: C.yellow }
1155
- : showEditor ? { label: "EDIT", color: C.green }
1156
- : diff ? { label: "DIFF", color: C.accent2 }
1157
- : busy ? { label: "WORKING", color: C.accent2 }
1158
- : { label: focus.toUpperCase(), color: C.accent };
1586
+ /** Starts a command in the chat's input, for commands that need something typed after them. */
1587
+ const typeCommand = (text) => {
1588
+ setFocus("chat");
1589
+ setChatPrefill((p) => ({ text, nonce: (p?.nonce ?? 0) + 1 }));
1590
+ };
1591
+ const toggleMouse = () => {
1592
+ updateSettings({ mouse: !settings.mouse });
1593
+ 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
+ };
1595
+ /** Opens the Games panel and gives it the keyboard, or closes it. */
1596
+ const toggleGames = (open = !gamesOpen) => {
1597
+ setGamesOpen(open);
1598
+ if (open) {
1599
+ if (focus !== "game")
1600
+ beforeGame.current = focus;
1601
+ setFocus("game");
1602
+ }
1603
+ else if (focus === "game")
1604
+ setFocus(beforeGame.current);
1605
+ };
1606
+ // Plain functions, not hooks: this runs after the settings screen's early return.
1607
+ const leaveGames = () => setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
1608
+ const gamesSettings = () => {
1609
+ setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
1610
+ setView("settings");
1611
+ };
1612
+ /** Back to the file that was open before this one. */
1613
+ const goBack = () => {
1614
+ clearFollow();
1615
+ const previous = recent.find((f) => f !== openFile);
1616
+ if (!previous)
1617
+ return info("No earlier file to go back to yet: Ctrl+P lists the files you've opened.");
1618
+ open(previous);
1619
+ setReveal(previous);
1620
+ };
1621
+ const toggleProblems = () => {
1622
+ setCodePanel((p) => (p === "problems" ? "code" : "problems"));
1623
+ setEditing(false);
1624
+ setFocus("code");
1625
+ };
1626
+ // Everything Nou can do, for the command palette (? or Ctrl+P then >), with the key that does it.
1627
+ const paletteActions = [
1628
+ { title: "Find a file", keys: "Ctrl+P", run: () => setQuickOpen("") },
1629
+ { title: "Find in the open file", keys: "Ctrl+F", run: startFind },
1630
+ { title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
1631
+ { title: "Go back to the previous file", keys: "b", run: goBack },
1632
+ { title: "MCP servers: tools from other programs", keys: "/mcp", run: () => void runCommand("/mcp") },
1633
+ { title: "Browser control (Playwright MCP)", keys: "/mcp add browser", run: () => void runCommand("/mcp add browser") },
1634
+ { title: "Import MCP servers from Claude Code, Cursor, Codex...", keys: "/mcp import", run: () => void runCommand("/mcp import") },
1635
+ { title: gamesOpen ? "Close the Games panel" : "Games: Pong, Snake, Breakout", keys: "/games", run: () => toggleGames() },
1636
+ { title: "Next tab", keys: "]", run: () => cycleTab(1) },
1637
+ { title: "Previous tab", keys: "[", run: () => cycleTab(-1) },
1638
+ { title: "Close the tab", keys: "x", run: () => closeTab() },
1639
+ { title: "Edit the open file", keys: "e · double-click", run: startEditing },
1640
+ { title: "Diff the open file (vs git, Nou's edit, this session)", keys: "v", run: cycleDiff },
1641
+ { title: "Undo Nou's last change", keys: "u", run: undoLast },
1642
+ { title: "Rewind everything from Nou's last turn", keys: "/rewind", run: rewind },
1643
+ { title: "Changes this session", keys: "/diff", run: () => showSessionDiff("") },
1644
+ { title: "Earlier sessions", keys: "/sessions", run: showSessions },
1645
+ { title: "Recent changes", keys: "/history", run: showHistory },
1646
+ { title: "Problems: every error and warning", keys: "p", run: toggleProblems },
1647
+ { title: "Switch model", keys: "/model", run: () => setView("settings") },
1648
+ { title: "Connect a provider (API key)", keys: "/connect", run: () => typeCommand("/connect ") },
1649
+ { title: "Switch engine (Nou, Claude Code, Codex, pi)", keys: "/engine", run: () => void runCommand("/engine") },
1650
+ { title: "Ask before every edit and command", keys: "Shift+Tab", run: () => cycleAutoMode("ask") },
1651
+ { title: "Auto-edit: apply edits without asking", keys: "Shift+Tab", run: () => cycleAutoMode("auto-edit") },
1652
+ { title: "Auto: edits and commands without asking", keys: "Shift+Tab", run: () => cycleAutoMode("auto") },
1653
+ { title: `Follow mode ${follow ? "off" : "on"}: jump to files Nou works on`, keys: "f", run: () => setFollow((f) => !f) },
1654
+ { title: `Replay speed for follow mode: ${settings.followSpeed} (next: ${FOLLOW_SPEEDS[(FOLLOW_SPEEDS.indexOf(settings.followSpeed) + 1) % FOLLOW_SPEEDS.length]})`, keys: "settings", run: () => updateSettings({ followSpeed: FOLLOW_SPEEDS[(FOLLOW_SPEEDS.indexOf(settings.followSpeed) + 1) % FOLLOW_SPEEDS.length] }) },
1655
+ { title: `Mouse ${settings.mouse ? "off (select text in the terminal)" : "on"}`, keys: "m", run: toggleMouse },
1656
+ { title: "Copy the last reply", keys: "/copy", run: () => void runCommand("/copy") },
1657
+ { title: "Copy the last code block", keys: "/copy code", run: () => void runCommand("/copy code") },
1658
+ { title: "Copy the open file", keys: "/copy file", run: () => void runCommand("/copy file") },
1659
+ { title: "Summarize the conversation to free up context", keys: "/compact", run: () => void runCommand("/compact") },
1660
+ { title: "Start a fresh conversation", keys: "/clear", run: () => void runCommand("/clear") },
1661
+ { title: "Download an Ollama model", keys: "/pull", run: () => typeCommand("/pull ") },
1662
+ { title: "Plugins", keys: "/plugins", run: () => void runCommand("/plugins") },
1663
+ { title: "Settings: model, engine, preferences, plugins", keys: "s", run: () => setView("settings") },
1664
+ { title: "Reset the pane widths", keys: "=", run: () => {
1665
+ setPaneWidths(null);
1666
+ updateSettings({ paneWidths: null });
1667
+ } },
1668
+ { title: "Go to the chat", keys: "Tab · Esc", run: () => setFocus("chat") },
1669
+ { title: "Go to the files", keys: "Tab", run: () => setFocus("tree") },
1670
+ { title: "Go to the code", keys: "Tab", run: () => setFocus("code") },
1671
+ { title: "All commands and keys", keys: "/help", run: () => void runCommand("/help") },
1672
+ ...pluginCommands.map((c) => ({ title: `${c.description}`, keys: `/${c.name}`, run: () => typeCommand(`/${c.name} `) })),
1673
+ { title: "Quit Nou", keys: "Ctrl+C ×2", run: quit },
1674
+ ];
1675
+ const mode = resizing
1676
+ ? { label: "RESIZE", color: C.cyan }
1677
+ : approval
1678
+ ? { label: "APPROVE", color: C.yellow }
1679
+ : showEditor ? { label: "EDIT", color: C.green }
1680
+ : diff ? { label: "DIFF", color: C.accent2 }
1681
+ : busy ? { label: "WORKING", color: C.accent2 }
1682
+ : { label: focus.toUpperCase(), color: C.accent };
1159
1683
  // Shortcuts for the status bar, most useful first; the ones that don't fit are left out (all of them are in /help).
1160
- const hints = approval && !(showEditor && focus === "code")
1161
- ? [["y", "accept"], ["n", "reject"], ...(pendingChange && diff !== pendingChange ? [["o", "show the edit"]] : []), ["Esc", "stop"], ["Shift+Tab", "auto mode"]]
1162
- : showEditor && focus === "code"
1163
- ? editorState?.selection
1164
- ? [["Ctrl+C", "copy"], ["Ctrl+X", "cut"], ["Tab", "indent"], ["Ctrl+/", "comment"], ["Esc", "deselect"]]
1165
- : [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Shift+→", "select"], ["Ctrl+F", "find"], ["Ctrl+/", "comment"], ["/help", "all keys"]]
1166
- : focus === "chat"
1167
- ? busy
1168
- ? [["Esc", "stop"], ["Shift+↑↓", "scroll"], ["Tab", "next pane"]]
1169
- : [["Enter", "send"], ["/", "commands"], ["Tab", "next pane"], ["Ctrl+P", "find file"], ["Shift+↑↓", "scroll"]]
1170
- : focus === "tree"
1171
- ? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
1172
- : diff
1173
- ? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
1174
- : [["double-click/e", "edit"], ["v", "diff"], ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
1175
- const statusUsed = mode.label.length + 2 + autoMode.length + 2 + 10 + ` ✖ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
1684
+ const hints = resizing
1685
+ ? [[`files ${treeWidth} · code ${codeWidth} · chat ${chatWidth}`, "columns"], ["release", "to keep it"], ["=", "reset"]]
1686
+ : approval && !(showEditor && focus === "code")
1687
+ ? [["y", "accept"], ["n", "reject"], ...(pendingChange && diff !== pendingChange ? [["o", "show the edit"]] : []), ["Esc", "stop"], ["Shift+Tab", "auto mode"]]
1688
+ : showEditor && focus === "code"
1689
+ ? editorState?.selection
1690
+ ? [["Ctrl+C", "copy"], ["Ctrl+X", "cut"], ["Tab", "indent"], ["Ctrl+/", "comment"], ["Esc", "deselect"]]
1691
+ : [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Shift+→", "select"], ["Ctrl+F", "find"], ["Ctrl+/", "comment"], ["/help", "all keys"]]
1692
+ : focus === "chat"
1693
+ ? chatMenu ? [["↑↓", "choose"], ["Tab", "complete"], ["Enter", "run"], ["Esc", "close"]]
1694
+ : busy
1695
+ ? [["Esc", "stop"], ["Shift+↑↓", "scroll"], ["Tab", "next pane"]]
1696
+ : [["Enter", "send"], ["/", "commands"], ["Tab", "next pane"], ["Ctrl+P", "find file"], ["Shift+↑↓", "scroll"]]
1697
+ : focus === "tree"
1698
+ ? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
1699
+ : diff
1700
+ ? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
1701
+ : [["double-click/e", "edit"], ["Ctrl+F", "find"], ["v", "diff"], ...(recent.length > 1 ? [["b", "back"]] : []), ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
1702
+ const followChip = follow ? ` ◉ follow${followLag ? ` ·${followLag}` : ""} ` : " ○ follow ";
1703
+ const statusUsed = mode.label.length + 2 + autoMode.length + 2 + followChip.length + ` ✖ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
1176
1704
  const shownHints = [];
1177
1705
  for (let used = statusUsed, i = 0; i < hints.length; i++) {
1178
1706
  used += hints[i][0].length + hints[i][1].length + 4;
@@ -1180,12 +1708,47 @@ ${snippet}
1180
1708
  break;
1181
1709
  shownHints.push(hints[i]);
1182
1710
  }
1183
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`, 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, { 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, { 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 })), _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, onSubmit: onSend, onAnswer: onAnswer })] }), _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: follow ? " ◉ follow " : " ○ follow " }), _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
1711
+ // The status bar and the header can be clicked: what each part shows, it does.
1712
+ const hintAction = {
1713
+ y: () => answer(true), n: approval ? () => answer(false) : undefined, o: openPending,
1714
+ "Shift+Tab": () => cycleAutoMode(), "/": () => typeCommand("/"), "Ctrl+P": () => setQuickOpen(""),
1715
+ Tab: () => setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]),
1716
+ e: startEditing, "double-click/e": startEditing, b: goBack, "Ctrl+F": showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
1717
+ s: () => setView("settings"), "/help": () => void runCommand("/help"),
1718
+ Esc: busy ? stop : diff && !showEditor ? () => setDiffMode("off") : undefined,
1719
+ };
1720
+ const zones = [];
1721
+ {
1722
+ let x = ` ${mode.label} `.length;
1723
+ const add = (width, run) => {
1724
+ if (run)
1725
+ zones.push({ from: x, to: x + width, run });
1726
+ x += width;
1727
+ };
1728
+ add(` ${autoMode} `.length, () => cycleAutoMode());
1729
+ add(followChip.length, () => setFollow((f) => !f));
1730
+ add(` ✖ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
1731
+ if (checking.length)
1732
+ add(3 + checking.join(", ").length);
1733
+ if (!exitArmed) {
1734
+ shownHints.forEach(([key, label], i) => {
1735
+ add(i === 0 ? 2 : 3);
1736
+ add(key.length + 1 + label.length, hintAction[key]);
1737
+ });
1738
+ }
1739
+ }
1740
+ const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
1741
+ 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
1184
1743
  ? _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)` : ""] })
1185
- : 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)))] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
1186
- setQuickOpen(false);
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) => {
1745
+ setQuickOpen(null);
1746
+ clearFollow();
1187
1747
  open(file);
1188
1748
  setReveal(file);
1189
1749
  setFocus("code");
1190
- }, onClose: () => setQuickOpen(false) }))] }));
1750
+ }, onRun: (action) => {
1751
+ setQuickOpen(null);
1752
+ action.run();
1753
+ }, onClose: () => setQuickOpen(null) }))] }));
1191
1754
  }