nou-cli 0.4.0 → 0.5.1
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 +18 -3
- package/dist/index.js +4 -5
- package/dist/src/env.js +3 -0
- package/dist/src/ui/App.js +182 -51
- package/dist/src/ui/Chat.js +92 -45
- package/dist/src/ui/ChatInput.js +11 -2
- package/dist/src/ui/CodeView.js +41 -22
- package/dist/src/ui/Editor.js +487 -99
- package/dist/src/ui/FileTree.js +64 -37
- package/dist/src/ui/Header.js +3 -2
- package/dist/src/ui/ProblemsView.js +2 -2
- package/dist/src/ui/clipboard.js +30 -0
- package/dist/src/ui/fast-output.js +250 -0
- package/dist/src/ui/mouse.js +9 -4
- package/dist/src/ui/render-options.js +11 -0
- package/dist/src/ui/scroll.js +30 -0
- package/package.json +3 -2
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { readdirSync } from "fs";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { Box, Text, useInput } from "ink";
|
|
6
6
|
import { IGNORED } from "../commands/index.js";
|
|
7
7
|
import { C, GIT_COLORS, fileIcon } from "./theme.js";
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
9
|
+
import { Scrollbar, topFor } from "./scroll.js";
|
|
9
10
|
function listDir(dir) {
|
|
10
11
|
try {
|
|
11
12
|
return readdirSync(path.resolve(process.cwd(), dir), { withFileTypes: true })
|
|
@@ -30,16 +31,28 @@ function flatten(expanded) {
|
|
|
30
31
|
walk(".", 0);
|
|
31
32
|
return nodes;
|
|
32
33
|
}
|
|
33
|
-
export function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, wheel, click, onOpen, onHover }) {
|
|
34
|
+
export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
|
|
34
35
|
const [expanded, setExpanded] = useState(() => new Set(["src"]));
|
|
35
36
|
const [cursor, setCursor] = useState(0);
|
|
37
|
+
// The first row shown. The wheel moves it on its own; the keyboard moves the cursor and the view follows.
|
|
38
|
+
const [top, setTop] = useState(0);
|
|
36
39
|
const [changesOnly, setChangesOnly] = useState(false);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return [...new Set([...git.keys(), ...changed])].sort().map((p) => ({ path: p, name: p, depth: 0, isDir: false }));
|
|
41
|
-
}, [expanded, version, changesOnly, git, changed]);
|
|
40
|
+
// Reading folders hits the disk, so only redo it when something that changes the list changes.
|
|
41
|
+
const all = useMemo(() => (changesOnly ? null : flatten(expanded)), [expanded, version, changesOnly]);
|
|
42
|
+
const nodes = useMemo(() => all ?? [...new Set([...git.keys(), ...changed])].sort().map((p) => ({ path: p, name: p, depth: 0, isDir: false })), [all, changesOnly ? git : null, changesOnly ? changed : null]);
|
|
42
43
|
const rows = Math.max(1, height - 3);
|
|
44
|
+
const maxTop = Math.max(0, nodes.length - rows);
|
|
45
|
+
// Keep the cursor in view when it moves (keyboard, reveal, click), not when the wheel scrolls.
|
|
46
|
+
const follow = useRef(true);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!follow.current)
|
|
49
|
+
return;
|
|
50
|
+
setTop((t) => (cursor < t ? Math.max(0, cursor - 2) : cursor >= t + rows ? Math.min(maxTop, cursor - rows + 3) : Math.min(t, maxTop)));
|
|
51
|
+
}, [cursor, rows, maxTop]);
|
|
52
|
+
const moveCursor = (fn) => {
|
|
53
|
+
follow.current = true;
|
|
54
|
+
setCursor(fn);
|
|
55
|
+
};
|
|
43
56
|
// Folders that contain git changes get a dot.
|
|
44
57
|
const dirtyDirs = useMemo(() => {
|
|
45
58
|
const dirs = new Set();
|
|
@@ -71,7 +84,7 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
|
|
|
71
84
|
const index = nodes.findIndex((n) => n.path === pendingReveal.current);
|
|
72
85
|
if (index < 0)
|
|
73
86
|
return;
|
|
74
|
-
|
|
87
|
+
moveCursor(() => index);
|
|
75
88
|
pendingReveal.current = null;
|
|
76
89
|
}, [nodes]);
|
|
77
90
|
useEffect(() => {
|
|
@@ -92,16 +105,20 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
|
|
|
92
105
|
const node = nodes[cursor];
|
|
93
106
|
if (input === "c") {
|
|
94
107
|
setChangesOnly((v) => !v);
|
|
95
|
-
|
|
108
|
+
moveCursor(() => 0);
|
|
96
109
|
}
|
|
97
110
|
else if (key.upArrow || input === "k")
|
|
98
|
-
|
|
111
|
+
moveCursor((c) => Math.max(0, c - 1));
|
|
99
112
|
else if (key.downArrow || input === "j")
|
|
100
|
-
|
|
113
|
+
moveCursor((c) => Math.min(nodes.length - 1, c + 1));
|
|
101
114
|
else if (key.pageUp)
|
|
102
|
-
|
|
115
|
+
moveCursor((c) => Math.max(0, c - rows));
|
|
103
116
|
else if (key.pageDown)
|
|
104
|
-
|
|
117
|
+
moveCursor((c) => Math.min(nodes.length - 1, c + rows));
|
|
118
|
+
else if (key.home || input === "g")
|
|
119
|
+
moveCursor(() => 0);
|
|
120
|
+
else if (key.end || input === "G")
|
|
121
|
+
moveCursor(() => Math.max(0, nodes.length - 1));
|
|
105
122
|
else if (!node)
|
|
106
123
|
return;
|
|
107
124
|
else if (key.return || key.rightArrow || input === "l") {
|
|
@@ -117,16 +134,25 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
|
|
|
117
134
|
const parent = node.path.split("/").slice(0, -1).join("/");
|
|
118
135
|
const index = nodes.findIndex((n) => n.path === parent);
|
|
119
136
|
if (index >= 0)
|
|
120
|
-
|
|
137
|
+
moveCursor(() => index);
|
|
121
138
|
}
|
|
122
139
|
}
|
|
123
140
|
}, { isActive: focused });
|
|
124
|
-
const start = Math.
|
|
141
|
+
const start = Math.min(top, maxTop);
|
|
125
142
|
const visible = nodes.slice(start, start + rows);
|
|
143
|
+
// The wheel scrolls the list; the selection stays where it is.
|
|
126
144
|
useEffect(() => {
|
|
127
|
-
if (wheel)
|
|
128
|
-
|
|
145
|
+
if (!wheel?.lines)
|
|
146
|
+
return;
|
|
147
|
+
follow.current = false;
|
|
148
|
+
setTop((t) => Math.max(0, Math.min(maxTop, Math.min(t, maxTop) + wheel.lines)));
|
|
129
149
|
}, [wheel?.nonce]);
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!scrollTo)
|
|
152
|
+
return;
|
|
153
|
+
follow.current = false;
|
|
154
|
+
setTop(topFor(scrollTo.fraction, nodes.length, rows));
|
|
155
|
+
}, [scrollTo?.nonce]);
|
|
130
156
|
// Clicking a row selects it; a file opens, a folder expands or collapses.
|
|
131
157
|
useEffect(() => {
|
|
132
158
|
if (!click)
|
|
@@ -134,32 +160,33 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
|
|
|
134
160
|
const node = visible[click.row];
|
|
135
161
|
if (!node)
|
|
136
162
|
return;
|
|
163
|
+
follow.current = false; // already on screen: don't jump the view
|
|
137
164
|
setCursor(start + click.row);
|
|
138
165
|
if (node.isDir)
|
|
139
166
|
toggle(node.path);
|
|
140
167
|
else
|
|
141
168
|
onOpen(node.path);
|
|
142
169
|
}, [click?.nonce]);
|
|
143
|
-
const inner = width -
|
|
170
|
+
const inner = width - 3; // minus the borders and the scrollbar
|
|
144
171
|
const title = changesOnly
|
|
145
172
|
? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
|
|
146
173
|
: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent, children: "\u25A4" }), " Explorer", git.size ? _jsxs(Text, { color: C.dim, children: [" c: changes (", git.size, ")"] }) : null] });
|
|
147
|
-
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", children: [visible.length === 0 && _jsxs(Text, { color: C.dim, children: [" ", changesOnly ? "No changes ✓" : "(empty)"] }), visible.map((node, i) => {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
174
|
+
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "row", height: rows, children: [_jsxs(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: [visible.length === 0 && _jsxs(Text, { color: C.dim, children: [" ", changesOnly ? "No changes ✓" : "(empty)"] }), visible.map((node, i) => {
|
|
175
|
+
const selected = start + i === cursor;
|
|
176
|
+
const code = git.get(node.path);
|
|
177
|
+
const isOpen = node.path === openFile;
|
|
178
|
+
const { icon, color } = node.isDir
|
|
179
|
+
? { icon: expanded.has(node.path) ? "▾" : "▸", color: C.accent }
|
|
180
|
+
: fileIcon(node.path);
|
|
181
|
+
const counts = problemCounts.get(node.path);
|
|
182
|
+
const problemMark = counts?.errors ? "✖" : counts?.warnings ? "▲" : "";
|
|
183
|
+
const marker = problemMark || code || (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
|
|
184
|
+
const markerColor = counts?.errors ? C.red : counts?.warnings ? C.yellow : code ? GIT_COLORS[code] ?? C.yellow : changed.has(node.path) ? C.green : C.yellow;
|
|
185
|
+
const label = `${" ".repeat(node.depth)}${icon} ${node.name}`;
|
|
186
|
+
const nameColor = isOpen ? C.cyan : counts?.errors ? C.red : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
|
|
187
|
+
const room = Math.max(1, inner - marker.length - 1);
|
|
188
|
+
const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
|
|
189
|
+
const iconAt = shown.indexOf(icon);
|
|
190
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), _jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) }), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
|
|
191
|
+
})] }), _jsx(Scrollbar, { total: nodes.length, top: start, rows: rows, focused: focused })] }) }));
|
|
192
|
+
});
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { memo } from "react";
|
|
2
3
|
import path from "path";
|
|
3
4
|
import { Box, Text, useAnimation } from "ink";
|
|
4
5
|
import { C, SPINNER, gradient } from "./theme.js";
|
|
5
6
|
import { currentVersion } from "../update/index.js";
|
|
6
7
|
const VERSION = `v${currentVersion()}`;
|
|
7
|
-
export function Header({ width, branch, changes, model, local, busy, update }) {
|
|
8
|
+
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
|
|
8
9
|
const { frame } = useAnimation({ interval: 120, isActive: !!busy });
|
|
9
10
|
const project = path.basename(process.cwd());
|
|
10
11
|
return (_jsxs(Box, { width: width, justifyContent: "space-between", 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 " }), _jsx(Text, { color: C.text, children: project }), 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] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }), _jsxs(Text, { children: [update && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] })] }));
|
|
11
|
-
}
|
|
12
|
+
});
|
|
@@ -33,8 +33,8 @@ export function ProblemsView({ width, height, focused, problems, checking, wheel
|
|
|
33
33
|
}, [problems, onlyErrors]);
|
|
34
34
|
useEffect(() => setCursor((c) => Math.min(c, Math.max(0, rows.length - 1))), [rows.length]);
|
|
35
35
|
useEffect(() => {
|
|
36
|
-
if (wheel)
|
|
37
|
-
setCursor((c) => Math.max(0, Math.min(rows.length - 1, c + wheel.
|
|
36
|
+
if (wheel?.lines)
|
|
37
|
+
setCursor((c) => Math.max(0, Math.min(rows.length - 1, c + wheel.lines)));
|
|
38
38
|
}, [wheel?.nonce]);
|
|
39
39
|
const visibleRows = Math.max(1, height - 4);
|
|
40
40
|
useInput((input, key) => {
|
package/dist/src/ui/clipboard.js
CHANGED
|
@@ -27,3 +27,33 @@ export async function copyToClipboard(text) {
|
|
|
27
27
|
return true;
|
|
28
28
|
return process.stdout.isTTY === true;
|
|
29
29
|
}
|
|
30
|
+
/** Commands that print the clipboard, in order, for this platform. */
|
|
31
|
+
function readCommands() {
|
|
32
|
+
if (process.platform === "win32")
|
|
33
|
+
return [["powershell", ["-NoProfile", "-Command", "Get-Clipboard -Raw"]]];
|
|
34
|
+
if (process.platform === "darwin")
|
|
35
|
+
return [["pbpaste", []]];
|
|
36
|
+
return [["wl-paste", ["--no-newline"]], ["xclip", ["-selection", "clipboard", "-o"]], ["xsel", ["--clipboard", "--output"]]];
|
|
37
|
+
}
|
|
38
|
+
function capture(cmd, args) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
let out = "";
|
|
41
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
42
|
+
child.stdout.setEncoding("utf-8");
|
|
43
|
+
child.stdout.on("data", (chunk) => (out += chunk));
|
|
44
|
+
child.on("error", () => resolve(null));
|
|
45
|
+
child.on("close", (code) => resolve(code === 0 ? out : null));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The system clipboard's text, or null if no clipboard tool is available (e.g. over SSH; there the
|
|
50
|
+
* terminal's own paste, Ctrl+Shift+V or Cmd+V, still works).
|
|
51
|
+
*/
|
|
52
|
+
export async function readClipboard() {
|
|
53
|
+
for (const [cmd, args] of readCommands()) {
|
|
54
|
+
const text = await capture(cmd, args);
|
|
55
|
+
if (text !== null)
|
|
56
|
+
return process.platform === "win32" ? text.replace(/\r?\n$/, "") : text;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A faster final stage for Ink's renderer. Every frame Ink lays the whole screen out as a grid of styled
|
|
3
|
+
* cells and turns it back into a string. Ink 8 does that from scratch each time: a fresh cache per frame
|
|
4
|
+
* (so every line of text on screen is re-tokenized), and a style diff between every pair of neighbouring
|
|
5
|
+
* cells that builds three Sets each, about 30,000 throwaway objects for a 200x50 terminal. That was most of
|
|
6
|
+
* the CPU time of every keystroke, scroll and streamed token.
|
|
7
|
+
*
|
|
8
|
+
* This installs a drop-in version of Ink's `Output` methods that produces exactly the same string (the
|
|
9
|
+
* unit tests compare the two) but keeps its caches across frames and shares one array per distinct style,
|
|
10
|
+
* so most neighbouring cells compare by reference and each style change is computed once. It only patches
|
|
11
|
+
* the Ink version it was written against; anything else keeps Ink's own code.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
16
|
+
const PATCHED_VERSIONS = /^8\.0\./;
|
|
17
|
+
/** Plenty for every line on screen plus scrollback; cleared rather than evicted one by one. */
|
|
18
|
+
const MAX_CACHED = 20_000;
|
|
19
|
+
const IGNORABLE = /^\p{Default_Ignorable_Code_Point}+$/u;
|
|
20
|
+
/** Builds the patched methods against Ink's own tokenizer and string-width. Exported for tests. */
|
|
21
|
+
export function makeFastOutput(tok, stringWidth) {
|
|
22
|
+
// One shared array per distinct style, so "same style" is a reference check.
|
|
23
|
+
const interned = new Map();
|
|
24
|
+
const NONE = [];
|
|
25
|
+
interned.set("", NONE);
|
|
26
|
+
const intern = (styles) => {
|
|
27
|
+
if (styles.length === 0)
|
|
28
|
+
return NONE;
|
|
29
|
+
const key = styles.map((c) => `${c.code}\u0000${c.endCode}`).join("\u0001");
|
|
30
|
+
let shared = interned.get(key);
|
|
31
|
+
if (!shared) {
|
|
32
|
+
shared = styles;
|
|
33
|
+
interned.set(key, shared);
|
|
34
|
+
}
|
|
35
|
+
return shared;
|
|
36
|
+
};
|
|
37
|
+
// The escape codes to get from one style to another, computed once per pair.
|
|
38
|
+
const transitions = new Map();
|
|
39
|
+
const transition = (from, to) => {
|
|
40
|
+
if (from === to)
|
|
41
|
+
return "";
|
|
42
|
+
let row = transitions.get(from);
|
|
43
|
+
if (!row)
|
|
44
|
+
transitions.set(from, (row = new Map()));
|
|
45
|
+
let codes = row.get(to);
|
|
46
|
+
if (codes === undefined) {
|
|
47
|
+
codes = from === NONE ? tok.ansiCodesToString(to) : tok.ansiCodesToString(tok.diffAnsiCodes(from, to));
|
|
48
|
+
row.set(to, codes);
|
|
49
|
+
}
|
|
50
|
+
return codes;
|
|
51
|
+
};
|
|
52
|
+
/** Same result as ansi-tokenize's styledCharsToString, for interned styles. */
|
|
53
|
+
const toString = (chars) => {
|
|
54
|
+
let out = "";
|
|
55
|
+
let prev = NONE;
|
|
56
|
+
for (const char of chars) {
|
|
57
|
+
if (char.styles !== prev)
|
|
58
|
+
out += transition(prev, char.styles);
|
|
59
|
+
out += char.value;
|
|
60
|
+
prev = char.styles;
|
|
61
|
+
}
|
|
62
|
+
return out + transition(prev, NONE);
|
|
63
|
+
};
|
|
64
|
+
const styledChars = new Map();
|
|
65
|
+
const getStyledChars = (line) => {
|
|
66
|
+
let cached = styledChars.get(line);
|
|
67
|
+
if (cached === undefined) {
|
|
68
|
+
if (styledChars.size > MAX_CACHED)
|
|
69
|
+
styledChars.clear();
|
|
70
|
+
cached = tok.styledCharsFromTokens(tok.tokenize(line))
|
|
71
|
+
.filter((c) => !IGNORABLE.test(c.value))
|
|
72
|
+
.map((c) => ({ ...c, styles: intern(c.styles) }));
|
|
73
|
+
styledChars.set(line, cached);
|
|
74
|
+
}
|
|
75
|
+
return cached;
|
|
76
|
+
};
|
|
77
|
+
// A write's text split at newlines and re-serialized per line, as Ink does before clipping.
|
|
78
|
+
const splitLines = new Map();
|
|
79
|
+
const linesOf = (text) => {
|
|
80
|
+
let cached = splitLines.get(text);
|
|
81
|
+
if (cached === undefined) {
|
|
82
|
+
if (splitLines.size > MAX_CACHED)
|
|
83
|
+
splitLines.clear();
|
|
84
|
+
const rows = [[]];
|
|
85
|
+
for (const c of getStyledChars(text)) {
|
|
86
|
+
if (c.value === "\n")
|
|
87
|
+
rows.push([]);
|
|
88
|
+
else
|
|
89
|
+
rows[rows.length - 1].push(c);
|
|
90
|
+
}
|
|
91
|
+
cached = rows.map(toString);
|
|
92
|
+
splitLines.set(text, cached);
|
|
93
|
+
}
|
|
94
|
+
return cached;
|
|
95
|
+
};
|
|
96
|
+
const widths = new Map();
|
|
97
|
+
const width = (text) => {
|
|
98
|
+
let cached = widths.get(text);
|
|
99
|
+
if (cached === undefined) {
|
|
100
|
+
if (widths.size > MAX_CACHED)
|
|
101
|
+
widths.clear();
|
|
102
|
+
cached = stringWidth(text);
|
|
103
|
+
widths.set(text, cached);
|
|
104
|
+
}
|
|
105
|
+
return cached;
|
|
106
|
+
};
|
|
107
|
+
const widest = (text) => {
|
|
108
|
+
let max = 0;
|
|
109
|
+
for (const line of text.split("\n"))
|
|
110
|
+
max = Math.max(max, width(line));
|
|
111
|
+
return max;
|
|
112
|
+
};
|
|
113
|
+
const blank = (cell) => ({ ...cell, value: " ", fullWidth: false });
|
|
114
|
+
const EMPTY_CELL = { type: "char", value: " ", fullWidth: false, styles: NONE };
|
|
115
|
+
const slice = (line, from, to) => {
|
|
116
|
+
if (from >= to)
|
|
117
|
+
return "";
|
|
118
|
+
const result = [];
|
|
119
|
+
let column = 0;
|
|
120
|
+
for (const c of getStyledChars(line)) {
|
|
121
|
+
const w = width(c.value);
|
|
122
|
+
const start = column;
|
|
123
|
+
column += w;
|
|
124
|
+
if (column <= from)
|
|
125
|
+
continue;
|
|
126
|
+
if (start >= to)
|
|
127
|
+
break;
|
|
128
|
+
result.push(w > 1 && (start < from || column > to) ? blank(c) : c);
|
|
129
|
+
}
|
|
130
|
+
return toString(result);
|
|
131
|
+
};
|
|
132
|
+
const isClipEmpty = (clip) => (clip.x1 !== undefined && clip.x2 !== undefined && clip.x1 >= clip.x2) || (clip.y1 !== undefined && clip.y2 !== undefined && clip.y1 >= clip.y2);
|
|
133
|
+
const bound = (a, b, tighter) => a === undefined ? b : b === undefined ? a : tighter(a, b);
|
|
134
|
+
const intersect = (outer, inner) => outer
|
|
135
|
+
? { x1: bound(outer.x1, inner.x1, Math.max), x2: bound(outer.x2, inner.x2, Math.min), y1: bound(outer.y1, inner.y1, Math.max), y2: bound(outer.y2, inner.y2, Math.min) }
|
|
136
|
+
: inner;
|
|
137
|
+
/** Ink's applyWriteOperation, with shared caches. */
|
|
138
|
+
const apply = (output, operation, clip) => {
|
|
139
|
+
const { text, transformers } = operation;
|
|
140
|
+
let { x, y } = operation;
|
|
141
|
+
let lines = linesOf(text);
|
|
142
|
+
if (clip) {
|
|
143
|
+
if (isClipEmpty(clip))
|
|
144
|
+
return;
|
|
145
|
+
const clipX = typeof clip.x1 === "number" && typeof clip.x2 === "number";
|
|
146
|
+
const clipY = typeof clip.y1 === "number" && typeof clip.y2 === "number";
|
|
147
|
+
if (clipX && (x + widest(text) < clip.x1 || x > clip.x2))
|
|
148
|
+
return;
|
|
149
|
+
if (clipY && (y + lines.length < clip.y1 || y > clip.y2))
|
|
150
|
+
return;
|
|
151
|
+
if (clipX) {
|
|
152
|
+
lines = lines.map((line) => {
|
|
153
|
+
const from = x < clip.x1 ? clip.x1 - x : 0;
|
|
154
|
+
const w = width(line);
|
|
155
|
+
return slice(line, from, x + w > clip.x2 ? clip.x2 - x : w);
|
|
156
|
+
});
|
|
157
|
+
if (x < clip.x1)
|
|
158
|
+
x = clip.x1;
|
|
159
|
+
}
|
|
160
|
+
if (clipY) {
|
|
161
|
+
const from = y < clip.y1 ? clip.y1 - y : 0;
|
|
162
|
+
const to = y + lines.length > clip.y2 ? clip.y2 - y : lines.length;
|
|
163
|
+
lines = lines.slice(from, to);
|
|
164
|
+
if (y < clip.y1)
|
|
165
|
+
y = clip.y1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (let index = 0; index < lines.length; index++) {
|
|
169
|
+
let line = lines[index];
|
|
170
|
+
const row = output[y + index];
|
|
171
|
+
if (!row)
|
|
172
|
+
continue;
|
|
173
|
+
for (const transformer of transformers)
|
|
174
|
+
line = transformer(line, index + y - operation.y);
|
|
175
|
+
const chars = getStyledChars(line);
|
|
176
|
+
if (chars.length === 0)
|
|
177
|
+
continue;
|
|
178
|
+
let offsetX = x;
|
|
179
|
+
if (row[offsetX]?.value === "" && offsetX > 0 && width(row[offsetX - 1]?.value ?? "") > 1)
|
|
180
|
+
row[offsetX - 1] = blank(row[offsetX - 1]);
|
|
181
|
+
for (const c of chars) {
|
|
182
|
+
row[offsetX] = c;
|
|
183
|
+
const w = Math.max(1, width(c.value));
|
|
184
|
+
for (let offset = 1; offset < w; offset++) {
|
|
185
|
+
row[offsetX + offset] = { type: "char", value: offsetX < 0 ? " " : "", fullWidth: false, styles: c.styles };
|
|
186
|
+
}
|
|
187
|
+
offsetX += w;
|
|
188
|
+
}
|
|
189
|
+
if (row[offsetX]?.value === "")
|
|
190
|
+
row[offsetX] = blank(row[offsetX]);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
function get() {
|
|
194
|
+
const output = [];
|
|
195
|
+
for (let y = 0; y < this.height; y++)
|
|
196
|
+
output.push(new Array(this.width).fill(EMPTY_CELL));
|
|
197
|
+
const clips = [];
|
|
198
|
+
for (const operation of this.operations) {
|
|
199
|
+
if (operation.type === "clip")
|
|
200
|
+
clips.push(intersect(clips[clips.length - 1], operation.clip));
|
|
201
|
+
else if (operation.type === "unclip")
|
|
202
|
+
clips.pop();
|
|
203
|
+
else
|
|
204
|
+
apply(output, operation, clips[clips.length - 1]);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
output: output.map((row) => toString(row.filter((c) => c !== undefined)).trimEnd()).join("\n"),
|
|
208
|
+
height: output.length,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return { get };
|
|
212
|
+
}
|
|
213
|
+
let installed = null;
|
|
214
|
+
/** Finds a package the way Node would from `fromDir` (nested node_modules first) and returns its ESM entry. */
|
|
215
|
+
function resolveFrom(fromDir, name) {
|
|
216
|
+
for (let dir = fromDir;; dir = path.dirname(dir)) {
|
|
217
|
+
const pkgDir = path.join(dir, "node_modules", name);
|
|
218
|
+
if (existsSync(path.join(pkgDir, "package.json"))) {
|
|
219
|
+
const pkg = JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf-8"));
|
|
220
|
+
const root = typeof pkg.exports === "object" && pkg.exports !== null ? pkg.exports["."] ?? pkg.exports : pkg.exports;
|
|
221
|
+
const entry = typeof root === "string" ? root : root?.import ?? root?.default ?? pkg.module ?? pkg.main ?? "index.js";
|
|
222
|
+
return pathToFileURL(path.join(pkgDir, typeof entry === "string" ? entry : entry.default)).href;
|
|
223
|
+
}
|
|
224
|
+
if (path.dirname(dir) === dir)
|
|
225
|
+
throw new Error(`can't find ${name}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Loads the modules Ink itself uses (they may be nested under Ink, so resolve from there). */
|
|
229
|
+
async function inkModules() {
|
|
230
|
+
const inkUrl = import.meta.resolve("ink");
|
|
231
|
+
const inkDir = path.dirname(path.dirname(fileURLToPath(inkUrl)));
|
|
232
|
+
const version = JSON.parse(readFileSync(path.join(inkDir, "package.json"), "utf-8")).version;
|
|
233
|
+
const load = (name) => import(resolveFrom(inkDir, name));
|
|
234
|
+
const [output, tokenizer, stringWidth] = await Promise.all([import(new URL("./output.js", inkUrl).href), load("@alcalzone/ansi-tokenize"), load("string-width")]);
|
|
235
|
+
return { version, Output: output.default, tokenizer: tokenizer, stringWidth: stringWidth.default };
|
|
236
|
+
}
|
|
237
|
+
/** Swaps in the faster output stage. Returns whether it did (false: an Ink version it doesn't know). */
|
|
238
|
+
export function installFastOutput() {
|
|
239
|
+
installed ??= inkModules()
|
|
240
|
+
.then(({ version, Output, tokenizer, stringWidth }) => {
|
|
241
|
+
if (!PATCHED_VERSIONS.test(version) || typeof Output?.prototype?.get !== "function")
|
|
242
|
+
return false;
|
|
243
|
+
Output.prototype.get = makeFastOutput(tokenizer, stringWidth).get;
|
|
244
|
+
return true;
|
|
245
|
+
})
|
|
246
|
+
.catch(() => false);
|
|
247
|
+
return installed;
|
|
248
|
+
}
|
|
249
|
+
/** Ink's Output class and the modules it uses, for tests that compare both versions. */
|
|
250
|
+
export const loadInkOutput = inkModules;
|
package/dist/src/ui/mouse.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
2
|
import { useStdin } from "ink";
|
|
3
|
-
// Report clicks and wheel (1000)
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
// Report clicks and wheel (1000), and movement while a button is held (1002, without it terminals never
|
|
4
|
+
// send drags: no resizing panes, dragging scrollbars or selecting), using SGR coordinates (1006), which
|
|
5
|
+
// work past column 223.
|
|
6
|
+
export const ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
|
|
7
|
+
const DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
|
|
6
8
|
/** Mouse events in a chunk of terminal input (SGR format: ESC [ < button ; x ; y M|m). */
|
|
7
9
|
export function parseMouse(chunk) {
|
|
8
10
|
const events = [];
|
|
@@ -12,8 +14,11 @@ export function parseMouse(chunk) {
|
|
|
12
14
|
const x = Number(m[2]) - 1;
|
|
13
15
|
const y = Number(m[3]) - 1;
|
|
14
16
|
const button = code & 0b11000011; // drop the shift/alt/ctrl bits
|
|
17
|
+
const shift = (code & 4) !== 0;
|
|
15
18
|
if (button === 64 || button === 65)
|
|
16
|
-
events.push({ kind: "wheel", dir: button === 64 ? -1 : 1, x, y });
|
|
19
|
+
events.push({ kind: "wheel", dir: button === 64 ? -1 : 1, x, y, horizontal: shift });
|
|
20
|
+
else if (button === 66 || button === 67)
|
|
21
|
+
events.push({ kind: "wheel", dir: button === 66 ? -1 : 1, x, y, horizontal: true });
|
|
17
22
|
else if (button === 0 && code & 32)
|
|
18
23
|
events.push({ kind: "drag", x, y }); // 32: moved with the left button held
|
|
19
24
|
else if (button === 0)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** How Nou renders to the terminal (shared by the app and the rendering benchmark). */
|
|
2
|
+
export const RENDER_OPTIONS = {
|
|
3
|
+
alternateScreen: true,
|
|
4
|
+
// Ctrl+C is handled inside the app: press it twice to quit, so a stray press doesn't lose work.
|
|
5
|
+
exitOnCtrlC: false,
|
|
6
|
+
// Rewrite only the lines that changed, not the whole screen: far less to send to the terminal per
|
|
7
|
+
// keystroke or scroll, which matters most over SSH and in slower terminals (e.g. on Windows).
|
|
8
|
+
incrementalRendering: true,
|
|
9
|
+
// Up to 60 frames a second, so scrolling and typing keep up with the keyboard and mouse.
|
|
10
|
+
maxFps: 60,
|
|
11
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { C } from "./theme.js";
|
|
4
|
+
/** Lines a single wheel notch moves. */
|
|
5
|
+
export const WHEEL_STEP = 3;
|
|
6
|
+
/** Where to scroll to for a scrollbar click at `fraction` of the track. */
|
|
7
|
+
export const topFor = (fraction, total, rows) => Math.round(Math.max(0, Math.min(1, fraction)) * Math.max(0, total - rows));
|
|
8
|
+
/** The scrollbar thumb's first row and size for `rows` visible out of `total`, starting at `top`. */
|
|
9
|
+
export function thumb(total, top, rows) {
|
|
10
|
+
if (total <= rows)
|
|
11
|
+
return { start: 0, size: rows };
|
|
12
|
+
const size = Math.max(1, Math.round((rows / total) * rows));
|
|
13
|
+
const maxTop = total - rows;
|
|
14
|
+
const start = Math.round((Math.min(top, maxTop) / maxTop) * (rows - size));
|
|
15
|
+
return { start, size };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A one-column scrollbar beside a pane's content. Drawn only when there's more than fits; it keeps its
|
|
19
|
+
* column either way so the content doesn't shift when it appears. One Text node for the whole column.
|
|
20
|
+
*/
|
|
21
|
+
export function Scrollbar({ total, top, rows, focused }) {
|
|
22
|
+
if (total <= rows)
|
|
23
|
+
return _jsx(Box, { width: 1, flexShrink: 0 });
|
|
24
|
+
const { start, size } = thumb(total, top, rows);
|
|
25
|
+
// Only the thumb is drawn: a track beside the pane's border would read as a double line.
|
|
26
|
+
const track = (n) => Array.from({ length: n }, () => " ").join("\n");
|
|
27
|
+
const before = track(start);
|
|
28
|
+
const after = track(rows - start - size);
|
|
29
|
+
return (_jsx(Box, { width: 1, flexShrink: 0, children: _jsxs(Text, { children: [_jsxs(Text, { color: C.dim, children: [before, before ? "\n" : ""] }), _jsx(Text, { color: focused ? C.accent : C.text, children: Array.from({ length: size }, () => "┃").join("\n") }), _jsxs(Text, { color: C.dim, children: [after ? "\n" : "", after] })] }) }));
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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": {
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"eval": "tsx evals/run.ts",
|
|
25
25
|
"prepublishOnly": "npm run build",
|
|
26
26
|
"screenshot": "node -e \"process.env.FORCE_COLOR='3';require('child_process').execSync('npx tsx scripts/screenshot.tsx',{stdio:'inherit'})\"",
|
|
27
|
-
"test": "tsx tests/unit.ts && tsx tests/e2e.tsx"
|
|
27
|
+
"test": "tsx tests/unit.ts && tsx tests/e2e.tsx",
|
|
28
|
+
"bench": "tsx scripts/bench.tsx"
|
|
28
29
|
},
|
|
29
30
|
"repository": {
|
|
30
31
|
"type": "git",
|