nou-cli 0.5.16 → 0.5.19
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 +5 -5
- package/dist/src/engines/claude.js +153 -43
- package/dist/src/ui/App.js +43 -11
- package/dist/src/ui/Chat.js +45 -11
- package/dist/src/ui/Header.js +48 -21
- package/dist/src/ui/Tabs.js +27 -0
- package/dist/src/ui/commands.js +3 -2
- package/dist/src/workspaces/host.js +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,8 +50,8 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
|
|
|
50
50
|
| 🔀 **Live diffs** | Every edit Nou proposes shows up side by side, and the files it is editing shimmer in the file tree while it works. <kbd>y</kbd> accepts, <kbd>n</kbd> rejects. Nothing touches disk without you. |
|
|
51
51
|
| ✍️ **Built-in editor** | Press <kbd>e</kbd> to edit the open file yourself, with undo/redo and **auto-save**. |
|
|
52
52
|
| 📍 **Shows you where things are** | Ask "where is the LiveKit integration?" and Nou finds it and opens it in the middle panel, jumped to the exact lines and highlighted. Any file a reply mentions (`src/a.ts:12`) is a link: click it. `/open name[:line]` opens a file yourself. |
|
|
53
|
-
| 🪟 **Workspaces** | Several projects in one window, a tab each in the header: click **+** and pick a folder (or `/workspace new ../api`): it opens beside this one with its own files, chats and agents. Click a tab (or `/workspace 2`) to switch; the ones in the background keep working, and a tab shows ● while its agent is busy. A tab's **×** closes it (so does quitting it, Ctrl+C twice). The first time, Nou installs a small pseudo-terminal package ([@lydell/node-pty](https://www.npmjs.com/package/@lydell/node-pty), prebuilt for macOS, Linux and Windows) and reopens as a workspaces window, with your conversation in the first tab. Or start that way: `nou --workspaces . ../api`. |
|
|
54
|
-
| 🗂️ **Several agents at once** | `/new` (or **+** in the chat's title) opens another conversation tab while the first keeps working. Each tab has its own agent and history; a working tab shows ●, one waiting for your OK shows !, and a click (or `/tab 2`) switches. A tab's **×** (or `/close`) closes it, even one in the background; it stays in `/resume`. |
|
|
53
|
+
| 🪟 **Workspaces** | Several projects in one window, a tab each in the header: click **+** and pick a folder (or `/workspace new ../api`): it opens beside this one with its own files, chats and agents. Click a tab (or `/workspace 2`) to switch; the ones in the background keep working, and a tab shows ● while its agent is busy. A tab's **×** closes it (so does quitting it, Ctrl+C twice). `/workspace rename <name>` names a tab. With more tabs than fit, the ones around the current tab show and **‹ ›** step to the rest. The first time, Nou installs a small pseudo-terminal package ([@lydell/node-pty](https://www.npmjs.com/package/@lydell/node-pty), prebuilt for macOS, Linux and Windows) and reopens as a workspaces window, with your conversation in the first tab. Or start that way: `nou --workspaces . ../api`. |
|
|
54
|
+
| 🗂️ **Several agents at once** | `/new` (or **+** in the chat's title) opens another conversation tab while the first keeps working. Each tab has its own agent and history; a working tab shows ●, one waiting for your OK shows !, and a click (or `/tab 2`) switches. A tab's **×** (or `/close`) closes it, even one in the background; it stays in `/resume`. `/rename <name>` names a chat's tab (and its `/resume` entry). Many tabs: names shorten to fit, then **‹ ›** step to the ones that don't. |
|
|
55
55
|
| 👀 **Show me** | Ask how something works or where it happens (`/showme where do we check the login?`, or just ask) and Nou explains it with the code open: each place it talks about opens in the code view, highlighted, and they're listed in the chat to click. `/showme next` and `/showme back` step through them, and `/showme stop` clears the highlights. While it shows, the code view and the file tree take its purple (◉ SHOWING 2/6, and each file's places), as they take green while you edit. Works with every engine: Nou's points at places with a tool, and Claude Code, Codex and pi are asked to list them at the end of their reply, which Nou reads and hides. |
|
|
56
56
|
| 🧑🏫 **Teach me** | `/teach add a --verbose flag` and Nou finds where the change goes, then walks you through typing it yourself: each step opens the file at the right line, says why, and shows the code to type dim ahead of your cursor, like autocomplete (<kbd>Tab</kbd> fills in a line). It moves on when what you typed matches, and saves as it goes. `/teach next` skips a step, `/teach stop` ends it. Works with every engine (Nou's, Claude Code, Codex and pi): any edit one makes anyway is put back. |
|
|
57
57
|
| 🎮 **Games for a break** | Add Pong, Snake, Breakout (it gets faster each wall you clear), Dodge, Hop and Pairs in settings → Games, then `/games` opens a panel docked under the chat: it splits that column instead of covering anything. Drag its title onto the files, code or chat to dock it there, and × at its top right closes it. Drag its top edge to make it taller or shorter. A game runs only while the panel has the keyboard: Esc goes back to your code and it waits. Your best score in each is kept, and a panel too small for a game says how much room it needs. |
|
|
@@ -110,7 +110,7 @@ Every change is reviewed like a code review:
|
|
|
110
110
|
| <kbd>z</kbd> | in the code view: fold every function and block to its first line, or open them all again (also `/fold`, `/unfold`); click a ▾/▸ beside a line number to fold or open just that one |
|
|
111
111
|
| <kbd>Ctrl</kbd>+<kbd>L</kbd> | draw the whole screen again (also `/redraw`), if it ever looks shifted or clicks land a row off. Output from libraries goes to `~/.nou/nou.log` instead of the screen |
|
|
112
112
|
| <kbd>Ctrl</kbd>+<kbd>O</kbd> | in the chat: open or fold every "▸ N steps" line (what the agent read and ran between replies folds into one; click one to open just it) |
|
|
113
|
-
| <kbd>m</kbd> | mouse on/off, so you can select and copy text in the terminal (in the chat, just drag over the text: it's copied when you let go; double-click copies a word, path or link, triple-click a paragraph) |
|
|
113
|
+
| <kbd>m</kbd> | mouse on/off, so you can select and copy text in the terminal (in the chat, just drag over the text: it's copied when you let go; double-click copies a word, path or link, triple-click a paragraph; <kbd>Ctrl</kbd>+<kbd>C</kbd> while it's highlighted copies it again rather than stopping Nou) |
|
|
114
114
|
| <kbd>s</kbd> | settings: model, preferences, plugins |
|
|
115
115
|
| <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command, from any pane; the rest of Nou keeps working while it waits (<kbd>o</kbd> shows the edit) |
|
|
116
116
|
| <kbd>Enter</kbd> while Nou works | queue the message: it goes when Nou finishes, together with anything else you queued, as one message; commands that only change the screen, like `/terminal`, `/games` or `/open`, run right away. To send now instead of waiting: <kbd>Alt</kbd>+<kbd>Enter</kbd> (stops Nou and sends it), click the queued message, or `/now`. Nou answers it first, then finishes what it was working on, unless your message says to drop it; or turn on **Send while Nou works** in settings so <kbd>Enter</kbd> sends right away (<kbd>Alt</kbd>+<kbd>Enter</kbd> then queues) |
|
|
@@ -131,11 +131,11 @@ In the editor:
|
|
|
131
131
|
| <kbd>Ctrl</kbd>+<kbd>K</kbd> · <kbd>Ctrl</kbd>+<kbd>G</kbd> | hover info · go to definition (with a language server) |
|
|
132
132
|
| <kbd>Esc</kbd> | clear the selection, or leave the editor |
|
|
133
133
|
|
|
134
|
-
Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/effort [fastest|fast|balanced|smarter|smartest]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/resume [n|last]` · `/terminal` · `/zoom` · `/workspace [new <folder> | n]` · `/files` · `/actions` · `/now` · `/showme <question>` · `/teach <change>` · `/help`
|
|
134
|
+
Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/effort [fastest|fast|balanced|smarter|smartest]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/resume [n|last]` · `/terminal` · `/zoom` · `/workspace [new <folder> | n | rename <name>]` · `/rename [name]` · `/files` · `/actions` · `/now` · `/showme <question>` · `/teach <change>` · `/help`
|
|
135
135
|
|
|
136
136
|
A small typo in a command still works when it only changes the screen (`/termnial` opens the terminal); for others Nou asks "Did you mean...?" instead of guessing.
|
|
137
137
|
|
|
138
|
-
New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol, which Nou checks for before it starts; also xterm's modifyOtherKeys form), or type `\` and press <kbd>Enter</kbd> in any terminal.
|
|
138
|
+
New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol, which Nou checks for before it starts; also xterm's modifyOtherKeys form), or type `\` and press <kbd>Enter</kbd> in any terminal. On Windows, <kbd>Ctrl</kbd>+<kbd>Enter</kbd> works in every Windows Terminal and the console; <kbd>Shift</kbd>+<kbd>Enter</kbd> needs Windows Terminal 1.25 or later (older versions send it as a plain Enter).
|
|
139
139
|
|
|
140
140
|
Conversations are saved per folder after each reply. `/resume` opens a picker (↑↓ and Enter, type to filter, or type its number) and `/resume 2` (or `nou --continue` for the last one, `nou --resume` for the list) picks one back up with the engine it was on: Nou's own engine restores its messages, Claude Code its session and Codex its thread; pi is handed the conversation so far. Sessions you started in Claude Code's or Codex's own CLI in this folder are listed too (marked "CLI"), and resuming one carries it on in that engine inside Nou.
|
|
141
141
|
|
|
@@ -48,6 +48,7 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
48
48
|
super(choice, hooks, "Claude Code");
|
|
49
49
|
}
|
|
50
50
|
resetConversation() {
|
|
51
|
+
this.closeLive();
|
|
51
52
|
this.claudeSession = undefined;
|
|
52
53
|
}
|
|
53
54
|
snapshot() {
|
|
@@ -57,6 +58,7 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
57
58
|
restore(state) {
|
|
58
59
|
if (typeof state?.claudeSession !== "string")
|
|
59
60
|
return false;
|
|
61
|
+
this.closeLive();
|
|
60
62
|
this.claudeSession = state.claudeSession;
|
|
61
63
|
return true;
|
|
62
64
|
}
|
|
@@ -64,15 +66,45 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
64
66
|
setModel(choice) {
|
|
65
67
|
this.choice = choice;
|
|
66
68
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
dispose() {
|
|
70
|
+
this.closeLive();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* One Claude Code process per conversation, kept running between messages: each message is fed into it
|
|
74
|
+
* and a turn is over at its result. A process per message didn't work: one that left something running
|
|
75
|
+
* (a dev server, a background command) never closed its stream, so the turn never ended, and the next
|
|
76
|
+
* message's process found the session still in use and ended without a word.
|
|
77
|
+
*/
|
|
78
|
+
live = null;
|
|
79
|
+
/** The turn being waited on: settled at its result, or when the process ends. */
|
|
80
|
+
waiting = null;
|
|
81
|
+
streamed = "";
|
|
82
|
+
closeLive() {
|
|
83
|
+
const live = this.live;
|
|
84
|
+
this.live = null;
|
|
85
|
+
live?.close();
|
|
86
|
+
}
|
|
87
|
+
/** What the process was started with; a change (model, effort, project context) means a new one. */
|
|
88
|
+
liveKey() {
|
|
89
|
+
return JSON.stringify([this.choice.provider.id === "anthropic" ? this.choice.model : null, this.effort ?? null, this.extras]);
|
|
90
|
+
}
|
|
91
|
+
openLive(sdk, key) {
|
|
92
|
+
const queue = [];
|
|
93
|
+
let wake = null;
|
|
94
|
+
let closed = false;
|
|
95
|
+
const input = async function* () {
|
|
96
|
+
while (!closed) {
|
|
97
|
+
if (queue.length)
|
|
98
|
+
yield queue.shift();
|
|
99
|
+
else
|
|
100
|
+
await new Promise((resolve) => (wake = resolve));
|
|
101
|
+
}
|
|
102
|
+
};
|
|
71
103
|
const allow = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } };
|
|
72
104
|
const deny = (reason) => ({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } });
|
|
73
|
-
const preToolUse = async (
|
|
74
|
-
const tool =
|
|
75
|
-
const args = (
|
|
105
|
+
const preToolUse = async (hookInput) => {
|
|
106
|
+
const tool = hookInput.tool_name;
|
|
107
|
+
const args = (hookInput.tool_input ?? {});
|
|
76
108
|
if (EDIT_TOOLS.has(tool) && args.file_path) {
|
|
77
109
|
const ok = await this.tracker.approve(args.file_path, proposedContent(tool, args), tool);
|
|
78
110
|
return ok ? allow : deny("The user rejected this change. Ask them what they would like instead.");
|
|
@@ -86,9 +118,9 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
86
118
|
// Reads and searches follow Claude Code's own rules; anything else that needs asking reaches canUseTool.
|
|
87
119
|
return {};
|
|
88
120
|
};
|
|
89
|
-
const postToolUse = async (
|
|
90
|
-
const tool =
|
|
91
|
-
const args = (
|
|
121
|
+
const postToolUse = async (hookInput) => {
|
|
122
|
+
const tool = hookInput.tool_name;
|
|
123
|
+
const args = (hookInput.tool_input ?? {});
|
|
92
124
|
if (EDIT_TOOLS.has(tool) && args.file_path) {
|
|
93
125
|
const problems = await this.tracker.applied(args.file_path);
|
|
94
126
|
return problems
|
|
@@ -109,10 +141,9 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
109
141
|
return {};
|
|
110
142
|
};
|
|
111
143
|
const query = sdk.query({
|
|
112
|
-
prompt,
|
|
144
|
+
prompt: input(),
|
|
113
145
|
options: {
|
|
114
146
|
cwd: process.cwd(),
|
|
115
|
-
abortController,
|
|
116
147
|
...(this.claudeSession ? { resume: this.claudeSession } : {}),
|
|
117
148
|
...(this.choice.provider.id === "anthropic" ? { model: this.choice.model } : {}),
|
|
118
149
|
...(this.effort && EFFORTS.has(this.effort) ? { effort: this.effort } : {}),
|
|
@@ -122,45 +153,124 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
122
153
|
settingSources: ["project"],
|
|
123
154
|
systemPrompt: { type: "preset", preset: "claude_code", append: this.extras.join("\n\n") },
|
|
124
155
|
hooks: { PreToolUse: [{ hooks: [preToolUse] }], PostToolUse: [{ hooks: [postToolUse] }] },
|
|
125
|
-
canUseTool: async (tool,
|
|
126
|
-
const ok = await this.hooks.approve({ kind: "tool", name: tool, args:
|
|
156
|
+
canUseTool: async (tool, toolInput) => {
|
|
157
|
+
const ok = await this.hooks.approve({ kind: "tool", name: tool, args: toolInput });
|
|
127
158
|
if (!ok)
|
|
128
|
-
this.hooks.onEntry({ kind: "tool", text: `Denied: ${describeTool(tool,
|
|
129
|
-
return ok ? { behavior: "allow", updatedInput:
|
|
159
|
+
this.hooks.onEntry({ kind: "tool", text: `Denied: ${describeTool(tool, toolInput)}` });
|
|
160
|
+
return ok ? { behavior: "allow", updatedInput: toolInput } : { behavior: "deny", message: "The user denied this." };
|
|
130
161
|
},
|
|
131
162
|
},
|
|
132
163
|
});
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
const live = {
|
|
165
|
+
key,
|
|
166
|
+
query,
|
|
167
|
+
push: (text) => {
|
|
168
|
+
queue.push({ type: "user", message: { role: "user", content: text }, parent_tool_use_id: null, session_id: this.claudeSession ?? "" });
|
|
169
|
+
wake?.();
|
|
170
|
+
wake = null;
|
|
171
|
+
},
|
|
172
|
+
close: () => {
|
|
173
|
+
process.off("exit", live.close);
|
|
174
|
+
closed = true;
|
|
175
|
+
wake?.();
|
|
176
|
+
try {
|
|
177
|
+
query.close?.();
|
|
143
178
|
}
|
|
179
|
+
catch {
|
|
180
|
+
// already gone
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
process.once("exit", live.close); // Claude Code doesn't outlive Nou
|
|
185
|
+
// Reads everything Claude Code says, for as long as the process runs. Between turns that's what a
|
|
186
|
+
// background task it started brings back (it may carry on by itself); it goes to the chat too.
|
|
187
|
+
void (async () => {
|
|
188
|
+
let failure;
|
|
189
|
+
try {
|
|
190
|
+
for await (const message of query)
|
|
191
|
+
this.handle(message, live);
|
|
144
192
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
streamed = "";
|
|
148
|
-
this.hooks.onStream("");
|
|
149
|
-
if (text)
|
|
150
|
-
this.hooks.onEntry({ kind: "assistant", text });
|
|
151
|
-
this.hooks.onBusy("Claude Code is working...");
|
|
193
|
+
catch (error) {
|
|
194
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
152
195
|
}
|
|
153
|
-
|
|
154
|
-
this.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
this.hooks.onEntry({ kind: "error", text: `Claude Code: ${detail}` });
|
|
159
|
-
}
|
|
160
|
-
// The turn is over: don't wait for Claude Code's process to close its stream as well. Something it
|
|
161
|
-
// left running (a background command, an MCP server) kept that open, and Nou "working" forever.
|
|
162
|
-
break;
|
|
196
|
+
finally {
|
|
197
|
+
if (this.live === live)
|
|
198
|
+
this.live = null;
|
|
199
|
+
if (this.waiting?.live === live)
|
|
200
|
+
this.waiting.done(failure ?? (closed ? undefined : new Error("Claude Code stopped before it answered.")));
|
|
163
201
|
}
|
|
202
|
+
})();
|
|
203
|
+
return live;
|
|
204
|
+
}
|
|
205
|
+
handle(message, live) {
|
|
206
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
207
|
+
this.claudeSession = message.session_id;
|
|
208
|
+
}
|
|
209
|
+
else if (message.type === "stream_event" && !message.parent_tool_use_id) {
|
|
210
|
+
const event = message.event;
|
|
211
|
+
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
|
|
212
|
+
this.streamed += event.delta.text;
|
|
213
|
+
this.hooks.onStream(this.streamed);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else if (message.type === "assistant" && !message.parent_tool_use_id) {
|
|
217
|
+
const text = message.message.content.filter((b) => b.type === "text").map((b) => b.text).join("").trim();
|
|
218
|
+
this.streamed = "";
|
|
219
|
+
this.hooks.onStream("");
|
|
220
|
+
if (text)
|
|
221
|
+
this.hooks.onEntry({ kind: "assistant", text });
|
|
222
|
+
if (this.waiting)
|
|
223
|
+
this.hooks.onBusy("Claude Code is working...");
|
|
224
|
+
}
|
|
225
|
+
else if (message.type === "result") {
|
|
226
|
+
this.claudeSession = message.session_id ?? this.claudeSession;
|
|
227
|
+
this.streamed = "";
|
|
228
|
+
this.hooks.onStream("");
|
|
229
|
+
if (message.subtype !== "success" || message.is_error) {
|
|
230
|
+
const detail = message.errors?.join("; ") || message.result || message.subtype;
|
|
231
|
+
// A turn you stopped ends with an error result; that isn't news.
|
|
232
|
+
if (!this.stopping)
|
|
233
|
+
this.hooks.onEntry({ kind: "error", text: `Claude Code: ${detail}` });
|
|
234
|
+
}
|
|
235
|
+
this.stopping = false;
|
|
236
|
+
if (this.waiting?.live === live)
|
|
237
|
+
this.waiting.done();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
stopping = false;
|
|
241
|
+
async run(prompt, signal) {
|
|
242
|
+
const sdk = await loadPackage("@anthropic-ai/claude-agent-sdk");
|
|
243
|
+
const key = this.liveKey();
|
|
244
|
+
if (this.live && this.live.key !== key)
|
|
245
|
+
this.closeLive();
|
|
246
|
+
const live = (this.live ??= this.openLive(sdk, key));
|
|
247
|
+
this.streamed = "";
|
|
248
|
+
let settle;
|
|
249
|
+
const finished = new Promise((resolve, reject) => (settle = (error) => (error ? reject(error) : resolve())));
|
|
250
|
+
const waiting = { live, done: (error) => settle(error) };
|
|
251
|
+
this.waiting = waiting;
|
|
252
|
+
// Stopping: Claude Code interrupts the turn and ends it with a result; if it doesn't promptly, the
|
|
253
|
+
// process goes (the next message starts a new one, resuming the session).
|
|
254
|
+
const onAbort = () => {
|
|
255
|
+
this.stopping = true;
|
|
256
|
+
Promise.resolve(live.query.interrupt?.()).catch(() => { });
|
|
257
|
+
setTimeout(() => {
|
|
258
|
+
if (this.waiting !== waiting)
|
|
259
|
+
return;
|
|
260
|
+
if (this.live === live)
|
|
261
|
+
this.closeLive();
|
|
262
|
+
waiting.done();
|
|
263
|
+
}, 3000);
|
|
264
|
+
};
|
|
265
|
+
signal.addEventListener("abort", onAbort);
|
|
266
|
+
try {
|
|
267
|
+
live.push(prompt);
|
|
268
|
+
await finished;
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
signal.removeEventListener("abort", onAbort);
|
|
272
|
+
if (this.waiting === waiting)
|
|
273
|
+
this.waiting = null;
|
|
164
274
|
}
|
|
165
275
|
if (signal.aborted)
|
|
166
276
|
throw new Error("aborted");
|
package/dist/src/ui/App.js
CHANGED
|
@@ -172,6 +172,11 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
172
172
|
}, [tour]);
|
|
173
173
|
const shellRef = useRef(null);
|
|
174
174
|
const beforeTerm = useRef("code");
|
|
175
|
+
/** Names you gave conversations (/rename), by conversation: their tab and /resume show them. */
|
|
176
|
+
const chatNames = useRef(new Map());
|
|
177
|
+
const [namesVersion, setNamesVersion] = useState(0);
|
|
178
|
+
/** What the header calls this workspace when it's the only one (/workspace rename). */
|
|
179
|
+
const [projectName, setProjectName] = useState(path.basename(process.cwd()));
|
|
175
180
|
const [follow, setFollow] = useState(settings.followMode);
|
|
176
181
|
// Follow mode replays what the agent reads a step at a time (it reads far faster than you can watch):
|
|
177
182
|
// the steps waiting, and the timer that shows the next one.
|
|
@@ -378,7 +383,10 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
378
383
|
return w.has(change.path) ? w : new Set(w).add(change.path);
|
|
379
384
|
});
|
|
380
385
|
// Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
|
|
381
|
-
|
|
386
|
+
// A proposed edit opens only when it waits for your OK; approved by auto-edit, it's just Nou working,
|
|
387
|
+
// which follow mode shows if it's on (it used to open anyway, so follow off still followed edits).
|
|
388
|
+
const waitsForYou = status === "pending" && !settingsRef.current.autoApproveEdits && !guideAsk.current;
|
|
389
|
+
if (!background && ((waitsForYou && !draftActiveRef.current) || followRef.current)) {
|
|
382
390
|
clearFollow(); // an edit is shown as it happens, ahead of reads still waiting
|
|
383
391
|
setOpenFile(change.path);
|
|
384
392
|
setContent(status === "applied" ? change.after : readText(change.path));
|
|
@@ -502,8 +510,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
502
510
|
/** The tabs as the chat's title row shows them: named for what you asked first, marked when working or waiting. */
|
|
503
511
|
function chatTabItems() {
|
|
504
512
|
return chatTabs.map((id, i) => {
|
|
505
|
-
const t = id === activeTab.current ? { entries, busy, approval } : tabStore.current.get(id);
|
|
506
|
-
const first = t?.entries.find((e) => e.kind === "user")?.text.replace(/\s+/g, " ");
|
|
513
|
+
const t = id === activeTab.current ? { entries, busy, approval, conversationId } : tabStore.current.get(id);
|
|
514
|
+
const first = chatNames.current.get(t?.conversationId ?? "") ?? t?.entries.find((e) => e.kind === "user")?.text.replace(/\s+/g, " ");
|
|
507
515
|
const label = first ? (first.length > 14 ? `${first.slice(0, 13)}…` : first) : `chat ${i + 1}`;
|
|
508
516
|
return { id, label, state: t?.approval ? "waiting" : t?.busy ? "busy" : "idle" };
|
|
509
517
|
});
|
|
@@ -518,7 +526,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
518
526
|
function saveTab(t) {
|
|
519
527
|
const first = t.entries.find((e) => e.kind === "user");
|
|
520
528
|
if (first)
|
|
521
|
-
saveConversation({ id: t.conversationId, title: first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: t.agent.id, entries: t.entries, state: t.agent.snapshot?.() });
|
|
529
|
+
saveConversation({ id: t.conversationId, title: chatNames.current.get(t.conversationId) ?? first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: t.agent.id, entries: t.entries, state: t.agent.snapshot?.() });
|
|
522
530
|
}
|
|
523
531
|
if (!agentRef.current) {
|
|
524
532
|
const saved = loadSettings().engine;
|
|
@@ -541,8 +549,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
541
549
|
if (busy || !first)
|
|
542
550
|
return;
|
|
543
551
|
const engine = agentRef.current;
|
|
544
|
-
saveConversation({ id: conversationId, title: first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: engine.id, entries, state: engine.snapshot?.() });
|
|
545
|
-
}, [!busy, entries.length, conversationId]);
|
|
552
|
+
saveConversation({ id: conversationId, title: chatNames.current.get(conversationId) ?? first.text.replace(/\s+/g, " ").slice(0, 80), updated: Date.now(), engine: engine.id, entries, state: engine.snapshot?.() });
|
|
553
|
+
}, [!busy, entries.length, conversationId, namesVersion]);
|
|
546
554
|
/** Earlier conversations for the palette, read from disk when it opens. */
|
|
547
555
|
const recentChats = useMemo(() => (quickOpen !== null ? resumable() : []), [quickOpen !== null]);
|
|
548
556
|
useEffect(() => {
|
|
@@ -1106,7 +1114,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1106
1114
|
return setView("settings");
|
|
1107
1115
|
// Workspace tabs in the header: click one to switch to it, + to open another.
|
|
1108
1116
|
if (event.kind === "click" && event.y === 0) {
|
|
1109
|
-
const zone = headerZones(workspaceTabs()).find((z) => event.x >= z.from && event.x < z.to);
|
|
1117
|
+
const zone = headerZones(workspaceTabs(), columns, modelLabel, choice.provider.local, update, projectName).find((z) => event.x >= z.from && event.x < z.to);
|
|
1110
1118
|
if (zone?.tab === "new")
|
|
1111
1119
|
return pickWorkspaceFolder();
|
|
1112
1120
|
if (typeof zone?.tab === "number")
|
|
@@ -1140,7 +1148,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1140
1148
|
// The chat's title row holds the conversation tabs: a click switches, + opens a new one.
|
|
1141
1149
|
if (event.kind === "click" && pane === "chat" && event.y === 2) {
|
|
1142
1150
|
const at = event.x - treeWidth - codeWidth - 1;
|
|
1143
|
-
const { zones, plus } = chatTabZones(chatTabItems());
|
|
1151
|
+
const { zones, plus } = chatTabZones(chatTabItems(), activeTab.current, columns - treeWidth - codeWidth);
|
|
1144
1152
|
if (Math.abs(at - plus) <= 1)
|
|
1145
1153
|
return newChatTab();
|
|
1146
1154
|
const zone = zones.find((z) => at >= z.from && at < z.to);
|
|
@@ -1222,6 +1230,10 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1222
1230
|
}
|
|
1223
1231
|
}
|
|
1224
1232
|
}, settings.mouse);
|
|
1233
|
+
const chatSelecting = useRef(false);
|
|
1234
|
+
const onChatSelecting = useEvent((selecting) => {
|
|
1235
|
+
chatSelecting.current = selecting;
|
|
1236
|
+
});
|
|
1225
1237
|
// Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
|
|
1226
1238
|
useInput((input, key) => {
|
|
1227
1239
|
if (!(key.ctrl && input === "c"))
|
|
@@ -1230,6 +1242,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1230
1242
|
return; // the editor copies it
|
|
1231
1243
|
if (focus === "terminal")
|
|
1232
1244
|
return; // the terminal stops its command
|
|
1245
|
+
if (chatSelecting.current)
|
|
1246
|
+
return; // the chat copies what's highlighted
|
|
1233
1247
|
if (busy || approval) {
|
|
1234
1248
|
stop();
|
|
1235
1249
|
approval?.resolve(false);
|
|
@@ -1580,7 +1594,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1580
1594
|
setFocus("chat");
|
|
1581
1595
|
setChatPrefill((p) => ({ text: "/", nonce: (p?.nonce ?? 0) + 1 }));
|
|
1582
1596
|
}
|
|
1583
|
-
else if (input === "f")
|
|
1597
|
+
else if (input === "f" || input === "F")
|
|
1584
1598
|
toggleFollow();
|
|
1585
1599
|
else if (input === "m")
|
|
1586
1600
|
toggleMouse();
|
|
@@ -1763,6 +1777,16 @@ ${snippet}
|
|
|
1763
1777
|
return info(`No file matches "${wanted}". To find where something is in the code, ask in the chat.`, "error");
|
|
1764
1778
|
return openRef({ path: file, ...(m[2] ? { line: Number(m[2]) } : {}), ...(m[3] ? { to: Number(m[3]) } : {}) });
|
|
1765
1779
|
}
|
|
1780
|
+
case "rename": {
|
|
1781
|
+
// Names this conversation: its tab, and /resume, show the name (/rename alone: back to the first message).
|
|
1782
|
+
const name = arg.trim().slice(0, 40);
|
|
1783
|
+
if (name)
|
|
1784
|
+
chatNames.current.set(conversationId, name);
|
|
1785
|
+
else
|
|
1786
|
+
chatNames.current.delete(conversationId);
|
|
1787
|
+
setNamesVersion((v) => v + 1);
|
|
1788
|
+
return info(name ? `This chat is now called ${name}. (/workspace rename <name> names the workspace.)` : "This chat is named by its first message again.");
|
|
1789
|
+
}
|
|
1766
1790
|
case "follow": {
|
|
1767
1791
|
const want = arg.trim().toLowerCase();
|
|
1768
1792
|
if (want && !/^(on|off)$/.test(want))
|
|
@@ -2450,8 +2474,16 @@ ${snippet}
|
|
|
2450
2474
|
return info(`There's no workspace ${n}. /workspace lists them.`, "error");
|
|
2451
2475
|
return void sendToHost({ type: "switch", index: n - 1 });
|
|
2452
2476
|
}
|
|
2477
|
+
if (sub === "rename") {
|
|
2478
|
+
const name = more.join(" ").trim().slice(0, 30);
|
|
2479
|
+
if (isHosted())
|
|
2480
|
+
sendToHost({ type: "rename", name });
|
|
2481
|
+
else
|
|
2482
|
+
setProjectName(name || path.basename(process.cwd()));
|
|
2483
|
+
return info(name ? `This workspace is now called ${name}.` : "This workspace is called by its folder's name again.");
|
|
2484
|
+
}
|
|
2453
2485
|
if (sub !== "new")
|
|
2454
|
-
return info("Try /workspace new <folder>, /workspace <n> or /workspace.", "error");
|
|
2486
|
+
return info("Try /workspace new <folder>, /workspace <n>, /workspace rename <name> or /workspace.", "error");
|
|
2455
2487
|
const wanted = more.join(" ");
|
|
2456
2488
|
if (!wanted)
|
|
2457
2489
|
return pickWorkspaceFolder();
|
|
@@ -2655,7 +2687,7 @@ ${snippet}
|
|
|
2655
2687
|
}
|
|
2656
2688
|
const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
|
|
2657
2689
|
clickable.current = { status: zones, modelFrom: columns - (" LOCAL ".length + ` ${modelLabel} `.length + 1) };
|
|
2658
|
-
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, tabs: tabsOfWorkspaces }), _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, ...(tourFiles ? { showing: tourFiles } : {}), git: git, problemCounts: problemCounts, wheelPane: "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, wheelPane: "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, wheelPane: "code", scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tour && tour.stops[tour.at]?.file === openFile && !diff ? { showing: `${tour.at + 1}/${tour.stops.length}` } : {}), ...(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, wheelPane: "code", scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest, foldClick: foldClick, foldAll: foldAll })), _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, wheelPane: "chat", scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, sendNowFirst: settings.sendNowWhileBusy, onSendNow: sendNow, onAnswer: onAnswer, click: chatClick, select: chatSelect, tabs: chatTabsProp(), onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: C.onBadge, 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
|
|
2690
|
+
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, tabs: tabsOfWorkspaces, project: projectName }), _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, ...(tourFiles ? { showing: tourFiles } : {}), git: git, problemCounts: problemCounts, wheelPane: "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, wheelPane: "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, wheelPane: "code", scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tour && tour.stops[tour.at]?.file === openFile && !diff ? { showing: `${tour.at + 1}/${tour.stops.length}` } : {}), ...(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, wheelPane: "code", scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest, foldClick: foldClick, foldAll: foldAll })), _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, wheelPane: "chat", scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, sendNowFirst: settings.sendNowWhileBusy, onSendNow: sendNow, onSelecting: onChatSelecting, onAnswer: onAnswer, click: chatClick, select: chatSelect, tabs: chatTabsProp(), onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: C.onBadge, 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
|
|
2659
2691
|
? _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)` : ""] })
|
|
2660
2692
|
: 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", wheelPane: "terminal", onLeave: leaveTerminal, onClose: () => toggleTerminal(false), onResize: resizeTerminal }) })), layout.current.game && (_jsx(Box, { position: "absolute", marginLeft: layout.current.game.x, marginTop: layout.current.game.y, children: _jsx(GameGuard, { children: _jsx(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, best: settings.gameBest ?? {}, onScore: onGameScore, onLeave: leaveGames, onOpenSettings: gamesSettings }) }) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: resumePicker ?? paletteActions, initialQuery: quickOpen, ...(resumePicker ? { placeholder: "type to filter", title: pickerTitle } : {}), onPick: (file) => {
|
|
2661
2693
|
setQuickOpen(null);
|
package/dist/src/ui/Chat.js
CHANGED
|
@@ -7,6 +7,7 @@ import path from "path";
|
|
|
7
7
|
import { closeOpenMarkdown, linkAt, linkFileRefs, linkUrls, renderMarkdown, wrap } from "./text.js";
|
|
8
8
|
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
9
9
|
import { copyToClipboard } from "./clipboard.js";
|
|
10
|
+
import { fitAround } from "./Tabs.js";
|
|
10
11
|
import { C, SPINNER, ANIMATION_MS } from "./theme.js";
|
|
11
12
|
import { useTerminalFocused } from "./focus.js";
|
|
12
13
|
import { Pane } from "./Pane.js";
|
|
@@ -85,28 +86,49 @@ function cachedLines(entry, inner) {
|
|
|
85
86
|
}
|
|
86
87
|
/**
|
|
87
88
|
* Where each tab sits in the chat's title row (columns from the pane's inner left edge), and the "+"
|
|
88
|
-
* that opens a new one: shared by the drawing and the clicks.
|
|
89
|
+
* that opens a new one: shared by the drawing and the clicks. Only the tabs that fit are shown, around the
|
|
90
|
+
* active one; ‹ and › stand for the rest and go to the tab before or after.
|
|
89
91
|
*/
|
|
90
|
-
export function chatTabZones(
|
|
92
|
+
export function chatTabZones(all, active, paneWidth = 10_000) {
|
|
91
93
|
// One tab: just "Chat +", to leave the title row its room.
|
|
92
|
-
if (
|
|
93
|
-
return { zones: [{ id:
|
|
94
|
+
if (all.length === 1)
|
|
95
|
+
return { zones: [{ id: all[0].id, from: 3, to: 7 }], plus: 8, width: 9, from: 0, to: 1, tabs: all };
|
|
96
|
+
const room = paneWidth - 2 - 3 - 3; // the borders, " ◆ " and " + "
|
|
97
|
+
const sizeOf = (t) => tabText(t).length + TAB_CLOSE.length + 1;
|
|
98
|
+
// Short of room, names are shortened first (down to a few letters each), so every tab stays in view;
|
|
99
|
+
// only then do the ones that still don't fit go behind ‹ ›.
|
|
100
|
+
let tabs = all;
|
|
101
|
+
for (let max = 14; max >= 6 && tabs.reduce((n, t) => n + sizeOf(t), 0) > room; max--) {
|
|
102
|
+
tabs = all.map((t) => (t.label.length > max ? { ...t, label: `${t.label.slice(0, max - 1)}…` } : t));
|
|
103
|
+
}
|
|
104
|
+
const sizes = tabs.map(sizeOf);
|
|
105
|
+
const { from, to } = fitAround(sizes, Math.max(0, tabs.findIndex((t) => t.id === active)), room, TAB_PREV.length + TAB_NEXT.length);
|
|
94
106
|
let x = 3; // " ◆ "
|
|
107
|
+
const zones = [];
|
|
108
|
+
if (from > 0) {
|
|
109
|
+
zones.push({ id: tabs[from - 1].id, from: x, to: x + TAB_PREV.length });
|
|
110
|
+
x += TAB_PREV.length;
|
|
111
|
+
}
|
|
95
112
|
// Each tab, then its × (which closes it).
|
|
96
|
-
const
|
|
113
|
+
for (const t of tabs.slice(from, to)) {
|
|
97
114
|
const w = tabText(t).length;
|
|
98
|
-
|
|
115
|
+
zones.push({ id: t.id, from: x, to: x + w }, { id: t.id, from: x + w, to: x + w + TAB_CLOSE.length, close: true });
|
|
99
116
|
x += w + TAB_CLOSE.length + 1;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
117
|
+
}
|
|
118
|
+
if (to < tabs.length) {
|
|
119
|
+
zones.push({ id: tabs[to].id, from: x, to: x + TAB_NEXT.length });
|
|
120
|
+
x += TAB_NEXT.length;
|
|
121
|
+
}
|
|
122
|
+
return { zones, plus: x + 1, width: x + 3, from, to, tabs };
|
|
103
123
|
}
|
|
124
|
+
const TAB_PREV = "‹ ";
|
|
125
|
+
const TAB_NEXT = "› ";
|
|
104
126
|
const TAB_CLOSE = "× ";
|
|
105
127
|
const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
|
|
106
128
|
/** Characters of a word for a double-click: paths and web addresses come whole (src/a.ts, https://x.io/y). */
|
|
107
129
|
const WORD_CHAR = /[\p{L}\p{N}_.\-/@:~#%?=&+]/u;
|
|
108
130
|
const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
|
|
109
|
-
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheelPane, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [], sendNowFirst = false, onSendNow }) {
|
|
131
|
+
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheelPane, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [], sendNowFirst = false, onSendNow, onSelecting }) {
|
|
110
132
|
const wheel = usePaneWheel(wheelPane);
|
|
111
133
|
const [input, setInputText] = useState("");
|
|
112
134
|
// Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
|
|
@@ -343,6 +365,17 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
343
365
|
setTimeout(() => setFlash(null), 2000);
|
|
344
366
|
});
|
|
345
367
|
};
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
onSelecting?.(!!selection);
|
|
370
|
+
}, [!!selection]);
|
|
371
|
+
// Ctrl+C with text highlighted copies it (again), as it does everywhere else, and clears the highlight:
|
|
372
|
+
// it doesn't stop Nou. With nothing highlighted, Ctrl+C stops Nou as before.
|
|
373
|
+
useInput((input, key) => {
|
|
374
|
+
if (!key.ctrl || input !== "c" || !selection)
|
|
375
|
+
return;
|
|
376
|
+
copySelection(selection);
|
|
377
|
+
setSelection(null);
|
|
378
|
+
}, { isActive: !!selection });
|
|
346
379
|
// Typing or new messages leave the selection be; a new conversation clears it.
|
|
347
380
|
useEffect(() => {
|
|
348
381
|
if (!entries.length)
|
|
@@ -404,7 +437,8 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
404
437
|
? line.spans.map((span, i) => (_jsx(Text, { bold: !!span.bold || !!line.bold, italic: !!span.italic, dimColor: !!span.dim && !span.link, underline: !!span.link, ...(span.link ? { color: C.cyan } : span.code ? { color: C.yellow } : line.color ? { color: line.color } : { color: C.text }), children: span.text }, i)))
|
|
405
438
|
: _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
|
|
406
439
|
const empty = entries.length === 0 && !streaming;
|
|
407
|
-
const
|
|
440
|
+
const strip = tabs ? chatTabZones(tabs.items, tabs.active, width) : { from: 0, to: 0, tabs: [] };
|
|
441
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " ", tabs && tabs.items.length > 1 ? (_jsxs(_Fragment, { children: [strip.from > 0 ? _jsx(Text, { color: C.accent2, children: TAB_PREV }) : null, strip.tabs.slice(strip.from, strip.to).map((t) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.id === tabs.active ? { backgroundColor: C.panel, color: C.text, bold: true } : { color: t.state === "waiting" ? C.yellow : C.dim }), children: tabText(t) }), _jsx(Text, { color: C.dim, ...(t.id === tabs.active ? { backgroundColor: C.panel } : {}), children: TAB_CLOSE }), " "] }, t.id))), strip.to < tabs.items.length ? _jsx(Text, { color: C.accent2, children: TAB_NEXT }) : null, _jsx(Text, { color: C.accent2, bold: true, children: " + " })] })) : _jsxs(_Fragment, { children: ["Chat", tabs ? _jsx(Text, { color: C.accent2, bold: true, children: " +" }) : null] }), flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
|
|
408
442
|
? unread
|
|
409
443
|
? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
|
410
444
|
: _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -5,50 +5,77 @@ import { Box, Text, useAnimation } from "ink";
|
|
|
5
5
|
import { C, gradient, ANIMATION_MS } from "./theme.js";
|
|
6
6
|
import { useTerminalFocused } from "./focus.js";
|
|
7
7
|
import { currentVersion } from "../update/index.js";
|
|
8
|
+
import { fitAround } from "./Tabs.js";
|
|
8
9
|
const VERSION = `v${currentVersion()}`;
|
|
9
10
|
/** What comes before the project (or the workspace tabs): " ◆ NOU v1.2.3 │ ". */
|
|
10
11
|
const LEAD = ` ◆ NOU ${VERSION} │ `;
|
|
11
12
|
const tabLabel = (t, i) => ` ${i + 1} ${t.name}${t.busy ? " ●" : ""} `;
|
|
12
13
|
/** Each workspace tab ends with a × that closes it. */
|
|
13
14
|
const CLOSE = "× ";
|
|
15
|
+
/** The markers for workspace tabs that don't fit: click to go to the one before or after. */
|
|
16
|
+
const PREV = "‹ ";
|
|
17
|
+
const NEXT = "› ";
|
|
14
18
|
/**
|
|
15
|
-
*
|
|
16
|
-
* (
|
|
19
|
+
* The header's right side: the update notice (when there's room), where the model runs and which. Plain
|
|
20
|
+
* characters only: symbols that are also emoji (☁, ⬆) are drawn two columns wide by some terminals.
|
|
21
|
+
*/
|
|
22
|
+
const rightSide = (width, model, local, update) => ({
|
|
23
|
+
notice: update && width >= 110 ? `↑ v${update} available · run \`nou update\` ` : "",
|
|
24
|
+
badge: ` ${local ? "LOCAL" : "CLOUD"} `,
|
|
25
|
+
model: ` ${model} `,
|
|
26
|
+
});
|
|
27
|
+
/** Columns the left side of the header gets: what the right leaves, less a margin. */
|
|
28
|
+
const leftWidth = (width, model, local, update) => {
|
|
29
|
+
const right = rightSide(width, model, local, update);
|
|
30
|
+
return Math.max(0, width - (right.notice.length + right.badge.length + right.model.length + 1) - 4);
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Which workspace tabs the header shows, around the active one: as many as fit, with ‹ › for the rest (the
|
|
34
|
+
* + always shows). The drawing and the clicks both use it.
|
|
35
|
+
*/
|
|
36
|
+
export function headerLayout(tabs, width, model, local, update) {
|
|
37
|
+
const room = leftWidth(width, model, local, update) - LEAD.length - 3 - 2; // " + " and a little air
|
|
38
|
+
const sizes = tabs.map((t, i) => tabLabel(t, i).length + CLOSE.length + 1);
|
|
39
|
+
const { from, to } = fitAround(sizes, tabs.findIndex((t) => t.active), room, PREV.length + NEXT.length);
|
|
40
|
+
return { from, to, before: from > 0, after: to < tabs.length };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Where each workspace tab, its ×, the ‹ › and the + that opens one sit in the header row: for clicks. With
|
|
44
|
+
* one workspace (not hosted) there's just the project's name and the +.
|
|
17
45
|
*/
|
|
18
|
-
export function headerZones(tabs) {
|
|
46
|
+
export function headerZones(tabs, width = 10_000, model = "", local = true, update = null, project = path.basename(process.cwd())) {
|
|
19
47
|
const zones = [];
|
|
20
48
|
let x = LEAD.length;
|
|
21
49
|
if (!tabs.length) {
|
|
22
|
-
x +=
|
|
50
|
+
x += project.length + 1;
|
|
23
51
|
}
|
|
24
52
|
else {
|
|
25
|
-
tabs
|
|
26
|
-
|
|
53
|
+
const { from, to, before, after } = headerLayout(tabs, width, model, local, update);
|
|
54
|
+
if (before) {
|
|
55
|
+
zones.push({ from: x, to: x + PREV.length, tab: from - 1 });
|
|
56
|
+
x += PREV.length;
|
|
57
|
+
}
|
|
58
|
+
for (let i = from; i < to; i++) {
|
|
59
|
+
const w = tabLabel(tabs[i], i).length;
|
|
27
60
|
zones.push({ from: x, to: x + w, tab: i });
|
|
28
61
|
zones.push({ from: x + w, to: x + w + CLOSE.length, tab: i, close: true });
|
|
29
62
|
x += w + CLOSE.length + 1;
|
|
30
|
-
}
|
|
63
|
+
}
|
|
64
|
+
if (after) {
|
|
65
|
+
zones.push({ from: x, to: x + NEXT.length, tab: to });
|
|
66
|
+
x += NEXT.length;
|
|
67
|
+
}
|
|
31
68
|
}
|
|
32
69
|
zones.push({ from: x, to: x + 3, tab: "new" });
|
|
33
70
|
return zones;
|
|
34
71
|
}
|
|
35
|
-
|
|
36
|
-
* The header's right side: the update notice (when there's room), where the model runs and which. Plain
|
|
37
|
-
* characters only: symbols that are also emoji (☁, ⬆) are drawn two columns wide by some terminals.
|
|
38
|
-
*/
|
|
39
|
-
const rightSide = (width, model, local, update) => ({
|
|
40
|
-
notice: update && width >= 110 ? `↑ v${update} available · run \`nou update\` ` : "",
|
|
41
|
-
badge: ` ${local ? "LOCAL" : "CLOUD"} `,
|
|
42
|
-
model: ` ${model} `,
|
|
43
|
-
});
|
|
44
|
-
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update, tabs = [] }) {
|
|
72
|
+
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update, tabs = [], project = path.basename(process.cwd()) }) {
|
|
45
73
|
// The spinner stops while Nou's window is in the background: nobody sees it, and each frame is a redraw.
|
|
46
74
|
const windowFocused = useTerminalFocused();
|
|
47
75
|
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
|
|
48
|
-
const project = path.basename(process.cwd());
|
|
49
76
|
const right = rightSide(width, model, local, update);
|
|
50
|
-
const
|
|
51
|
-
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { width:
|
|
52
|
-
? tabs.map((t,
|
|
77
|
+
const shown = headerLayout(tabs, width, model, local, update);
|
|
78
|
+
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { width: leftWidth(width, model, local, update), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), tabs.length
|
|
79
|
+
? _jsxs(_Fragment, { children: [shown.before ? _jsx(Text, { color: C.accent, children: PREV }) : null, tabs.slice(shown.from, shown.to).map((t, j) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: t.active ? C.accent : C.text, bold: t.active, children: tabLabel(t, shown.from + j) }), _jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: C.dim, children: CLOSE }), " "] }, t.id))), shown.after ? _jsx(Text, { color: C.accent, children: NEXT }) : null] })
|
|
53
80
|
: _jsxs(Text, { color: C.text, children: [project, " "] }), _jsx(Text, { color: C.accent2, children: " + " }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] }))] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [right.notice && _jsx(Text, { color: C.green, bold: true, children: right.notice }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: C.onBadge, bold: true, children: right.badge }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: right.model }), " "] }) })] }));
|
|
54
81
|
});
|
package/dist/src/ui/Tabs.js
CHANGED
|
@@ -11,6 +11,33 @@ function labels(tabs) {
|
|
|
11
11
|
return twin ? `${path.posix.basename(path.posix.dirname(file))}/${name}` : name;
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Which of a row of tabs fit in `room` columns, kept around the active one: grows right, then left, while
|
|
16
|
+
* they fit, keeping `markers` columns for the ‹ › that stand for the ones left out. Returns [from, to).
|
|
17
|
+
*/
|
|
18
|
+
export function fitAround(sizes, active, room, markers = 2) {
|
|
19
|
+
const at = Math.max(0, Math.min(sizes.length - 1, active));
|
|
20
|
+
if (!sizes.length)
|
|
21
|
+
return { from: 0, to: 0 };
|
|
22
|
+
let from = at;
|
|
23
|
+
let to = at + 1;
|
|
24
|
+
let used = sizes[at];
|
|
25
|
+
const total = sizes.reduce((a, b) => a + b, 0);
|
|
26
|
+
if (total <= room)
|
|
27
|
+
return { from: 0, to: sizes.length };
|
|
28
|
+
for (let grew = true; grew;) {
|
|
29
|
+
grew = false;
|
|
30
|
+
if (to < sizes.length && used + sizes[to] <= room - markers) {
|
|
31
|
+
used += sizes[to++];
|
|
32
|
+
grew = true;
|
|
33
|
+
}
|
|
34
|
+
if (from > 0 && used + sizes[from - 1] <= room - markers) {
|
|
35
|
+
used += sizes[--from];
|
|
36
|
+
grew = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { from, to };
|
|
40
|
+
}
|
|
14
41
|
/**
|
|
15
42
|
* Lays out the tabs that fit in `width` columns, keeping the active one in view: the ones that don't fit
|
|
16
43
|
* are left out at the ends, marked with ‹ ›.
|
package/dist/src/ui/commands.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Commands that only change what's on screen (or handle a busy Nou themselves): they run right away
|
|
3
3
|
* while Nou works instead of waiting in the queue behind it.
|
|
4
4
|
*/
|
|
5
|
-
const INSTANT = new Set(["follow", "files", "actions", "palette", "zoom", "now", "fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
|
|
5
|
+
const INSTANT = new Set(["rename", "follow", "files", "actions", "palette", "zoom", "now", "fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
|
|
6
6
|
/** Whether a typed command runs right away while Nou works (see INSTANT); `/showme` counts only to look or step. */
|
|
7
7
|
/**
|
|
8
8
|
* The command a mistyped name means: the same letters in another order ("/termnial"), or one letter off
|
|
@@ -51,9 +51,10 @@ export const SLASH_COMMANDS = [
|
|
|
51
51
|
{ name: "models", args: "[provider]", description: "list the models a provider offers" },
|
|
52
52
|
{ name: "connect", args: "<provider>", description: "enter an API key (and URL) for a provider" },
|
|
53
53
|
{ name: "open", args: "<file>[:line]", description: "open a file in the code view (part of its name is enough)" },
|
|
54
|
+
{ name: "rename", args: "[name]", description: "name this chat's tab (alone: back to its first message)" },
|
|
54
55
|
{ name: "follow", args: "[on|off]", description: "the code view shows each file the agent reads or edits as it works" },
|
|
55
56
|
{ name: "effort", args: "[fastest|fast|balanced|smarter|smartest]", description: "speed vs smarts: how hard the model thinks (cheaper + faster, or smarter)" },
|
|
56
|
-
{ name: "workspace", args: "[new <folder> | n]", description: "several projects in this window, a tab each in the header: open one, or switch" },
|
|
57
|
+
{ name: "workspace", args: "[new <folder> | n | rename <name>]", description: "several projects in this window, a tab each in the header: open one, or switch" },
|
|
57
58
|
{ name: "files", description: "find a file by part of its name (also Ctrl+P)" },
|
|
58
59
|
{ name: "actions", description: "every action Nou has, searchable, with its key (also ?)" },
|
|
59
60
|
{ name: "zoom", args: "in|out|reset", description: "make the code panel bigger or smaller (+ and - with the code focused, = resets)" },
|
|
@@ -33,7 +33,7 @@ export async function runHost(folders, firstArgs, command = [process.execPath, p
|
|
|
33
33
|
let nextId = 1;
|
|
34
34
|
const size = () => ({ cols: process.stdout.columns || 120, rows: process.stdout.rows || 40 });
|
|
35
35
|
const send = (w, msg) => w.socket?.write(`${JSON.stringify(msg)}\n`);
|
|
36
|
-
const tabs = () => workspaces.map((w) => ({ id: w.id, name: tabName(w.folder), folder: w.folder, busy: w.busy, active: w === active }));
|
|
36
|
+
const tabs = () => workspaces.map((w) => ({ id: w.id, name: w.name || tabName(w.folder), folder: w.folder, busy: w.busy, active: w === active }));
|
|
37
37
|
const broadcast = () => {
|
|
38
38
|
const list = tabs();
|
|
39
39
|
for (const w of workspaces)
|
|
@@ -63,6 +63,10 @@ export async function runHost(folders, firstArgs, command = [process.execPath, p
|
|
|
63
63
|
from.busy = msg.busy;
|
|
64
64
|
broadcast();
|
|
65
65
|
}
|
|
66
|
+
else if (msg.type === "rename") {
|
|
67
|
+
from.name = msg.name.trim().slice(0, 30) || undefined;
|
|
68
|
+
broadcast();
|
|
69
|
+
}
|
|
66
70
|
else if (msg.type === "switch") {
|
|
67
71
|
const to = workspaces[msg.index];
|
|
68
72
|
if (to && to !== active)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.19",
|
|
4
4
|
"description": "Nou: an IDE-style AI coding assistant for your terminal. Local (Ollama, LM Studio) and cloud (Claude, OpenAI, Gemini...) models, live diffs, git-aware, built-in editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|