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