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