nou-cli 0.5.10 → 0.5.12
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 -4
- package/dist/src/cli.js +13 -3
- package/dist/src/ui/App.js +72 -18
- package/dist/src/ui/Chat.js +17 -4
- package/dist/src/ui/ChatInput.js +28 -5
- package/dist/src/ui/CodeView.js +6 -5
- package/dist/src/ui/Editor.js +4 -3
- package/dist/src/ui/FileTree.js +6 -5
- package/dist/src/ui/Header.js +13 -2
- package/dist/src/ui/ProblemsView.js +3 -3
- package/dist/src/ui/Settings.js +2 -2
- package/dist/src/ui/background.js +27 -18
- package/dist/src/ui/commands.js +3 -2
- package/dist/src/ui/keys.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
|
|
|
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
53
|
| 🪟 **Workspaces** | Several projects in one window, a tab each in the header: click **+** (or `/workspace new ../api`) and the other folder 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. Quitting one (Ctrl+C twice) closes its tab. 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
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`. |
|
|
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. 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. |
|
|
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. |
|
|
58
58
|
| 🧠 **A capable engine** | Reads and searches in parallel, numbered reads for exact line references, edits that survive small whitespace mismatches, several edits to a file as one change, a plan for longer tasks, full command output (stderr too) with exit codes and timeouts, retries when a provider is overloaded or a connection drops (Codex too: it picks the turn back up instead of stopping at "Reconnecting..."), and conversation caching with Claude. It follows your repo's `AGENTS.md`, `CLAUDE.md` and `NOU.md` (and `~/.nou/NOU.md`). |
|
|
@@ -94,8 +94,9 @@ 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) | zoom the code panel bigger or smaller, a step at a time: the files and chat give or take the room; <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 |
|
|
97
98
|
| <kbd>b</kbd> | back to the previous file (press again to flip back) |
|
|
98
|
-
| <kbd>Ctrl</kbd>+<kbd>F</kbd> | find in the open file: matches are highlighted and counted; <kbd>Enter</kbd>/<kbd>↓</kbd> next, <kbd>↑</kbd> previous, <kbd>Esc</kbd> close |
|
|
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 |
|
|
99
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 |
|
|
100
101
|
| <kbd>Ctrl</kbd>+click | on a name in the code (or <kbd>Option</kbd>+click, or right-click): go to its definition; an imported name goes to its import, and the import's path opens that file |
|
|
101
102
|
| <kbd>e</kbd> or a double-click | edit the open file (see the editor keys below) |
|
|
@@ -130,11 +131,11 @@ In the editor:
|
|
|
130
131
|
| <kbd>Ctrl</kbd>+<kbd>K</kbd> · <kbd>Ctrl</kbd>+<kbd>G</kbd> | hover info · go to definition (with a language server) |
|
|
131
132
|
| <kbd>Esc</kbd> | clear the selection, or leave the editor |
|
|
132
133
|
|
|
133
|
-
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` · `/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]` · `/now` · `/showme <question>` · `/teach <change>` · `/help`
|
|
134
135
|
|
|
135
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.
|
|
136
137
|
|
|
137
|
-
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), 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.
|
|
138
139
|
|
|
139
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.
|
|
140
141
|
|
package/dist/src/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { App } from "./ui/App.js";
|
|
|
15
15
|
import { installFastOutput } from "./ui/fast-output.js";
|
|
16
16
|
import { RENDER_OPTIONS } from "./ui/render-options.js";
|
|
17
17
|
import { setScreenClear } from "./ui/redraw.js";
|
|
18
|
-
import {
|
|
18
|
+
import { detectTerminal } from "./ui/background.js";
|
|
19
19
|
import { setPalette } from "./ui/theme.js";
|
|
20
20
|
import { runHost, workspaceFolder } from "./workspaces/host.js";
|
|
21
21
|
import { pendingWorkspaces } from "./workspaces/client.js";
|
|
@@ -96,7 +96,11 @@ async function main() {
|
|
|
96
96
|
console.error(`Not a folder, so not opened: ${missing.join(", ")}`);
|
|
97
97
|
const uiTheme = loadSettings().uiTheme;
|
|
98
98
|
// Every workspace uses the colors found for this terminal (they can't ask it themselves from the background).
|
|
99
|
-
|
|
99
|
+
// The host asks the terminal once and tells every workspace (in the background they can't ask it).
|
|
100
|
+
const term = await detectTerminal();
|
|
101
|
+
process.env.NOU_PALETTE = uiTheme === "dark" || uiTheme === "light" ? uiTheme : term.palette ?? "dark";
|
|
102
|
+
if (term.kitty !== null)
|
|
103
|
+
process.env.NOU_KITTY = term.kitty ? "1" : "0";
|
|
100
104
|
const firstArgs = args.includes("--continue") || args.includes("-c") ? ["--continue"] : [];
|
|
101
105
|
process.exit(await runHost(folders.length ? folders : [process.cwd()], firstArgs));
|
|
102
106
|
}
|
|
@@ -163,7 +167,13 @@ async function main() {
|
|
|
163
167
|
// Colors that are readable on this terminal: a dark or a light set, from the setting or the background.
|
|
164
168
|
const uiTheme = loadSettings().uiTheme;
|
|
165
169
|
const fromHost = process.env.NOU_PALETTE;
|
|
166
|
-
|
|
170
|
+
const hostKnows = fromHost === "light" || fromHost === "dark";
|
|
171
|
+
const term = hostKnows ? { palette: fromHost, kitty: process.env.NOU_KITTY ? process.env.NOU_KITTY === "1" : null } : await detectTerminal();
|
|
172
|
+
setPalette(uiTheme === "dark" || uiTheme === "light" ? uiTheme : term.palette ?? "dark");
|
|
173
|
+
// Turn the kitty keyboard protocol on when the terminal said it speaks it (Shift/Ctrl/Cmd+Enter, Cmd+F and
|
|
174
|
+
// Ctrl+= need it), off when it answered without it; ask the way Ink does only if it answered nothing.
|
|
175
|
+
if (term.kitty !== null && RENDER_OPTIONS.kittyKeyboard)
|
|
176
|
+
RENDER_OPTIONS.kittyKeyboard = { ...RENDER_OPTIONS.kittyKeyboard, mode: term.kitty ? "enabled" : "disabled" };
|
|
167
177
|
await installFastOutput();
|
|
168
178
|
// While Nou is on screen, anything a library prints (console.log, warnings, stderr) goes to a log file:
|
|
169
179
|
// written into the terminal, it would scroll the screen out from under the mouse.
|
package/dist/src/ui/App.js
CHANGED
|
@@ -30,6 +30,7 @@ import { ENABLE as MOUSE_ON, useMouse } from "./mouse.js";
|
|
|
30
30
|
import { connectToHost, isHosted, reopenAsWorkspaces, sendToHost, useWorkspaceTabs, workspaceTabs } from "../workspaces/client.js";
|
|
31
31
|
import { PTY_PACKAGE, workspaceFolder } from "../workspaces/host.js";
|
|
32
32
|
import { C, palette } from "./theme.js";
|
|
33
|
+
import { FIND_KEY, isFind } from "./keys.js";
|
|
33
34
|
import { FileTree } from "./FileTree.js";
|
|
34
35
|
import { CodeView } from "./CodeView.js";
|
|
35
36
|
import { Editor } from "./Editor.js";
|
|
@@ -83,7 +84,7 @@ Keys:
|
|
|
83
84
|
m mouse on/off (off: select text to copy; Shift+drag also works in most terminals)
|
|
84
85
|
/ start a command from any pane
|
|
85
86
|
e edit open file (or double-click in the code) v diff (git / Nou / session) f follow mode
|
|
86
|
-
|
|
87
|
+
${FIND_KEY} find in the open file (Enter/↓ next, ↑ previous)
|
|
87
88
|
b back to the previous file (Ctrl+P lists recent files first) [ ] previous/next tab · x close tab
|
|
88
89
|
Ctrl+click, Option+click or right-click a name: go to its definition (an import's path opens the file)
|
|
89
90
|
p problems u undo Nou's edit s settings
|
|
@@ -92,7 +93,7 @@ Mouse: wheel scrolls (Shift+wheel sideways) · click/drag a scrollbar · double-
|
|
|
92
93
|
In the editor:
|
|
93
94
|
Shift+arrows / mouse drag select · Ctrl+A all · Ctrl+C/X/V copy/cut/paste · Esc clear selection or leave
|
|
94
95
|
Ctrl+←/→ word · Ctrl+Backspace or Ctrl+W delete word · Ctrl+Home/End file start/end
|
|
95
|
-
|
|
96
|
+
${FIND_KEY} find · Ctrl+L go to line · Ctrl+/ comment · Ctrl+D duplicate line · Alt+↑/↓ move line
|
|
96
97
|
Tab/Shift+Tab indent/outdent · Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
|
|
97
98
|
/** A callback that keeps one identity across renders but always runs the latest code, so memoized panes don't re-render for it. */
|
|
98
99
|
function useEvent(fn) {
|
|
@@ -158,6 +159,14 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
158
159
|
*/
|
|
159
160
|
const [tour, setTour] = useState(null);
|
|
160
161
|
const tourFound = useRef(null);
|
|
162
|
+
/** The files on /showme's tour and which of its places are in each, for the file tree. */
|
|
163
|
+
const tourFiles = useMemo(() => {
|
|
164
|
+
if (!tour)
|
|
165
|
+
return null;
|
|
166
|
+
const map = new Map();
|
|
167
|
+
tour.stops.forEach((s, i) => map.set(s.file, [...(map.get(s.file) ?? []), i + 1]));
|
|
168
|
+
return map;
|
|
169
|
+
}, [tour]);
|
|
161
170
|
const shellRef = useRef(null);
|
|
162
171
|
const beforeTerm = useRef("code");
|
|
163
172
|
const [follow, setFollow] = useState(settings.followMode);
|
|
@@ -255,7 +264,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
255
264
|
const [findRequest, setFindRequest] = useState(0);
|
|
256
265
|
const startFind = () => {
|
|
257
266
|
if (!openFile)
|
|
258
|
-
return info(
|
|
267
|
+
return info(`Open a file first (Ctrl+P), then ${FIND_KEY} searches it.`);
|
|
259
268
|
setDiffMode("off");
|
|
260
269
|
setEditing(false);
|
|
261
270
|
setCodePanel("code");
|
|
@@ -270,6 +279,12 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
270
279
|
const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0, heights: { tree: 0, code: 0, chat: 0 }, game: null, terminal: null });
|
|
271
280
|
/** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
|
|
272
281
|
const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
|
|
282
|
+
/**
|
|
283
|
+
* How far the code panel is zoomed (+ and - in the code, /zoom in|out): each step makes it a few columns
|
|
284
|
+
* wider (taken from the files and chat) or narrower (given to them). 0 is the normal layout.
|
|
285
|
+
*/
|
|
286
|
+
const [codeZoom, setCodeZoom] = useState(0);
|
|
287
|
+
const zoomCode = (by) => setCodeZoom((z) => (by === 0 ? 0 : Math.max(-6, Math.min(8, z + by))));
|
|
273
288
|
const paneWidthsRef = useRef(paneWidths);
|
|
274
289
|
paneWidthsRef.current = paneWidths;
|
|
275
290
|
/** What a mouse drag is moving: a pane border, a pane's scrollbar, or the editor's selection. */
|
|
@@ -1469,6 +1484,12 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1469
1484
|
if (focus !== "chat" && input === "o" && pendingChange)
|
|
1470
1485
|
return openPending();
|
|
1471
1486
|
}
|
|
1487
|
+
// Zoom the code panel from anywhere: Ctrl+= / Ctrl+- / Ctrl+0, in terminals that report Ctrl with those
|
|
1488
|
+
// keys (the kitty keyboard protocol Nou asks for). Elsewhere Ctrl+- is the same key as Ctrl+/, so it's
|
|
1489
|
+
// left alone; + and - in the code or the files work in every terminal.
|
|
1490
|
+
if (key.ctrl && (input === "=" || input === "+" || input === "-" || input === "0") && !key.meta) {
|
|
1491
|
+
return zoomCode(input === "0" ? 0 : input === "-" ? -1 : 1);
|
|
1492
|
+
}
|
|
1472
1493
|
if (key.ctrl && input === "p") {
|
|
1473
1494
|
setQuickOpen("");
|
|
1474
1495
|
return;
|
|
@@ -1479,6 +1500,16 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1479
1500
|
return; // so does the find bar
|
|
1480
1501
|
if (focus === "game" || focus === "terminal")
|
|
1481
1502
|
return; // and the Games panel and the terminal
|
|
1503
|
+
// Cmd+F (Mac) or Ctrl+F from the files or the chat: find in the open file (the code view takes it itself
|
|
1504
|
+
// when it has the keyboard). Other Cmd+letters aren't Nou's: a plain letter is, so Cmd+F never also
|
|
1505
|
+
// toggles follow mode (f).
|
|
1506
|
+
if (isFind(input, key)) {
|
|
1507
|
+
if (focus !== "code")
|
|
1508
|
+
startFind();
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (key.super)
|
|
1512
|
+
return;
|
|
1482
1513
|
if (key.tab && key.shift)
|
|
1483
1514
|
return cycleAutoMode();
|
|
1484
1515
|
if (key.tab && focus === "chat" && chatMenuOpen.current)
|
|
@@ -1526,9 +1557,13 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1526
1557
|
updateSettings({ paneWidths: moveBorder(focus === "tree" ? "tree" : "chat", (focus === "tree" ? treeWidth - 1 : treeWidth + codeWidth) + step) });
|
|
1527
1558
|
}
|
|
1528
1559
|
else if (input === "=") {
|
|
1560
|
+
setCodeZoom(0);
|
|
1529
1561
|
setPaneWidths(null);
|
|
1530
1562
|
updateSettings({ paneWidths: null });
|
|
1531
1563
|
}
|
|
1564
|
+
else if ((input === "+" || input === "-") && (focus === "code" || focus === "tree")) {
|
|
1565
|
+
zoomCode(input === "+" ? 1 : -1);
|
|
1566
|
+
}
|
|
1532
1567
|
else if (input === "v")
|
|
1533
1568
|
cycleDiff();
|
|
1534
1569
|
else if ((input === "]" || input === "[") && diffMode === "session")
|
|
@@ -1619,7 +1654,7 @@ ${snippet}
|
|
|
1619
1654
|
if (action)
|
|
1620
1655
|
return info("Usage: /mcp, /mcp add <name> <command|url>, /mcp add browser, /mcp remove <name>, /mcp import, /mcp reconnect", "error");
|
|
1621
1656
|
const list = mcpRef.current.status();
|
|
1622
|
-
const mark = { ready: "●", connecting: "◌", failed: "
|
|
1657
|
+
const mark = { ready: "●", connecting: "◌", failed: "✗", off: "○" };
|
|
1623
1658
|
const lines = list.map((x) => ` ${mark[x.state]} ${x.name.padEnd(16)} ${x.state === "ready" ? `${x.tools} tools` : x.state}${x.scope === "project" ? " · this project" : ""}${x.error ? ` · ${x.error}` : ""}`);
|
|
1624
1659
|
const others = findOtherMcpServers().filter((f) => !list.some((x) => x.name === f.name));
|
|
1625
1660
|
return info([
|
|
@@ -1656,7 +1691,7 @@ ${snippet}
|
|
|
1656
1691
|
const mark = { on: "●", off: "○", changed: "⚠" };
|
|
1657
1692
|
const lines = allPlugins.map((p) => p.scope === "starter"
|
|
1658
1693
|
? ` + ${p.id.padEnd(16)} ${p.plugin?.description ?? ""}`
|
|
1659
|
-
: ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` ·
|
|
1694
|
+
: ` ${mark[trustState(p, settings)]} ${p.id.padEnd(16)} ${p.scope}${p.error ? ` · ✗ ${p.error}` : ""}`);
|
|
1660
1695
|
return info(`Plugins (● on, ○ off, + starter you can add with /plugins add <name>):\n${lines.join("\n")}\nWrite your own: /plugins new <name>`);
|
|
1661
1696
|
};
|
|
1662
1697
|
const runCommand = async (input) => {
|
|
@@ -1819,6 +1854,14 @@ ${snippet}
|
|
|
1819
1854
|
return toggleTerminal();
|
|
1820
1855
|
case "redraw":
|
|
1821
1856
|
return redraw();
|
|
1857
|
+
case "zoom":
|
|
1858
|
+
if (/^(in|\+|bigger)$/.test(arg))
|
|
1859
|
+
return zoomCode(1);
|
|
1860
|
+
if (/^(out|-|smaller)$/.test(arg))
|
|
1861
|
+
return zoomCode(-1);
|
|
1862
|
+
if (/^(reset|0|off)$/.test(arg))
|
|
1863
|
+
return zoomCode(0);
|
|
1864
|
+
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 +).");
|
|
1822
1865
|
case "workspace":
|
|
1823
1866
|
case "workspaces":
|
|
1824
1867
|
case "ws":
|
|
@@ -1973,7 +2016,7 @@ ${snippet}
|
|
|
1973
2016
|
if (!stops.length)
|
|
1974
2017
|
return;
|
|
1975
2018
|
const list = stops.map((s, i) => `${i + 1}. ${s.file}:${s.line}${s.end > s.line ? `-${s.end}` : ""}${s.note ? ` · ${s.note}` : ""}`).join("\n");
|
|
1976
|
-
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" : ""}):\n${list}`);
|
|
2019
|
+
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}`);
|
|
1977
2020
|
setTour({ stops, at: 0 });
|
|
1978
2021
|
}, [busy]);
|
|
1979
2022
|
// Each place: open it in the code view with its lines highlighted.
|
|
@@ -1986,9 +2029,12 @@ ${snippet}
|
|
|
1986
2029
|
/** /showme <question>: explains it and opens the places in the code; /showme next|back|stop steps through them. */
|
|
1987
2030
|
const showPlaces = (arg) => {
|
|
1988
2031
|
const words = arg.trim();
|
|
1989
|
-
if (/^(stop|end|done)$/.test(words)) {
|
|
2032
|
+
if (/^(stop|end|done|clear)$/.test(words)) {
|
|
2033
|
+
// Done looking: the highlighted lines go back to plain code.
|
|
1990
2034
|
setTour(null);
|
|
1991
|
-
|
|
2035
|
+
const highlighted = !!focusLine?.to;
|
|
2036
|
+
setFocusLine((f) => (f?.to !== undefined ? null : f));
|
|
2037
|
+
return info(tour || highlighted ? "Done showing: the highlights are cleared." : "Nothing is being shown.");
|
|
1992
2038
|
}
|
|
1993
2039
|
if (/^(next|back|prev|previous)$/.test(words)) {
|
|
1994
2040
|
if (!tour)
|
|
@@ -2239,8 +2285,16 @@ ${snippet}
|
|
|
2239
2285
|
} }));
|
|
2240
2286
|
}
|
|
2241
2287
|
const height = Math.max(10, rows - 2);
|
|
2242
|
-
const
|
|
2243
|
-
const
|
|
2288
|
+
const baseTree = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
|
|
2289
|
+
const baseChat = paneWidths ? Math.max(MIN_CHAT, Math.min(Math.round(columns * paneWidths.chat), columns - baseTree - MIN_CODE)) : Math.max(34, Math.floor(columns * 0.3));
|
|
2290
|
+
// Zooming the code panel: each step moves a few columns between it and the files and chat, as far as their
|
|
2291
|
+
// minimums (zooming in) or its own (zooming out) allow.
|
|
2292
|
+
const zoomCols = codeZoom * Math.max(4, Math.round(columns * 0.06));
|
|
2293
|
+
const spare = { tree: baseTree - MIN_TREE, chat: baseChat - MIN_CHAT };
|
|
2294
|
+
const taken = zoomCols > 0 ? Math.min(zoomCols, spare.tree + spare.chat) : -Math.min(-zoomCols, Math.max(0, columns - baseTree - baseChat - MIN_CODE));
|
|
2295
|
+
const fromTree = zoomCols > 0 ? Math.round(taken * (spare.tree / Math.max(1, spare.tree + spare.chat))) : Math.round(taken / 2);
|
|
2296
|
+
const treeWidth = baseTree - fromTree;
|
|
2297
|
+
const chatWidth = baseChat - (taken - fromTree);
|
|
2244
2298
|
const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
|
|
2245
2299
|
const tabKey = tabs.length && openFile ? `${tabs.join("\0")}|${openFile}|${tabsWidth(codeWidth)}|${showEditor && !!editorState?.dirty}` : "";
|
|
2246
2300
|
if (tabStripCache.current?.key !== tabKey) {
|
|
@@ -2376,7 +2430,7 @@ ${snippet}
|
|
|
2376
2430
|
// Everything Nou can do, for the command palette (? or Ctrl+P then >), with the key that does it.
|
|
2377
2431
|
const paletteActions = [
|
|
2378
2432
|
{ title: "Find a file", keys: "Ctrl+P", run: () => setQuickOpen("") },
|
|
2379
|
-
{ title: "Find in the open file", keys:
|
|
2433
|
+
{ title: "Find in the open file", keys: FIND_KEY, run: startFind },
|
|
2380
2434
|
{ title: "Open a file at a line", keys: "/open", run: () => typeCommand("/open ") },
|
|
2381
2435
|
{ title: "Go back to the previous file", keys: "b", run: goBack },
|
|
2382
2436
|
{ title: "Cancel your last message (edit it and resend)", keys: "/cancel", run: cancelLast },
|
|
@@ -2442,7 +2496,7 @@ ${snippet}
|
|
|
2442
2496
|
: showEditor && focus === "code"
|
|
2443
2497
|
? editorState?.selection
|
|
2444
2498
|
? [["Ctrl+C", "copy"], ["Ctrl+X", "cut"], ["Tab", "indent"], ["Ctrl+/", "comment"], ["Esc", "deselect"]]
|
|
2445
|
-
: [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Shift+→", "select"], [
|
|
2499
|
+
: [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Shift+→", "select"], [FIND_KEY, "find"], ["Ctrl+/", "comment"], ["/help", "all keys"]]
|
|
2446
2500
|
: focus === "chat"
|
|
2447
2501
|
? chatMenu ? [["↑↓", "choose"], ["Tab", "complete"], ["Enter", "run"], ["Esc", "close"]]
|
|
2448
2502
|
: busy
|
|
@@ -2452,9 +2506,9 @@ ${snippet}
|
|
|
2452
2506
|
? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
|
|
2453
2507
|
: diff
|
|
2454
2508
|
? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
|
|
2455
|
-
: [["double-click/e", "edit"], [
|
|
2509
|
+
: [["double-click/e", "edit"], [FIND_KEY, "find"], ["v", "diff"], ...(recent.length > 1 ? [["b", "back"]] : []), ["u", "undo"], ["/", "command"], ["p", "problems"], ["+ -", "zoom"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
|
|
2456
2510
|
const followChip = follow ? ` ◉ follow${followLag ? ` ·${followLag}` : ""} ` : " ○ follow ";
|
|
2457
|
-
const statusUsed = mode.label.length + 2 + autoMode.length + 2 + followChip.length + `
|
|
2511
|
+
const statusUsed = mode.label.length + 2 + autoMode.length + 2 + followChip.length + ` ✗ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
|
|
2458
2512
|
const shownHints = [];
|
|
2459
2513
|
for (let used = statusUsed, i = 0; i < hints.length; i++) {
|
|
2460
2514
|
used += hints[i][0].length + hints[i][1].length + 4;
|
|
@@ -2467,7 +2521,7 @@ ${snippet}
|
|
|
2467
2521
|
y: () => answer(true), n: approval ? () => answer(false) : undefined, o: openPending,
|
|
2468
2522
|
"Shift+Tab": () => cycleAutoMode(), "/": () => typeCommand("/"), "Ctrl+P": () => setQuickOpen(""),
|
|
2469
2523
|
Tab: () => setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]),
|
|
2470
|
-
e: startEditing, "double-click/e": startEditing, b: goBack,
|
|
2524
|
+
"+ -": () => zoomCode(1), e: startEditing, "double-click/e": startEditing, b: goBack, [FIND_KEY]: showEditor ? undefined : startFind, v: cycleDiff, u: undoLast, p: toggleProblems, m: toggleMouse,
|
|
2471
2525
|
s: () => setView("settings"), "/help": () => void runCommand("/help"),
|
|
2472
2526
|
Esc: busy ? stop : diff && !showEditor ? () => setDiffMode("off") : undefined,
|
|
2473
2527
|
};
|
|
@@ -2481,7 +2535,7 @@ ${snippet}
|
|
|
2481
2535
|
};
|
|
2482
2536
|
add(` ${autoMode} `.length, () => cycleAutoMode());
|
|
2483
2537
|
add(followChip.length, () => setFollow((f) => !f));
|
|
2484
|
-
add(`
|
|
2538
|
+
add(` ✗ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
|
|
2485
2539
|
if (checking.length)
|
|
2486
2540
|
add(3 + checking.join(", ").length);
|
|
2487
2541
|
if (!exitArmed) {
|
|
@@ -2492,8 +2546,8 @@ ${snippet}
|
|
|
2492
2546
|
}
|
|
2493
2547
|
}
|
|
2494
2548
|
const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
|
|
2495
|
-
clickable.current = { status: zones, modelFrom: columns - ("
|
|
2496
|
-
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, git: git, problemCounts: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest, 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, wheel: wheelFor("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: `
|
|
2549
|
+
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, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(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, wheel: wheelFor("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, wheel: wheelFor("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
|
|
2497
2551
|
? _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)` : ""] })
|
|
2498
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", wheel: wheelFor("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: "Pick a conversation to resume (type to filter)" } : {}), onPick: (file) => {
|
|
2499
2553
|
setQuickOpen(null);
|
package/dist/src/ui/Chat.js
CHANGED
|
@@ -9,7 +9,7 @@ import { copyToClipboard } from "./clipboard.js";
|
|
|
9
9
|
import { C, SPINNER, ANIMATION_MS } from "./theme.js";
|
|
10
10
|
import { useTerminalFocused } from "./focus.js";
|
|
11
11
|
import { Pane } from "./Pane.js";
|
|
12
|
-
import { matchCommands } from "./commands.js";
|
|
12
|
+
import { SLASH_COMMANDS, matchCommands } from "./commands.js";
|
|
13
13
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
14
14
|
/**
|
|
15
15
|
* What the agent did between replies (files read, commands run...) folds into one line, like Claude's
|
|
@@ -63,7 +63,7 @@ function entryLines(entry, inner, links = true) {
|
|
|
63
63
|
else {
|
|
64
64
|
const style = entry.kind === "tool"
|
|
65
65
|
? { icon: "⚙", color: C.dim }
|
|
66
|
-
: entry.kind === "error" ? { icon: "
|
|
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 } : {}) });
|
|
@@ -203,6 +203,14 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
203
203
|
const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
|
|
204
204
|
const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
|
|
205
205
|
const showMenu = matches.length > 0;
|
|
206
|
+
// A known command typed at the start of the input ("/zoom in") is drawn in the command color.
|
|
207
|
+
const commandLength = useMemo(() => {
|
|
208
|
+
const name = /^\/(\S+)/.exec(input)?.[1];
|
|
209
|
+
if (!name)
|
|
210
|
+
return 0;
|
|
211
|
+
const known = SLASH_COMMANDS.some((c) => c.name === name) || !!extraCommands?.some((c) => c.name === name);
|
|
212
|
+
return known ? name.length + 1 : 0;
|
|
213
|
+
}, [input, extraCommands]);
|
|
206
214
|
useEffect(() => onMenu?.(showMenu), [showMenu]);
|
|
207
215
|
const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
|
|
208
216
|
const menuStart = Math.max(0, Math.min(menuRow - menuRows + 1, matches.length - menuRows));
|
|
@@ -408,6 +416,11 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
408
416
|
}) })] })) })), [lines, firstShown, visible.length, selection, inner, empty]);
|
|
409
417
|
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: messages }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), showMenu ? (_jsx(Box, { flexDirection: "column", children: matches.slice(menuStart, menuStart + menuRows).map((c, i) => {
|
|
410
418
|
const selected = menuStart + i === menuRow;
|
|
411
|
-
|
|
412
|
-
|
|
419
|
+
// What's typed so far shows in the command color; the rest of the name follows. A plugin's
|
|
420
|
+
// command says so, so Nou's own stand apart.
|
|
421
|
+
const typed = input.slice(1).toLowerCase();
|
|
422
|
+
const typedLen = typed && c.name.startsWith(typed) ? typed.length : 0;
|
|
423
|
+
const fromPlugin = !!extraCommands?.some((e) => e.name === c.name);
|
|
424
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsx(Text, { color: C.accent2, bold: true, children: "/" }), _jsx(Text, { color: C.yellow, bold: true, children: c.name.slice(0, typedLen) }), _jsx(Text, { color: C.accent2, bold: selected, children: c.name.slice(typedLen) }), fromPlugin && _jsx(Text, { color: C.cyan, children: " plugin" }), c.args && inner >= 56 ? _jsxs(Text, { color: C.dim, children: [" ", c.args] }) : null /* narrow: the description matters more */, _jsxs(Text, { color: C.dim, children: [" ", c.description] })] }, c.name));
|
|
425
|
+
}) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " })), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, command: commandLength, placeholder: busy ? (inner >= 44 ? (sendNowFirst ? "Enter sends now · Alt+Enter queues · Esc stops" : "Enter queues · Alt+Enter sends now · Esc stops") : sendNowFirst ? "Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
|
|
413
426
|
});
|
package/dist/src/ui/ChatInput.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
|
-
import { Box, Text, useInput, usePaste } from "ink";
|
|
3
|
+
import { Box, Text, useInput, usePaste, useStdin } from "ink";
|
|
4
4
|
import { C } from "./theme.js";
|
|
5
5
|
/** Word-wraps `text` into segments of at most `width` characters, honoring newlines. */
|
|
6
6
|
export function layout(text, width) {
|
|
@@ -35,7 +35,7 @@ function cursorLine(segments, cursor) {
|
|
|
35
35
|
}
|
|
36
36
|
return segments.length - 1;
|
|
37
37
|
}
|
|
38
|
-
export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6, arrowsTaken = false }) {
|
|
38
|
+
export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6, arrowsTaken = false, command = 0 }) {
|
|
39
39
|
const [cursor, setCursor] = useState(value.length);
|
|
40
40
|
const [recall, setRecall] = useState(null);
|
|
41
41
|
const stash = useRef("");
|
|
@@ -57,6 +57,24 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
|
|
|
57
57
|
setCursor(Math.max(0, Math.min(next.length, nextCursor)));
|
|
58
58
|
};
|
|
59
59
|
const insert = (text) => set(value.slice(0, pos) + text + value.slice(pos), pos + text.length);
|
|
60
|
+
// Shift/Ctrl/Cmd+Enter as xterm's modifyOtherKeys sends them (ESC [27;<mods>;13~, from terminals set up that
|
|
61
|
+
// way): Ink drops these, so they're caught here and add a line, like the kitty protocol's form does.
|
|
62
|
+
const { stdin } = useStdin();
|
|
63
|
+
const insertLine = useRef(() => { });
|
|
64
|
+
insertLine.current = () => insert("\n");
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!focus || !stdin)
|
|
67
|
+
return;
|
|
68
|
+
const onData = (data) => {
|
|
69
|
+
// eslint-disable-next-line no-control-regex -- the sequence starts with ESC
|
|
70
|
+
if (/\x1b\[27;([2-9]|1[0-6]);13~/.test(String(data)))
|
|
71
|
+
insertLine.current();
|
|
72
|
+
};
|
|
73
|
+
stdin.on("data", onData);
|
|
74
|
+
return () => {
|
|
75
|
+
stdin.off("data", onData);
|
|
76
|
+
};
|
|
77
|
+
}, [focus, stdin]);
|
|
60
78
|
const recallHistory = (step) => {
|
|
61
79
|
if (!history.length)
|
|
62
80
|
return false;
|
|
@@ -131,11 +149,16 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
|
|
|
131
149
|
return (_jsx(Box, { flexDirection: "column", children: shown.map((s, i) => {
|
|
132
150
|
const text = value.slice(s.start, s.end).replace(/\n/g, "");
|
|
133
151
|
const isCursor = focus && first + i === line;
|
|
152
|
+
// A known command at the start ("/zoom in") in the command color, the rest as typed.
|
|
153
|
+
const paint = (from, to) => {
|
|
154
|
+
const cmdEnd = Math.max(from, Math.min(to, command - s.start));
|
|
155
|
+
return (_jsxs(_Fragment, { children: [cmdEnd > from && _jsx(Text, { color: C.accent2, bold: true, children: text.slice(from, cmdEnd) }), _jsx(Text, { color: C.text, children: text.slice(cmdEnd, to) })] }));
|
|
156
|
+
};
|
|
134
157
|
if (!isCursor) {
|
|
135
|
-
return _jsxs(Text, { children: [prompt(i),
|
|
158
|
+
return _jsxs(Text, { children: [prompt(i), text ? paint(0, text.length) : " "] }, first + i);
|
|
136
159
|
}
|
|
137
160
|
const at = pos - s.start;
|
|
138
|
-
return (_jsxs(Text, { children: [prompt(i),
|
|
161
|
+
return (_jsxs(Text, { children: [prompt(i), paint(0, at), _jsx(Text, { inverse: true, children: text[at] ?? " " }), paint(at + 1, text.length)] }, first + i));
|
|
139
162
|
}) }));
|
|
140
163
|
}
|
|
141
164
|
/** How many rows the input will take, so the chat can size its history above it. */
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -5,6 +5,7 @@ import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
|
5
5
|
import { artWork } from "../util/index.js";
|
|
6
6
|
import { foldRanges, highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines, unfoldedLines } from "./text.js";
|
|
7
7
|
import { C, fileIcon, gradient } from "./theme.js";
|
|
8
|
+
import { isFind } from "./keys.js";
|
|
8
9
|
import { Pane } from "./Pane.js";
|
|
9
10
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
11
|
/** The current match: a yellow background; the others are shown inverted. */
|
|
@@ -12,7 +13,7 @@ const CURRENT_MATCH = ["\x1b[43m\x1b[30m", "\x1b[49m\x1b[39m"];
|
|
|
12
13
|
/** Columns ←/→ and Shift+wheel move sideways. */
|
|
13
14
|
const SIDE_STEP = 8;
|
|
14
15
|
const EDIT_HINT = " double-click or e to edit · v diff";
|
|
15
|
-
export const CodeView = memo(function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
|
|
16
|
+
export const CodeView = memo(function CodeView({ showing, width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
|
|
16
17
|
const [top, setTop] = useState(0);
|
|
17
18
|
// Columns scrolled sideways (colors are kept: see sliceAnsi).
|
|
18
19
|
const [left, setLeft] = useState(0);
|
|
@@ -165,7 +166,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
165
166
|
return setFind((f) => f && { ...f, index: f.index - 1 });
|
|
166
167
|
if (key.backspace || key.delete)
|
|
167
168
|
return setFind((f) => f && { query: f.query.slice(0, -1), index: 0 });
|
|
168
|
-
if (
|
|
169
|
+
if (isFind(input, key))
|
|
169
170
|
return;
|
|
170
171
|
if (input && !key.ctrl && !key.meta && input >= " ")
|
|
171
172
|
setFind((f) => f && { query: f.query + input, index: 0 });
|
|
@@ -173,7 +174,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
173
174
|
useInput((input, key) => {
|
|
174
175
|
if (find)
|
|
175
176
|
return;
|
|
176
|
-
if (
|
|
177
|
+
if (isFind(input, key) && !diff)
|
|
177
178
|
return setFind({ query: "", index: 0 });
|
|
178
179
|
if (key.upArrow || input === "k")
|
|
179
180
|
setTop((t) => Math.max(0, Math.min(t, maxTop) - 1));
|
|
@@ -238,8 +239,8 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
238
239
|
const warningCount = problems.filter((d) => d.severity === "warning").length;
|
|
239
240
|
// The edit hint only when it fits: a cut-off hint reads as noise.
|
|
240
241
|
const titleRoom = tabs ? 0 : width - 4 - (3 + filePath.length + (errorCount ? 5 + String(errorCount).length : 0) + (warningCount ? 5 + String(warningCount).length : 0) + scroll.length + (total > rows ? 7 : 0));
|
|
241
|
-
const title = (_jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath] }), errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \
|
|
242
|
-
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: shown.slice(start, start + rows).map((n) => {
|
|
242
|
+
const title = (_jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath] }), showing && _jsxs(_Fragment, { children: [" ", _jsx(Text, { backgroundColor: C.accent2, color: C.onBadge, bold: true, children: ` ◉ SHOWING ${showing} ` })] }), errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2717 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused && titleRoom >= EDIT_HINT.length ? EDIT_HINT : ""] })] }));
|
|
243
|
+
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, ...(showing ? { accent: C.accent2 } : {}), children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: shown.slice(start, start + rows).map((n) => {
|
|
243
244
|
const line = lines[n - 1] ?? "";
|
|
244
245
|
const fold = ranges.get(n);
|
|
245
246
|
const isFolded = fold !== undefined && folded.has(n);
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "path";
|
|
|
4
4
|
import { Box, Text, useInput, usePaste } from "ink";
|
|
5
5
|
import { highlightLines, sliceAnsi } from "./text.js";
|
|
6
6
|
import { C, fileIcon } from "./theme.js";
|
|
7
|
+
import { FIND_KEY, isFind } from "./keys.js";
|
|
7
8
|
import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
8
9
|
import { Pane } from "./Pane.js";
|
|
9
10
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
@@ -401,7 +402,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
401
402
|
return find(prompt.text, key.upArrow);
|
|
402
403
|
if (key.backspace || key.delete)
|
|
403
404
|
return setPrompt({ ...prompt, text: prompt.text.slice(0, -1) });
|
|
404
|
-
if (
|
|
405
|
+
if (isFind(input, key))
|
|
405
406
|
return find(prompt.text);
|
|
406
407
|
if (input && !key.ctrl && !key.meta && (prompt.kind === "find" || /^\d+$/.test(input))) {
|
|
407
408
|
const text = prompt.text + input;
|
|
@@ -433,7 +434,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
433
434
|
return step(undo, redo);
|
|
434
435
|
if (key.ctrl && input === "y")
|
|
435
436
|
return step(redo, undo);
|
|
436
|
-
if (
|
|
437
|
+
if (isFind(input, key)) {
|
|
437
438
|
const word = selectedText(buf);
|
|
438
439
|
return setPrompt({ kind: "find", text: word.includes("\n") ? "" : word });
|
|
439
440
|
}
|
|
@@ -631,7 +632,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
631
632
|
? prompt.kind === "find" ? `Find: ${prompt.text}▏ Enter/↓ next · Shift+Enter/↑ previous · Esc close` : `Go to line: ${prompt.text}▏ Enter go · Esc cancel`
|
|
632
633
|
: flash ?? (cursorProblem
|
|
633
634
|
? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
|
|
634
|
-
: `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""}
|
|
635
|
+
: `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""} ${FIND_KEY} find · Ctrl+/ comment · Esc done`);
|
|
635
636
|
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;
|
|
636
637
|
const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
|
|
637
638
|
const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -32,7 +32,7 @@ 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, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
|
|
35
|
+
export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, showing, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
|
|
36
36
|
const [expanded, setExpanded] = useState(() => new Set(["src"]));
|
|
37
37
|
const [cursor, setCursor] = useState(0);
|
|
38
38
|
// The first row shown. The wheel moves it on its own; the keyboard moves the cursor and the view follows.
|
|
@@ -182,11 +182,12 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
|
|
|
182
182
|
? { icon: expanded.has(node.path) ? "▾" : "▸", color: C.accent }
|
|
183
183
|
: fileIcon(node.path);
|
|
184
184
|
const counts = problemCounts.get(node.path);
|
|
185
|
-
const problemMark = counts?.errors ? "
|
|
186
|
-
const
|
|
187
|
-
const
|
|
185
|
+
const problemMark = counts?.errors ? "✗" : counts?.warnings ? "▲" : "";
|
|
186
|
+
const stops = showing?.get(node.path);
|
|
187
|
+
const marker = stops ? `◉${stops.join(",")}` : problemMark || code || (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
|
|
188
|
+
const markerColor = stops ? C.accent2 : counts?.errors ? C.red : counts?.warnings ? C.yellow : code ? GIT_COLORS[code] ?? C.yellow : changed.has(node.path) ? C.green : C.yellow;
|
|
188
189
|
const label = `${" ".repeat(node.depth)}${icon} ${node.name}`;
|
|
189
|
-
const nameColor = isOpen ? C.cyan : counts?.errors ? C.red : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
|
|
190
|
+
const nameColor = stops ? C.accent2 : isOpen ? C.cyan : counts?.errors ? C.red : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
|
|
190
191
|
const room = Math.max(1, inner - marker.length - 1);
|
|
191
192
|
const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
|
|
192
193
|
const iconAt = shown.indexOf(icon);
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -29,12 +29,23 @@ export function headerZones(tabs) {
|
|
|
29
29
|
zones.push({ from: x, to: x + 3, tab: "new" });
|
|
30
30
|
return zones;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* The header's right side: the update notice (when there's room), where the model runs and which. Plain
|
|
34
|
+
* characters only: symbols that are also emoji (☁, ⬆) are drawn two columns wide by some terminals.
|
|
35
|
+
*/
|
|
36
|
+
const rightSide = (width, model, local, update) => ({
|
|
37
|
+
notice: update && width >= 110 ? `↑ v${update} available · run \`nou update\` ` : "",
|
|
38
|
+
badge: ` ${local ? "LOCAL" : "CLOUD"} `,
|
|
39
|
+
model: ` ${model} `,
|
|
40
|
+
});
|
|
32
41
|
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update, tabs = [] }) {
|
|
33
42
|
// The spinner stops while Nou's window is in the background: nobody sees it, and each frame is a redraw.
|
|
34
43
|
const windowFocused = useTerminalFocused();
|
|
35
44
|
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
|
|
36
45
|
const project = path.basename(process.cwd());
|
|
37
|
-
|
|
46
|
+
const right = rightSide(width, model, local, update);
|
|
47
|
+
const rightWidth = right.notice.length + right.badge.length + right.model.length + 1;
|
|
48
|
+
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
|
|
38
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)))
|
|
39
|
-
: _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: [
|
|
50
|
+
: _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 }), " "] }) })] }));
|
|
40
51
|
});
|
|
@@ -5,7 +5,7 @@ import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
|
|
|
5
5
|
import { useTerminalFocused } from "./focus.js";
|
|
6
6
|
import { Pane } from "./Pane.js";
|
|
7
7
|
export const SEVERITY_STYLE = {
|
|
8
|
-
error: { icon: "
|
|
8
|
+
error: { icon: "✗", color: C.red },
|
|
9
9
|
warning: { icon: "▲", color: C.yellow },
|
|
10
10
|
info: { icon: "ℹ", color: C.accent },
|
|
11
11
|
};
|
|
@@ -63,13 +63,13 @@ export function ProblemsView({ width, height, focused, problems, checking, wheel
|
|
|
63
63
|
const errors = problems.filter((d) => d.severity === "error").length;
|
|
64
64
|
const warnings = problems.filter((d) => d.severity === "warning").length;
|
|
65
65
|
const start = Math.max(0, Math.min(cursor - Math.floor(visibleRows / 2), rows.length - visibleRows));
|
|
66
|
-
const title = (_jsxs(_Fragment, { children: [" ", _jsxs(Text, { color: C.red, children: ["\
|
|
66
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsxs(Text, { color: C.red, children: ["\u2717 ", errors] }), " ", _jsxs(Text, { color: C.yellow, children: ["\u25B2 ", warnings] }), " Problems", checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " checking ", checking.join(", ")] }), _jsx(Text, { color: C.dim, children: onlyErrors ? " (errors only)" : "" })] }));
|
|
67
67
|
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, ...(focused ? { accent: C.red } : {}), children: [_jsxs(Box, { flexDirection: "column", height: visibleRows, children: [rows.length === 0 && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.green, children: checking.length ? "Checking…" : "✓ No problems found" }), _jsx(Text, { color: C.dim, children: "Checked by the syntax parser, plus ESLint, TypeScript, Ruff and language servers when your project has them." })] })), rows.slice(start, start + visibleRows).map((row, i) => {
|
|
68
68
|
const selected = focused && start + i === cursor;
|
|
69
69
|
const bg = selected ? { backgroundColor: C.panel } : {};
|
|
70
70
|
if (row.kind === "file") {
|
|
71
71
|
const { icon, color } = fileIcon(row.file);
|
|
72
|
-
return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [_jsx(Text, { color: color, children: icon }), " ", _jsx(Text, { bold: true, color: C.text, children: row.file }), row.errors > 0 && _jsxs(Text, { color: C.red, children: [" \
|
|
72
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [_jsx(Text, { color: color, children: icon }), " ", _jsx(Text, { bold: true, color: C.text, children: row.file }), row.errors > 0 && _jsxs(Text, { color: C.red, children: [" \u2717 ", row.errors] }), row.warnings > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", row.warnings] })] }, `f${row.file}`));
|
|
73
73
|
}
|
|
74
74
|
const style = SEVERITY_STYLE[row.d.severity];
|
|
75
75
|
return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [" ", _jsx(Text, { color: style.color, children: style.icon }), _jsxs(Text, { color: C.dim, children: [" ", String(row.d.line).padStart(4), ":", String(row.d.col).padEnd(3), " "] }), _jsx(Text, { ...(selected ? { color: C.text } : {}), children: row.d.message }), _jsxs(Text, { color: C.dim, children: [" ", row.d.source, row.d.code ? ` ${row.d.code}` : ""] })] }, `${row.d.file}:${row.d.line}:${row.d.col}:${i}`));
|
package/dist/src/ui/Settings.js
CHANGED
|
@@ -213,7 +213,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
213
213
|
const selected = i === row;
|
|
214
214
|
const current = p.id === choice.provider.id;
|
|
215
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: " \uD83D\uDD12" }) : null] }, p.id));
|
|
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: ["\
|
|
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);
|
|
219
219
|
if (i < connecting.step) {
|
|
@@ -286,7 +286,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
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
287
|
? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u26A0 Changed since you enabled it. Review the code, then press Enter to enable again." })
|
|
288
288
|
: p.error
|
|
289
|
-
? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \
|
|
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));
|
|
291
291
|
}), _jsx(Text, { children: " " }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Starters: Enter adds one to ", PLUGIN_DIRS.find((d) => d.scope === "user").dir, ", where you can edit it."] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Write your own: /plugins new <name>, or drop a .js/.mjs file into ", PLUGIN_DIRS.map((d) => d.dir).join(" or "), ", then press r."] })] }));
|
|
292
292
|
};
|
|
@@ -1,42 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Asks the terminal, once, before Nou draws anything:
|
|
3
|
+
* - its background color (OSC 11), so the colors are readable on it (COLORFGBG answers that without asking);
|
|
4
|
+
* - whether it speaks the kitty keyboard protocol (CSI ? u), which tells Shift/Ctrl/Cmd+Enter, Cmd+F and
|
|
5
|
+
* Ctrl+= apart from plain keys. Ink can ask too, but only after the first frame is drawn, and with a big
|
|
6
|
+
* project the answer lands after its 200 ms timeout, so the protocol never came on (Shift+Enter sent).
|
|
7
|
+
* Then DA1 (CSI c), which every terminal answers, and in order: once its answer is in, so are the others.
|
|
8
8
|
*/
|
|
9
|
-
export async function
|
|
9
|
+
export async function detectTerminal(timeoutMs = 400) {
|
|
10
10
|
const fromEnv = paletteFromColorFgBg(process.env.COLORFGBG);
|
|
11
|
-
if (fromEnv)
|
|
12
|
-
return fromEnv;
|
|
13
11
|
const stdin = process.stdin;
|
|
14
12
|
if (!stdin.isTTY || !process.stdout.isTTY || typeof stdin.setRawMode !== "function")
|
|
15
|
-
return null;
|
|
13
|
+
return { palette: fromEnv, kitty: null };
|
|
16
14
|
return new Promise((resolve) => {
|
|
17
15
|
let reply = "";
|
|
18
16
|
const wasRaw = stdin.isRaw;
|
|
19
|
-
const finish = (
|
|
17
|
+
const finish = () => {
|
|
20
18
|
clearTimeout(timer);
|
|
21
19
|
stdin.off("data", onData);
|
|
22
20
|
stdin.setRawMode(wasRaw);
|
|
23
21
|
stdin.pause();
|
|
24
|
-
resolve(
|
|
22
|
+
resolve(readTerminalReply(reply, fromEnv));
|
|
25
23
|
};
|
|
26
24
|
const onData = (data) => {
|
|
27
25
|
reply += data.toString("latin1");
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
finish(palette);
|
|
26
|
+
if (DA1.test(reply))
|
|
27
|
+
finish();
|
|
31
28
|
};
|
|
32
|
-
const timer = setTimeout(
|
|
29
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
33
30
|
stdin.setRawMode(true);
|
|
34
31
|
stdin.resume();
|
|
35
32
|
stdin.on("data", onData);
|
|
36
|
-
// ST-terminated (ESC \): the form most terminals answer in kind.
|
|
37
|
-
process.stdout.write("\x1b]11;?\x1b\\");
|
|
33
|
+
// OSC 11 is ST-terminated (ESC \): the form most terminals answer in kind.
|
|
34
|
+
process.stdout.write(`${fromEnv ? "" : "\x1b]11;?\x1b\\"}\x1b[?u\x1b[c`);
|
|
38
35
|
});
|
|
39
36
|
}
|
|
37
|
+
// eslint-disable-next-line no-control-regex -- terminal answers start with ESC
|
|
38
|
+
const KITTY = /\x1b\[\?\d+u/;
|
|
39
|
+
// eslint-disable-next-line no-control-regex -- terminal answers start with ESC
|
|
40
|
+
const DA1 = /\x1b\[\?[\d;]*c/;
|
|
41
|
+
/** What the terminal's answers say (see detectTerminal); `fromEnv` is the background COLORFGBG gave, if any. */
|
|
42
|
+
export function readTerminalReply(reply, fromEnv = null) {
|
|
43
|
+
return { palette: fromEnv ?? paletteFromOsc11(reply), kitty: KITTY.test(reply) ? true : DA1.test(reply) ? false : null };
|
|
44
|
+
}
|
|
45
|
+
/** Just the background (see detectTerminal). */
|
|
46
|
+
export async function detectBackground(timeoutMs = 400) {
|
|
47
|
+
return (await detectTerminal(timeoutMs)).palette;
|
|
48
|
+
}
|
|
40
49
|
/** "15;0" (light text on black) → dark; "0;15" → light. The last number is the background's color index. */
|
|
41
50
|
export function paletteFromColorFgBg(value) {
|
|
42
51
|
const bg = Number(value?.split(";").at(-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(["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(["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
|
|
@@ -39,7 +39,7 @@ function editDistance(a, b) {
|
|
|
39
39
|
export function runsWhileBusy(text) {
|
|
40
40
|
const [name, ...rest] = text.slice(1).trim().split(/\s+/);
|
|
41
41
|
if (name === "showme" || name === "teach" || name === "teachme")
|
|
42
|
-
return !rest.length || /^(next|skip|back|prev|previous|stop|end|done)$/.test(rest.join(" "));
|
|
42
|
+
return !rest.length || /^(next|skip|back|prev|previous|stop|end|done|clear)$/.test(rest.join(" "));
|
|
43
43
|
if (!text.startsWith("/") || !name)
|
|
44
44
|
return false;
|
|
45
45
|
return INSTANT.has(name) || INSTANT.has(closestCommand(name, SLASH_COMMANDS.map((c) => c.name)) ?? "");
|
|
@@ -53,6 +53,7 @@ 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: "zoom", args: "in|out|reset", description: "make the code panel bigger or smaller (+ and - with the code focused, = resets)" },
|
|
56
57
|
{ name: "now", description: "stop Nou and send your queued messages right away (or click them)" },
|
|
57
58
|
{ name: "settings", description: "models, engine, preferences and plugins" },
|
|
58
59
|
{ name: "auto", args: "[ask|auto-edit|auto]", description: "how much Nou does without asking" },
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keys named the way the platform names them. On a Mac, Find is Cmd+F: terminals that report the Cmd key
|
|
3
|
+
* (kitty's keyboard protocol, which Nou asks for: Ghostty, kitty, WezTerm, iTerm2) send it as "super". Ctrl+F
|
|
4
|
+
* works everywhere too, for terminals that keep Cmd for themselves.
|
|
5
|
+
*/
|
|
6
|
+
export const FIND_KEY = process.platform === "darwin" ? "Cmd+F" : "Ctrl+F";
|
|
7
|
+
/** Ctrl+F, or Cmd+F where the terminal reports Cmd. */
|
|
8
|
+
export const isFind = (input, key) => input === "f" && (!!key.ctrl || !!key.super);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.12",
|
|
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": {
|