nou-cli 0.5.12 → 0.5.14
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 +6 -6
- package/dist/src/games/index.js +6 -6
- package/dist/src/ui/App.js +117 -26
- package/dist/src/ui/Chat.js +19 -16
- package/dist/src/ui/CodeView.js +4 -3
- package/dist/src/ui/Editor.js +4 -3
- package/dist/src/ui/FileTree.js +3 -2
- package/dist/src/ui/Header.js +5 -2
- package/dist/src/ui/ProblemsView.js +4 -2
- package/dist/src/ui/QuickOpen.js +4 -2
- package/dist/src/ui/Settings.js +7 -7
- package/dist/src/ui/Terminal.js +3 -1
- package/dist/src/ui/commands.js +11 -1
- package/dist/src/ui/fast-width.js +5 -0
- package/dist/src/ui/keys.js +3 -4
- package/dist/src/ui/line-update-shim.js +55 -0
- package/dist/src/ui/scroll.js +27 -0
- package/dist/src/ui/text.js +1 -1
- package/dist/src/ui/theme.js +1 -1
- package/dist/src/workspaces/client.js +2 -0
- package/dist/src/workspaces/host.js +11 -0
- 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 **+** (or `/workspace new ../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. `/close` closes one; 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). 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`. |
|
|
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. |
|
|
@@ -69,7 +69,7 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
|
|
|
69
69
|
| 🎚️ **Speed vs smarts** | Pick how hard the model thinks in plain words: **Fastest** and **Fast** (cheaper + faster), **Balanced** (the default), **Smarter** and **Smartest** (thinks longer, costs more). In <kbd>s</kbd> settings, or `/effort smarter`. |
|
|
70
70
|
| 🛞 **Pick your agent** | Nou's own, or Claude Code, Codex or pi, switchable like models, with the same diffs, approvals and undo. |
|
|
71
71
|
| 🧩 **Plugins** | Add your own tools and slash commands with a single file. |
|
|
72
|
-
| 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> fuzzy-finds any file. |
|
|
72
|
+
| 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> (or `/files`) fuzzy-finds any file; <kbd>?</kbd> (or `/actions`) lists every action. |
|
|
73
73
|
| 🔋 **Easy on your battery** | Idle, Nou uses next to no CPU. When its window is in the background (in terminals that report focus: iTerm2, Terminal.app, kitty, Ghostty, WezTerm, Windows Terminal, VS Code), spinners and git polling pause until you come back, and TypeScript checks are incremental. |
|
|
74
74
|
| 👓 **Easy to read** | Hints and secondary text keep at least 4.5:1 contrast. Nou asks your terminal whether its background is light or dark and picks matching colors (code switches to GitHub Light on a light background); set it yourself in <kbd>s</kbd> settings → **Nou's colors**. |
|
|
75
75
|
|
|
@@ -94,7 +94,7 @@ Every change is reviewed like a code review:
|
|
|
94
94
|
| <kbd>Tab</kbd> | switch between files, code and chat |
|
|
95
95
|
| <kbd>Shift</kbd>+<kbd>Tab</kbd> | ask → auto-edit (edits apply without asking) → auto (commands too); everything stays undoable |
|
|
96
96
|
| <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file; the files you've opened are listed first |
|
|
97
|
-
| <kbd>Ctrl</kbd>+<kbd>=</kbd> / <kbd>Ctrl</kbd>+<kbd>-</kbd> from anywhere (or <kbd>+</kbd> / <kbd>-</kbd> in the code or files) |
|
|
97
|
+
| <kbd>Ctrl</kbd>+<kbd>=</kbd> / <kbd>Ctrl</kbd>+<kbd>-</kbd> from anywhere (or <kbd>+</kbd> / <kbd>-</kbd> in the code or files) | widen or narrow the code panel, a step at a time: the files and chat give or take the room (a terminal can't change one pane's text size); <kbd>Ctrl</kbd>+<kbd>0</kbd> or <kbd>=</kbd> puts it back. Ctrl+= and Ctrl+- need a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, iTerm2). For bigger text, use your terminal's own zoom |
|
|
98
98
|
| <kbd>b</kbd> | back to the previous file (press again to flip back) |
|
|
99
99
|
| <kbd>Ctrl</kbd>+<kbd>F</kbd> (<kbd>Cmd</kbd>+<kbd>F</kbd> on a Mac, in terminals that report Cmd: Ghostty, kitty, WezTerm, iTerm2) | find in the open file, from any pane: matches are highlighted and counted; <kbd>Enter</kbd>/<kbd>↓</kbd> next, <kbd>↑</kbd> previous, <kbd>Esc</kbd> close |
|
|
100
100
|
| 🗂️ Tabs | every file you open gets a tab over the code: click one to switch, any tab's × to close it, ‹ › for ones that don't fit · <kbd>[</kbd>/<kbd>]</kbd> previous/next · <kbd>x</kbd> or <kbd>Ctrl</kbd>+<kbd>W</kbd> close |
|
|
@@ -113,7 +113,7 @@ Every change is reviewed like a code review:
|
|
|
113
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) |
|
|
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
|
-
| <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
|
|
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) |
|
|
117
117
|
| <kbd>Esc</kbd> | stop Nou (anything queued goes back in the input) · close a diff · back to chat |
|
|
118
118
|
| <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
|
|
119
119
|
|
|
@@ -131,7 +131,7 @@ 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]` · `/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]` · `/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
|
|
package/dist/src/games/index.js
CHANGED
|
@@ -221,7 +221,7 @@ export const breakout = {
|
|
|
221
221
|
return {
|
|
222
222
|
lines: grid.map((r) => r.join("")),
|
|
223
223
|
score: s.score,
|
|
224
|
-
status: `level ${s.level} · score ${s.score} · lives ${"
|
|
224
|
+
status: `level ${s.level} · score ${s.score} · lives ${"◆".repeat(Math.max(0, s.lives))}`,
|
|
225
225
|
...(s.lives <= 0 ? { over: `Game over: ${s.score} points, level ${s.level}.` } : {}),
|
|
226
226
|
};
|
|
227
227
|
},
|
|
@@ -230,13 +230,13 @@ export const breakout = {
|
|
|
230
230
|
export const dodge = {
|
|
231
231
|
id: "dodge",
|
|
232
232
|
title: "Dodge",
|
|
233
|
-
blurb: "Rocks fall faster and thicker the longer you last. Catch
|
|
233
|
+
blurb: "Rocks fall faster and thicker the longer you last. Catch ✦ for points.",
|
|
234
234
|
controls: "←→ or a/d move",
|
|
235
235
|
tickMs: 90,
|
|
236
236
|
minWidth: 12,
|
|
237
237
|
minHeight: 6,
|
|
238
238
|
speed: (s) => Math.max(45, 90 - Math.floor(s.ticks / 60) * 5),
|
|
239
|
-
colors: { "▲": "#61afef", "●": "#d19a66", "
|
|
239
|
+
colors: { "▲": "#61afef", "●": "#d19a66", "✦": "#e5c07b" },
|
|
240
240
|
create(w, h, seed = Date.now()) {
|
|
241
241
|
[w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
|
|
242
242
|
return { w, h, x: Math.floor(w / 2), rocks: [], stars: [], score: 0, ticks: 0, dead: false, random: rng(seed) };
|
|
@@ -271,7 +271,7 @@ export const dodge = {
|
|
|
271
271
|
for (const r of s.rocks)
|
|
272
272
|
put(grid, r.x, r.y, "●");
|
|
273
273
|
for (const r of s.stars)
|
|
274
|
-
put(grid, r.x, r.y, "
|
|
274
|
+
put(grid, r.x, r.y, "✦");
|
|
275
275
|
put(grid, s.x, s.h - 1, "▲");
|
|
276
276
|
return { lines: grid.map((r) => r.join("")), score: s.score, status: `score ${s.score}`, ...(s.dead ? { over: `Hit! ${s.score} points.` } : {}) };
|
|
277
277
|
},
|
|
@@ -322,7 +322,7 @@ export const hop = {
|
|
|
322
322
|
return { lines: grid.map((r) => r.join("")), score: s.score, status: `walls hopped ${s.score}`, ...(s.dead ? { over: `Bonk! ${s.score} hopped.` } : {}) };
|
|
323
323
|
},
|
|
324
324
|
};
|
|
325
|
-
const SYMBOLS = ["◆", "●", "▲", "■", "
|
|
325
|
+
const SYMBOLS = ["◆", "●", "▲", "■", "✦", "◈", "⊕", "⊗", "◎", "△", "○", "✧"];
|
|
326
326
|
/** Cards face down: flip two at a time and find every matching pair in as few moves as you can. */
|
|
327
327
|
export const pairs = {
|
|
328
328
|
id: "pairs",
|
|
@@ -332,7 +332,7 @@ export const pairs = {
|
|
|
332
332
|
tickMs: 100,
|
|
333
333
|
minWidth: 16,
|
|
334
334
|
minHeight: 5,
|
|
335
|
-
colors: { "◆": "#e06c75", "●": "#e5c07b", "▲": "#61afef", "■": "#98c379", "
|
|
335
|
+
colors: { "◆": "#e06c75", "●": "#e5c07b", "▲": "#61afef", "■": "#98c379", "✦": "#e5c07b", "◈": "#e06c75", "⊕": "#98c379", "⊗": "#c678dd", "◎": "#d19a66", "△": "#56b6c2", "○": "#c678dd", "✧": "#56b6c2", "▒": "#5c6370", "▸": "#e5c07b", "◂": "#e5c07b" },
|
|
336
336
|
create(w, h, seed = Date.now()) {
|
|
337
337
|
[w, h] = [Math.max(w, this.minWidth), Math.max(h, this.minHeight)];
|
|
338
338
|
// As many cards as fit (each 4 wide, rows 2 apart), an even number, up to every symbol twice.
|
package/dist/src/ui/App.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
4
|
+
import os from "os";
|
|
4
5
|
import path from "path";
|
|
5
6
|
import { Box, Text, useApp, useInput, useWindowSize } from "ink";
|
|
6
7
|
import { nouRulesOnly } from "../context/index.js";
|
|
@@ -34,8 +35,8 @@ import { FIND_KEY, isFind } from "./keys.js";
|
|
|
34
35
|
import { FileTree } from "./FileTree.js";
|
|
35
36
|
import { CodeView } from "./CodeView.js";
|
|
36
37
|
import { Editor } from "./Editor.js";
|
|
37
|
-
import { SLASH_COMMANDS, closestCommand, onlyChangesScreen, runsWhileBusy } from "./commands.js";
|
|
38
|
-
import { WHEEL_STEP } from "./scroll.js";
|
|
38
|
+
import { SLASH_COMMANDS, closestCommand, finishAfter, onlyChangesScreen, runsWhileBusy } from "./commands.js";
|
|
39
|
+
import { WHEEL_STEP, sendWheel } from "./scroll.js";
|
|
39
40
|
import { Chat, chatTabZones } from "./Chat.js";
|
|
40
41
|
import { copyToClipboard, openInBrowser } from "./clipboard.js";
|
|
41
42
|
import { Header, headerZones } from "./Header.js";
|
|
@@ -124,6 +125,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
124
125
|
const [quickOpen, setQuickOpen] = useState(null);
|
|
125
126
|
/** /resume's picker: the conversations, in the palette's place while it's open. */
|
|
126
127
|
const [resumePicker, setResumePicker] = useState(null);
|
|
128
|
+
/** What the open picker is for, said in its empty search box. */
|
|
129
|
+
const [pickerTitle, setPickerTitle] = useState("Resume a conversation");
|
|
127
130
|
const [focus, setFocus] = useState("chat");
|
|
128
131
|
/** The Games panel: whether it's open, the pane it's being dragged over, and the pane to go back to. */
|
|
129
132
|
const [gamesOpen, setGamesOpen] = useState(false);
|
|
@@ -245,7 +248,6 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
245
248
|
const [pendingNote, setPendingNote] = useState(null);
|
|
246
249
|
const [exitArmed, setExitArmed] = useState(false);
|
|
247
250
|
const [update, setUpdate] = useState(null);
|
|
248
|
-
const [wheel, setWheel] = useState(null);
|
|
249
251
|
const [scrollTo, setScrollTo] = useState(null);
|
|
250
252
|
const [editorMouse, setEditorMouse] = useState([]);
|
|
251
253
|
/** Wheel events waiting for the next frame, added up per pane. */
|
|
@@ -284,6 +286,16 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
284
286
|
* wider (taken from the files and chat) or narrower (given to them). 0 is the normal layout.
|
|
285
287
|
*/
|
|
286
288
|
const [codeZoom, setCodeZoom] = useState(0);
|
|
289
|
+
// A resized terminal is drawn again in full: drawing only the lines that changed, over a screen the
|
|
290
|
+
// terminal has rewrapped, can leave pieces of the old layout behind.
|
|
291
|
+
const firstSize = useRef(true);
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (firstSize.current) {
|
|
294
|
+
firstSize.current = false;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
redraw();
|
|
298
|
+
}, [columns, rows]);
|
|
287
299
|
const zoomCode = (by) => setCodeZoom((z) => (by === 0 ? 0 : Math.max(-6, Math.min(8, z + by))));
|
|
288
300
|
const paneWidthsRef = useRef(paneWidths);
|
|
289
301
|
paneWidthsRef.current = paneWidths;
|
|
@@ -462,10 +474,20 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
462
474
|
switchChatTab(id);
|
|
463
475
|
}
|
|
464
476
|
/** Closes the conversation tab on screen (stopping its engine); the conversation stays saved for /resume. */
|
|
465
|
-
function closeChatTab() {
|
|
477
|
+
function closeChatTab(id = activeTab.current) {
|
|
466
478
|
if (chatTabs.length < 2)
|
|
467
479
|
return info("That's the only conversation tab. /clear starts a fresh conversation in it.");
|
|
468
|
-
|
|
480
|
+
if (id !== activeTab.current) {
|
|
481
|
+
// A tab in the background (its ×): stop its agent and drop it; the one on screen stays.
|
|
482
|
+
const t = tabStore.current.get(id);
|
|
483
|
+
t?.approval?.resolve(false);
|
|
484
|
+
t?.agent.cancel();
|
|
485
|
+
t?.agent.dispose?.();
|
|
486
|
+
tabStore.current.delete(id);
|
|
487
|
+
tabHooks.current.delete(id);
|
|
488
|
+
setChatTabs((tabs) => tabs.filter((x) => x !== id));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
469
491
|
const at = chatTabs.indexOf(id);
|
|
470
492
|
const next = chatTabs[at + 1] ?? chatTabs[at - 1];
|
|
471
493
|
const agent = agentRef.current;
|
|
@@ -621,6 +643,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
621
643
|
redraw();
|
|
622
644
|
},
|
|
623
645
|
error: (text) => info(text, "error"),
|
|
646
|
+
quit: () => quit(),
|
|
624
647
|
});
|
|
625
648
|
}, []);
|
|
626
649
|
useEffect(() => {
|
|
@@ -694,6 +717,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
694
717
|
info(`Earlier conversations here (also in the picker: ↑↓ and Enter, or type a number here):\n${rows.join("\n")}`);
|
|
695
718
|
// A picker over the screen: ↑↓ and Enter, or type to filter by title.
|
|
696
719
|
setResumePicker(list.map((c, i) => ({ title: `${i + 1}. ${c.title}`, keys: `${where(c)} · ${ago(c.updated)}`, run: () => void resumePicked(c) })));
|
|
720
|
+
setPickerTitle("Resume a conversation · ↑↓ and Enter, or type its number");
|
|
697
721
|
setQuickOpen(">");
|
|
698
722
|
return;
|
|
699
723
|
}
|
|
@@ -880,7 +904,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
880
904
|
return;
|
|
881
905
|
const added = newProblems(before, after);
|
|
882
906
|
setPendingNote(added.length
|
|
883
|
-
? { short:
|
|
907
|
+
? { short: `▲ +${added.length} problem${added.length === 1 ? "" : "s"}`, detail: added.slice(0, 3).map((d) => formatDiagnostic(d)).join("\n") }
|
|
884
908
|
: { short: "✓ no new problems", detail: null });
|
|
885
909
|
})();
|
|
886
910
|
return () => {
|
|
@@ -907,6 +931,9 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
907
931
|
const tree = border === "tree" ? Math.min(Math.max(x + 1, MIN_TREE), columns - chatWidth - MIN_CODE) : treeWidth;
|
|
908
932
|
const chat = border === "chat" ? Math.min(Math.max(columns - x, MIN_CHAT), columns - treeWidth - MIN_CODE) : chatWidth;
|
|
909
933
|
const next = { tree: tree / columns, chat: chat / columns };
|
|
934
|
+
// Widths come from the panes as they are on screen (zoom included), so the zoom folds into them: the
|
|
935
|
+
// border lands where it's dropped instead of a zoom step away.
|
|
936
|
+
setCodeZoom(0);
|
|
910
937
|
setPaneWidths(next);
|
|
911
938
|
return next;
|
|
912
939
|
};
|
|
@@ -946,7 +973,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
946
973
|
const { pane } = q;
|
|
947
974
|
q.lines -= lines;
|
|
948
975
|
q.columns -= columns;
|
|
949
|
-
|
|
976
|
+
sendWheel(pane, lines, columns);
|
|
950
977
|
if (q.lines || q.columns)
|
|
951
978
|
q.timer = setTimeout(flushWheel, 16);
|
|
952
979
|
};
|
|
@@ -1081,9 +1108,9 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1081
1108
|
if (event.kind === "click" && event.y === 0) {
|
|
1082
1109
|
const zone = headerZones(workspaceTabs()).find((z) => event.x >= z.from && event.x < z.to);
|
|
1083
1110
|
if (zone?.tab === "new")
|
|
1084
|
-
return
|
|
1111
|
+
return pickWorkspaceFolder();
|
|
1085
1112
|
if (typeof zone?.tab === "number")
|
|
1086
|
-
return void sendToHost({ type: "switch", index: zone.tab });
|
|
1113
|
+
return void sendToHost({ type: zone.close ? "close" : "switch", index: zone.tab });
|
|
1087
1114
|
}
|
|
1088
1115
|
if (event.kind === "click" && event.y === paneHeight + 1)
|
|
1089
1116
|
return clickable.current.status.find((z) => event.x >= z.from && event.x < z.to)?.run();
|
|
@@ -1117,6 +1144,8 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1117
1144
|
if (Math.abs(at - plus) <= 1)
|
|
1118
1145
|
return newChatTab();
|
|
1119
1146
|
const zone = zones.find((z) => at >= z.from && at < z.to);
|
|
1147
|
+
if (zone?.close)
|
|
1148
|
+
return closeChatTab(zone.id);
|
|
1120
1149
|
if (zone)
|
|
1121
1150
|
return switchChatTab(zone.id);
|
|
1122
1151
|
}
|
|
@@ -1612,7 +1641,7 @@ ${snippet}
|
|
|
1612
1641
|
</editor_context>`;
|
|
1613
1642
|
};
|
|
1614
1643
|
const contextChip = openFile
|
|
1615
|
-
? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? "
|
|
1644
|
+
? `${openFile}${showEditor && editorState ? `:${editorState.line}${editorState.dirty ? " ± unsaved" : ""}` : viewport ? `:${viewport.top}-${viewport.bottom}` : ""}`
|
|
1616
1645
|
: null;
|
|
1617
1646
|
/** /mcp: list servers; add (a preset, a command, or a URL), remove, or import the ones from other tools. */
|
|
1618
1647
|
const mcpCommand = async ([action, name, ...more]) => {
|
|
@@ -1688,7 +1717,7 @@ ${snippet}
|
|
|
1688
1717
|
}
|
|
1689
1718
|
if (action)
|
|
1690
1719
|
return info("Usage: /plugins, /plugins add <name>, /plugins new <name>", "error");
|
|
1691
|
-
const mark = { on: "●", off: "○", changed: "
|
|
1720
|
+
const mark = { on: "●", off: "○", changed: "▲" };
|
|
1692
1721
|
const lines = allPlugins.map((p) => p.scope === "starter"
|
|
1693
1722
|
? ` + ${p.id.padEnd(16)} ${p.plugin?.description ?? ""}`
|
|
1694
1723
|
: ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✗ ${p.error}` : ""}`);
|
|
@@ -1862,6 +1891,11 @@ ${snippet}
|
|
|
1862
1891
|
if (/^(reset|0|off)$/.test(arg))
|
|
1863
1892
|
return zoomCode(0);
|
|
1864
1893
|
return info("/zoom in makes the code panel bigger, /zoom out smaller, /zoom reset puts it back (or + and - with the code focused, = to reset). The files and chat give or take the room. For bigger text, use your terminal's own zoom (Cmd/Ctrl and +).");
|
|
1894
|
+
case "files":
|
|
1895
|
+
return setQuickOpen("");
|
|
1896
|
+
case "actions":
|
|
1897
|
+
case "palette":
|
|
1898
|
+
return setQuickOpen(">");
|
|
1865
1899
|
case "workspace":
|
|
1866
1900
|
case "workspaces":
|
|
1867
1901
|
case "ws":
|
|
@@ -1983,6 +2017,17 @@ ${snippet}
|
|
|
1983
2017
|
agentRef.current?.cancel();
|
|
1984
2018
|
stopped.current = true;
|
|
1985
2019
|
};
|
|
2020
|
+
/** What Nou was working on when a message cut in (see interruptToSend), so it finishes that too. */
|
|
2021
|
+
const unfinished = useRef(null);
|
|
2022
|
+
const lastAsked = useRef(null);
|
|
2023
|
+
/** Stops Nou so what's queued goes now; the request it was on isn't dropped, it's handed back with it. */
|
|
2024
|
+
const interruptToSend = () => {
|
|
2025
|
+
// What the agent was last asked, unfinished work handed back included: cutting in twice keeps both.
|
|
2026
|
+
if (lastAsked.current)
|
|
2027
|
+
unfinished.current = lastAsked.current;
|
|
2028
|
+
stop();
|
|
2029
|
+
stopped.current = false; // what's queued is meant to go
|
|
2030
|
+
};
|
|
1986
2031
|
useEffect(() => {
|
|
1987
2032
|
if (busy || approval || !queued.length)
|
|
1988
2033
|
return;
|
|
@@ -1997,7 +2042,10 @@ ${snippet}
|
|
|
1997
2042
|
const isCommand = (text) => text.startsWith("/");
|
|
1998
2043
|
const take = isCommand(queued[0]) ? 1 : Math.max(1, queued.findIndex(isCommand) === -1 ? queued.length : queued.findIndex(isCommand));
|
|
1999
2044
|
setQueued(queued.slice(take));
|
|
2000
|
-
|
|
2045
|
+
const text = queued.slice(0, take).join("\n\n");
|
|
2046
|
+
const previous = unfinished.current;
|
|
2047
|
+
unfinished.current = null;
|
|
2048
|
+
send(text, previous && !isCommand(text) ? finishAfter(text, previous) : undefined);
|
|
2001
2049
|
}, [busy, approval, queued]);
|
|
2002
2050
|
// When a reply points at places in the code (Nou's engine with show_places, the others with a ```nou-show
|
|
2003
2051
|
// block, which is taken out of the reply), the first one opens highlighted and the rest are listed.
|
|
@@ -2016,7 +2064,7 @@ ${snippet}
|
|
|
2016
2064
|
if (!stops.length)
|
|
2017
2065
|
return;
|
|
2018
2066
|
const list = stops.map((s, i) => `${i + 1}. ${s.file}:${s.line}${s.end > s.line ? `-${s.end}` : ""}${s.note ? ` · ${s.note}` : ""}`).join("\n");
|
|
2019
|
-
info(
|
|
2067
|
+
info(`◉ ${stops.length === 1 ? "Here it is" : `${stops.length} places`}, open in the code view (click one to jump there${stops.length > 1 ? "; /showme next and /showme back step through them" : ""}; /showme stop clears the highlights):\n${list}`);
|
|
2020
2068
|
setTour({ stops, at: 0 });
|
|
2021
2069
|
}, [busy]);
|
|
2022
2070
|
// Each place: open it in the code view with its lines highlighted.
|
|
@@ -2043,7 +2091,7 @@ ${snippet}
|
|
|
2043
2091
|
if (at < 0 || at >= tour.stops.length)
|
|
2044
2092
|
return info(at < 0 ? "That's the first place." : "That was the last place.");
|
|
2045
2093
|
const stop = tour.stops[at];
|
|
2046
|
-
info(
|
|
2094
|
+
info(`◉ ${at + 1} of ${tour.stops.length}: ${stop.file}:${stop.line}${stop.end > stop.line ? `-${stop.end}` : ""}${stop.note ? ` · ${stop.note}` : ""}`);
|
|
2047
2095
|
return setTour({ ...tour, at });
|
|
2048
2096
|
}
|
|
2049
2097
|
if (!words)
|
|
@@ -2093,7 +2141,7 @@ ${snippet}
|
|
|
2093
2141
|
writeFileSync(step.file, "");
|
|
2094
2142
|
setTreeVersion((v) => v + 1);
|
|
2095
2143
|
}
|
|
2096
|
-
info(
|
|
2144
|
+
info(`▸ Step ${guide.at + 1} of ${guide.steps.length}: ${step.file}:${step.line}\n${step.why}\n\n\`\`\`\n${step.code}\n\`\`\``);
|
|
2097
2145
|
openAt(step.file, step.line, true);
|
|
2098
2146
|
}, [guide?.at, guide?.steps]);
|
|
2099
2147
|
const guideStep = guide?.steps[guide.at] ?? null;
|
|
@@ -2111,7 +2159,7 @@ ${snippet}
|
|
|
2111
2159
|
setGuide({ ...guide, steps, at: guide.at + 1 });
|
|
2112
2160
|
}
|
|
2113
2161
|
else {
|
|
2114
|
-
info(
|
|
2162
|
+
info(`✓ That's it: all ${steps.length} step${steps.length > 1 ? "s" : ""} done and saved. Ask Nou to check it over if you like.`);
|
|
2115
2163
|
setGuide(null);
|
|
2116
2164
|
}
|
|
2117
2165
|
});
|
|
@@ -2152,8 +2200,7 @@ ${snippet}
|
|
|
2152
2200
|
const sendNow = () => {
|
|
2153
2201
|
if (!queued.length)
|
|
2154
2202
|
return;
|
|
2155
|
-
|
|
2156
|
-
stopped.current = false; // what's queued is meant to go
|
|
2203
|
+
interruptToSend();
|
|
2157
2204
|
};
|
|
2158
2205
|
/**
|
|
2159
2206
|
* From the chat: while Nou works, a message waits its turn, or (`now`: Alt+Enter) stops Nou and goes right
|
|
@@ -2164,14 +2211,14 @@ ${snippet}
|
|
|
2164
2211
|
return send(text);
|
|
2165
2212
|
const now = settings.sendNowWhileBusy ? !alt : alt;
|
|
2166
2213
|
if (now) {
|
|
2167
|
-
|
|
2168
|
-
stopped.current = false; // this one is meant to go
|
|
2214
|
+
interruptToSend();
|
|
2169
2215
|
setQueued((q) => [text, ...q]);
|
|
2170
2216
|
}
|
|
2171
2217
|
else
|
|
2172
2218
|
setQueued((q) => [...q, text]);
|
|
2173
2219
|
};
|
|
2174
|
-
|
|
2220
|
+
/** `forAgent`: what the agent gets instead of `text` (the chat still shows `text`). */
|
|
2221
|
+
const send = (text, forAgent) => {
|
|
2175
2222
|
// Right after /resume lists them, a number picks a conversation instead of being sent to the agent.
|
|
2176
2223
|
const picking = pickingResume.current;
|
|
2177
2224
|
pickingResume.current = false;
|
|
@@ -2188,7 +2235,8 @@ ${snippet}
|
|
|
2188
2235
|
return;
|
|
2189
2236
|
}
|
|
2190
2237
|
setEntries((prev) => [...prev, { kind: "user", text }]);
|
|
2191
|
-
|
|
2238
|
+
lastAsked.current = forAgent ?? text;
|
|
2239
|
+
void agentRef.current.send(forAgent ?? text, editorContext() ?? undefined);
|
|
2192
2240
|
};
|
|
2193
2241
|
function saveFromEditor(text, file = openFile) {
|
|
2194
2242
|
if (!file)
|
|
@@ -2311,7 +2359,6 @@ ${snippet}
|
|
|
2311
2359
|
const dockLeft = gameDock === "tree" ? 0 : gameDock === "code" ? treeWidth : treeWidth + codeWidth;
|
|
2312
2360
|
const dockWidth = gameDock === "tree" ? treeWidth : gameDock === "code" ? codeWidth : chatWidth;
|
|
2313
2361
|
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 };
|
|
2314
|
-
const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
|
|
2315
2362
|
const scrollFor = (pane) => (scrollTo?.pane === pane ? scrollTo : null);
|
|
2316
2363
|
const totals = diagnostics.counts();
|
|
2317
2364
|
const checking = diagnostics.busy;
|
|
@@ -2330,6 +2377,48 @@ ${snippet}
|
|
|
2330
2377
|
setRepaint((n) => n + 1);
|
|
2331
2378
|
}
|
|
2332
2379
|
/** /workspace: list them, `new [folder]` opens one, a number switches. */
|
|
2380
|
+
/**
|
|
2381
|
+
* + in the header (or /workspace new on its own): a picker of folders to open as a workspace: this project
|
|
2382
|
+
* again (a second workspace on the same code), then the projects beside it, latest first. Enter opens it.
|
|
2383
|
+
*/
|
|
2384
|
+
function pickWorkspaceFolder() {
|
|
2385
|
+
const here = process.cwd();
|
|
2386
|
+
const parent = path.dirname(here);
|
|
2387
|
+
const home = os.homedir();
|
|
2388
|
+
// Where each folder is, kept short (its end is what tells them apart) so the names keep their room.
|
|
2389
|
+
const short = (p) => {
|
|
2390
|
+
const shown = p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
2391
|
+
return shown.length > 22 ? `…${shown.slice(-21)}` : shown;
|
|
2392
|
+
};
|
|
2393
|
+
let beside = [];
|
|
2394
|
+
try {
|
|
2395
|
+
beside = readdirSync(parent, { withFileTypes: true })
|
|
2396
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith(".") && path.join(parent, d.name) !== here)
|
|
2397
|
+
.map((d) => {
|
|
2398
|
+
const full = path.join(parent, d.name);
|
|
2399
|
+
let time = 0;
|
|
2400
|
+
try {
|
|
2401
|
+
time = statSync(full).mtimeMs;
|
|
2402
|
+
}
|
|
2403
|
+
catch {
|
|
2404
|
+
// unreadable: listed last
|
|
2405
|
+
}
|
|
2406
|
+
return { path: full, time };
|
|
2407
|
+
})
|
|
2408
|
+
.sort((a, b) => b.time - a.time)
|
|
2409
|
+
.slice(0, 60);
|
|
2410
|
+
}
|
|
2411
|
+
catch {
|
|
2412
|
+
// the parent can't be listed: just this project, or type a path with /workspace new <folder>
|
|
2413
|
+
}
|
|
2414
|
+
const open = (folder) => () => void workspaceCommand(`new ${folder}`);
|
|
2415
|
+
setResumePicker([
|
|
2416
|
+
{ title: `${path.basename(here)} (this project)`, keys: short(here), run: open(here) },
|
|
2417
|
+
...beside.map((b) => ({ title: path.basename(b.path), keys: short(b.path), run: open(b.path) })),
|
|
2418
|
+
]);
|
|
2419
|
+
setPickerTitle("Open a folder as a new workspace · any other: /workspace new <path>");
|
|
2420
|
+
setQuickOpen(">");
|
|
2421
|
+
}
|
|
2333
2422
|
async function workspaceCommand(arg) {
|
|
2334
2423
|
const [sub, ...more] = arg.split(/\s+/);
|
|
2335
2424
|
const tabs = workspaceTabs();
|
|
@@ -2347,6 +2436,8 @@ ${snippet}
|
|
|
2347
2436
|
if (sub !== "new")
|
|
2348
2437
|
return info("Try /workspace new <folder>, /workspace <n> or /workspace.", "error");
|
|
2349
2438
|
const wanted = more.join(" ");
|
|
2439
|
+
if (!wanted)
|
|
2440
|
+
return pickWorkspaceFolder();
|
|
2350
2441
|
if (isHosted())
|
|
2351
2442
|
return void sendToHost({ type: "new", folder: wanted });
|
|
2352
2443
|
const folder = workspaceFolder(wanted, process.cwd());
|
|
@@ -2547,9 +2638,9 @@ ${snippet}
|
|
|
2547
2638
|
}
|
|
2548
2639
|
const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
|
|
2549
2640
|
clickable.current = { status: zones, modelFrom: columns - (" LOCAL ".length + ` ${modelLabel} `.length + 1) };
|
|
2550
|
-
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,
|
|
2641
|
+
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
|
|
2551
2642
|
? _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)` : ""] })
|
|
2552
|
-
: 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",
|
|
2643
|
+
: 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) => {
|
|
2553
2644
|
setQuickOpen(null);
|
|
2554
2645
|
clearFollow();
|
|
2555
2646
|
open(file);
|
package/dist/src/ui/Chat.js
CHANGED
|
@@ -10,7 +10,7 @@ import { C, SPINNER, ANIMATION_MS } from "./theme.js";
|
|
|
10
10
|
import { useTerminalFocused } from "./focus.js";
|
|
11
11
|
import { Pane } from "./Pane.js";
|
|
12
12
|
import { SLASH_COMMANDS, matchCommands } from "./commands.js";
|
|
13
|
-
import { Scrollbar, topFor } from "./scroll.js";
|
|
13
|
+
import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
|
|
14
14
|
/**
|
|
15
15
|
* What the agent did between replies (files read, commands run...) folds into one line, like Claude's
|
|
16
16
|
* "▸ 5 steps": click it (or Ctrl+O for all of them) to see each step. `open` holds the runs shown open.
|
|
@@ -18,8 +18,8 @@ import { Scrollbar, topFor } from "./scroll.js";
|
|
|
18
18
|
function stepLines(steps, open, live, inner) {
|
|
19
19
|
const last = steps[steps.length - 1].text.replace(/\s+/g, " ");
|
|
20
20
|
const summary = `${open ? "▾" : "▸"} ${steps.length} steps${open ? "" : ` · ${live ? "now: " : "last: "}${last}`}`;
|
|
21
|
-
const head = { text: ` ${summary.length > inner - 4 ? `${summary.slice(0, inner - 5)}…` : summary}`, color: open ? C.accent2 : C.dim, lead: "
|
|
22
|
-
return open ? [head, ...steps.flatMap((e) => cachedLines(e, inner).map((l) => (l.lead === "
|
|
21
|
+
const head = { text: ` ${summary.length > inner - 4 ? `${summary.slice(0, inner - 5)}…` : summary}`, color: open ? C.accent2 : C.dim, lead: " ◇", leadColor: C.dim, toggle: steps[0] };
|
|
22
|
+
return open ? [head, ...steps.flatMap((e) => cachedLines(e, inner).map((l) => (l.lead === " ◇" ? { ...l, lead: " " } : l)))] : [head];
|
|
23
23
|
}
|
|
24
24
|
const BAR = "▌ ";
|
|
25
25
|
// Whether a path in a message is a file in the project, remembered so each one is checked once.
|
|
@@ -62,8 +62,8 @@ function entryLines(entry, inner, links = true) {
|
|
|
62
62
|
}
|
|
63
63
|
else {
|
|
64
64
|
const style = entry.kind === "tool"
|
|
65
|
-
? { icon: "
|
|
66
|
-
: entry.kind === "error" ? { icon: "✗", color: C.red } : { icon: "
|
|
65
|
+
? { icon: "◇", color: C.dim }
|
|
66
|
+
: entry.kind === "error" ? { icon: "✗", color: C.red } : { icon: "ⓘ", color: C.accent };
|
|
67
67
|
wrap(entry.text, inner - 4).forEach((line, i) => {
|
|
68
68
|
const spans = links && entry.kind !== "user" ? linkFileRefs(linkUrls([{ text: ` ${line}` }]), isProjectFile) : [];
|
|
69
69
|
out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
|
|
@@ -91,19 +91,22 @@ export function chatTabZones(tabs) {
|
|
|
91
91
|
if (tabs.length === 1)
|
|
92
92
|
return { zones: [{ id: tabs[0].id, from: 3, to: 7 }], plus: 8, width: 9 };
|
|
93
93
|
let x = 3; // " ◆ "
|
|
94
|
-
|
|
94
|
+
// Each tab, then its × (which closes it).
|
|
95
|
+
const zones = tabs.flatMap((t) => {
|
|
95
96
|
const w = tabText(t).length;
|
|
96
|
-
const
|
|
97
|
-
x += w + 1;
|
|
98
|
-
return
|
|
97
|
+
const both = [{ id: t.id, from: x, to: x + w }, { id: t.id, from: x + w, to: x + w + TAB_CLOSE.length, close: true }];
|
|
98
|
+
x += w + TAB_CLOSE.length + 1;
|
|
99
|
+
return both;
|
|
99
100
|
});
|
|
100
101
|
return { zones, plus: x + 1, width: x + 3 };
|
|
101
102
|
}
|
|
103
|
+
const TAB_CLOSE = "× ";
|
|
102
104
|
const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
|
|
103
105
|
/** Characters of a word for a double-click: paths and web addresses come whole (src/a.ts, https://x.io/y). */
|
|
104
106
|
const WORD_CHAR = /[\p{L}\p{N}_.\-/@:~#%?=&+]/u;
|
|
105
107
|
const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
|
|
106
|
-
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip,
|
|
108
|
+
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 }) {
|
|
109
|
+
const wheel = usePaneWheel(wheelPane);
|
|
107
110
|
const [input, setInputText] = useState("");
|
|
108
111
|
// Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
|
|
109
112
|
// still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
|
|
@@ -171,21 +174,21 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
171
174
|
}, [scrollBack]);
|
|
172
175
|
const footer = [];
|
|
173
176
|
if (approval?.kind === "command") {
|
|
174
|
-
footer.push({ text: "
|
|
177
|
+
footer.push({ text: "› Run this command?", color: C.yellow, bold: true });
|
|
175
178
|
for (const line of wrap(`$ ${approval.command}`, inner - 2))
|
|
176
179
|
footer.push({ text: ` ${line}`, color: C.text });
|
|
177
180
|
footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
|
|
178
181
|
}
|
|
179
182
|
else if (approval?.kind === "tool") {
|
|
180
|
-
footer.push({ text:
|
|
183
|
+
footer.push({ text: `◇ ${approval.name.startsWith("mcp__") ? `MCP tool ${approval.name.split("__")[1]} → ${approval.name.split("__").slice(2).join("__")}` : `Plugin tool: ${approval.name}`}`, color: C.yellow, bold: true });
|
|
181
184
|
for (const line of wrap(JSON.stringify(approval.args), inner - 2).slice(0, 4))
|
|
182
185
|
footer.push({ text: ` ${line}`, color: C.text });
|
|
183
186
|
footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
|
|
184
187
|
}
|
|
185
188
|
else if (approval?.kind === "change") {
|
|
186
|
-
footer.push({ text:
|
|
189
|
+
footer.push({ text: `± Nou wants to ${approval.change.isNew ? "create" : "edit"} ${approval.change.path}`, color: C.yellow, bold: true });
|
|
187
190
|
if (approvalNote) {
|
|
188
|
-
footer.push({ text: "
|
|
191
|
+
footer.push({ text: "▲ This edit adds problems:", color: C.red, bold: true });
|
|
189
192
|
for (const line of wrap(approvalNote, inner - 2).slice(0, 6))
|
|
190
193
|
footer.push({ text: ` ${line}`, color: C.red });
|
|
191
194
|
}
|
|
@@ -196,7 +199,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
196
199
|
}
|
|
197
200
|
// Click what's queued (or /now) to stop Nou and send it right away.
|
|
198
201
|
for (const text of queued)
|
|
199
|
-
footer.push({ text:
|
|
202
|
+
footer.push({ text: `⧗ next: ${text.replace(/\s+/g, " ")}`, color: C.dim, sendNow: true });
|
|
200
203
|
if (queued.length && busy)
|
|
201
204
|
footer.push({ text: " ↳ click to send now, or /now", color: C.accent2, sendNow: true });
|
|
202
205
|
// Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
|
|
@@ -400,7 +403,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
400
403
|
? 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)))
|
|
401
404
|
: _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
|
|
402
405
|
const empty = entries.length === 0 && !streaming;
|
|
403
|
-
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " ", tabs && tabs.items.length > 1 ? (_jsxs(_Fragment, { children: [tabs.items.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) }), " "] }, t.id))), _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
|
|
406
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " ", tabs && tabs.items.length > 1 ? (_jsxs(_Fragment, { children: [tabs.items.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))), _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
|
|
404
407
|
? unread
|
|
405
408
|
? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
|
406
409
|
: _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/CodeView.js
CHANGED
|
@@ -7,13 +7,14 @@ import { foldRanges, highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines
|
|
|
7
7
|
import { C, fileIcon, gradient } from "./theme.js";
|
|
8
8
|
import { isFind } from "./keys.js";
|
|
9
9
|
import { Pane } from "./Pane.js";
|
|
10
|
-
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
|
+
import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
|
|
11
11
|
/** The current match: a yellow background; the others are shown inverted. */
|
|
12
12
|
const CURRENT_MATCH = ["\x1b[43m\x1b[30m", "\x1b[49m\x1b[39m"];
|
|
13
13
|
/** Columns ←/→ and Shift+wheel move sideways. */
|
|
14
14
|
const SIDE_STEP = 8;
|
|
15
15
|
const EDIT_HINT = " double-click or e to edit · v diff";
|
|
16
|
-
export const CodeView = memo(function CodeView({ showing, width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote,
|
|
16
|
+
export const CodeView = memo(function CodeView({ showing, width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheelPane, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
|
|
17
|
+
const wheel = usePaneWheel(wheelPane);
|
|
17
18
|
const [top, setTop] = useState(0);
|
|
18
19
|
// Columns scrolled sideways (colors are kept: see sliceAnsi).
|
|
19
20
|
const [left, setLeft] = useState(0);
|
|
@@ -231,7 +232,7 @@ export const CodeView = memo(function CodeView({ showing, width, height, focused
|
|
|
231
232
|
["drag", "a pane's border to resize it"],
|
|
232
233
|
["s", "settings: models, keys, plugins"],
|
|
233
234
|
];
|
|
234
|
-
return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, flexShrink: 0, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou!
|
|
235
|
+
return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, flexShrink: 0, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
|
|
235
236
|
}
|
|
236
237
|
const gutter = String(lines.length).length;
|
|
237
238
|
const { icon, color } = fileIcon(filePath);
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -7,7 +7,7 @@ import { C, fileIcon } from "./theme.js";
|
|
|
7
7
|
import { FIND_KEY, isFind } from "./keys.js";
|
|
8
8
|
import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
9
9
|
import { Pane } from "./Pane.js";
|
|
10
|
-
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
|
+
import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
|
|
11
11
|
import { copyToClipboard, readClipboard } from "./clipboard.js";
|
|
12
12
|
import { guideProgress } from "../guide/index.js";
|
|
13
13
|
const TAB = " ";
|
|
@@ -94,7 +94,8 @@ const PlainRow = memo(function PlainRow({ num, numColor, text }) {
|
|
|
94
94
|
return _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: numColor, children: num }), text || " "] });
|
|
95
95
|
});
|
|
96
96
|
/** A text editor: arrows and the mouse to move, Shift to select, Ctrl+C/X/V clipboard, Ctrl+S save, Ctrl+Z undo, Esc leave. */
|
|
97
|
-
export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion,
|
|
97
|
+
export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheelPane, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs, guide, onGuideDone }) {
|
|
98
|
+
const wheel = usePaneWheel(wheelPane);
|
|
98
99
|
const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
|
|
99
100
|
const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
|
|
100
101
|
const comment = useMemo(() => commentFor(filePath), [filePath]);
|
|
@@ -636,7 +637,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
636
637
|
const statusColor = guideStatus && !confirmExit && !prompt ? (progress < 0 ? C.red : C.accent2) : confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
|
|
637
638
|
const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
|
|
638
639
|
const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
|
|
639
|
-
return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: C.onBadge, children: " \
|
|
640
|
+
return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: C.onBadge, children: " \u00B1 EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
|
|
640
641
|
const n = v.top + i;
|
|
641
642
|
const isCursorLine = n === buf.row;
|
|
642
643
|
const problem = byLine.get(n + 1)?.[0];
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -7,7 +7,7 @@ import { IGNORED } from "../commands/index.js";
|
|
|
7
7
|
import { C, GIT_COLORS, fileIcon, gradient, ANIMATION_MS } from "./theme.js";
|
|
8
8
|
import { useTerminalFocused } from "./focus.js";
|
|
9
9
|
import { Pane } from "./Pane.js";
|
|
10
|
-
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
|
+
import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
|
|
11
11
|
function listDir(dir) {
|
|
12
12
|
try {
|
|
13
13
|
return readdirSync(path.resolve(process.cwd(), dir), { withFileTypes: true })
|
|
@@ -32,7 +32,8 @@ function flatten(expanded) {
|
|
|
32
32
|
walk(".", 0);
|
|
33
33
|
return nodes;
|
|
34
34
|
}
|
|
35
|
-
export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, showing, git, problemCounts,
|
|
35
|
+
export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, showing, git, problemCounts, wheelPane, scrollTo, click, onOpen, onHover }) {
|
|
36
|
+
const wheel = usePaneWheel(wheelPane);
|
|
36
37
|
const [expanded, setExpanded] = useState(() => new Set(["src"]));
|
|
37
38
|
const [cursor, setCursor] = useState(0);
|
|
38
39
|
// The first row shown. The wheel moves it on its own; the keyboard moves the cursor and the view follows.
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -9,6 +9,8 @@ const VERSION = `v${currentVersion()}`;
|
|
|
9
9
|
/** What comes before the project (or the workspace tabs): " ◆ NOU v1.2.3 │ ". */
|
|
10
10
|
const LEAD = ` ◆ NOU ${VERSION} │ `;
|
|
11
11
|
const tabLabel = (t, i) => ` ${i + 1} ${t.name}${t.busy ? " ●" : ""} `;
|
|
12
|
+
/** Each workspace tab ends with a × that closes it. */
|
|
13
|
+
const CLOSE = "× ";
|
|
12
14
|
/**
|
|
13
15
|
* Where each workspace tab, and the + that opens one, sits in the header row: for clicks. With one workspace
|
|
14
16
|
* (not hosted) there's just the project's name and the +.
|
|
@@ -23,7 +25,8 @@ export function headerZones(tabs) {
|
|
|
23
25
|
tabs.forEach((t, i) => {
|
|
24
26
|
const w = tabLabel(t, i).length;
|
|
25
27
|
zones.push({ from: x, to: x + w, tab: i });
|
|
26
|
-
x
|
|
28
|
+
zones.push({ from: x + w, to: x + w + CLOSE.length, tab: i, close: true });
|
|
29
|
+
x += w + CLOSE.length + 1;
|
|
27
30
|
});
|
|
28
31
|
}
|
|
29
32
|
zones.push({ from: x, to: x + 3, tab: "new" });
|
|
@@ -46,6 +49,6 @@ export const Header = memo(function Header({ width, branch, changes, model, loca
|
|
|
46
49
|
const right = rightSide(width, model, local, update);
|
|
47
50
|
const rightWidth = right.notice.length + right.badge.length + right.model.length + 1;
|
|
48
51
|
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { width: Math.max(0, width - rightWidth - 4), 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
|
|
49
|
-
? tabs.map((t, i) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: t.active ? C.accent : C.text, bold: t.active, children: tabLabel(t, i) }), " "] }, t.id)))
|
|
52
|
+
? tabs.map((t, i) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: t.active ? C.accent : C.text, bold: t.active, children: tabLabel(t, i) }), _jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: C.dim, children: CLOSE }), " "] }, t.id)))
|
|
50
53
|
: _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 }), " "] }) })] }));
|
|
51
54
|
});
|
|
@@ -4,12 +4,14 @@ import { Box, Text, useAnimation, useInput } from "ink";
|
|
|
4
4
|
import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
|
|
5
5
|
import { useTerminalFocused } from "./focus.js";
|
|
6
6
|
import { Pane } from "./Pane.js";
|
|
7
|
+
import { usePaneWheel } from "./scroll.js";
|
|
7
8
|
export const SEVERITY_STYLE = {
|
|
8
9
|
error: { icon: "✗", color: C.red },
|
|
9
10
|
warning: { icon: "▲", color: C.yellow },
|
|
10
|
-
info: { icon: "
|
|
11
|
+
info: { icon: "ⓘ", color: C.accent },
|
|
11
12
|
};
|
|
12
|
-
export function ProblemsView({ width, height, focused, problems, checking,
|
|
13
|
+
export function ProblemsView({ width, height, focused, problems, checking, wheelPane, onJump, onClose }) {
|
|
14
|
+
const wheel = usePaneWheel(wheelPane);
|
|
13
15
|
const [cursor, setCursor] = useState(0);
|
|
14
16
|
const [onlyErrors, setOnlyErrors] = useState(false);
|
|
15
17
|
const windowFocused = useTerminalFocused();
|
package/dist/src/ui/QuickOpen.js
CHANGED
|
@@ -58,7 +58,7 @@ export function score(file, query) {
|
|
|
58
58
|
return { score: total - file.length * 0.05, hits };
|
|
59
59
|
}
|
|
60
60
|
/** Ctrl+P: find a file. Start with ">" (or press ?) for every action Nou has, with its shortcut. */
|
|
61
|
-
export function QuickOpen({ width, top, files, recent = [], actions, initialQuery = "", placeholder = "Go to file… (> for commands)", onPick, onRun, onClose }) {
|
|
61
|
+
export function QuickOpen({ width, top, files, recent = [], actions, initialQuery = "", placeholder = "Go to file… (> for commands)", title, onPick, onRun, onClose }) {
|
|
62
62
|
const [query, setQuery] = useState(initialQuery);
|
|
63
63
|
const [cursor, setCursor] = useState(0);
|
|
64
64
|
const boxWidth = Math.min(80, Math.max(40, width - 10));
|
|
@@ -101,8 +101,10 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
|
|
|
101
101
|
setCursor((c) => Math.min(count - 1, c + rowsShown));
|
|
102
102
|
});
|
|
103
103
|
const recentSet = useMemo(() => new Set(recent), [recent]);
|
|
104
|
+
/** Cut at the end (a title reads from the start). */
|
|
105
|
+
const fit = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n));
|
|
104
106
|
const pad = (s) => (s.length > inner ? `…${s.slice(-(inner - 1))}` : s.padEnd(inner));
|
|
105
|
-
return (_jsxs(Box, { position: "absolute", top: top, left: Math.floor((width - boxWidth) / 2), width: boxWidth, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, children: [_jsxs(Text, { backgroundColor: C.panel, children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(TextInput, { value: query, onChange: (v) => {
|
|
107
|
+
return (_jsxs(Box, { position: "absolute", top: top, left: Math.floor((width - boxWidth) / 2), width: boxWidth, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, children: [_jsxs(Text, { backgroundColor: C.panel, children: [_jsx(Text, { color: C.accent2, bold: true, children: fit(` ${title ?? (commandMode ? "Every action · type to filter (? or /actions)" : "Find a file · type part of its name (Ctrl+P or /files)")}`, inner - 6) }), _jsx(Text, { color: C.dim, children: "Esc ×".padStart(6) })] }), _jsxs(Text, { backgroundColor: C.panel, children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(TextInput, { value: query, onChange: (v) => {
|
|
106
108
|
setQuery(v);
|
|
107
109
|
setCursor(0);
|
|
108
110
|
}, onSubmit: () => {
|
package/dist/src/ui/Settings.js
CHANGED
|
@@ -212,7 +212,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
212
212
|
const problem = p.unavailable();
|
|
213
213
|
const selected = i === row;
|
|
214
214
|
const current = p.id === choice.provider.id;
|
|
215
|
-
return (_jsxs(Text, { wrap: "truncate-end", ...(selected && column === "providers" ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? " ● " : " " }), _jsx(Text, { color: problem ? C.dim : C.text, bold: selected, children: p.label.padEnd(labelWidth).slice(0, labelWidth) }), _jsx(Text, { color: p.local ? C.green : C.accent2, children: p.local ? " local" : " cloud" }), problem ? _jsx(Text, { color: C.dim, children: " \
|
|
215
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...(selected && column === "providers" ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? " ● " : " " }), _jsx(Text, { color: problem ? C.dim : C.text, bold: selected, children: p.label.padEnd(labelWidth).slice(0, labelWidth) }), _jsx(Text, { color: p.local ? C.green : C.accent2, children: p.local ? " local" : " cloud" }), problem ? _jsx(Text, { color: C.dim, children: " \u25A3" }) : null] }, p.id));
|
|
216
216
|
})] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: column === "models" ? C.accent : C.dim, paddingX: 1, children: [_jsxs(Text, { color: C.dim, bold: true, children: ["MODELS \u00B7 ", provider.label] }), reason ? _jsxs(Text, { color: C.red, wrap: "wrap", children: ["\u2717 ", reason] }) : null, connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
|
|
217
217
|
const entered = connecting.values[f.env];
|
|
218
218
|
const shown = (v) => (f.secret ? "•".repeat(Math.min(v.length, 12)) : v);
|
|
@@ -221,14 +221,14 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
221
221
|
}
|
|
222
222
|
if (i > connecting.step)
|
|
223
223
|
return _jsxs(Text, { color: C.dim, children: [" ", f.label, f.optional ? " (optional)" : ""] }, f.env);
|
|
224
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { color: C.accent2, children: ["\
|
|
224
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { color: C.accent2, children: ["\u00B1 ", f.label, f.optional ? " (optional)" : "", ": "] }), _jsx(TextInput, { value: field, onChange: setField, onSubmit: submitField, ...(f.secret ? { mask: "•" } : {}), ...(f.placeholder ? { placeholder: f.placeholder } : {}) })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", f.env, process.env[f.env] ? (f.secret ? ` is set (from ${originOf(f.env)}); Enter keeps it` : "") : f.optional ? "; Enter skips" : ""] }), setOutsideNou(f.env) && (_jsxs(Text, { color: C.yellow, wrap: "wrap", children: [" A new one works now, but ", originOf(f.env), " sets ", f.env, " too and wins when Nou restarts: change or remove it there."] }))] }, f.env));
|
|
225
225
|
}), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Saved to ~/.nou/credentials.json (only you can read it). Your environment and .env still win." })] })) : provider.unavailable() ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.yellow, children: ["Not set up: ", provider.unavailable()] }), provider.connect
|
|
226
226
|
? _jsx(Text, { color: C.accent2, children: "Press Enter to connect it: paste your key here, no restart needed." })
|
|
227
|
-
: null, _jsx(Text, { color: C.dim, children: "Or add it to a .env file in your project (see .env.example) or your environment." })] })) : (_jsxs(_Fragment, { children: [typing ? (_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, children: "\
|
|
227
|
+
: null, _jsx(Text, { color: C.dim, children: "Or add it to a .env file in your project (see .env.example) or your environment." })] })) : (_jsxs(_Fragment, { children: [typing ? (_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, children: "\u00B1 " }), _jsx(TextInput, { value: custom, onChange: setCustom, onSubmit: (v) => {
|
|
228
228
|
setTyping(false);
|
|
229
229
|
if (v.trim())
|
|
230
230
|
onModel({ provider, model: v.trim() });
|
|
231
|
-
}, placeholder: `e.g. ${provider.defaultModel}` })] })) : (_jsx(Text, { ...(column === "models" && modelRow === 0 ? { backgroundColor: C.panel } : {}), color: C.accent2, children: "\
|
|
231
|
+
}, placeholder: `e.g. ${provider.defaultModel}` })] })) : (_jsx(Text, { ...(column === "models" && modelRow === 0 ? { backgroundColor: C.panel } : {}), color: C.accent2, children: "\u00B1 Type a model name\u2026" })), listed === "loading" && _jsxs(Text, { color: C.accent2, children: [SPINNER[frame % SPINNER.length], " loading models\u2026"] }), listed && typeof listed === "object" && !Array.isArray(listed) && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.red, children: ["Couldn't list models: ", listed.error] }), rejectedKey(listed.error) && provider.connect?.some((f) => f.secret) && (() => {
|
|
232
232
|
const key = provider.connect.find((f) => f.secret).env;
|
|
233
233
|
return _jsxs(Text, { color: C.yellow, wrap: "wrap", children: ["The ", key, " ", originOf(key) ? `from ${originOf(key)} ` : "", "was rejected. Press k to enter a different key."] });
|
|
234
234
|
})()] })), (() => {
|
|
@@ -269,7 +269,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
269
269
|
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [_jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Little games for a break while Nou works. Added ones show in the Games panel: /games." }), _jsx(Text, { children: " " }), GAMES.map((g, i) => ({ g, i })).slice(gameWindow.first, gameWindow.last).map(({ g, i }) => {
|
|
270
270
|
const selected = i === row;
|
|
271
271
|
const added = settings.games.includes(g.id);
|
|
272
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), added ? _jsx(Text, { color: C.green, bold: true, children: "\u2713 ADDED" }) : _jsx(Text, { color: C.accent2, bold: true, children: "\
|
|
272
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), added ? _jsx(Text, { color: C.green, bold: true, children: "\u2713 ADDED" }) : _jsx(Text, { color: C.accent2, bold: true, children: "\u2193 GET " }), _jsxs(Text, { color: C.text, bold: true, children: [" ", g.title] }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id));
|
|
273
273
|
})] }));
|
|
274
274
|
}
|
|
275
275
|
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [plugins.map((p, i) => {
|
|
@@ -284,7 +284,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
284
284
|
].filter(Boolean)
|
|
285
285
|
: [];
|
|
286
286
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), p.scope === "starter" ? _jsx(Text, { color: C.accent2, bold: true, children: "+ ADD" }) : toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope === "starter" ? "starter, optional" : p.scope, parts.length ? ` · ${parts.join(", ")}` : ""] })] }), state === "changed"
|
|
287
|
-
? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \
|
|
287
|
+
? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u25B2 Changed since you enabled it. Review the code, then press Enter to enable again." })
|
|
288
288
|
: p.error
|
|
289
289
|
? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \u2717 ", p.error] })
|
|
290
290
|
: _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", p.plugin?.description ?? (enabled ? "" : "Enable to load it.")] })] }, p.id));
|
|
@@ -293,7 +293,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
293
293
|
const hints = tab === "Model"
|
|
294
294
|
? connecting ? "Enter next · Esc cancel" : column === "models" ? `↑↓ choose · Enter use model · ← providers${provider.connect ? " · k change key" : ""}` : `↑↓ provider · Enter/→ ${provider.unavailable() ? "connect" : "pick a model"}${provider.connect ? " · k change key" : ""}`
|
|
295
295
|
: tab === "Engine" ? "↑↓ move · Enter use this engine" : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : tab === "Games" ? "↑↓ move · Enter add / remove · /games opens the panel" : "↑↓ move · Enter toggle · r rescan";
|
|
296
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\
|
|
296
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u25C7 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) }), _jsxs(Text, { color: C.dim, children: [" nou v", currentVersion()] })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
|
|
297
297
|
}
|
|
298
298
|
/** What a connect field starts with: the current URL to edit (keys stay hidden, so they start empty). */
|
|
299
299
|
function initialValue(f) {
|
package/dist/src/ui/Terminal.js
CHANGED
|
@@ -2,13 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { memo, useEffect, useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { C } from "./theme.js";
|
|
5
|
+
import { usePaneWheel } from "./scroll.js";
|
|
5
6
|
const COLORS = { out: C.text, command: C.accent, info: C.dim, error: C.red };
|
|
6
7
|
/**
|
|
7
8
|
* The terminal panel, under the code like VS Code's: type a command and Enter runs it. While one runs,
|
|
8
9
|
* what you type goes to it; Ctrl+C stops it. ↑↓ go through what you ran, Ctrl+L clears, Esc leaves.
|
|
9
10
|
* Drag its top border (or Alt+↑/↓) to make it taller or shorter.
|
|
10
11
|
*/
|
|
11
|
-
export const Terminal = memo(function Terminal({ shell, version, width, height, focused,
|
|
12
|
+
export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheelPane, onLeave, onClose, onResize }) {
|
|
13
|
+
const wheel = usePaneWheel(wheelPane);
|
|
12
14
|
const [input, setInput] = useState("");
|
|
13
15
|
const [history, setHistory] = useState([]);
|
|
14
16
|
const [back, setBack] = useState(-1);
|
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(["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(["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
|
|
@@ -53,6 +53,8 @@ export const SLASH_COMMANDS = [
|
|
|
53
53
|
{ name: "open", args: "<file>[:line]", description: "open a file in the code view (part of its name is enough)" },
|
|
54
54
|
{ name: "effort", args: "[fastest|fast|balanced|smarter|smartest]", description: "speed vs smarts: how hard the model thinks (cheaper + faster, or smarter)" },
|
|
55
55
|
{ name: "workspace", args: "[new <folder> | n]", description: "several projects in this window, a tab each in the header: open one, or switch" },
|
|
56
|
+
{ name: "files", description: "find a file by part of its name (also Ctrl+P)" },
|
|
57
|
+
{ name: "actions", description: "every action Nou has, searchable, with its key (also ?)" },
|
|
56
58
|
{ name: "zoom", args: "in|out|reset", description: "make the code panel bigger or smaller (+ and - with the code focused, = resets)" },
|
|
57
59
|
{ name: "now", description: "stop Nou and send your queued messages right away (or click them)" },
|
|
58
60
|
{ name: "settings", description: "models, engine, preferences and plugins" },
|
|
@@ -94,3 +96,11 @@ export function matchCommands(typed, extra = []) {
|
|
|
94
96
|
const contains = query ? all.filter((c) => !c.name.startsWith(query) && (c.name.includes(query) || c.description.includes(query))) : [];
|
|
95
97
|
return [...starts.sort((a, b) => Number(b.name === query) - Number(a.name === query)), ...contains];
|
|
96
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* A message sent while Nou was working stops it so it's answered now, but what Nou was on isn't dropped:
|
|
101
|
+
* the agent is told what that was and to finish it after, unless the new message says otherwise.
|
|
102
|
+
*/
|
|
103
|
+
export function finishAfter(text, previous) {
|
|
104
|
+
const was = previous.length > 6000 ? `${previous.slice(0, 6000)}…` : previous;
|
|
105
|
+
return `${text}\n\n[Sent while you were still working on my previous message, so you were interrupted. Handle this first, then carry on with and finish the previous request, picking up where you left off (don't redo finished steps), unless this message cancels or replaces it. The previous request was:\n${was}]`;
|
|
106
|
+
}
|
|
@@ -48,9 +48,14 @@ export function installFastWidth() {
|
|
|
48
48
|
if (typeof registerHooks !== "function" || process.env.NOU_NO_FAST_WIDTH)
|
|
49
49
|
return false;
|
|
50
50
|
const shim = new URL(`./width-shim${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url).href;
|
|
51
|
+
const lineShim = new URL(`./line-update-shim${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url).href;
|
|
51
52
|
registerHooks({
|
|
52
53
|
resolve(specifier, context, nextResolve) {
|
|
53
54
|
const resolved = nextResolve(specifier, context);
|
|
55
|
+
// Ink's line-update gets a fast path too (see line-update-shim.ts).
|
|
56
|
+
if (specifier === "./line-update.js" && /[\\/]ink[\\/]build[\\/]log-update\.js$/.test(context.parentURL ?? "")) {
|
|
57
|
+
return { ...resolved, url: `${lineShim}?of=${encodeURIComponent(resolved.url)}` };
|
|
58
|
+
}
|
|
54
59
|
if (specifier !== "string-width" || context.parentURL?.startsWith(shim))
|
|
55
60
|
return resolved;
|
|
56
61
|
// Each copy of string-width (Ink may have its own) gets its own wrapper.
|
package/dist/src/ui/keys.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (kitty
|
|
4
|
-
* works everywhere too, for terminals that keep Cmd for themselves.
|
|
2
|
+
* Find is shown as Ctrl+F everywhere (it works in every terminal). On a Mac, Cmd+F works too where the
|
|
3
|
+
* terminal reports Cmd (the kitty keyboard protocol, which Nou asks for: Ghostty, kitty, WezTerm, iTerm2).
|
|
5
4
|
*/
|
|
6
|
-
export const FIND_KEY =
|
|
5
|
+
export const FIND_KEY = "Ctrl+F";
|
|
7
6
|
/** Ctrl+F, or Cmd+F where the terminal reports Cmd. */
|
|
8
7
|
export const isFind = (input, key) => input === "f" && (!!key.ctrl || !!key.super);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stands in for Ink's line-update (see fast-width.ts), which works out how much of a changed line can stay
|
|
3
|
+
* on screen. For any line with a character outside plain ASCII (every line with a box border, so nearly all
|
|
4
|
+
* of Nou's) it splits both versions into graphemes, every frame. Lines made only of one-column characters
|
|
5
|
+
* that never combine (fast-width's NARROW) have one grapheme per character, so this answers those directly,
|
|
6
|
+
* with exactly what Ink would, and hands every other line to Ink's own code.
|
|
7
|
+
*/
|
|
8
|
+
import { NARROW } from "./fast-width.js";
|
|
9
|
+
// eslint-disable-next-line no-control-regex -- SGR starts with ESC
|
|
10
|
+
const SGR = /\u001b\[[\d;]*m/gu;
|
|
11
|
+
/** Ink's lineUpdate with the fast path in front. Same answers; exported for tests. */
|
|
12
|
+
export function fastLineUpdate(real) {
|
|
13
|
+
return (previous, next, columns) => {
|
|
14
|
+
if (columns === undefined || Number.isNaN(columns) || columns < 1)
|
|
15
|
+
return real(previous, next, columns);
|
|
16
|
+
let common = 0;
|
|
17
|
+
while (common < previous.length && common < next.length && previous[common] === next[common])
|
|
18
|
+
common++;
|
|
19
|
+
if (common === 0)
|
|
20
|
+
return real(previous, next, columns);
|
|
21
|
+
const previousText = previous.replaceAll(SGR, "");
|
|
22
|
+
const nextText = next.replaceAll(SGR, "");
|
|
23
|
+
// Controls are excluded by NARROW too (it starts at the space).
|
|
24
|
+
if (!NARROW.test(previousText) || !NARROW.test(nextText))
|
|
25
|
+
return real(previous, next, columns);
|
|
26
|
+
const codes = [...next.matchAll(SGR)];
|
|
27
|
+
let textLimit = common;
|
|
28
|
+
for (const match of codes) {
|
|
29
|
+
if (match.index >= common)
|
|
30
|
+
break;
|
|
31
|
+
textLimit -= Math.min(match[0].length, common - match.index);
|
|
32
|
+
}
|
|
33
|
+
// One character a grapheme and a column, so this is the grapheme walk Ink does.
|
|
34
|
+
let textLength = 0;
|
|
35
|
+
while (textLength < textLimit && previousText[textLength] === nextText[textLength])
|
|
36
|
+
textLength++;
|
|
37
|
+
const column = textLength;
|
|
38
|
+
const full = `\u001b[1G\u001b[K${next}`;
|
|
39
|
+
if (column === 0 || column >= columns || previousText.length > columns || nextText.length > columns)
|
|
40
|
+
return full;
|
|
41
|
+
let offset = textLength;
|
|
42
|
+
let styles = "";
|
|
43
|
+
for (const match of codes) {
|
|
44
|
+
if (match.index > offset || match.index + match[0].length > common)
|
|
45
|
+
break;
|
|
46
|
+
offset += match[0].length;
|
|
47
|
+
styles += match[0];
|
|
48
|
+
}
|
|
49
|
+
const partial = `\u001b[${column + 1}G\u001b[K${styles}${next.slice(offset)}`;
|
|
50
|
+
return partial.length < full.length ? partial : full;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const of = new URL(import.meta.url).searchParams.get("of");
|
|
54
|
+
const real = of ? (await import(of)).default : null;
|
|
55
|
+
export default real ? fastLineUpdate(real) : null;
|
package/dist/src/ui/scroll.js
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { useState, useSyncExternalStore } from "react";
|
|
3
4
|
import { C } from "./theme.js";
|
|
5
|
+
const wheels = new Map();
|
|
6
|
+
const wheelListeners = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* Hands a pane its wheel total. Only that pane draws again, not the whole app: scrolling is the most
|
|
9
|
+
* frequent thing there is, and every pane redrawing for it was most of its cost.
|
|
10
|
+
*/
|
|
11
|
+
export function sendWheel(pane, lines, columns) {
|
|
12
|
+
wheels.set(pane, { lines, columns, nonce: (wheels.get(pane)?.nonce ?? 0) + 1 });
|
|
13
|
+
for (const fn of wheelListeners.get(pane) ?? [])
|
|
14
|
+
fn();
|
|
15
|
+
}
|
|
16
|
+
/** The latest wheel total for `pane` (see sendWheel) since this pane appeared; null before that. */
|
|
17
|
+
export function usePaneWheel(pane) {
|
|
18
|
+
// A pane that opens later (the editor, say) mustn't replay a wheel meant for what was there before.
|
|
19
|
+
const [since] = useState(() => (pane ? wheels.get(pane)?.nonce ?? 0 : 0));
|
|
20
|
+
const wheel = useSyncExternalStore((fn) => {
|
|
21
|
+
if (!pane)
|
|
22
|
+
return () => { };
|
|
23
|
+
let set = wheelListeners.get(pane);
|
|
24
|
+
if (!set)
|
|
25
|
+
wheelListeners.set(pane, (set = new Set()));
|
|
26
|
+
set.add(fn);
|
|
27
|
+
return () => set.delete(fn);
|
|
28
|
+
}, () => (pane ? wheels.get(pane) ?? null : null));
|
|
29
|
+
return wheel && wheel.nonce > since ? wheel : null;
|
|
30
|
+
}
|
|
4
31
|
/** Lines a single wheel notch moves. */
|
|
5
32
|
export const WHEEL_STEP = 3;
|
|
6
33
|
/** Where to scroll to for a scrollbar click at `fraction` of the track. */
|
package/dist/src/ui/text.js
CHANGED
|
@@ -274,7 +274,7 @@ export function renderMarkdown(text, width, isFile) {
|
|
|
274
274
|
const item = raw.match(/^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?(.*)/);
|
|
275
275
|
if (item) {
|
|
276
276
|
const depth = Math.floor(item[1].length / 2) * 2;
|
|
277
|
-
const task = item[3] ? (/x/i.test(item[3]) ? "
|
|
277
|
+
const task = item[3] ? (/x/i.test(item[3]) ? "■ " : "□ ") : "";
|
|
278
278
|
const bullet = `${" ".repeat(depth)}${/\d/.test(item[2]) ? item[2] : "•"} ${task}`;
|
|
279
279
|
for (const line of wrapSpans([{ text: bullet }, ...spansOf(item[4])], width, bullet.length))
|
|
280
280
|
out.push({ kind: "text", spans: line });
|
package/dist/src/ui/theme.js
CHANGED
|
@@ -79,7 +79,7 @@ const ICONS = Object.fromEntries(Object.entries(iconColors()).map(([ext, color])
|
|
|
79
79
|
export function fileIcon(file) {
|
|
80
80
|
const base = path.basename(file).toLowerCase();
|
|
81
81
|
if (base.startsWith(".env"))
|
|
82
|
-
return { icon: "
|
|
82
|
+
return { icon: "⌁", color: C.yellow };
|
|
83
83
|
if (base.startsWith(".git"))
|
|
84
84
|
return { icon: "◌", color: C.orange };
|
|
85
85
|
if (base === "package.json")
|
|
@@ -68,6 +68,17 @@ export async function runHost(folders, firstArgs, command = [process.execPath, p
|
|
|
68
68
|
if (to && to !== active)
|
|
69
69
|
activate(to);
|
|
70
70
|
}
|
|
71
|
+
else if (msg.type === "close") {
|
|
72
|
+
const w = workspaces[msg.index];
|
|
73
|
+
if (!w)
|
|
74
|
+
return;
|
|
75
|
+
// Asked to quit, it saves and exits as when you quit it; one that doesn't is stopped.
|
|
76
|
+
if (w.socket)
|
|
77
|
+
send(w, { type: "quit" });
|
|
78
|
+
else
|
|
79
|
+
w.pty.kill();
|
|
80
|
+
setTimeout(() => workspaces.includes(w) && w.pty.kill(), 3000);
|
|
81
|
+
}
|
|
71
82
|
else if (msg.type === "new") {
|
|
72
83
|
const folder = workspaceFolder(msg.folder, from.folder);
|
|
73
84
|
if (!folder)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.14",
|
|
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": {
|