nou-cli 0.5.2 → 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 +12 -7
- package/dist/src/agent/index.js +33 -3
- package/dist/src/cli.js +3 -1
- package/dist/src/commands/index.js +1 -1
- package/dist/src/config/index.js +1 -1
- package/dist/src/conversations/external.js +165 -0
- package/dist/src/conversations/index.js +71 -0
- package/dist/src/engines/claude.js +10 -0
- package/dist/src/engines/codex.js +60 -5
- package/dist/src/providers/anthropic.js +3 -14
- package/dist/src/terminal/index.js +127 -0
- package/dist/src/ui/App.js +258 -39
- package/dist/src/ui/Chat.js +63 -5
- package/dist/src/ui/ChatInput.js +5 -0
- package/dist/src/ui/CodeView.js +1 -1
- package/dist/src/ui/FileTree.js +5 -4
- package/dist/src/ui/GamePanel.js +18 -11
- package/dist/src/ui/Tabs.js +6 -5
- package/dist/src/ui/Terminal.js +84 -0
- package/dist/src/ui/commands.js +3 -0
- package/dist/src/ui/render-options.js +4 -0
- package/dist/src/ui/text.js +16 -0
- package/package.json +1 -1
package/dist/src/ui/App.js
CHANGED
|
@@ -6,7 +6,9 @@ import { Box, Text, useApp, useInput, useWindowSize } from "ink";
|
|
|
6
6
|
import { nouRulesOnly } from "../context/index.js";
|
|
7
7
|
import { MCP_PRESETS, McpManager, loadMcpServers, removeUserServer, saveUserServers, USER_MCP_FILE } from "../mcp/index.js";
|
|
8
8
|
import { findOtherMcpServers } from "../mcp/import.js";
|
|
9
|
-
import {
|
|
9
|
+
import { ago, listConversations, loadConversation, saveConversation } from "../conversations/index.js";
|
|
10
|
+
import { externalEntries, externalSessions } from "../conversations/external.js";
|
|
11
|
+
import { buildSystemPrompt, newSessionId } from "../agent/index.js";
|
|
10
12
|
import { ENGINES, createEngine, engineInfo, installEngine, isEngineId, isInstalled } from "../engines/index.js";
|
|
11
13
|
import { harness, problemsIntroduced } from "../agent/harness.js";
|
|
12
14
|
import { describe, providers, resolveModel, saveModel, setupProblem } from "../providers/index.js";
|
|
@@ -35,6 +37,8 @@ import { copyToClipboard } from "./clipboard.js";
|
|
|
35
37
|
import { Header } from "./Header.js";
|
|
36
38
|
import { Tabs, layoutTabs } from "./Tabs.js";
|
|
37
39
|
import { GAME_PANEL, GamePanel } from "./GamePanel.js";
|
|
40
|
+
import { Terminal } from "./Terminal.js";
|
|
41
|
+
import { Shell } from "../terminal/index.js";
|
|
38
42
|
import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
|
|
39
43
|
import { Settings } from "./Settings.js";
|
|
40
44
|
const FOCUS_ORDER = ["tree", "code", "chat"];
|
|
@@ -42,13 +46,14 @@ const MIN_TREE = 14;
|
|
|
42
46
|
const MIN_CODE = 20;
|
|
43
47
|
const MIN_CHAT = 24;
|
|
44
48
|
const NO_LINES = new Set();
|
|
49
|
+
const NO_FILES = new Set();
|
|
45
50
|
const DOUBLE_CLICK_MS = 500;
|
|
46
51
|
/** How long each follow step stays on screen before the next. */
|
|
47
52
|
const FOLLOW_STEP_MS = { slow: 2500, normal: 1200, fast: 450, instant: 0 };
|
|
48
53
|
/** Steps follow mode may fall behind before it skips ahead. */
|
|
49
54
|
const MAX_FOLLOW_LAG = 12;
|
|
50
55
|
/** 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 -
|
|
56
|
+
const tabsWidth = (codeWidth) => Math.max(16, codeWidth - 3);
|
|
52
57
|
/** How much of the conversation a newly switched-to engine is given (the latest part when it's longer). */
|
|
53
58
|
const HANDOFF_CHARS = 24_000;
|
|
54
59
|
/** The conversation so far as text, for an engine taking over from another one; null when there's none. */
|
|
@@ -98,7 +103,7 @@ function readText(filePath) {
|
|
|
98
103
|
return "";
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
|
-
export function App({ initialModel, permContext }) {
|
|
106
|
+
export function App({ initialModel, permContext, resume }) {
|
|
102
107
|
const { exit } = useApp();
|
|
103
108
|
const { columns, rows } = useWindowSize();
|
|
104
109
|
const [settings, setSettings] = useState(loadSettings);
|
|
@@ -110,16 +115,17 @@ export function App({ initialModel, permContext }) {
|
|
|
110
115
|
/** The file finder / command palette: closed (null), or open with this query (">" for commands). */
|
|
111
116
|
const [quickOpen, setQuickOpen] = useState(null);
|
|
112
117
|
const [focus, setFocus] = useState("chat");
|
|
113
|
-
/** The Games panel: whether it's open,
|
|
118
|
+
/** The Games panel: whether it's open, the pane it's being dragged over, and the pane to go back to. */
|
|
114
119
|
const [gamesOpen, setGamesOpen] = useState(false);
|
|
115
120
|
const [gameDrag, setGameDrag] = useState(null);
|
|
116
121
|
const beforeGame = useRef("chat");
|
|
117
|
-
/**
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
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");
|
|
123
129
|
const [follow, setFollow] = useState(settings.followMode);
|
|
124
130
|
// Follow mode replays what the agent reads a step at a time (it reads far faster than you can watch):
|
|
125
131
|
// the steps waiting, and the timer that shows the next one.
|
|
@@ -134,6 +140,8 @@ export function App({ initialModel, permContext }) {
|
|
|
134
140
|
setFollowLag(0);
|
|
135
141
|
};
|
|
136
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);
|
|
137
145
|
const [streaming, setStreaming] = useState("");
|
|
138
146
|
// Streamed text arrives a token at a time; redrawing the whole screen for each one is wasted work.
|
|
139
147
|
const streamed = useRef({ text: "", timer: null });
|
|
@@ -154,6 +162,8 @@ export function App({ initialModel, permContext }) {
|
|
|
154
162
|
}
|
|
155
163
|
};
|
|
156
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);
|
|
157
167
|
const [choice, setChoice] = useState(initialModel);
|
|
158
168
|
const [approval, setApproval] = useState(null);
|
|
159
169
|
const [openFile, setOpenFile] = useState(null);
|
|
@@ -217,8 +227,10 @@ export function App({ initialModel, permContext }) {
|
|
|
217
227
|
};
|
|
218
228
|
const [codeDefine, setCodeDefine] = useState(null);
|
|
219
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);
|
|
220
232
|
// Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
|
|
221
|
-
const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
|
|
233
|
+
const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0, heights: { tree: 0, code: 0, chat: 0 }, game: null, terminal: null });
|
|
222
234
|
/** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
|
|
223
235
|
const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
|
|
224
236
|
const paneWidthsRef = useRef(paneWidths);
|
|
@@ -281,6 +293,11 @@ export function App({ initialModel, permContext }) {
|
|
|
281
293
|
}
|
|
282
294
|
const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
|
|
283
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
|
+
});
|
|
284
301
|
// Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
|
|
285
302
|
if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
|
|
286
303
|
clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
|
|
@@ -318,6 +335,24 @@ export function App({ initialModel, permContext }) {
|
|
|
318
335
|
useEffect(() => {
|
|
319
336
|
void checkForUpdate().then(setUpdate);
|
|
320
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
|
+
}, []);
|
|
321
356
|
// Discover plugins once and load the enabled ones.
|
|
322
357
|
useEffect(() => {
|
|
323
358
|
void (async () => {
|
|
@@ -440,6 +475,80 @@ export function App({ initialModel, permContext }) {
|
|
|
440
475
|
engine.note(handoff);
|
|
441
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." : ""}`);
|
|
442
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");
|
|
551
|
+
};
|
|
443
552
|
/** Downloads an Ollama model, showing progress in the status line. Esc stops it. */
|
|
444
553
|
const pullModel = async (model) => {
|
|
445
554
|
const ollama = providers.find((p) => p.id === "ollama");
|
|
@@ -629,7 +738,7 @@ export function App({ initialModel, permContext }) {
|
|
|
629
738
|
};
|
|
630
739
|
/** Scrolls a pane to where its scrollbar was clicked or dragged (rows start at y=3, under the border and title). */
|
|
631
740
|
const scrollPane = (pane, y) => {
|
|
632
|
-
const
|
|
741
|
+
const paneHeight = pane === "game" || pane === "terminal" ? 0 : layout.current.heights[pane];
|
|
633
742
|
const fraction = (y - 3) / Math.max(1, paneHeight - 4);
|
|
634
743
|
setScrollTo((s) => ({ pane, fraction, nonce: (s?.nonce ?? 0) + 1 }));
|
|
635
744
|
};
|
|
@@ -640,35 +749,58 @@ export function App({ initialModel, permContext }) {
|
|
|
640
749
|
useMouse((event) => {
|
|
641
750
|
if (view !== "main" || quickOpen !== null)
|
|
642
751
|
return;
|
|
643
|
-
// The Games panel
|
|
644
|
-
|
|
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) {
|
|
645
769
|
const drag = dragging.current;
|
|
646
|
-
if (drag
|
|
647
|
-
const
|
|
770
|
+
if (drag === "game") {
|
|
771
|
+
const { treeWidth, codeWidth } = layout.current;
|
|
772
|
+
const over = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
|
|
648
773
|
if (event.kind === "release") {
|
|
649
774
|
dragging.current = null;
|
|
650
775
|
setGameDrag(null);
|
|
651
|
-
updateSettings({
|
|
776
|
+
updateSettings({ gameDock: over });
|
|
652
777
|
}
|
|
653
778
|
else
|
|
654
|
-
setGameDrag(
|
|
779
|
+
setGameDrag(over);
|
|
655
780
|
return;
|
|
656
781
|
}
|
|
657
|
-
const
|
|
658
|
-
const inside = event.x >= g.x && event.x < g.x + GAME_PANEL.width && event.y >= g.y && event.y < g.y + GAME_PANEL.height;
|
|
782
|
+
const inside = event.x >= game.x && event.x < game.x + game.width && event.y >= game.y && event.y < game.y + game.height;
|
|
659
783
|
if (inside) {
|
|
660
784
|
if (event.kind !== "click")
|
|
661
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
|
+
}
|
|
662
793
|
if (focus !== "game")
|
|
663
794
|
beforeGame.current = focus;
|
|
664
795
|
setFocus("game");
|
|
665
|
-
if (event.y <=
|
|
666
|
-
dragging.current =
|
|
796
|
+
if (event.y <= game.y + 1)
|
|
797
|
+
dragging.current = "game";
|
|
667
798
|
return;
|
|
668
799
|
}
|
|
669
800
|
}
|
|
670
801
|
const { treeWidth, codeWidth, paneHeight } = layout.current;
|
|
671
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 }));
|
|
672
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 }]);
|
|
673
805
|
if (event.kind === "drag") {
|
|
674
806
|
lastClick.current = null; // a press that became a drag doesn't start a double-click
|
|
@@ -677,7 +809,9 @@ export function App({ initialModel, permContext }) {
|
|
|
677
809
|
moveBorder(drag, event.x);
|
|
678
810
|
else if (drag === "editor")
|
|
679
811
|
editorAt(event.x, event.y, "drag");
|
|
680
|
-
else if (drag
|
|
812
|
+
else if (drag === "chatText")
|
|
813
|
+
chatAt(event.x, event.y, "drag");
|
|
814
|
+
else if (drag && typeof drag === "object")
|
|
681
815
|
scrollPane(drag.scroll, event.y);
|
|
682
816
|
return;
|
|
683
817
|
}
|
|
@@ -692,6 +826,8 @@ export function App({ initialModel, permContext }) {
|
|
|
692
826
|
scrollPane(dragging.current.scroll, event.y);
|
|
693
827
|
if (dragging.current === "editor")
|
|
694
828
|
editorAt(event.x, event.y, "release");
|
|
829
|
+
if (dragging.current === "chatText")
|
|
830
|
+
chatAt(event.x, event.y, "release");
|
|
695
831
|
dragging.current = null;
|
|
696
832
|
return;
|
|
697
833
|
}
|
|
@@ -723,11 +859,18 @@ export function App({ initialModel, permContext }) {
|
|
|
723
859
|
return;
|
|
724
860
|
}
|
|
725
861
|
// The code view's title row holds the tabs: a click switches, the × closes.
|
|
726
|
-
if (event.kind === "click" && pane === "code" && event.y === 2 && tabs.length
|
|
727
|
-
const
|
|
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);
|
|
728
871
|
if (!zone)
|
|
729
872
|
return;
|
|
730
|
-
if (
|
|
873
|
+
if (Math.abs(at - zone.close) <= 1)
|
|
731
874
|
closeTab(zone.file);
|
|
732
875
|
else
|
|
733
876
|
switchTab(zone.file);
|
|
@@ -750,8 +893,11 @@ export function App({ initialModel, permContext }) {
|
|
|
750
893
|
if (pane === "tree" && event.y >= 3)
|
|
751
894
|
setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
|
|
752
895
|
// Messages start after the chat's border and the two-column bar in front of each line.
|
|
753
|
-
if (pane === "chat" && event.y >= 3)
|
|
896
|
+
if (pane === "chat" && event.y >= 3) {
|
|
754
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
|
+
}
|
|
755
901
|
// Two clicks in the same spot within half a second are a double-click.
|
|
756
902
|
const last = lastClick.current;
|
|
757
903
|
const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
|
|
@@ -776,6 +922,8 @@ export function App({ initialModel, permContext }) {
|
|
|
776
922
|
return;
|
|
777
923
|
if (showEditor && focus === "code" && editorStateRef.current?.selection)
|
|
778
924
|
return; // the editor copies it
|
|
925
|
+
if (focus === "terminal")
|
|
926
|
+
return; // the terminal stops its command
|
|
779
927
|
if (busy || approval) {
|
|
780
928
|
stop();
|
|
781
929
|
approval?.resolve(false);
|
|
@@ -1052,7 +1200,7 @@ export function App({ initialModel, permContext }) {
|
|
|
1052
1200
|
stop();
|
|
1053
1201
|
return answer(false);
|
|
1054
1202
|
}
|
|
1055
|
-
if (focus !== "chat" && /^[yYnN]$/.test(input))
|
|
1203
|
+
if (focus !== "chat" && focus !== "terminal" && /^[yYnN]$/.test(input))
|
|
1056
1204
|
return answer(/[yY]/.test(input));
|
|
1057
1205
|
if (focus !== "chat" && input === "o" && pendingChange)
|
|
1058
1206
|
return openPending();
|
|
@@ -1065,8 +1213,8 @@ export function App({ initialModel, permContext }) {
|
|
|
1065
1213
|
return; // the editor owns the keyboard
|
|
1066
1214
|
if (focus === "code" && codeFinding.current)
|
|
1067
1215
|
return; // so does the find bar
|
|
1068
|
-
if (focus === "game")
|
|
1069
|
-
return; // and the Games panel
|
|
1216
|
+
if (focus === "game" || focus === "terminal")
|
|
1217
|
+
return; // and the Games panel and the terminal
|
|
1070
1218
|
if (key.tab && key.shift)
|
|
1071
1219
|
return cycleAutoMode();
|
|
1072
1220
|
if (key.tab && focus === "chat" && chatMenuOpen.current)
|
|
@@ -1099,6 +1247,8 @@ export function App({ initialModel, permContext }) {
|
|
|
1099
1247
|
setFollow((f) => !f);
|
|
1100
1248
|
else if (input === "m")
|
|
1101
1249
|
toggleMouse();
|
|
1250
|
+
else if (input === "`")
|
|
1251
|
+
toggleTerminal();
|
|
1102
1252
|
else if (input === "p")
|
|
1103
1253
|
toggleProblems();
|
|
1104
1254
|
else if (input === "u")
|
|
@@ -1119,7 +1269,7 @@ export function App({ initialModel, permContext }) {
|
|
|
1119
1269
|
stepSessionFile(input === "]" ? 1 : -1);
|
|
1120
1270
|
else if (input === "]" || input === "[")
|
|
1121
1271
|
cycleTab(input === "]" ? 1 : -1);
|
|
1122
|
-
else if (input === "x")
|
|
1272
|
+
else if (input === "x" || (key.ctrl && input === "w"))
|
|
1123
1273
|
closeTab();
|
|
1124
1274
|
else if (input === "e")
|
|
1125
1275
|
startEditing();
|
|
@@ -1274,6 +1424,8 @@ ${snippet}
|
|
|
1274
1424
|
}
|
|
1275
1425
|
case "games":
|
|
1276
1426
|
return toggleGames();
|
|
1427
|
+
case "cancel":
|
|
1428
|
+
return cancelLast();
|
|
1277
1429
|
case "mcp":
|
|
1278
1430
|
return mcpCommand(rest);
|
|
1279
1431
|
case "problems":
|
|
@@ -1380,7 +1532,12 @@ ${snippet}
|
|
|
1380
1532
|
case "clear":
|
|
1381
1533
|
agentRef.current.clear();
|
|
1382
1534
|
setEntries([]);
|
|
1535
|
+
setConversationId(newSessionId()); // the last one stays saved for /resume
|
|
1383
1536
|
return;
|
|
1537
|
+
case "terminal":
|
|
1538
|
+
return toggleTerminal();
|
|
1539
|
+
case "resume":
|
|
1540
|
+
return resumeConversation(rest[0] ?? "");
|
|
1384
1541
|
case "plugins":
|
|
1385
1542
|
return pluginsCommand(rest);
|
|
1386
1543
|
case "version":
|
|
@@ -1427,7 +1584,38 @@ ${snippet}
|
|
|
1427
1584
|
};
|
|
1428
1585
|
const setupCheckRef = useRef(setupCheck);
|
|
1429
1586
|
setupCheckRef.current = setupCheck;
|
|
1430
|
-
/**
|
|
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). */
|
|
1431
1619
|
const [queued, setQueued] = useState([]);
|
|
1432
1620
|
/** Set when you stop Nou: what was queued goes back into the input instead of being sent. */
|
|
1433
1621
|
const stopped = useRef(false);
|
|
@@ -1445,9 +1633,12 @@ ${snippet}
|
|
|
1445
1633
|
setQueued([]);
|
|
1446
1634
|
return;
|
|
1447
1635
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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"));
|
|
1451
1642
|
}, [busy, approval, queued]);
|
|
1452
1643
|
useEffect(() => {
|
|
1453
1644
|
if (busy)
|
|
@@ -1577,8 +1768,17 @@ ${snippet}
|
|
|
1577
1768
|
const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
|
|
1578
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));
|
|
1579
1770
|
const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
|
|
1580
|
-
const tabStrip = tabs.length
|
|
1581
|
-
|
|
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 };
|
|
1582
1782
|
const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
|
|
1583
1783
|
const scrollFor = (pane) => (scrollTo?.pane === pane ? scrollTo : null);
|
|
1584
1784
|
const totals = diagnostics.counts();
|
|
@@ -1592,6 +1792,21 @@ ${snippet}
|
|
|
1592
1792
|
updateSettings({ mouse: !settings.mouse });
|
|
1593
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.");
|
|
1594
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
|
+
}
|
|
1595
1810
|
/** Opens the Games panel and gives it the keyboard, or closes it. */
|
|
1596
1811
|
const toggleGames = (open = !gamesOpen) => {
|
|
1597
1812
|
setGamesOpen(open);
|
|
@@ -1629,6 +1844,10 @@ ${snippet}
|
|
|
1629
1844
|
{ title: "Find in the open file", keys: "Ctrl+F", run: startFind },
|
|
1630
1845
|
{ title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
|
|
1631
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) })),
|
|
1632
1851
|
{ title: "MCP servers: tools from other programs", keys: "/mcp", run: () => void runCommand("/mcp") },
|
|
1633
1852
|
{ title: "Browser control (Playwright MCP)", keys: "/mcp add browser", run: () => void runCommand("/mcp add browser") },
|
|
1634
1853
|
{ title: "Import MCP servers from Claude Code, Cursor, Codex...", keys: "/mcp import", run: () => void runCommand("/mcp import") },
|
|
@@ -1739,9 +1958,9 @@ ${snippet}
|
|
|
1739
1958
|
}
|
|
1740
1959
|
const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
|
|
1741
1960
|
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:
|
|
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
|
|
1743
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)` : ""] })
|
|
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)))] }) }),
|
|
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) => {
|
|
1745
1964
|
setQuickOpen(null);
|
|
1746
1965
|
clearFollow();
|
|
1747
1966
|
open(file);
|