nou-cli 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -9
- package/dist/index.js +9 -99
- package/dist/src/agent/index.js +199 -33
- package/dist/src/cli.js +117 -0
- package/dist/src/commands/index.js +233 -25
- package/dist/src/config/index.js +14 -0
- package/dist/src/context/index.js +33 -10
- 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/games/index.js +217 -0
- package/dist/src/mcp/import.js +106 -0
- package/dist/src/mcp/index.js +184 -0
- package/dist/src/providers/anthropic.js +23 -3
- package/dist/src/providers/index.js +13 -0
- package/dist/src/providers/openai.js +6 -2
- package/dist/src/syntax/index.js +10 -20
- package/dist/src/syntax/themes.js +57 -0
- package/dist/src/terminal/index.js +127 -0
- package/dist/src/tools/index.js +86 -2
- package/dist/src/ui/App.js +900 -118
- package/dist/src/ui/Chat.js +143 -22
- package/dist/src/ui/ChatInput.js +9 -2
- package/dist/src/ui/CodeView.js +111 -20
- package/dist/src/ui/Editor.js +13 -6
- package/dist/src/ui/FileTree.js +6 -5
- package/dist/src/ui/GamePanel.js +99 -0
- package/dist/src/ui/Header.js +1 -1
- package/dist/src/ui/QuickOpen.js +51 -12
- package/dist/src/ui/Settings.js +58 -13
- package/dist/src/ui/Tabs.js +56 -0
- package/dist/src/ui/Terminal.js +84 -0
- package/dist/src/ui/clipboard.js +9 -7
- package/dist/src/ui/commands.js +36 -0
- package/dist/src/ui/definition.js +135 -0
- package/dist/src/ui/fast-width.js +62 -0
- package/dist/src/ui/mouse.js +4 -1
- package/dist/src/ui/render-options.js +4 -0
- package/dist/src/ui/text.js +132 -12
- package/dist/src/ui/width-shim.js +7 -0
- package/package.json +2 -1
package/dist/src/ui/App.js
CHANGED
|
@@ -3,65 +3,84 @@ 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 {
|
|
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";
|
|
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";
|
|
7
12
|
import { ENGINES, createEngine, engineInfo, installEngine, isEngineId, isInstalled } from "../engines/index.js";
|
|
8
13
|
import { harness, problemsIntroduced } from "../agent/harness.js";
|
|
9
|
-
import { describe, providers, resolveModel, saveModel } from "../providers/index.js";
|
|
14
|
+
import { describe, providers, resolveModel, saveModel, setupProblem } from "../providers/index.js";
|
|
10
15
|
import { sameModel } from "../providers/ollama.js";
|
|
11
|
-
import { loadSettings, saveSettings } from "../config/index.js";
|
|
16
|
+
import { FOLLOW_SPEEDS, isTrustedFolder, loadSettings, saveSettings } from "../config/index.js";
|
|
12
17
|
import { branch as gitBranch, diffAgainstHead, isRepo, sameStatus, status as gitStatus, statusAsync as gitStatusAsync } from "../git/index.js";
|
|
13
18
|
import { BUILTIN_PLUGINS, addStarter, discoverPlugins, isEnabled, loadPlugin, scaffoldPlugin, starterPlugins, trustPatch, trustState } from "../plugins/index.js";
|
|
14
19
|
import { DiagnosticsManager, formatDiagnostic, newProblems } from "../diagnostics/index.js";
|
|
15
20
|
import { Timeline } from "../history/index.js";
|
|
16
21
|
import { lsp } from "../lsp/index.js";
|
|
17
22
|
import { onGrammarLoaded, preload } from "../syntax/index.js";
|
|
23
|
+
import { setCodeTheme } from "../syntax/themes.js";
|
|
18
24
|
import { checkForUpdate, currentVersion } from "../update/index.js";
|
|
19
|
-
import { changedLines, lineCounts } from "./text.js";
|
|
25
|
+
import { changedLines, lineCounts, loadFallbackHighlighter } from "./text.js";
|
|
26
|
+
import { findDefinition, wordAt } from "./definition.js";
|
|
20
27
|
import { ProblemsView } from "./ProblemsView.js";
|
|
21
28
|
import { useMouse } from "./mouse.js";
|
|
22
29
|
import { C } from "./theme.js";
|
|
23
30
|
import { FileTree } from "./FileTree.js";
|
|
24
31
|
import { CodeView } from "./CodeView.js";
|
|
25
32
|
import { Editor } from "./Editor.js";
|
|
33
|
+
import { SLASH_COMMANDS } from "./commands.js";
|
|
26
34
|
import { WHEEL_STEP } from "./scroll.js";
|
|
27
35
|
import { Chat } from "./Chat.js";
|
|
28
36
|
import { copyToClipboard } from "./clipboard.js";
|
|
29
37
|
import { Header } from "./Header.js";
|
|
30
|
-
import {
|
|
38
|
+
import { Tabs, layoutTabs } from "./Tabs.js";
|
|
39
|
+
import { GAME_PANEL, GamePanel } from "./GamePanel.js";
|
|
40
|
+
import { Terminal } from "./Terminal.js";
|
|
41
|
+
import { Shell } from "../terminal/index.js";
|
|
42
|
+
import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
|
|
31
43
|
import { Settings } from "./Settings.js";
|
|
32
44
|
const FOCUS_ORDER = ["tree", "code", "chat"];
|
|
33
45
|
const MIN_TREE = 14;
|
|
34
46
|
const MIN_CODE = 20;
|
|
35
47
|
const MIN_CHAT = 24;
|
|
36
48
|
const NO_LINES = new Set();
|
|
49
|
+
const NO_FILES = new Set();
|
|
37
50
|
const DOUBLE_CLICK_MS = 500;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
/** How long each follow step stays on screen before the next. */
|
|
52
|
+
const FOLLOW_STEP_MS = { slow: 2500, normal: 1200, fast: 450, instant: 0 };
|
|
53
|
+
/** Steps follow mode may fall behind before it skips ahead. */
|
|
54
|
+
const MAX_FOLLOW_LAG = 12;
|
|
55
|
+
/** Columns the tabs get in the code view's title, leaving room for the scroll position and badges. */
|
|
56
|
+
const tabsWidth = (codeWidth) => Math.max(16, codeWidth - 3);
|
|
57
|
+
/** How much of the conversation a newly switched-to engine is given (the latest part when it's longer). */
|
|
58
|
+
const HANDOFF_CHARS = 24_000;
|
|
59
|
+
/** The conversation so far as text, for an engine taking over from another one; null when there's none. */
|
|
60
|
+
export function conversationSoFar(entries) {
|
|
61
|
+
const said = entries
|
|
62
|
+
.filter((e) => e.kind === "user" || e.kind === "assistant" || e.kind === "tool")
|
|
63
|
+
.map((e) => (e.kind === "user" ? `User: ${e.text}` : e.kind === "assistant" ? `Assistant: ${e.text}` : `(${e.text})`));
|
|
64
|
+
if (!said.some((line) => line.startsWith("User:")))
|
|
65
|
+
return null;
|
|
66
|
+
let text = said.join("\n\n");
|
|
67
|
+
if (text.length > HANDOFF_CHARS)
|
|
68
|
+
text = `[earlier messages left out]\n${text.slice(-HANDOFF_CHARS)}`;
|
|
69
|
+
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>`;
|
|
70
|
+
}
|
|
71
|
+
/** /help: every command from the shared list, then the keys. */
|
|
72
|
+
const HELP = `Commands (type / in the chat for a menu, or press ? for every action):
|
|
73
|
+
${SLASH_COMMANDS.map((c) => ` /${`${c.name}${c.args ? ` ${c.args}` : ""}`.padEnd(30)} ${c.description}`).join("\n")}
|
|
59
74
|
Keys:
|
|
60
|
-
Ctrl+P find a file
|
|
75
|
+
? every action, searchable (also Ctrl+P then >) Ctrl+P find a file
|
|
76
|
+
Tab switch pane Esc stop / back
|
|
61
77
|
Shift+Tab ask / auto-edit / auto < > resize pane = reset
|
|
62
78
|
m mouse on/off (off: select text to copy; Shift+drag also works in most terminals)
|
|
63
79
|
/ start a command from any pane
|
|
64
80
|
e edit open file (or double-click in the code) v diff (git / Nou / session) f follow mode
|
|
81
|
+
Ctrl+F find in the open file (Enter/↓ next, ↑ previous)
|
|
82
|
+
b back to the previous file (Ctrl+P lists recent files first) [ ] previous/next tab · x close tab
|
|
83
|
+
Ctrl+click, Option+click or right-click a name: go to its definition (an import's path opens the file)
|
|
65
84
|
p problems u undo Nou's edit s settings
|
|
66
85
|
c (in files) show only changed files Ctrl+C twice quit
|
|
67
86
|
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
|
|
@@ -84,16 +103,45 @@ function readText(filePath) {
|
|
|
84
103
|
return "";
|
|
85
104
|
}
|
|
86
105
|
}
|
|
87
|
-
export function App({ initialModel, permContext }) {
|
|
106
|
+
export function App({ initialModel, permContext, resume }) {
|
|
88
107
|
const { exit } = useApp();
|
|
89
108
|
const { columns, rows } = useWindowSize();
|
|
90
109
|
const [settings, setSettings] = useState(loadSettings);
|
|
110
|
+
setCodeTheme(settings.codeTheme); // before anything below highlights code
|
|
91
111
|
const [view, setView] = useState("main");
|
|
92
112
|
const [connectTo, setConnectTo] = useState();
|
|
93
|
-
|
|
113
|
+
/** Why settings were opened for you (a request failed on the provider's setup). */
|
|
114
|
+
const [setupReason, setSetupReason] = useState(null);
|
|
115
|
+
/** The file finder / command palette: closed (null), or open with this query (">" for commands). */
|
|
116
|
+
const [quickOpen, setQuickOpen] = useState(null);
|
|
94
117
|
const [focus, setFocus] = useState("chat");
|
|
118
|
+
/** The Games panel: whether it's open, the pane it's being dragged over, and the pane to go back to. */
|
|
119
|
+
const [gamesOpen, setGamesOpen] = useState(false);
|
|
120
|
+
const [gameDrag, setGameDrag] = useState(null);
|
|
121
|
+
const beforeGame = useRef("chat");
|
|
122
|
+
/** It docks under a pane, splitting that pane's column: under the one it's dragged over while moving. */
|
|
123
|
+
const gameDock = gameDrag ?? settings.gameDock ?? "chat";
|
|
124
|
+
/** The terminal panel under the code (` or /terminal): its shell lives on while the panel is closed. */
|
|
125
|
+
const [termOpen, setTermOpen] = useState(false);
|
|
126
|
+
const [termVersion, setTermVersion] = useState(0);
|
|
127
|
+
const shellRef = useRef(null);
|
|
128
|
+
const beforeTerm = useRef("code");
|
|
95
129
|
const [follow, setFollow] = useState(settings.followMode);
|
|
130
|
+
// Follow mode replays what the agent reads a step at a time (it reads far faster than you can watch):
|
|
131
|
+
// the steps waiting, and the timer that shows the next one.
|
|
132
|
+
const followQueue = useRef([]);
|
|
133
|
+
const followTimer = useRef(null);
|
|
134
|
+
const [followLag, setFollowLag] = useState(0);
|
|
135
|
+
const clearFollow = () => {
|
|
136
|
+
followQueue.current = [];
|
|
137
|
+
if (followTimer.current)
|
|
138
|
+
clearTimeout(followTimer.current);
|
|
139
|
+
followTimer.current = null;
|
|
140
|
+
setFollowLag(0);
|
|
141
|
+
};
|
|
96
142
|
const [entries, setEntries] = useState([]);
|
|
143
|
+
/** This conversation, as saved for /resume: a new one after /clear, an old one once resumed. */
|
|
144
|
+
const [conversationId, setConversationId] = useState(newSessionId);
|
|
97
145
|
const [streaming, setStreaming] = useState("");
|
|
98
146
|
// Streamed text arrives a token at a time; redrawing the whole screen for each one is wasted work.
|
|
99
147
|
const streamed = useRef({ text: "", timer: null });
|
|
@@ -114,11 +162,17 @@ export function App({ initialModel, permContext }) {
|
|
|
114
162
|
}
|
|
115
163
|
};
|
|
116
164
|
const [busy, setBusy] = useState(null);
|
|
165
|
+
/** Files Nou has changed (or is asking to) since it started working: they shimmer in the tree until it's done. */
|
|
166
|
+
const [working, setWorking] = useState(NO_FILES);
|
|
117
167
|
const [choice, setChoice] = useState(initialModel);
|
|
118
168
|
const [approval, setApproval] = useState(null);
|
|
119
169
|
const [openFile, setOpenFile] = useState(null);
|
|
120
170
|
const [content, setContent] = useState("");
|
|
121
171
|
const [reveal, setReveal] = useState(null);
|
|
172
|
+
/** The open files, as tabs over the code view, in the order they were opened. */
|
|
173
|
+
const [tabs, setTabs] = useState([]);
|
|
174
|
+
/** Files opened this session, latest first: Ctrl+P lists them first, and b goes back to the previous one. */
|
|
175
|
+
const [recent, setRecent] = useState([]);
|
|
122
176
|
const [treeVersion, setTreeVersion] = useState(0);
|
|
123
177
|
// The last change Nou made per file, so we can flag edited lines and re-open the diff.
|
|
124
178
|
const [changes, setChanges] = useState(new Map());
|
|
@@ -150,17 +204,44 @@ export function App({ initialModel, permContext }) {
|
|
|
150
204
|
const [editorMouse, setEditorMouse] = useState([]);
|
|
151
205
|
/** Wheel events waiting for the next frame, added up per pane. */
|
|
152
206
|
const pendingWheel = useRef({ pane: "chat", lines: 0, columns: 0, timer: null });
|
|
207
|
+
/** The chat's command menu is open (Tab completes there instead of switching panes). */
|
|
208
|
+
const chatMenuOpen = useRef(false);
|
|
209
|
+
const [chatMenu, setChatMenu] = useState(false);
|
|
153
210
|
const [chatPrefill, setChatPrefill] = useState(null);
|
|
154
211
|
const [treeClick, setTreeClick] = useState(null);
|
|
212
|
+
/** Whether the code view's find bar is open (it takes the keyboard). */
|
|
213
|
+
const codeFinding = useRef(false);
|
|
214
|
+
const onCodeFinding = useCallback((open) => {
|
|
215
|
+
codeFinding.current = open;
|
|
216
|
+
}, []);
|
|
217
|
+
/** Bumped to open the code view's find bar from outside it (the palette, the status bar). */
|
|
218
|
+
const [findRequest, setFindRequest] = useState(0);
|
|
219
|
+
const startFind = () => {
|
|
220
|
+
if (!openFile)
|
|
221
|
+
return info("Open a file first (Ctrl+P), then Ctrl+F searches it.");
|
|
222
|
+
setDiffMode("off");
|
|
223
|
+
setEditing(false);
|
|
224
|
+
setCodePanel("code");
|
|
225
|
+
setFocus("code");
|
|
226
|
+
setFindRequest((n) => n + 1);
|
|
227
|
+
};
|
|
228
|
+
const [codeDefine, setCodeDefine] = useState(null);
|
|
229
|
+
const [chatClick, setChatClick] = useState(null);
|
|
230
|
+
/** Dragging over the chat's messages selects them (copied when you let go). */
|
|
231
|
+
const [chatSelect, setChatSelect] = useState(null);
|
|
155
232
|
// Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
|
|
156
|
-
const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
|
|
233
|
+
const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0, heights: { tree: 0, code: 0, chat: 0 }, game: null, terminal: null });
|
|
157
234
|
/** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
|
|
158
235
|
const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
|
|
159
236
|
const paneWidthsRef = useRef(paneWidths);
|
|
160
237
|
paneWidthsRef.current = paneWidths;
|
|
161
238
|
/** What a mouse drag is moving: a pane border, a pane's scrollbar, or the editor's selection. */
|
|
239
|
+
/** Where the status bar's parts and the header's model badge are, from the last render. */
|
|
240
|
+
const clickable = useRef({ status: [], modelFrom: Infinity });
|
|
162
241
|
const lastClick = useRef(null);
|
|
163
242
|
const dragging = useRef(null);
|
|
243
|
+
/** Which border is being dragged, for the status bar. */
|
|
244
|
+
const [resizing, setResizing] = useState(null);
|
|
164
245
|
const pullAbort = useRef(null);
|
|
165
246
|
const diagnosticsRef = useRef(null);
|
|
166
247
|
diagnosticsRef.current ??= new DiagnosticsManager();
|
|
@@ -175,11 +256,12 @@ export function App({ initialModel, permContext }) {
|
|
|
175
256
|
const settingsRef = useRef(settings);
|
|
176
257
|
settingsRef.current = settings;
|
|
177
258
|
const refreshGit = useCallback(() => setGit(gitStatus()), []);
|
|
178
|
-
const allFiles = useMemo(() => (quickOpen ? listAllFiles() : []), [quickOpen]);
|
|
259
|
+
const allFiles = useMemo(() => (quickOpen !== null ? listAllFiles() : []), [quickOpen !== null]);
|
|
179
260
|
const repo = useMemo(() => isRepo(), []);
|
|
180
261
|
const branch = useMemo(() => (repo ? gitBranch() : null), [repo, git]);
|
|
181
262
|
const open = useCallback((filePath) => {
|
|
182
263
|
setOpenFile(filePath);
|
|
264
|
+
setRecent((r) => [filePath, ...r.filter((f) => f !== filePath)].slice(0, 30));
|
|
183
265
|
setContent(readText(filePath));
|
|
184
266
|
setDiffMode("off");
|
|
185
267
|
setEditing(false);
|
|
@@ -193,23 +275,32 @@ export function App({ initialModel, permContext }) {
|
|
|
193
275
|
const engineConfig = useRef({});
|
|
194
276
|
if (!hooksRef.current) {
|
|
195
277
|
hooksRef.current = {
|
|
196
|
-
onEntry: (entry) =>
|
|
278
|
+
onEntry: (entry) => {
|
|
279
|
+
setEntries((prev) => [...prev, entry]);
|
|
280
|
+
if (entry.kind === "error")
|
|
281
|
+
setupCheckRef.current(entry.text);
|
|
282
|
+
},
|
|
197
283
|
onStream: showStream,
|
|
198
284
|
onBusy: setBusy,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
open(filePath);
|
|
203
|
-
setReveal(filePath);
|
|
285
|
+
onOpen: (filePath, line, to) => {
|
|
286
|
+
clearFollow(); // what was asked for wins over reads still waiting to be shown
|
|
287
|
+
openAtRef.current(filePath, line ?? 1, false, to);
|
|
204
288
|
},
|
|
289
|
+
onRead: (filePath, line, to) => followStepRef.current({ file: filePath, ...(line ? { line } : {}), ...(to ? { to } : {}) }),
|
|
205
290
|
onChange: (change, status, turn) => {
|
|
206
291
|
if (status === "applied") {
|
|
207
292
|
timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
|
|
208
293
|
}
|
|
209
294
|
const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
|
|
210
295
|
setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
|
|
296
|
+
setWorking((w) => {
|
|
297
|
+
if (status === "rejected")
|
|
298
|
+
return w.has(change.path) ? new Set([...w].filter((p) => p !== change.path)) : w;
|
|
299
|
+
return w.has(change.path) ? w : new Set(w).add(change.path);
|
|
300
|
+
});
|
|
211
301
|
// Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
|
|
212
302
|
if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
|
|
303
|
+
clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
|
|
213
304
|
setOpenFile(change.path);
|
|
214
305
|
setContent(status === "applied" ? change.after : readText(change.path));
|
|
215
306
|
setDiffMode("off");
|
|
@@ -239,9 +330,29 @@ export function App({ initialModel, permContext }) {
|
|
|
239
330
|
}
|
|
240
331
|
useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
|
|
241
332
|
useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
|
|
333
|
+
useEffect(() => setSyntaxVersion((v) => v + 1), [settings.codeTheme]); // re-color the code
|
|
334
|
+
useEffect(() => void loadFallbackHighlighter(), []); // once Nou is on screen (it's slow to load)
|
|
242
335
|
useEffect(() => {
|
|
243
336
|
void checkForUpdate().then(setUpdate);
|
|
244
337
|
}, []);
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
if (!busy)
|
|
340
|
+
setWorking(NO_FILES); // done: the files stop shimmering (● still marks them)
|
|
341
|
+
}, [!busy]);
|
|
342
|
+
// After each reply, the conversation is saved so /resume can pick it back up.
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
const first = entries.find((e) => e.kind === "user");
|
|
345
|
+
if (busy || !first)
|
|
346
|
+
return;
|
|
347
|
+
const engine = agentRef.current;
|
|
348
|
+
saveConversation({ id: conversationId, title: first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: engine.id, entries, state: engine.snapshot?.() });
|
|
349
|
+
}, [!busy, entries.length, conversationId]);
|
|
350
|
+
/** Earlier conversations for the palette, read from disk when it opens. */
|
|
351
|
+
const recentChats = useMemo(() => (quickOpen !== null ? resumable() : []), [quickOpen !== null]);
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (resume)
|
|
354
|
+
void resumeConversation(resume === "last" ? "last" : "");
|
|
355
|
+
}, []);
|
|
245
356
|
// Discover plugins once and load the enabled ones.
|
|
246
357
|
useEffect(() => {
|
|
247
358
|
void (async () => {
|
|
@@ -254,6 +365,7 @@ export function App({ initialModel, permContext }) {
|
|
|
254
365
|
// Starter plugins are listed until you add them (or have your own plugin with the same name).
|
|
255
366
|
const allPlugins = useMemo(() => [...BUILTIN_PLUGINS, ...plugins, ...starters.filter((s) => !plugins.some((p) => p.id === s.id))], [plugins, starters]);
|
|
256
367
|
const activePlugins = useMemo(() => allPlugins.filter((p) => isEnabled(p, settings) && p.plugin), [allPlugins, settings.plugins, settings.trustedPlugins]);
|
|
368
|
+
const pluginCommands = useMemo(() => activePlugins.flatMap((p) => (p.plugin.commands ?? []).map((c) => ({ name: c.name, args: "[args]", description: `${c.description} (${p.plugin.name})` }))), [activePlugins]);
|
|
257
369
|
// Checkers come from enabled plugins (built-in ones included).
|
|
258
370
|
const firstCheck = useRef(true);
|
|
259
371
|
useEffect(() => {
|
|
@@ -263,12 +375,32 @@ export function App({ initialModel, permContext }) {
|
|
|
263
375
|
setTimeout(() => void diagnostics.checkProject(), 1500);
|
|
264
376
|
}
|
|
265
377
|
}, [activePlugins]);
|
|
266
|
-
//
|
|
378
|
+
// MCP servers (yours, and in a trusted folder the project's): connected in the background; their tools
|
|
379
|
+
// join the engine's as they come up.
|
|
380
|
+
const mcpRef = useRef(null);
|
|
381
|
+
const [mcpVersion, setMcpVersion] = useState(0);
|
|
382
|
+
mcpRef.current ??= new McpManager(() => setMcpVersion((v) => v + 1));
|
|
383
|
+
const connectMcp = () => mcpRef.current.connect(loadMcpServers(isTrustedFolder()));
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
void connectMcp().then(() => {
|
|
386
|
+
// Servers you use in other tools, offered once there's nothing set up here yet.
|
|
387
|
+
if (loadMcpServers(false).length)
|
|
388
|
+
return;
|
|
389
|
+
const found = findOtherMcpServers();
|
|
390
|
+
if (found.length) {
|
|
391
|
+
const from = [...new Set(found.map((f) => f.from.replace(/ \(this project\)$/, "")))].join(", ");
|
|
392
|
+
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.`);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
return () => mcpRef.current?.close();
|
|
396
|
+
}, []);
|
|
397
|
+
// Keep the agent's system prompt and tools in sync with settings, plugins and MCP servers.
|
|
267
398
|
useEffect(() => {
|
|
268
399
|
const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
|
|
269
|
-
|
|
400
|
+
// Other engines read AGENTS.md / CLAUDE.md themselves: they get only the NOU.md rules on top.
|
|
401
|
+
engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, nouRulesOnly(permContext)].filter(Boolean), extraTools: [...extraTools, ...mcpRef.current.tools()], effort: settings.effort };
|
|
270
402
|
agentRef.current.configure(engineConfig.current);
|
|
271
|
-
}, [settings.gitContext, settings.effort, activePlugins]);
|
|
403
|
+
}, [settings.gitContext, settings.effort, activePlugins, mcpVersion]);
|
|
272
404
|
// Pick up changes made outside Nou (other editors, git commands).
|
|
273
405
|
const polling = useRef(false);
|
|
274
406
|
useEffect(() => {
|
|
@@ -338,7 +470,84 @@ export function App({ initialModel, permContext }) {
|
|
|
338
470
|
engine.configure(engineConfig.current);
|
|
339
471
|
agentRef.current = engine;
|
|
340
472
|
updateSettings({ engine: id });
|
|
341
|
-
|
|
473
|
+
const handoff = conversationSoFar(entries);
|
|
474
|
+
if (handoff)
|
|
475
|
+
engine.note(handoff);
|
|
476
|
+
info(`Now using the ${target.label} engine (model: ${target.model(choice)}). ${handoff ? "It has the conversation so far." : "This starts a new conversation."}${target.approvals === "after" ? " It edits without asking first; review its changes in the diff view and press u to undo." : ""}`);
|
|
477
|
+
};
|
|
478
|
+
/**
|
|
479
|
+
* /resume: lists this folder's earlier conversations, or picks one back up (by number, or "last"). The
|
|
480
|
+
* engine it was with carries on from its own saved state where it can (Nou's messages, Claude Code's
|
|
481
|
+
* session, Codex's thread); otherwise it's handed the conversation, like switching engines.
|
|
482
|
+
*/
|
|
483
|
+
const resumeConversation = async (arg) => {
|
|
484
|
+
if (busy)
|
|
485
|
+
return info("Wait for the current reply to finish (or press Esc) before resuming another conversation.", "error");
|
|
486
|
+
const list = resumable();
|
|
487
|
+
if (!list.length)
|
|
488
|
+
return info("No earlier conversations in this folder yet: Nou's are saved after each reply, and Claude Code's and Codex's own sessions here show up too.");
|
|
489
|
+
if (!arg) {
|
|
490
|
+
const rows = list.slice(0, 10).map((c, i) => ` ${String(i + 1).padStart(2)}. ${c.title} (${c.messages} message${c.messages === 1 ? "" : "s"} · ${"file" in c ? `${engineInfo(c.engine).label} CLI` : engineInfo(isEngineId(c.engine) ? c.engine : "nou").label} · ${ago(c.updated)})`);
|
|
491
|
+
return info(`Earlier conversations here. /resume <number> picks one up (or /resume last):\n${rows.join("\n")}`);
|
|
492
|
+
}
|
|
493
|
+
const pick = arg === "last" ? list[0] : list[Number(arg) - 1] ?? list.find((c) => c.id === arg);
|
|
494
|
+
if (!pick)
|
|
495
|
+
return info(`There's no conversation ${arg}: /resume lists them.`, "error");
|
|
496
|
+
if ("file" in pick)
|
|
497
|
+
return resumeExternal(pick);
|
|
498
|
+
const saved = loadConversation(pick.id);
|
|
499
|
+
if (!saved)
|
|
500
|
+
return info(`Couldn't read conversation ${arg}.`, "error");
|
|
501
|
+
const engine = engineFor(isEngineId(saved.engine) ? saved.engine : null);
|
|
502
|
+
const restored = saved.engine === engine.id && !!saved.state && !!engine.restore?.(saved.state);
|
|
503
|
+
if (!restored) {
|
|
504
|
+
const handoff = conversationSoFar(saved.entries);
|
|
505
|
+
if (handoff)
|
|
506
|
+
engine.note(handoff);
|
|
507
|
+
}
|
|
508
|
+
setConversationId(saved.id);
|
|
509
|
+
setQueued([]);
|
|
510
|
+
setEntries([...saved.entries, { kind: "info", text: `Resumed "${saved.title}" from ${ago(saved.updated)} with ${engineInfo(engine.id).label}. ${restored ? "It picks up right where it left off." : "It has the conversation so far."}` }]);
|
|
511
|
+
setFocus("chat");
|
|
512
|
+
};
|
|
513
|
+
/** Nou's saved conversations and Claude Code's and Codex's own sessions here, latest first, without doubles. */
|
|
514
|
+
function resumable() {
|
|
515
|
+
const own = listConversations().filter((c) => c.id !== conversationId);
|
|
516
|
+
const current = agentRef.current.snapshot?.();
|
|
517
|
+
const known = new Set([...own.map((c) => c.ref), current?.claudeSession, current?.codexThread].filter(Boolean));
|
|
518
|
+
return [...own, ...externalSessions().filter((s) => !known.has(s.id))].sort((a, b) => b.updated - a.updated);
|
|
519
|
+
}
|
|
520
|
+
/** The engine to resume with: back to `id` when it's installed (it picks up best), else the current one, cleared. */
|
|
521
|
+
const engineFor = (id) => {
|
|
522
|
+
let engine = agentRef.current;
|
|
523
|
+
if (id && id !== engine.id && isInstalled(engineInfo(id))) {
|
|
524
|
+
engine.dispose?.();
|
|
525
|
+
engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
|
|
526
|
+
engine.configure(engineConfig.current);
|
|
527
|
+
agentRef.current = engine;
|
|
528
|
+
updateSettings({ engine: id });
|
|
529
|
+
}
|
|
530
|
+
engine.clear();
|
|
531
|
+
return engine;
|
|
532
|
+
};
|
|
533
|
+
/** A session from Claude Code's or Codex's own CLI: that engine resumes it, so it carries on with all of it. */
|
|
534
|
+
const resumeExternal = (session) => {
|
|
535
|
+
const label = engineInfo(session.engine).label;
|
|
536
|
+
if (!isInstalled(engineInfo(session.engine))) {
|
|
537
|
+
return info(`That's a ${label} session: switch to its engine first (/engine ${session.engine}, which installs it), then /resume it again.`, "error");
|
|
538
|
+
}
|
|
539
|
+
const engine = engineFor(session.engine);
|
|
540
|
+
const restored = !!engine.restore?.(session.engine === "claude-code" ? { claudeSession: session.id } : { codexThread: session.id });
|
|
541
|
+
const entries = externalEntries(session);
|
|
542
|
+
if (!restored) {
|
|
543
|
+
const handoff = conversationSoFar(entries);
|
|
544
|
+
if (handoff)
|
|
545
|
+
engine.note(handoff);
|
|
546
|
+
}
|
|
547
|
+
setConversationId(newSessionId());
|
|
548
|
+
setQueued([]);
|
|
549
|
+
setEntries([...entries, { kind: "info", text: `Resumed "${session.title}" from ${label}'s CLI (${ago(session.updated)}). ${label} picks up right where it left off, with Nou's diffs and undo from here on.` }]);
|
|
550
|
+
setFocus("chat");
|
|
342
551
|
};
|
|
343
552
|
/** Downloads an Ollama model, showing progress in the status line. Esc stops it. */
|
|
344
553
|
const pullModel = async (model) => {
|
|
@@ -488,10 +697,16 @@ export function App({ initialModel, permContext }) {
|
|
|
488
697
|
return next;
|
|
489
698
|
};
|
|
490
699
|
/** Adds a wheel event to its pane's total; the panes get one update per frame however many events arrive. */
|
|
700
|
+
const lastVerticalWheel = useRef(0);
|
|
491
701
|
const queueWheel = (pane, dir, horizontal) => {
|
|
702
|
+
// A trackpad scrolling down also sends stray sideways steps: while scrolling up or down, ignore them.
|
|
703
|
+
if (!horizontal)
|
|
704
|
+
lastVerticalWheel.current = Date.now();
|
|
705
|
+
else if (Date.now() - lastVerticalWheel.current < 300)
|
|
706
|
+
return;
|
|
492
707
|
const q = pendingWheel.current;
|
|
493
708
|
if (q.timer && q.pane !== pane)
|
|
494
|
-
flushWheel();
|
|
709
|
+
flushWheel(true);
|
|
495
710
|
q.pane = pane;
|
|
496
711
|
if (horizontal)
|
|
497
712
|
q.columns += dir * WHEEL_STEP;
|
|
@@ -499,21 +714,31 @@ export function App({ initialModel, permContext }) {
|
|
|
499
714
|
q.lines += dir * WHEEL_STEP;
|
|
500
715
|
q.timer ??= setTimeout(flushWheel, 16);
|
|
501
716
|
};
|
|
502
|
-
|
|
717
|
+
/**
|
|
718
|
+
* Scrolls by what the wheel added up, spread over a few frames: half of what's left each frame, so a
|
|
719
|
+
* notch glides over 2-3 frames and a fast flick eases out instead of jumping dozens of lines at once.
|
|
720
|
+
* `all` sends the rest at once (the pointer moved to another pane).
|
|
721
|
+
*/
|
|
722
|
+
const flushWheel = (all = false) => {
|
|
503
723
|
const q = pendingWheel.current;
|
|
504
724
|
if (q.timer)
|
|
505
725
|
clearTimeout(q.timer);
|
|
506
726
|
q.timer = null;
|
|
507
727
|
if (!q.lines && !q.columns)
|
|
508
728
|
return;
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
729
|
+
const part = (n) => (all || Math.abs(n) <= 1 ? n : Math.sign(n) * Math.ceil(Math.abs(n) / 2));
|
|
730
|
+
const lines = part(q.lines);
|
|
731
|
+
const columns = part(q.columns);
|
|
732
|
+
const { pane } = q;
|
|
733
|
+
q.lines -= lines;
|
|
734
|
+
q.columns -= columns;
|
|
512
735
|
setWheel((w) => ({ pane, lines, columns, nonce: (w?.nonce ?? 0) + 1 }));
|
|
736
|
+
if (q.lines || q.columns)
|
|
737
|
+
q.timer = setTimeout(flushWheel, 16);
|
|
513
738
|
};
|
|
514
739
|
/** Scrolls a pane to where its scrollbar was clicked or dragged (rows start at y=3, under the border and title). */
|
|
515
740
|
const scrollPane = (pane, y) => {
|
|
516
|
-
const
|
|
741
|
+
const paneHeight = pane === "game" || pane === "terminal" ? 0 : layout.current.heights[pane];
|
|
517
742
|
const fraction = (y - 3) / Math.max(1, paneHeight - 4);
|
|
518
743
|
setScrollTo((s) => ({ pane, fraction, nonce: (s?.nonce ?? 0) + 1 }));
|
|
519
744
|
};
|
|
@@ -522,10 +747,60 @@ export function App({ initialModel, permContext }) {
|
|
|
522
747
|
// (starting the editor). Drag to select in the editor, drag a scrollbar to scroll, drag the border
|
|
523
748
|
// between panes to resize them.
|
|
524
749
|
useMouse((event) => {
|
|
525
|
-
if (view !== "main" || quickOpen)
|
|
750
|
+
if (view !== "main" || quickOpen !== null)
|
|
526
751
|
return;
|
|
752
|
+
// The Games panel sits under a pane: it takes the clicks that land on it, × on its title closes it,
|
|
753
|
+
// and dragging its title docks it under the pane you let go over.
|
|
754
|
+
const term = layout.current.terminal;
|
|
755
|
+
if (term && event.x >= term.x && event.x < term.x + term.width && event.y >= term.y && event.y < term.y + term.height && !dragging.current) {
|
|
756
|
+
if (event.kind === "wheel")
|
|
757
|
+
return queueWheel("terminal", event.dir, false);
|
|
758
|
+
if (event.kind !== "click")
|
|
759
|
+
return;
|
|
760
|
+
if (event.y === term.y + 1 && event.x >= term.x + term.width - 5)
|
|
761
|
+
return toggleTerminal(false); // ×
|
|
762
|
+
if (focus !== "terminal")
|
|
763
|
+
beforeTerm.current = focus;
|
|
764
|
+
setFocus("terminal");
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const game = layout.current.game;
|
|
768
|
+
if (gamesOpen && game) {
|
|
769
|
+
const drag = dragging.current;
|
|
770
|
+
if (drag === "game") {
|
|
771
|
+
const { treeWidth, codeWidth } = layout.current;
|
|
772
|
+
const over = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
|
|
773
|
+
if (event.kind === "release") {
|
|
774
|
+
dragging.current = null;
|
|
775
|
+
setGameDrag(null);
|
|
776
|
+
updateSettings({ gameDock: over });
|
|
777
|
+
}
|
|
778
|
+
else
|
|
779
|
+
setGameDrag(over);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const inside = event.x >= game.x && event.x < game.x + game.width && event.y >= game.y && event.y < game.y + game.height;
|
|
783
|
+
if (inside) {
|
|
784
|
+
if (event.kind !== "click")
|
|
785
|
+
return;
|
|
786
|
+
if (event.y === game.y + 1 && event.x >= game.x + game.width - 5) {
|
|
787
|
+
// × closes it
|
|
788
|
+
setGamesOpen(false);
|
|
789
|
+
if (focus === "game")
|
|
790
|
+
setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (focus !== "game")
|
|
794
|
+
beforeGame.current = focus;
|
|
795
|
+
setFocus("game");
|
|
796
|
+
if (event.y <= game.y + 1)
|
|
797
|
+
dragging.current = "game";
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
527
801
|
const { treeWidth, codeWidth, paneHeight } = layout.current;
|
|
528
802
|
const codeLeft = treeWidth;
|
|
803
|
+
const chatAt = (x, y, kind) => setChatSelect((c) => ({ row: y - 3, col: x - treeWidth - codeWidth - 3, kind, nonce: (c?.nonce ?? 0) + 1 }));
|
|
529
804
|
const editorAt = (x, y, kind) => setEditorMouse((m) => [...m.slice(-15), { x: x - codeLeft - 1, y: y - 3, kind, seq: (m[m.length - 1]?.seq ?? 0) + 1 }]);
|
|
530
805
|
if (event.kind === "drag") {
|
|
531
806
|
lastClick.current = null; // a press that became a drag doesn't start a double-click
|
|
@@ -534,32 +809,48 @@ export function App({ initialModel, permContext }) {
|
|
|
534
809
|
moveBorder(drag, event.x);
|
|
535
810
|
else if (drag === "editor")
|
|
536
811
|
editorAt(event.x, event.y, "drag");
|
|
537
|
-
else if (drag)
|
|
812
|
+
else if (drag === "chatText")
|
|
813
|
+
chatAt(event.x, event.y, "drag");
|
|
814
|
+
else if (drag && typeof drag === "object")
|
|
538
815
|
scrollPane(drag.scroll, event.y);
|
|
539
816
|
return;
|
|
540
817
|
}
|
|
541
818
|
if (event.kind === "release") {
|
|
542
819
|
// The release says where the button let go, so a drag lands there even in a terminal that
|
|
543
820
|
// doesn't report the movement in between.
|
|
544
|
-
if (dragging.current === "tree" || dragging.current === "chat")
|
|
821
|
+
if (dragging.current === "tree" || dragging.current === "chat") {
|
|
545
822
|
updateSettings({ paneWidths: moveBorder(dragging.current, event.x) });
|
|
546
|
-
|
|
823
|
+
setResizing(null);
|
|
824
|
+
}
|
|
825
|
+
else if (dragging.current && typeof dragging.current === "object" && "scroll" in dragging.current)
|
|
547
826
|
scrollPane(dragging.current.scroll, event.y);
|
|
548
827
|
if (dragging.current === "editor")
|
|
549
828
|
editorAt(event.x, event.y, "release");
|
|
829
|
+
if (dragging.current === "chatText")
|
|
830
|
+
chatAt(event.x, event.y, "release");
|
|
550
831
|
dragging.current = null;
|
|
551
832
|
return;
|
|
552
833
|
}
|
|
834
|
+
if (event.kind === "click" && !approval && event.y === 0 && event.x >= clickable.current.modelFrom)
|
|
835
|
+
return setView("settings");
|
|
836
|
+
if (event.kind === "click" && event.y === paneHeight + 1)
|
|
837
|
+
return clickable.current.status.find((z) => event.x >= z.from && event.x < z.to)?.run();
|
|
553
838
|
if (event.y < 1 || event.y > paneHeight)
|
|
554
839
|
return; // header or status bar
|
|
555
840
|
const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
|
|
556
841
|
if (event.kind === "wheel")
|
|
557
842
|
return queueWheel(pane, event.dir, event.horizontal);
|
|
558
843
|
// Each border is two columns: the left pane's edge and the right pane's.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
844
|
+
// On the panes' top border row, a little either side of a border grabs it too: an easier target.
|
|
845
|
+
const reach = event.y === 1 ? 2 : 1;
|
|
846
|
+
for (const border of ["tree", "chat"]) {
|
|
847
|
+
const edge = border === "tree" ? treeWidth - 0.5 : treeWidth + codeWidth - 0.5;
|
|
848
|
+
if (Math.abs(event.x - edge) <= reach) {
|
|
849
|
+
dragging.current = border;
|
|
850
|
+
setResizing(border);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
563
854
|
// Each pane's scrollbar is the column inside its right border.
|
|
564
855
|
const scrollbarX = pane === "tree" ? treeWidth - 2 : pane === "code" ? treeWidth + codeWidth - 2 : columns - 2;
|
|
565
856
|
if (event.x === scrollbarX && event.y >= 3) {
|
|
@@ -567,12 +858,46 @@ export function App({ initialModel, permContext }) {
|
|
|
567
858
|
scrollPane(pane, event.y);
|
|
568
859
|
return;
|
|
569
860
|
}
|
|
861
|
+
// The code view's title row holds the tabs: a click switches, the × closes.
|
|
862
|
+
if (event.kind === "click" && pane === "code" && event.y === 2 && tabs.length && !diff) {
|
|
863
|
+
const at = event.x - codeLeft - 1;
|
|
864
|
+
const layout = layoutTabs(tabs, openFile, tabsWidth(codeWidth));
|
|
865
|
+
setFocus("code"); // so x, Ctrl+W and [ ] work next
|
|
866
|
+
if (layout.prev !== null && at === layout.prev)
|
|
867
|
+
return cycleTab(-1);
|
|
868
|
+
if (layout.next !== null && at === layout.next)
|
|
869
|
+
return cycleTab(1);
|
|
870
|
+
const zone = layout.zones.find((z) => at >= z.from && at < z.to);
|
|
871
|
+
if (!zone)
|
|
872
|
+
return;
|
|
873
|
+
if (Math.abs(at - zone.close) <= 1)
|
|
874
|
+
closeTab(zone.file);
|
|
875
|
+
else
|
|
876
|
+
switchTab(zone.file);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
// Ctrl/Alt+click or right-click on code: go to the definition of what's under the pointer.
|
|
880
|
+
if (event.kind === "click" && event.define && pane === "code" && event.y >= 3 && codePanel === "code") {
|
|
881
|
+
lastClick.current = null;
|
|
882
|
+
setFocus("code");
|
|
883
|
+
if (showEditor)
|
|
884
|
+
editorAt(event.x, event.y, "define");
|
|
885
|
+
else if (openFile && !diff)
|
|
886
|
+
setCodeDefine((d) => ({ row: event.y - 3, x: event.x - codeLeft - 1, nonce: (d?.nonce ?? 0) + 1 }));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
570
889
|
if (approval)
|
|
571
890
|
return;
|
|
572
891
|
setFocus(pane);
|
|
573
892
|
// Rows start under the pane's top border (y=1) and title (y=2).
|
|
574
893
|
if (pane === "tree" && event.y >= 3)
|
|
575
894
|
setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
|
|
895
|
+
// Messages start after the chat's border and the two-column bar in front of each line.
|
|
896
|
+
if (pane === "chat" && event.y >= 3) {
|
|
897
|
+
setChatClick((c) => ({ row: event.y - 3, col: event.x - treeWidth - codeWidth - 3, nonce: (c?.nonce ?? 0) + 1 }));
|
|
898
|
+
dragging.current = "chatText";
|
|
899
|
+
chatAt(event.x, event.y, "press");
|
|
900
|
+
}
|
|
576
901
|
// Two clicks in the same spot within half a second are a double-click.
|
|
577
902
|
const last = lastClick.current;
|
|
578
903
|
const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
|
|
@@ -597,9 +922,10 @@ export function App({ initialModel, permContext }) {
|
|
|
597
922
|
return;
|
|
598
923
|
if (showEditor && focus === "code" && editorStateRef.current?.selection)
|
|
599
924
|
return; // the editor copies it
|
|
925
|
+
if (focus === "terminal")
|
|
926
|
+
return; // the terminal stops its command
|
|
600
927
|
if (busy || approval) {
|
|
601
|
-
|
|
602
|
-
agentRef.current?.cancel();
|
|
928
|
+
stop();
|
|
603
929
|
approval?.resolve(false);
|
|
604
930
|
setApproval(null);
|
|
605
931
|
return;
|
|
@@ -725,8 +1051,11 @@ export function App({ initialModel, permContext }) {
|
|
|
725
1051
|
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
1052
|
return false;
|
|
727
1053
|
};
|
|
728
|
-
/**
|
|
729
|
-
|
|
1054
|
+
/**
|
|
1055
|
+
* Opens a file at a line, in the editor if `edit` (used by the Problems list and go-to-definition).
|
|
1056
|
+
* With `to`, lines `line` to `to` are highlighted: something the user asked Nou to find.
|
|
1057
|
+
*/
|
|
1058
|
+
const openAt = (file, line, edit = false, to) => {
|
|
730
1059
|
if (!settleDraft(file))
|
|
731
1060
|
return;
|
|
732
1061
|
if (file !== openFile)
|
|
@@ -739,12 +1068,107 @@ export function App({ initialModel, permContext }) {
|
|
|
739
1068
|
setFocus("code");
|
|
740
1069
|
}
|
|
741
1070
|
else {
|
|
742
|
-
setFocusLine((f) => ({ line, nonce: (f?.nonce ?? 0) + 1 }));
|
|
1071
|
+
setFocusLine((f) => ({ line, ...(to !== undefined ? { to, path: file } : {}), nonce: (f?.nonce ?? 0) + 1 }));
|
|
743
1072
|
}
|
|
744
1073
|
};
|
|
1074
|
+
// Every file that's shown gets a tab (however it was opened: the tree, Nou, a link...).
|
|
1075
|
+
useEffect(() => {
|
|
1076
|
+
if (openFile)
|
|
1077
|
+
setTabs((t) => (t.includes(openFile) ? t : [...t, openFile].slice(-20)));
|
|
1078
|
+
}, [openFile]);
|
|
1079
|
+
/** Switches to another open file's tab. */
|
|
1080
|
+
const switchTab = (file) => {
|
|
1081
|
+
if (file === openFile || !settleDraft(file))
|
|
1082
|
+
return;
|
|
1083
|
+
clearFollow();
|
|
1084
|
+
open(file);
|
|
1085
|
+
setReveal(file);
|
|
1086
|
+
};
|
|
1087
|
+
const cycleTab = (step) => {
|
|
1088
|
+
if (tabs.length < 2 || !openFile)
|
|
1089
|
+
return;
|
|
1090
|
+
const i = tabs.indexOf(openFile);
|
|
1091
|
+
switchTab(tabs[(i + step + tabs.length) % tabs.length]);
|
|
1092
|
+
};
|
|
1093
|
+
/** Closes a tab (the open file's by default), moving to its neighbour. */
|
|
1094
|
+
const closeTab = (file = openFile) => {
|
|
1095
|
+
if (!file)
|
|
1096
|
+
return;
|
|
1097
|
+
const i = tabs.indexOf(file);
|
|
1098
|
+
const rest = tabs.filter((f) => f !== file);
|
|
1099
|
+
if (file !== openFile)
|
|
1100
|
+
return setTabs(rest);
|
|
1101
|
+
const next = rest[Math.min(Math.max(0, i), rest.length - 1)];
|
|
1102
|
+
if (!settleDraft(next ?? ""))
|
|
1103
|
+
return;
|
|
1104
|
+
if (draft.current?.path === file)
|
|
1105
|
+
draft.current = null;
|
|
1106
|
+
setTabs(rest);
|
|
1107
|
+
if (next) {
|
|
1108
|
+
open(next);
|
|
1109
|
+
setReveal(next);
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
setOpenFile(null);
|
|
1113
|
+
setContent("");
|
|
1114
|
+
setEditing(false);
|
|
1115
|
+
setDiffMode("off");
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
const showFollowStep = (step) => {
|
|
1119
|
+
if (step.line)
|
|
1120
|
+
openAt(step.file, step.line, false, step.to ?? step.line);
|
|
1121
|
+
else if (step.file !== openFile) {
|
|
1122
|
+
if (!settleDraft(step.file))
|
|
1123
|
+
return;
|
|
1124
|
+
open(step.file);
|
|
1125
|
+
setReveal(step.file);
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
const showFollowRef = useRef(showFollowStep);
|
|
1129
|
+
showFollowRef.current = showFollowStep;
|
|
1130
|
+
/** Shows the next waiting step, then waits as long as the follow speed says before the one after. */
|
|
1131
|
+
const playFollow = () => {
|
|
1132
|
+
const step = followQueue.current.shift();
|
|
1133
|
+
setFollowLag(followQueue.current.length);
|
|
1134
|
+
if (!step) {
|
|
1135
|
+
followTimer.current = null;
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
showFollowRef.current(step);
|
|
1139
|
+
followTimer.current = setTimeout(playFollow, FOLLOW_STEP_MS[settingsRef.current.followSpeed] ?? FOLLOW_STEP_MS.normal);
|
|
1140
|
+
};
|
|
1141
|
+
const followStep = (step) => {
|
|
1142
|
+
if (!followRef.current)
|
|
1143
|
+
return;
|
|
1144
|
+
if (!FOLLOW_STEP_MS[settings.followSpeed])
|
|
1145
|
+
return showFollowStep(step);
|
|
1146
|
+
followQueue.current.push(step);
|
|
1147
|
+
// Never fall too far behind: past that, the oldest steps are skipped.
|
|
1148
|
+
if (followQueue.current.length > MAX_FOLLOW_LAG)
|
|
1149
|
+
followQueue.current.splice(0, followQueue.current.length - MAX_FOLLOW_LAG);
|
|
1150
|
+
setFollowLag(followQueue.current.length);
|
|
1151
|
+
if (!followTimer.current)
|
|
1152
|
+
playFollow();
|
|
1153
|
+
};
|
|
1154
|
+
const followStepRef = useRef(followStep);
|
|
1155
|
+
followStepRef.current = followStep;
|
|
1156
|
+
useEffect(() => {
|
|
1157
|
+
if (!follow)
|
|
1158
|
+
clearFollow();
|
|
1159
|
+
}, [follow]);
|
|
1160
|
+
const openAtRef = useRef(openAt);
|
|
1161
|
+
openAtRef.current = openAt;
|
|
1162
|
+
/** A file named in the chat (clicked, or shown by Nou): open it there, highlighting the lines it names. */
|
|
1163
|
+
const openRef = useEvent((ref) => {
|
|
1164
|
+
clearFollow();
|
|
1165
|
+
const file = path.relative(process.cwd(), path.resolve(ref.path)).replace(/\\/g, "/");
|
|
1166
|
+
openAt(file, ref.line ?? 1, false, ref.line ? ref.to ?? ref.line : undefined);
|
|
1167
|
+
});
|
|
745
1168
|
const startEditing = () => {
|
|
746
1169
|
if (!openFile || !settleDraft(openFile))
|
|
747
1170
|
return;
|
|
1171
|
+
clearFollow();
|
|
748
1172
|
setDiffMode("off");
|
|
749
1173
|
setCodePanel("code");
|
|
750
1174
|
setEditorStart((s) => ({ line: viewport?.top ?? 1, nonce: s.nonce + 1 }));
|
|
@@ -773,31 +1197,35 @@ export function App({ initialModel, permContext }) {
|
|
|
773
1197
|
// handles them itself when its input is empty), Esc stops the turn, and every other key works as usual.
|
|
774
1198
|
if (approval && !(showEditor && focus === "code")) {
|
|
775
1199
|
if (key.escape) {
|
|
776
|
-
|
|
1200
|
+
stop();
|
|
777
1201
|
return answer(false);
|
|
778
1202
|
}
|
|
779
|
-
if (focus !== "chat" && /^[yYnN]$/.test(input))
|
|
1203
|
+
if (focus !== "chat" && focus !== "terminal" && /^[yYnN]$/.test(input))
|
|
780
1204
|
return answer(/[yY]/.test(input));
|
|
781
1205
|
if (focus !== "chat" && input === "o" && pendingChange)
|
|
782
1206
|
return openPending();
|
|
783
1207
|
}
|
|
784
1208
|
if (key.ctrl && input === "p") {
|
|
785
|
-
setQuickOpen(
|
|
1209
|
+
setQuickOpen("");
|
|
786
1210
|
return;
|
|
787
1211
|
}
|
|
788
1212
|
if (showEditor && focus === "code")
|
|
789
1213
|
return; // the editor owns the keyboard
|
|
1214
|
+
if (focus === "code" && codeFinding.current)
|
|
1215
|
+
return; // so does the find bar
|
|
1216
|
+
if (focus === "game" || focus === "terminal")
|
|
1217
|
+
return; // and the Games panel and the terminal
|
|
790
1218
|
if (key.tab && key.shift)
|
|
791
1219
|
return cycleAutoMode();
|
|
1220
|
+
if (key.tab && focus === "chat" && chatMenuOpen.current)
|
|
1221
|
+
return; // Tab completes the command
|
|
792
1222
|
if (key.tab) {
|
|
793
1223
|
setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]);
|
|
794
1224
|
return;
|
|
795
1225
|
}
|
|
796
1226
|
if (key.escape) {
|
|
797
|
-
if (busy)
|
|
798
|
-
|
|
799
|
-
agentRef.current?.cancel();
|
|
800
|
-
}
|
|
1227
|
+
if (busy)
|
|
1228
|
+
stop();
|
|
801
1229
|
else if (diffMode !== "off") {
|
|
802
1230
|
setDiffMode("off");
|
|
803
1231
|
setSessionView(null);
|
|
@@ -808,22 +1236,21 @@ export function App({ initialModel, permContext }) {
|
|
|
808
1236
|
}
|
|
809
1237
|
if (focus === "chat")
|
|
810
1238
|
return; // letters belong to the text box
|
|
811
|
-
if (input === "
|
|
1239
|
+
if (input === "?")
|
|
1240
|
+
setQuickOpen(">"); // every action, searchable
|
|
1241
|
+
else if (input === "/") {
|
|
812
1242
|
// Commands work from anywhere: / jumps to the chat's command box.
|
|
813
1243
|
setFocus("chat");
|
|
814
1244
|
setChatPrefill((p) => ({ text: "/", nonce: (p?.nonce ?? 0) + 1 }));
|
|
815
1245
|
}
|
|
816
1246
|
else if (input === "f")
|
|
817
1247
|
setFollow((f) => !f);
|
|
818
|
-
else if (input === "m")
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
else if (input === "p")
|
|
823
|
-
|
|
824
|
-
setEditing(false);
|
|
825
|
-
setFocus("code");
|
|
826
|
-
}
|
|
1248
|
+
else if (input === "m")
|
|
1249
|
+
toggleMouse();
|
|
1250
|
+
else if (input === "`")
|
|
1251
|
+
toggleTerminal();
|
|
1252
|
+
else if (input === "p")
|
|
1253
|
+
toggleProblems();
|
|
827
1254
|
else if (input === "u")
|
|
828
1255
|
undoLast();
|
|
829
1256
|
else if (input === "<" || input === ">") {
|
|
@@ -840,8 +1267,14 @@ export function App({ initialModel, permContext }) {
|
|
|
840
1267
|
cycleDiff();
|
|
841
1268
|
else if ((input === "]" || input === "[") && diffMode === "session")
|
|
842
1269
|
stepSessionFile(input === "]" ? 1 : -1);
|
|
1270
|
+
else if (input === "]" || input === "[")
|
|
1271
|
+
cycleTab(input === "]" ? 1 : -1);
|
|
1272
|
+
else if (input === "x" || (key.ctrl && input === "w"))
|
|
1273
|
+
closeTab();
|
|
843
1274
|
else if (input === "e")
|
|
844
1275
|
startEditing();
|
|
1276
|
+
else if (input === "b")
|
|
1277
|
+
goBack();
|
|
845
1278
|
else if (input === "s" || input === ",")
|
|
846
1279
|
setView("settings");
|
|
847
1280
|
else if (input === "r") {
|
|
@@ -850,7 +1283,7 @@ export function App({ initialModel, permContext }) {
|
|
|
850
1283
|
}
|
|
851
1284
|
else if (input === "q")
|
|
852
1285
|
requestQuit();
|
|
853
|
-
}, { isActive: view === "main" &&
|
|
1286
|
+
}, { isActive: view === "main" && quickOpen === null && !(codePanel === "problems" && focus === "code") });
|
|
854
1287
|
/** What the user is looking at, attached to each message so "this file" / "here" make sense. */
|
|
855
1288
|
const editorContext = () => {
|
|
856
1289
|
if (!openFile)
|
|
@@ -880,6 +1313,56 @@ ${snippet}
|
|
|
880
1313
|
const contextChip = openFile
|
|
881
1314
|
? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ✎ unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
|
|
882
1315
|
: null;
|
|
1316
|
+
/** /mcp: list servers; add (a preset, a command, or a URL), remove, or import the ones from other tools. */
|
|
1317
|
+
const mcpCommand = async ([action, name, ...more]) => {
|
|
1318
|
+
if (action === "add") {
|
|
1319
|
+
if (!name)
|
|
1320
|
+
return info(`Usage: /mcp add <name> <command and arguments | url>, or a ready-made one: ${Object.keys(MCP_PRESETS).join(", ")}`, "error");
|
|
1321
|
+
const preset = MCP_PRESETS[name];
|
|
1322
|
+
const target = more.join(" ").trim();
|
|
1323
|
+
if (!preset && !target)
|
|
1324
|
+
return info(`Usage: /mcp add ${name} <command and arguments | url>`, "error");
|
|
1325
|
+
const config = preset && !target ? preset.config : /^https?:\/\//.test(target) ? { url: target } : { command: more[0], args: more.slice(1) };
|
|
1326
|
+
saveUserServers({ [name]: config });
|
|
1327
|
+
info(`Added ${name} to ${USER_MCP_FILE}. Connecting...`);
|
|
1328
|
+
await connectMcp();
|
|
1329
|
+
const st = mcpRef.current.status().find((x) => x.name === name);
|
|
1330
|
+
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");
|
|
1331
|
+
}
|
|
1332
|
+
if (action === "remove") {
|
|
1333
|
+
if (!name || !removeUserServer(name))
|
|
1334
|
+
return info(`No server called "${name ?? ""}" in ${USER_MCP_FILE}.`, "error");
|
|
1335
|
+
await connectMcp();
|
|
1336
|
+
return info(`Removed ${name}.`);
|
|
1337
|
+
}
|
|
1338
|
+
if (action === "import") {
|
|
1339
|
+
const have = new Set(loadMcpServers(true).map((e) => e.name));
|
|
1340
|
+
const found = findOtherMcpServers().filter((f) => !have.has(f.name));
|
|
1341
|
+
if (!found.length)
|
|
1342
|
+
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.");
|
|
1343
|
+
saveUserServers(Object.fromEntries(found.map((f) => [f.name, f.config])));
|
|
1344
|
+
info(`Imported ${found.map((f) => `${f.name} (from ${f.from})`).join(", ")} into ${USER_MCP_FILE}. Connecting...`);
|
|
1345
|
+
await connectMcp();
|
|
1346
|
+
return mcpCommand([]);
|
|
1347
|
+
}
|
|
1348
|
+
if (action === "reconnect") {
|
|
1349
|
+
mcpRef.current.close();
|
|
1350
|
+
await connectMcp();
|
|
1351
|
+
return mcpCommand([]);
|
|
1352
|
+
}
|
|
1353
|
+
if (action)
|
|
1354
|
+
return info("Usage: /mcp, /mcp add <name> <command|url>, /mcp add browser, /mcp remove <name>, /mcp import, /mcp reconnect", "error");
|
|
1355
|
+
const list = mcpRef.current.status();
|
|
1356
|
+
const mark = { ready: "●", connecting: "◌", failed: "✖", off: "○" };
|
|
1357
|
+
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}` : ""}`);
|
|
1358
|
+
const others = findOtherMcpServers().filter((f) => !list.some((x) => x.name === f.name));
|
|
1359
|
+
return info([
|
|
1360
|
+
list.length ? `MCP servers:\n${lines.join("\n")}` : "No MCP servers yet.",
|
|
1361
|
+
`Ready-made: ${Object.entries(MCP_PRESETS).map(([k, v]) => `/mcp add ${k} (${v.about})`).join(" · ")}`,
|
|
1362
|
+
others.length ? `In your other tools: ${others.map((f) => `${f.name} (${f.from})`).join(", ")}. /mcp import adds them.` : "",
|
|
1363
|
+
`Yours are in ${USER_MCP_FILE}; a project's in .mcp.json (used in trusted folders).`,
|
|
1364
|
+
].filter(Boolean).join("\n"));
|
|
1365
|
+
};
|
|
883
1366
|
const pluginsCommand = async ([action, name]) => {
|
|
884
1367
|
if (action === "add") {
|
|
885
1368
|
const starter = starters.find((s) => s.id === name);
|
|
@@ -924,6 +1407,27 @@ ${snippet}
|
|
|
924
1407
|
return showSessionDiff(arg);
|
|
925
1408
|
case "sessions":
|
|
926
1409
|
return showSessions();
|
|
1410
|
+
case "open": {
|
|
1411
|
+
// A path (with :line or :line-line), or part of a file's name.
|
|
1412
|
+
const m = arg.match(/^(.*?)(?::(\d+)(?:-(\d+))?)?$/);
|
|
1413
|
+
const wanted = m[1].replace(/^\.\//, "");
|
|
1414
|
+
if (!wanted)
|
|
1415
|
+
return info("Usage: /open <file>[:line] (part of the name is enough), or ask in the chat where something is.", "error");
|
|
1416
|
+
const files = listAllFiles();
|
|
1417
|
+
const file = files.includes(wanted) ? wanted : files
|
|
1418
|
+
.map((f) => ({ f, m: score(f, wanted) }))
|
|
1419
|
+
.filter((r) => r.m)
|
|
1420
|
+
.sort((a, b) => b.m.score - a.m.score)[0]?.f;
|
|
1421
|
+
if (!file)
|
|
1422
|
+
return info(`No file matches "${wanted}". To find where something is in the code, ask in the chat.`, "error");
|
|
1423
|
+
return openRef({ path: file, ...(m[2] ? { line: Number(m[2]) } : {}), ...(m[3] ? { to: Number(m[3]) } : {}) });
|
|
1424
|
+
}
|
|
1425
|
+
case "games":
|
|
1426
|
+
return toggleGames();
|
|
1427
|
+
case "cancel":
|
|
1428
|
+
return cancelLast();
|
|
1429
|
+
case "mcp":
|
|
1430
|
+
return mcpCommand(rest);
|
|
927
1431
|
case "problems":
|
|
928
1432
|
setCodePanel("problems");
|
|
929
1433
|
setFocus("code");
|
|
@@ -1028,7 +1532,12 @@ ${snippet}
|
|
|
1028
1532
|
case "clear":
|
|
1029
1533
|
agentRef.current.clear();
|
|
1030
1534
|
setEntries([]);
|
|
1535
|
+
setConversationId(newSessionId()); // the last one stays saved for /resume
|
|
1031
1536
|
return;
|
|
1537
|
+
case "terminal":
|
|
1538
|
+
return toggleTerminal();
|
|
1539
|
+
case "resume":
|
|
1540
|
+
return resumeConversation(rest[0] ?? "");
|
|
1032
1541
|
case "plugins":
|
|
1033
1542
|
return pluginsCommand(rest);
|
|
1034
1543
|
case "version":
|
|
@@ -1054,11 +1563,110 @@ ${snippet}
|
|
|
1054
1563
|
}
|
|
1055
1564
|
}
|
|
1056
1565
|
};
|
|
1566
|
+
/** Takes you to where the provider's setup is fixed: its key and URL form, or its list of models. */
|
|
1567
|
+
const fixSetup = (provider, reason, form) => {
|
|
1568
|
+
setConnectTo(form && provider.connect ? provider : undefined);
|
|
1569
|
+
setSetupReason(reason);
|
|
1570
|
+
setView("settings");
|
|
1571
|
+
};
|
|
1572
|
+
// A request that failed because of the provider's setup (not the request) opens the fix for it.
|
|
1573
|
+
const setupCheck = (error) => {
|
|
1574
|
+
// Only a failed request to the model ("provider:model: ..."), never a tool's error or a problem report.
|
|
1575
|
+
if (agentRef.current?.id !== "nou" || !error.startsWith(`${describe(choice)}: `))
|
|
1576
|
+
return;
|
|
1577
|
+
const provider = choice.provider;
|
|
1578
|
+
const problem = setupProblem(provider, error);
|
|
1579
|
+
if (!problem)
|
|
1580
|
+
return;
|
|
1581
|
+
const what = problem === "key" ? "the key was rejected" : problem === "url" ? "its URL didn't answer" : "it doesn't have that model";
|
|
1582
|
+
info(`${provider.label}: ${what}. Opened its settings to fix it (Esc to come back).`);
|
|
1583
|
+
fixSetup(provider, error.split("\n")[0], problem !== "model");
|
|
1584
|
+
};
|
|
1585
|
+
const setupCheckRef = useRef(setupCheck);
|
|
1586
|
+
setupCheckRef.current = setupCheck;
|
|
1587
|
+
/**
|
|
1588
|
+
* Takes back your last message: stops Nou if it's still on it, removes the message and its reply from the
|
|
1589
|
+
* conversation (yours on screen and the model's), and puts the text back in the input to edit and resend.
|
|
1590
|
+
* Files it changed stay changed: /rewind undoes those.
|
|
1591
|
+
*/
|
|
1592
|
+
// What was queued behind it comes back into the input too, rather than being sent.
|
|
1593
|
+
const [retract, setRetract] = useState(null);
|
|
1594
|
+
const cancelLast = () => {
|
|
1595
|
+
setRetract({ queued });
|
|
1596
|
+
setQueued([]);
|
|
1597
|
+
if (busy)
|
|
1598
|
+
stop();
|
|
1599
|
+
};
|
|
1600
|
+
useEffect(() => {
|
|
1601
|
+
if (!retract || busy)
|
|
1602
|
+
return;
|
|
1603
|
+
const later = retract.queued;
|
|
1604
|
+
setRetract(null);
|
|
1605
|
+
const engine = agentRef.current;
|
|
1606
|
+
const at = entries.findLastIndex((e) => e.kind === "user");
|
|
1607
|
+
if (at < 0)
|
|
1608
|
+
return info("There's no message to take back.");
|
|
1609
|
+
if (!engine.retractLast)
|
|
1610
|
+
return info(`${engineInfo(engine.id).label} keeps its own history, so a message can't be taken back there: /clear starts over.`, "error");
|
|
1611
|
+
engine.retractLast();
|
|
1612
|
+
const text = [entries[at].text, ...later].join("\n");
|
|
1613
|
+
stopped.current = false;
|
|
1614
|
+
setEntries((prev) => [...prev.slice(0, at), { kind: "info", text: "Took back your last message; it's in the input to edit and resend. (Any files it changed stay changed: /rewind undoes them.)" }]);
|
|
1615
|
+
setChatPrefill((p) => ({ text, nonce: (p?.nonce ?? 0) + 1 }));
|
|
1616
|
+
setFocus("chat");
|
|
1617
|
+
}, [retract, busy]);
|
|
1618
|
+
/** Messages sent while Nou works: they go together when it finishes (commands each in turn). */
|
|
1619
|
+
const [queued, setQueued] = useState([]);
|
|
1620
|
+
/** Set when you stop Nou: what was queued goes back into the input instead of being sent. */
|
|
1621
|
+
const stopped = useRef(false);
|
|
1622
|
+
const stop = () => {
|
|
1623
|
+
pullAbort.current?.abort();
|
|
1624
|
+
agentRef.current?.cancel();
|
|
1625
|
+
stopped.current = true;
|
|
1626
|
+
};
|
|
1627
|
+
useEffect(() => {
|
|
1628
|
+
if (busy || approval || !queued.length)
|
|
1629
|
+
return;
|
|
1630
|
+
if (stopped.current) {
|
|
1631
|
+
stopped.current = false;
|
|
1632
|
+
setChatPrefill((p) => ({ text: queued.join("\n"), nonce: (p?.nonce ?? 0) + 1 }));
|
|
1633
|
+
setQueued([]);
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
// Messages queued together go together, as one message, so they're answered at once rather than one
|
|
1637
|
+
// after another. A command runs on its own, in its turn.
|
|
1638
|
+
const isCommand = (text) => text.startsWith("/");
|
|
1639
|
+
const take = isCommand(queued[0]) ? 1 : Math.max(1, queued.findIndex(isCommand) === -1 ? queued.length : queued.findIndex(isCommand));
|
|
1640
|
+
setQueued(queued.slice(take));
|
|
1641
|
+
send(queued.slice(0, take).join("\n\n"));
|
|
1642
|
+
}, [busy, approval, queued]);
|
|
1643
|
+
useEffect(() => {
|
|
1644
|
+
if (busy)
|
|
1645
|
+
stopped.current = false;
|
|
1646
|
+
}, [busy]);
|
|
1647
|
+
/** From the chat: while Nou works, a message waits its turn, or (`now`) stops Nou and goes right away. */
|
|
1648
|
+
const submit = (text, now = false) => {
|
|
1649
|
+
if (!busy)
|
|
1650
|
+
return send(text);
|
|
1651
|
+
if (now) {
|
|
1652
|
+
stop();
|
|
1653
|
+
stopped.current = false; // this one is meant to go
|
|
1654
|
+
setQueued((q) => [text, ...q]);
|
|
1655
|
+
}
|
|
1656
|
+
else
|
|
1657
|
+
setQueued((q) => [...q, text]);
|
|
1658
|
+
};
|
|
1057
1659
|
const send = (text) => {
|
|
1058
1660
|
if (text.startsWith("/")) {
|
|
1059
1661
|
void runCommand(text);
|
|
1060
1662
|
return;
|
|
1061
1663
|
}
|
|
1664
|
+
const missing = agentRef.current?.id === "nou" ? choice.provider.unavailable() : null;
|
|
1665
|
+
if (missing) {
|
|
1666
|
+
info(`${choice.provider.label} isn't set up yet (${missing}). Opened its settings: enter what it needs, then send again.`, "error");
|
|
1667
|
+
fixSetup(choice.provider, `${choice.provider.label} isn't set up: ${missing}`, true);
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1062
1670
|
setEntries((prev) => [...prev, { kind: "user", text }]);
|
|
1063
1671
|
void agentRef.current.send(text, editorContext() ?? undefined);
|
|
1064
1672
|
};
|
|
@@ -1085,6 +1693,7 @@ ${snippet}
|
|
|
1085
1693
|
}
|
|
1086
1694
|
// Stable callbacks for the memoized panes.
|
|
1087
1695
|
const onTreeOpen = useEvent((filePath) => {
|
|
1696
|
+
clearFollow(); // you went somewhere yourself: follow mode stops replaying what Nou read
|
|
1088
1697
|
open(filePath);
|
|
1089
1698
|
setFocus("code");
|
|
1090
1699
|
});
|
|
@@ -1092,23 +1701,36 @@ ${snippet}
|
|
|
1092
1701
|
const onJump = useEvent((file, line) => openAt(file, line));
|
|
1093
1702
|
const onCloseProblems = useEvent(() => setCodePanel("code"));
|
|
1094
1703
|
const onViewport = useEvent((top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })));
|
|
1095
|
-
const onSend = useEvent(
|
|
1704
|
+
const onSend = useEvent(submit);
|
|
1705
|
+
const onChatMenu = useEvent((open) => {
|
|
1706
|
+
chatMenuOpen.current = open;
|
|
1707
|
+
setChatMenu(open);
|
|
1708
|
+
});
|
|
1096
1709
|
const onAnswer = useEvent(answer);
|
|
1097
1710
|
const onEditorHover = useEvent((text, line, col) => (lspEnabled && openFile ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)));
|
|
1098
|
-
|
|
1711
|
+
/**
|
|
1712
|
+
* Go to definition (Ctrl+G, Ctrl/Alt+click, right-click): the language server's answer when there is one,
|
|
1713
|
+
* otherwise Nou's own (an imported name goes to its import line; a click on an import's path opens it).
|
|
1714
|
+
*/
|
|
1715
|
+
const goToDefinition = async (text, line, col, inEditor) => {
|
|
1099
1716
|
if (!openFile)
|
|
1100
1717
|
return;
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1718
|
+
const file = openFile;
|
|
1719
|
+
const target = (lspEnabled ? await lsp.definition(file, text, line, col).catch(() => null) : null) ?? findDefinition(file, text, line, col);
|
|
1720
|
+
if (!target) {
|
|
1721
|
+
const word = wordAt(text.split(/\r?\n/)[line - 1] ?? "", col)?.word;
|
|
1722
|
+
return info(word ? `No definition found for ${word}.` : "Ctrl+click (or Option+click, or right-click) a name to go to its definition.");
|
|
1723
|
+
}
|
|
1724
|
+
// Keep unsaved text when jumping within the same file.
|
|
1725
|
+
if (inEditor && target.file === file && draft.current)
|
|
1726
|
+
draft.current = { ...draft.current, text };
|
|
1727
|
+
if (inEditor)
|
|
1109
1728
|
openAt(target.file, target.line, true);
|
|
1110
|
-
|
|
1111
|
-
|
|
1729
|
+
else
|
|
1730
|
+
openAt(target.file, target.line, false, target.line);
|
|
1731
|
+
};
|
|
1732
|
+
const onEditorDefinition = useEvent((text, line, col) => void goToDefinition(text, line, col, true));
|
|
1733
|
+
const onCodeDefine = useEvent((line, col) => void goToDefinition(content, line, col, false));
|
|
1112
1734
|
const onEditorSave = useEvent((text) => saveFromEditor(text));
|
|
1113
1735
|
const onEditorExit = useEvent(() => {
|
|
1114
1736
|
draft.current = null;
|
|
@@ -1136,8 +1758,9 @@ ${snippet}
|
|
|
1136
1758
|
});
|
|
1137
1759
|
const lspEnabled = activePlugins.some((p) => p.id === "lsp");
|
|
1138
1760
|
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: () => {
|
|
1761
|
+
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
1762
|
setConnectTo(undefined);
|
|
1763
|
+
setSetupReason(null);
|
|
1141
1764
|
setView("main");
|
|
1142
1765
|
} }));
|
|
1143
1766
|
}
|
|
@@ -1145,34 +1768,158 @@ ${snippet}
|
|
|
1145
1768
|
const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
|
|
1146
1769
|
const chatWidth = paneWidths ? Math.max(MIN_CHAT, Math.min(Math.round(columns * paneWidths.chat), columns - treeWidth - MIN_CODE)) : Math.max(34, Math.floor(columns * 0.3));
|
|
1147
1770
|
const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
|
|
1148
|
-
|
|
1771
|
+
const tabStrip = tabs.length && openFile ? _jsx(Tabs, { tabs: tabs, active: openFile, width: tabsWidth(codeWidth), dirty: showEditor && !!editorState?.dirty }) : undefined;
|
|
1772
|
+
// The Games panel takes the bottom of its pane's column: up to 19 rows, and never more than half.
|
|
1773
|
+
const gameHeight = gamesOpen ? Math.min(GAME_PANEL.height, Math.floor(height / 2)) : 0;
|
|
1774
|
+
const heights = { tree: height, code: height, chat: height };
|
|
1775
|
+
heights[gameDock] -= gameHeight;
|
|
1776
|
+
// The terminal takes the bottom third of the code's column (above the Games panel when that's there too).
|
|
1777
|
+
const termHeight = termOpen ? Math.max(6, Math.min(Math.floor(height / 3), heights.code - 6)) : 0;
|
|
1778
|
+
heights.code -= termHeight;
|
|
1779
|
+
const dockLeft = gameDock === "tree" ? 0 : gameDock === "code" ? treeWidth : treeWidth + codeWidth;
|
|
1780
|
+
const dockWidth = gameDock === "tree" ? treeWidth : gameDock === "code" ? codeWidth : chatWidth;
|
|
1781
|
+
layout.current = { treeWidth, codeWidth, paneHeight: height, heights, game: gamesOpen ? { x: dockLeft, y: 1 + heights[gameDock] + (gameDock === "code" ? termHeight : 0), width: dockWidth, height: gameHeight } : null, terminal: termOpen ? { x: treeWidth, y: 1 + heights.code, width: codeWidth, height: termHeight } : null };
|
|
1149
1782
|
const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
|
|
1150
1783
|
const scrollFor = (pane) => (scrollTo?.pane === pane ? scrollTo : null);
|
|
1151
1784
|
const totals = diagnostics.counts();
|
|
1152
1785
|
const checking = diagnostics.busy;
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1786
|
+
/** Starts a command in the chat's input, for commands that need something typed after them. */
|
|
1787
|
+
const typeCommand = (text) => {
|
|
1788
|
+
setFocus("chat");
|
|
1789
|
+
setChatPrefill((p) => ({ text, nonce: (p?.nonce ?? 0) + 1 }));
|
|
1790
|
+
};
|
|
1791
|
+
const toggleMouse = () => {
|
|
1792
|
+
updateSettings({ mouse: !settings.mouse });
|
|
1793
|
+
info(settings.mouse ? "Mouse off: drag to select text and copy it as usual in your terminal. m turns it back on." : "Mouse on: wheel scrolls, click focuses, drag borders to resize.");
|
|
1794
|
+
};
|
|
1795
|
+
/** Opens the terminal panel (starting its shell the first time) and gives it the keyboard, or closes it. */
|
|
1796
|
+
function toggleTerminal(open = !termOpen) {
|
|
1797
|
+
shellRef.current ??= new Shell(() => setTermVersion((v) => v + 1));
|
|
1798
|
+
setTermOpen(open);
|
|
1799
|
+
if (open) {
|
|
1800
|
+
if (focus !== "terminal")
|
|
1801
|
+
beforeTerm.current = focus;
|
|
1802
|
+
setFocus("terminal");
|
|
1803
|
+
}
|
|
1804
|
+
else if (focus === "terminal")
|
|
1805
|
+
setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
|
|
1806
|
+
}
|
|
1807
|
+
function leaveTerminal() {
|
|
1808
|
+
setFocus(beforeTerm.current === "terminal" ? "code" : beforeTerm.current);
|
|
1809
|
+
}
|
|
1810
|
+
/** Opens the Games panel and gives it the keyboard, or closes it. */
|
|
1811
|
+
const toggleGames = (open = !gamesOpen) => {
|
|
1812
|
+
setGamesOpen(open);
|
|
1813
|
+
if (open) {
|
|
1814
|
+
if (focus !== "game")
|
|
1815
|
+
beforeGame.current = focus;
|
|
1816
|
+
setFocus("game");
|
|
1817
|
+
}
|
|
1818
|
+
else if (focus === "game")
|
|
1819
|
+
setFocus(beforeGame.current);
|
|
1820
|
+
};
|
|
1821
|
+
// Plain functions, not hooks: this runs after the settings screen's early return.
|
|
1822
|
+
const leaveGames = () => setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
|
|
1823
|
+
const gamesSettings = () => {
|
|
1824
|
+
setFocus(beforeGame.current === "game" ? "chat" : beforeGame.current);
|
|
1825
|
+
setView("settings");
|
|
1826
|
+
};
|
|
1827
|
+
/** Back to the file that was open before this one. */
|
|
1828
|
+
const goBack = () => {
|
|
1829
|
+
clearFollow();
|
|
1830
|
+
const previous = recent.find((f) => f !== openFile);
|
|
1831
|
+
if (!previous)
|
|
1832
|
+
return info("No earlier file to go back to yet: Ctrl+P lists the files you've opened.");
|
|
1833
|
+
open(previous);
|
|
1834
|
+
setReveal(previous);
|
|
1835
|
+
};
|
|
1836
|
+
const toggleProblems = () => {
|
|
1837
|
+
setCodePanel((p) => (p === "problems" ? "code" : "problems"));
|
|
1838
|
+
setEditing(false);
|
|
1839
|
+
setFocus("code");
|
|
1840
|
+
};
|
|
1841
|
+
// Everything Nou can do, for the command palette (? or Ctrl+P then >), with the key that does it.
|
|
1842
|
+
const paletteActions = [
|
|
1843
|
+
{ title: "Find a file", keys: "Ctrl+P", run: () => setQuickOpen("") },
|
|
1844
|
+
{ title: "Find in the open file", keys: "Ctrl+F", run: startFind },
|
|
1845
|
+
{ title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
|
|
1846
|
+
{ title: "Go back to the previous file", keys: "b", run: goBack },
|
|
1847
|
+
{ title: "Cancel your last message (edit it and resend)", keys: "/cancel", run: cancelLast },
|
|
1848
|
+
{ title: "Resume an earlier conversation", keys: "/resume", run: () => void resumeConversation("") },
|
|
1849
|
+
{ title: termOpen ? "Close the terminal" : "Open a terminal under the code", keys: "`", run: () => toggleTerminal() },
|
|
1850
|
+
...recentChats.slice(0, 5).map((c) => ({ title: `Resume: ${c.title}${"file" in c ? ` (${engineInfo(c.engine).label} CLI)` : ""}`, keys: ago(c.updated), run: () => void resumeConversation(c.id) })),
|
|
1851
|
+
{ title: "MCP servers: tools from other programs", keys: "/mcp", run: () => void runCommand("/mcp") },
|
|
1852
|
+
{ title: "Browser control (Playwright MCP)", keys: "/mcp add browser", run: () => void runCommand("/mcp add browser") },
|
|
1853
|
+
{ title: "Import MCP servers from Claude Code, Cursor, Codex...", keys: "/mcp import", run: () => void runCommand("/mcp import") },
|
|
1854
|
+
{ title: gamesOpen ? "Close the Games panel" : "Games: Pong, Snake, Breakout", keys: "/games", run: () => toggleGames() },
|
|
1855
|
+
{ title: "Next tab", keys: "]", run: () => cycleTab(1) },
|
|
1856
|
+
{ title: "Previous tab", keys: "[", run: () => cycleTab(-1) },
|
|
1857
|
+
{ title: "Close the tab", keys: "x", run: () => closeTab() },
|
|
1858
|
+
{ title: "Edit the open file", keys: "e · double-click", run: startEditing },
|
|
1859
|
+
{ title: "Diff the open file (vs git, Nou's edit, this session)", keys: "v", run: cycleDiff },
|
|
1860
|
+
{ title: "Undo Nou's last change", keys: "u", run: undoLast },
|
|
1861
|
+
{ title: "Rewind everything from Nou's last turn", keys: "/rewind", run: rewind },
|
|
1862
|
+
{ title: "Changes this session", keys: "/diff", run: () => showSessionDiff("") },
|
|
1863
|
+
{ title: "Earlier sessions", keys: "/sessions", run: showSessions },
|
|
1864
|
+
{ title: "Recent changes", keys: "/history", run: showHistory },
|
|
1865
|
+
{ title: "Problems: every error and warning", keys: "p", run: toggleProblems },
|
|
1866
|
+
{ title: "Switch model", keys: "/model", run: () => setView("settings") },
|
|
1867
|
+
{ title: "Connect a provider (API key)", keys: "/connect", run: () => typeCommand("/connect ") },
|
|
1868
|
+
{ title: "Switch engine (Nou, Claude Code, Codex, pi)", keys: "/engine", run: () => void runCommand("/engine") },
|
|
1869
|
+
{ title: "Ask before every edit and command", keys: "Shift+Tab", run: () => cycleAutoMode("ask") },
|
|
1870
|
+
{ title: "Auto-edit: apply edits without asking", keys: "Shift+Tab", run: () => cycleAutoMode("auto-edit") },
|
|
1871
|
+
{ title: "Auto: edits and commands without asking", keys: "Shift+Tab", run: () => cycleAutoMode("auto") },
|
|
1872
|
+
{ title: `Follow mode ${follow ? "off" : "on"}: jump to files Nou works on`, keys: "f", run: () => setFollow((f) => !f) },
|
|
1873
|
+
{ 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] }) },
|
|
1874
|
+
{ title: `Mouse ${settings.mouse ? "off (select text in the terminal)" : "on"}`, keys: "m", run: toggleMouse },
|
|
1875
|
+
{ title: "Copy the last reply", keys: "/copy", run: () => void runCommand("/copy") },
|
|
1876
|
+
{ title: "Copy the last code block", keys: "/copy code", run: () => void runCommand("/copy code") },
|
|
1877
|
+
{ title: "Copy the open file", keys: "/copy file", run: () => void runCommand("/copy file") },
|
|
1878
|
+
{ title: "Summarize the conversation to free up context", keys: "/compact", run: () => void runCommand("/compact") },
|
|
1879
|
+
{ title: "Start a fresh conversation", keys: "/clear", run: () => void runCommand("/clear") },
|
|
1880
|
+
{ title: "Download an Ollama model", keys: "/pull", run: () => typeCommand("/pull ") },
|
|
1881
|
+
{ title: "Plugins", keys: "/plugins", run: () => void runCommand("/plugins") },
|
|
1882
|
+
{ title: "Settings: model, engine, preferences, plugins", keys: "s", run: () => setView("settings") },
|
|
1883
|
+
{ title: "Reset the pane widths", keys: "=", run: () => {
|
|
1884
|
+
setPaneWidths(null);
|
|
1885
|
+
updateSettings({ paneWidths: null });
|
|
1886
|
+
} },
|
|
1887
|
+
{ title: "Go to the chat", keys: "Tab · Esc", run: () => setFocus("chat") },
|
|
1888
|
+
{ title: "Go to the files", keys: "Tab", run: () => setFocus("tree") },
|
|
1889
|
+
{ title: "Go to the code", keys: "Tab", run: () => setFocus("code") },
|
|
1890
|
+
{ title: "All commands and keys", keys: "/help", run: () => void runCommand("/help") },
|
|
1891
|
+
...pluginCommands.map((c) => ({ title: `${c.description}`, keys: `/${c.name}`, run: () => typeCommand(`/${c.name} `) })),
|
|
1892
|
+
{ title: "Quit Nou", keys: "Ctrl+C ×2", run: quit },
|
|
1893
|
+
];
|
|
1894
|
+
const mode = resizing
|
|
1895
|
+
? { label: "RESIZE", color: C.cyan }
|
|
1896
|
+
: approval
|
|
1897
|
+
? { label: "APPROVE", color: C.yellow }
|
|
1898
|
+
: showEditor ? { label: "EDIT", color: C.green }
|
|
1899
|
+
: diff ? { label: "DIFF", color: C.accent2 }
|
|
1900
|
+
: busy ? { label: "WORKING", color: C.accent2 }
|
|
1901
|
+
: { label: focus.toUpperCase(), color: C.accent };
|
|
1159
1902
|
// 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 =
|
|
1161
|
-
? [[
|
|
1162
|
-
: showEditor && focus === "code"
|
|
1163
|
-
?
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1903
|
+
const hints = resizing
|
|
1904
|
+
? [[`files ${treeWidth} · code ${codeWidth} · chat ${chatWidth}`, "columns"], ["release", "to keep it"], ["=", "reset"]]
|
|
1905
|
+
: approval && !(showEditor && focus === "code")
|
|
1906
|
+
? [["y", "accept"], ["n", "reject"], ...(pendingChange && diff !== pendingChange ? [["o", "show the edit"]] : []), ["Esc", "stop"], ["Shift+Tab", "auto mode"]]
|
|
1907
|
+
: showEditor && focus === "code"
|
|
1908
|
+
? editorState?.selection
|
|
1909
|
+
? [["Ctrl+C", "copy"], ["Ctrl+X", "cut"], ["Tab", "indent"], ["Ctrl+/", "comment"], ["Esc", "deselect"]]
|
|
1910
|
+
: [["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"]]
|
|
1911
|
+
: focus === "chat"
|
|
1912
|
+
? chatMenu ? [["↑↓", "choose"], ["Tab", "complete"], ["Enter", "run"], ["Esc", "close"]]
|
|
1913
|
+
: busy
|
|
1914
|
+
? [["Esc", "stop"], ["Shift+↑↓", "scroll"], ["Tab", "next pane"]]
|
|
1915
|
+
: [["Enter", "send"], ["/", "commands"], ["Tab", "next pane"], ["Ctrl+P", "find file"], ["Shift+↑↓", "scroll"]]
|
|
1916
|
+
: focus === "tree"
|
|
1917
|
+
? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
|
|
1918
|
+
: diff
|
|
1919
|
+
? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
|
|
1920
|
+
: [["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"]];
|
|
1921
|
+
const followChip = follow ? ` ◉ follow${followLag ? ` ·${followLag}` : ""} ` : " ○ follow ";
|
|
1922
|
+
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
1923
|
const shownHints = [];
|
|
1177
1924
|
for (let used = statusUsed, i = 0; i < hints.length; i++) {
|
|
1178
1925
|
used += hints[i][0].length + hints[i][1].length + 4;
|
|
@@ -1180,12 +1927,47 @@ ${snippet}
|
|
|
1180
1927
|
break;
|
|
1181
1928
|
shownHints.push(hints[i]);
|
|
1182
1929
|
}
|
|
1183
|
-
|
|
1930
|
+
// The status bar and the header can be clicked: what each part shows, it does.
|
|
1931
|
+
const hintAction = {
|
|
1932
|
+
y: () => answer(true), n: approval ? () => answer(false) : undefined, o: openPending,
|
|
1933
|
+
"Shift+Tab": () => cycleAutoMode(), "/": () => typeCommand("/"), "Ctrl+P": () => setQuickOpen(""),
|
|
1934
|
+
Tab: () => setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]),
|
|
1935
|
+
e: startEditing, "double-click/e": startEditing, b: goBack, "Ctrl+F": showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
|
|
1936
|
+
s: () => setView("settings"), "/help": () => void runCommand("/help"),
|
|
1937
|
+
Esc: busy ? stop : diff && !showEditor ? () => setDiffMode("off") : undefined,
|
|
1938
|
+
};
|
|
1939
|
+
const zones = [];
|
|
1940
|
+
{
|
|
1941
|
+
let x = ` ${mode.label} `.length;
|
|
1942
|
+
const add = (width, run) => {
|
|
1943
|
+
if (run)
|
|
1944
|
+
zones.push({ from: x, to: x + width, run });
|
|
1945
|
+
x += width;
|
|
1946
|
+
};
|
|
1947
|
+
add(` ${autoMode} `.length, () => cycleAutoMode());
|
|
1948
|
+
add(followChip.length, () => setFollow((f) => !f));
|
|
1949
|
+
add(` ✖ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
|
|
1950
|
+
if (checking.length)
|
|
1951
|
+
add(3 + checking.join(", ").length);
|
|
1952
|
+
if (!exitArmed) {
|
|
1953
|
+
shownHints.forEach(([key, label], i) => {
|
|
1954
|
+
add(i === 0 ? 2 : 3);
|
|
1955
|
+
add(key.length + 1 + label.length, hintAction[key]);
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
|
|
1960
|
+
clickable.current = { status: zones, modelFrom: columns - (" ⌂ LOCAL ".length + ` ${modelLabel} `.length + 1) };
|
|
1961
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, git: git, problemCounts: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest })), _jsx(Chat, { width: chatWidth, height: heights.chat, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, onAnswer: onAnswer, click: chatClick, select: chatSelect, onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: followChip }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
|
|
1184
1962
|
? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
|
|
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(
|
|
1963
|
+
: shownHints.map(([key, action], i) => (_jsxs(Text, { children: [i === 0 ? " " : _jsx(Text, { color: C.dim, children: " \u00B7 " }), _jsx(Text, { color: C.text, children: key }), _jsxs(Text, { color: C.dim, children: [" ", action] })] }, key)))] }) }), layout.current.terminal && shellRef.current && (_jsx(Box, { position: "absolute", marginLeft: layout.current.terminal.x, marginTop: layout.current.terminal.y, children: _jsx(Terminal, { shell: shellRef.current, version: termVersion, width: layout.current.terminal.width, height: layout.current.terminal.height, focused: focus === "terminal", wheel: wheelFor("terminal"), onLeave: leaveTerminal }) })), layout.current.game && (_jsx(Box, { position: "absolute", marginLeft: layout.current.game.x, marginTop: layout.current.game.y, children: _jsx(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, onLeave: leaveGames, onOpenSettings: gamesSettings }) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: paletteActions, initialQuery: quickOpen, onPick: (file) => {
|
|
1964
|
+
setQuickOpen(null);
|
|
1965
|
+
clearFollow();
|
|
1187
1966
|
open(file);
|
|
1188
1967
|
setReveal(file);
|
|
1189
1968
|
setFocus("code");
|
|
1190
|
-
},
|
|
1969
|
+
}, onRun: (action) => {
|
|
1970
|
+
setQuickOpen(null);
|
|
1971
|
+
action.run();
|
|
1972
|
+
}, onClose: () => setQuickOpen(null) }))] }));
|
|
1191
1973
|
}
|