nou-cli 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -9
- package/dist/index.js +9 -99
- package/dist/src/agent/index.js +199 -33
- package/dist/src/cli.js +117 -0
- package/dist/src/commands/index.js +233 -25
- package/dist/src/config/index.js +14 -0
- package/dist/src/context/index.js +33 -10
- package/dist/src/conversations/external.js +165 -0
- package/dist/src/conversations/index.js +71 -0
- package/dist/src/engines/claude.js +10 -0
- package/dist/src/engines/codex.js +60 -5
- package/dist/src/games/index.js +217 -0
- package/dist/src/mcp/import.js +106 -0
- package/dist/src/mcp/index.js +184 -0
- package/dist/src/providers/anthropic.js +23 -3
- package/dist/src/providers/index.js +13 -0
- package/dist/src/providers/openai.js +6 -2
- package/dist/src/syntax/index.js +10 -20
- package/dist/src/syntax/themes.js +57 -0
- package/dist/src/terminal/index.js +127 -0
- package/dist/src/tools/index.js +86 -2
- package/dist/src/ui/App.js +900 -118
- package/dist/src/ui/Chat.js +143 -22
- package/dist/src/ui/ChatInput.js +9 -2
- package/dist/src/ui/CodeView.js +111 -20
- package/dist/src/ui/Editor.js +13 -6
- package/dist/src/ui/FileTree.js +6 -5
- package/dist/src/ui/GamePanel.js +99 -0
- package/dist/src/ui/Header.js +1 -1
- package/dist/src/ui/QuickOpen.js +51 -12
- package/dist/src/ui/Settings.js +58 -13
- package/dist/src/ui/Tabs.js +56 -0
- package/dist/src/ui/Terminal.js +84 -0
- package/dist/src/ui/clipboard.js +9 -7
- package/dist/src/ui/commands.js +36 -0
- package/dist/src/ui/definition.js +135 -0
- package/dist/src/ui/fast-width.js +62 -0
- package/dist/src/ui/mouse.js +4 -1
- package/dist/src/ui/render-options.js +4 -0
- package/dist/src/ui/text.js +132 -12
- package/dist/src/ui/width-shim.js +7 -0
- package/package.json +2 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { projectFiles } from "../search/index.js";
|
|
4
|
+
const IDENT = /[A-Za-z_$][\w$]*/g;
|
|
5
|
+
/** Lines that bring names in from another file, with the module path as group 1 (or 2 for Python's from). */
|
|
6
|
+
const IMPORT_LINE = /^\s*(?:import\b.*?from\s+["']([^"']+)["']|import\s+["']([^"']+)["']|export\b.*?from\s+["']([^"']+)["']|.*\brequire\(\s*["']([^"']+)["']\s*\)|from\s+([\w.]+)\s+import\b|import\s+([\w.]+))/;
|
|
7
|
+
const STRING = /(["'`])((?:\\.|(?!\1).)*)\1/g;
|
|
8
|
+
/** The identifier at column `col` (1-based) of a line, if any. */
|
|
9
|
+
export function wordAt(text, col) {
|
|
10
|
+
for (const m of text.matchAll(IDENT)) {
|
|
11
|
+
if (col - 1 >= m.index && col - 1 < m.index + m[0].length)
|
|
12
|
+
return { word: m[0], start: m.index + 1 };
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
/** The module an import line names, e.g. "./b.js" or "pkg.mod". */
|
|
17
|
+
function importedModule(line) {
|
|
18
|
+
const m = line.match(IMPORT_LINE);
|
|
19
|
+
return m ? m.slice(1).find((g) => g) ?? null : null;
|
|
20
|
+
}
|
|
21
|
+
/** The file a module path resolves to from `fromFile`: relative imports and Python modules in the project. */
|
|
22
|
+
export function resolveModule(fromFile, spec) {
|
|
23
|
+
const dir = path.dirname(fromFile);
|
|
24
|
+
const candidates = [];
|
|
25
|
+
if (spec.startsWith(".") || spec.startsWith("/")) {
|
|
26
|
+
const base = path.join(dir, spec);
|
|
27
|
+
// TypeScript imports name the .js the .ts compiles to.
|
|
28
|
+
const stripped = base.replace(/\.(m|c)?js$/, "");
|
|
29
|
+
for (const b of [base, stripped]) {
|
|
30
|
+
candidates.push(b);
|
|
31
|
+
for (const ext of [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json", ".py"])
|
|
32
|
+
candidates.push(b + ext);
|
|
33
|
+
for (const ext of [".ts", ".tsx", ".js", ".jsx", ".py"])
|
|
34
|
+
candidates.push(path.join(b, `index${ext}`));
|
|
35
|
+
}
|
|
36
|
+
if (spec.startsWith(".") && /^\.+[\w.]/.test(spec) && fromFile.endsWith(".py")) {
|
|
37
|
+
// Python relative: from .mod import x / from ..pkg.mod import x
|
|
38
|
+
const dots = spec.match(/^\.+/)[0].length;
|
|
39
|
+
const up = path.join(dir, ...Array(dots - 1).fill(".."), ...spec.slice(dots).split("."));
|
|
40
|
+
candidates.push(`${up}.py`, path.join(up, "__init__.py"));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (/^[\w.]+$/.test(spec)) {
|
|
44
|
+
const parts = spec.split(".");
|
|
45
|
+
for (const root of [".", "src", dir])
|
|
46
|
+
candidates.push(path.join(root, ...parts) + ".py", path.join(root, ...parts, "__init__.py"));
|
|
47
|
+
}
|
|
48
|
+
const found = candidates.find((c) => {
|
|
49
|
+
try {
|
|
50
|
+
return statSync(c).isFile();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return found ? path.normalize(found).replace(/\\/g, "/") : null;
|
|
57
|
+
}
|
|
58
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
59
|
+
/** Lines that declare `name`, most specific first. */
|
|
60
|
+
function declares(name) {
|
|
61
|
+
const n = escape(name);
|
|
62
|
+
return new RegExp("^\\s*(?:export\\s+)?(?:default\\s+)?(?:declare\\s+)?(?:async\\s+)?(?:" +
|
|
63
|
+
`function\\*?\\s+${n}\\b|(?:abstract\\s+)?class\\s+${n}\\b|interface\\s+${n}\\b|type\\s+${n}\\b|enum\\s+${n}\\b|` +
|
|
64
|
+
`(?:const|let|var)\\s+${n}\\b|(?:const|let|var)\\s*\\{[^}]*\\b${n}\\b[^}]*\\}\\s*=|def\\s+${n}\\b|class\\s+${n}\\b|func\\s+(?:\\([^)]*\\)\\s*)?${n}\\b|fn\\s+${n}\\b|${n}\\s*=[^=])` +
|
|
65
|
+
`|^\\s*(?:public|private|protected|static|readonly|async|\\s)*${n}\\s*\\([^)]*\\)\\s*(?::[^{;]*)?\\{`);
|
|
66
|
+
}
|
|
67
|
+
function declarationIn(lines, name, skip = -1) {
|
|
68
|
+
const re = declares(name);
|
|
69
|
+
return lines.findIndex((l, i) => i !== skip && re.test(l));
|
|
70
|
+
}
|
|
71
|
+
function readLines(file) {
|
|
72
|
+
try {
|
|
73
|
+
const text = readFileSync(file, "utf-8");
|
|
74
|
+
return text.length > 2_000_000 ? null : text.split(/\r?\n/);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Where to go for a Ctrl+click at `line`:`col` (1-based) in `file`, whose current text is `text`. */
|
|
81
|
+
export function findDefinition(file, text, line, col) {
|
|
82
|
+
const lines = text.split(/\r?\n/);
|
|
83
|
+
const here = lines[line - 1] ?? "";
|
|
84
|
+
const spec = importedModule(here);
|
|
85
|
+
// On the path of an import: open that file.
|
|
86
|
+
if (spec) {
|
|
87
|
+
for (const m of here.matchAll(STRING)) {
|
|
88
|
+
if (col - 1 >= m.index && col - 1 < m.index + m[0].length) {
|
|
89
|
+
const target = resolveModule(file, m[2]);
|
|
90
|
+
return target ? { file: target, line: 1 } : null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const at = wordAt(here, col);
|
|
95
|
+
if (!at)
|
|
96
|
+
return null;
|
|
97
|
+
const { word } = at;
|
|
98
|
+
// On a name in an import line: where that file declares it (or just the file).
|
|
99
|
+
if (spec) {
|
|
100
|
+
const target = resolveModule(file, spec);
|
|
101
|
+
if (!target)
|
|
102
|
+
return null;
|
|
103
|
+
const theirs = readLines(target);
|
|
104
|
+
const index = theirs ? declarationIn(theirs, word) : -1;
|
|
105
|
+
return { file: target, line: index + 1 || 1 };
|
|
106
|
+
}
|
|
107
|
+
// Imported here: its import line, where the path is a click away.
|
|
108
|
+
const importLine = lines.findIndex((l) => importedModule(l) && new RegExp(`\\b${escape(word)}\\b`).test(l.replace(STRING, "")));
|
|
109
|
+
if (importLine >= 0 && importLine !== line - 1)
|
|
110
|
+
return { file, line: importLine + 1, col: lines[importLine].search(new RegExp(`\\b${escape(word)}\\b`)) + 1 };
|
|
111
|
+
// Declared in this file.
|
|
112
|
+
const local = declarationIn(lines, word, line - 1);
|
|
113
|
+
if (local >= 0)
|
|
114
|
+
return { file, line: local + 1 };
|
|
115
|
+
// Declared somewhere else in the project.
|
|
116
|
+
const ext = path.extname(file);
|
|
117
|
+
// A click waits on this, so it reads at most a few megabytes before giving up.
|
|
118
|
+
const files = projectFiles().filter((f) => f !== file && path.extname(f) === ext).slice(0, 3000);
|
|
119
|
+
let budget = 8_000_000;
|
|
120
|
+
for (const other of files) {
|
|
121
|
+
if (!existsSync(other) || budget <= 0)
|
|
122
|
+
continue;
|
|
123
|
+
const size = statSync(other).size;
|
|
124
|
+
if (size > 1_000_000)
|
|
125
|
+
continue;
|
|
126
|
+
budget -= size;
|
|
127
|
+
const theirs = readLines(other);
|
|
128
|
+
if (!theirs)
|
|
129
|
+
continue;
|
|
130
|
+
const index = declarationIn(theirs, word);
|
|
131
|
+
if (index >= 0)
|
|
132
|
+
return { file: other, line: index + 1 };
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A faster `string-width` for Ink. Ink measures every line of text on screen each frame (to wrap it, and
|
|
3
|
+
* again to work out how much of a changed line it can leave on screen), and string-width splits each one
|
|
4
|
+
* into graphemes and runs emoji regexes over them: a fifth of all rendering time, mostly spent on lines
|
|
5
|
+
* of plain text and box-drawing characters whose width is simply their length.
|
|
6
|
+
*
|
|
7
|
+
* This answers those with one regex test, remembers the rest, and hands anything unusual to the real
|
|
8
|
+
* string-width. Ink and its dependencies import string-width by name, so a module hook points that name
|
|
9
|
+
* here; it has to be installed before Ink loads (index.ts does it first).
|
|
10
|
+
*/
|
|
11
|
+
// A namespace import: Node before 22.15 has no registerHooks, and a named import of it would fail to load.
|
|
12
|
+
import * as nodeModule from "module";
|
|
13
|
+
/**
|
|
14
|
+
* Characters string-width counts as one column each and that never combine with what's next to them:
|
|
15
|
+
* ASCII, Latin, Greek, Cyrillic, punctuation, arrows, math, box drawing and the symbols Nou's UI uses.
|
|
16
|
+
* Emoji, combining marks and invisible characters are left out. The unit tests check every one.
|
|
17
|
+
*/
|
|
18
|
+
export const NARROW = /^[\u0020-\u007e\u00a0-\u00ac\u00ae-\u02ff\u0370-\u0482\u048a-\u04ff\u2010-\u2027\u202f-\u205e\u2190-\u2319\u231c-\u2328\u232b-\u23e8\u23ed-\u23ef\u23f1-\u23f2\u23f4-\u25fc\u25ff-\u2613\u2616-\u262f\u2638-\u2647\u2654-\u267e\u2680-\u2689\u2690-\u2692\u2694-\u26a0\u26a2-\u26a9\u26ac-\u26bc\u26bf-\u26c3\u26c6-\u26cd\u26cf-\u26d3\u26d5-\u26e9\u26eb-\u26f1\u26f4\u26f6-\u26f9\u26fb-\u26fc\u26fe-\u2704\u2706-\u2709\u270c-\u2727\u2729-\u274b\u274d\u274f-\u2752\u2756\u2758-\u2794\u2798-\u27af\u27b1-\u27be\u2b00-\u2b1a\u2b1d-\u2b4f\u2b51-\u2b54\u2b56-\u2bff]*$/;
|
|
19
|
+
/** Plenty for every line on screen and in scrollback; cleared rather than evicted one by one. */
|
|
20
|
+
const MAX_CACHED = 20_000;
|
|
21
|
+
/** Wraps a string-width function with the fast path and a cache. Same answers, exported for tests. */
|
|
22
|
+
export function fastStringWidth(real) {
|
|
23
|
+
const cache = new Map();
|
|
24
|
+
return (text, options) => {
|
|
25
|
+
if (options && (options.ambiguousIsNarrow === false || options.countAnsiEscapeCodes))
|
|
26
|
+
return real(text, options);
|
|
27
|
+
if (typeof text !== "string")
|
|
28
|
+
return real(text, options);
|
|
29
|
+
if (NARROW.test(text))
|
|
30
|
+
return text.length;
|
|
31
|
+
let width = cache.get(text);
|
|
32
|
+
if (width === undefined) {
|
|
33
|
+
if (cache.size > MAX_CACHED)
|
|
34
|
+
cache.clear();
|
|
35
|
+
width = real(text, options);
|
|
36
|
+
cache.set(text, width);
|
|
37
|
+
}
|
|
38
|
+
return width;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
let installed = false;
|
|
42
|
+
/** Points every `import "string-width"` that loads from now on at the fast version. Returns whether it could. */
|
|
43
|
+
export function installFastWidth() {
|
|
44
|
+
if (installed)
|
|
45
|
+
return true;
|
|
46
|
+
// registerHooks runs in-thread and synchronously (Node 22.15+); older versions keep the original.
|
|
47
|
+
const { registerHooks } = nodeModule;
|
|
48
|
+
if (typeof registerHooks !== "function" || process.env.NOU_NO_FAST_WIDTH)
|
|
49
|
+
return false;
|
|
50
|
+
const shim = new URL(`./width-shim${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url).href;
|
|
51
|
+
registerHooks({
|
|
52
|
+
resolve(specifier, context, nextResolve) {
|
|
53
|
+
const resolved = nextResolve(specifier, context);
|
|
54
|
+
if (specifier !== "string-width" || context.parentURL?.startsWith(shim))
|
|
55
|
+
return resolved;
|
|
56
|
+
// Each copy of string-width (Ink may have its own) gets its own wrapper.
|
|
57
|
+
return { ...resolved, url: `${shim}?of=${encodeURIComponent(resolved.url)}` };
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
installed = true;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
package/dist/src/ui/mouse.js
CHANGED
|
@@ -15,6 +15,7 @@ export function parseMouse(chunk) {
|
|
|
15
15
|
const y = Number(m[3]) - 1;
|
|
16
16
|
const button = code & 0b11000011; // drop the shift/alt/ctrl bits
|
|
17
17
|
const shift = (code & 4) !== 0;
|
|
18
|
+
const modified = (code & (8 | 16)) !== 0; // Alt (Option) or Ctrl
|
|
18
19
|
if (button === 64 || button === 65)
|
|
19
20
|
events.push({ kind: "wheel", dir: button === 64 ? -1 : 1, x, y, horizontal: shift });
|
|
20
21
|
else if (button === 66 || button === 67)
|
|
@@ -22,7 +23,9 @@ export function parseMouse(chunk) {
|
|
|
22
23
|
else if (button === 0 && code & 32)
|
|
23
24
|
events.push({ kind: "drag", x, y }); // 32: moved with the left button held
|
|
24
25
|
else if (button === 0)
|
|
25
|
-
events.push(
|
|
26
|
+
events.push(m[4] === "M" ? { kind: "click", x, y, ...(modified ? { define: true } : {}) } : { kind: "release", x, y });
|
|
27
|
+
else if (button === 2 && m[4] === "M")
|
|
28
|
+
events.push({ kind: "click", x, y, define: true }); // right-click
|
|
26
29
|
}
|
|
27
30
|
return events;
|
|
28
31
|
}
|
|
@@ -8,4 +8,8 @@ export const RENDER_OPTIONS = {
|
|
|
8
8
|
incrementalRendering: true,
|
|
9
9
|
// Up to 60 frames a second, so scrolling and typing keep up with the keyboard and mouse.
|
|
10
10
|
maxFps: 60,
|
|
11
|
+
// Terminals that speak the kitty keyboard protocol (kitty, Ghostty, WezTerm, iTerm2, foot...) then tell
|
|
12
|
+
// Enter from Ctrl+Enter, Cmd+Enter and Shift+Enter, so those can add a line in the chat. Others just
|
|
13
|
+
// don't answer the query and keys work as before.
|
|
14
|
+
kittyKeyboard: { mode: "auto", flags: ["disambiguateEscapeCodes"] },
|
|
11
15
|
};
|
package/dist/src/ui/text.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
-
import { highlight, supportsLanguage } from "cli-highlight";
|
|
3
2
|
import { diffLines } from "diff";
|
|
4
|
-
import { highlightTree } from "../syntax/index.js";
|
|
3
|
+
import { highlightTree, notifySyntaxLoaded } from "../syntax/index.js";
|
|
4
|
+
import { highlightJsTheme } from "../syntax/themes.js";
|
|
5
|
+
let fallback = null;
|
|
6
|
+
let loading = null;
|
|
7
|
+
export function loadFallbackHighlighter() {
|
|
8
|
+
loading ??= import("cli-highlight").then((m) => {
|
|
9
|
+
fallback = { highlight: m.highlight, supportsLanguage: m.supportsLanguage };
|
|
10
|
+
notifySyntaxLoaded();
|
|
11
|
+
}, () => { });
|
|
12
|
+
return loading;
|
|
13
|
+
}
|
|
5
14
|
const LANGUAGES = {
|
|
6
15
|
".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
|
|
7
16
|
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
@@ -23,10 +32,16 @@ export function highlightLines(filePath, text) {
|
|
|
23
32
|
if (tree)
|
|
24
33
|
return tree;
|
|
25
34
|
const language = LANGUAGES[path.extname(filePath).toLowerCase()];
|
|
26
|
-
if (!language ||
|
|
35
|
+
if (!language || text.length > 200_000)
|
|
36
|
+
return plain;
|
|
37
|
+
if (!fallback) {
|
|
38
|
+
void loadFallbackHighlighter();
|
|
39
|
+
return plain;
|
|
40
|
+
}
|
|
41
|
+
if (!fallback.supportsLanguage(language))
|
|
27
42
|
return plain;
|
|
28
43
|
try {
|
|
29
|
-
return highlight(plain.join("\n"), { language, ignoreIllegals: true }).split("\n");
|
|
44
|
+
return fallback.highlight(plain.join("\n"), { language, ignoreIllegals: true, theme: highlightJsTheme() }).split("\n");
|
|
30
45
|
}
|
|
31
46
|
catch {
|
|
32
47
|
return plain;
|
|
@@ -135,7 +150,7 @@ export function inlineSpans(text, base = { text: "" }) {
|
|
|
135
150
|
out.push({ ...base, text: text.slice(pos) });
|
|
136
151
|
return out.filter((s) => s.text);
|
|
137
152
|
}
|
|
138
|
-
const sameStyle = (a, b) => !!a.bold === !!b.bold && !!a.italic === !!b.italic && !!a.code === !!b.code && !!a.dim === !!b.dim;
|
|
153
|
+
const sameStyle = (a, b) => !!a.bold === !!b.bold && !!a.italic === !!b.italic && !!a.code === !!b.code && !!a.dim === !!b.dim && a.link === b.link;
|
|
139
154
|
/** Word-wraps styled spans; continuation lines start with `indent` spaces. */
|
|
140
155
|
function wrapSpans(spans, width, indent = 0) {
|
|
141
156
|
const max = Math.max(1, width);
|
|
@@ -186,7 +201,9 @@ function wrapSpans(spans, width, indent = 0) {
|
|
|
186
201
|
}
|
|
187
202
|
const cells = (row) => row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
|
|
188
203
|
/** Renders markdown for the terminal: headings, lists, quotes, code blocks, tables and inline styles, wrapped to `width`. */
|
|
189
|
-
export function renderMarkdown(text, width) {
|
|
204
|
+
export function renderMarkdown(text, width, isFile) {
|
|
205
|
+
// File references become links before wrapping, so a long path that wraps is still one link.
|
|
206
|
+
const spansOf = (line, base) => (isFile ? linkFileRefs(inlineSpans(line, base), isFile) : inlineSpans(line, base));
|
|
190
207
|
const out = [];
|
|
191
208
|
const src = splitLines(text);
|
|
192
209
|
let fence = null;
|
|
@@ -215,7 +232,7 @@ export function renderMarkdown(text, width) {
|
|
|
215
232
|
for (; j < src.length && /^\s*\|/.test(src[j]); j++)
|
|
216
233
|
rows.push(cells(src[j]));
|
|
217
234
|
i = j - 1;
|
|
218
|
-
const plain = rows.map((r) => r.map((c) =>
|
|
235
|
+
const plain = rows.map((r) => r.map((c) => spansOf(c).map((s) => s.text).join("")));
|
|
219
236
|
const columns = Math.max(...plain.map((r) => r.length));
|
|
220
237
|
const widths = Array.from({ length: columns }, (_, c) => Math.max(...plain.map((r) => (r[c] ?? "").length)));
|
|
221
238
|
const fits = widths.reduce((a, b) => a + b, 0) + 3 * (columns - 1) <= width;
|
|
@@ -224,7 +241,7 @@ export function renderMarkdown(text, width) {
|
|
|
224
241
|
row.forEach((cell, c) => {
|
|
225
242
|
if (c > 0)
|
|
226
243
|
spans.push({ text: " │ ", dim: true });
|
|
227
|
-
spans.push(...
|
|
244
|
+
spans.push(...spansOf(cell, r === 0 ? { text: "", bold: true } : { text: "" }));
|
|
228
245
|
if (fits && c < columns - 1)
|
|
229
246
|
spans.push({ text: " ".repeat(widths[c] - plain[r][c].length) });
|
|
230
247
|
});
|
|
@@ -237,7 +254,7 @@ export function renderMarkdown(text, width) {
|
|
|
237
254
|
}
|
|
238
255
|
const heading = raw.match(/^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
239
256
|
if (heading) {
|
|
240
|
-
for (const line of wrapSpans(
|
|
257
|
+
for (const line of wrapSpans(spansOf(heading[2], { text: "", bold: true }), width))
|
|
241
258
|
out.push({ kind: "heading", spans: line });
|
|
242
259
|
continue;
|
|
243
260
|
}
|
|
@@ -247,7 +264,7 @@ export function renderMarkdown(text, width) {
|
|
|
247
264
|
}
|
|
248
265
|
const quote = raw.match(/^\s*>\s?(.*)/);
|
|
249
266
|
if (quote) {
|
|
250
|
-
for (const line of wrapSpans(
|
|
267
|
+
for (const line of wrapSpans(spansOf(quote[1], { text: "", italic: true }), width - 2))
|
|
251
268
|
out.push({ kind: "quote", spans: [{ text: "│ ", dim: true }, ...line] });
|
|
252
269
|
continue;
|
|
253
270
|
}
|
|
@@ -256,12 +273,115 @@ export function renderMarkdown(text, width) {
|
|
|
256
273
|
const depth = Math.floor(item[1].length / 2) * 2;
|
|
257
274
|
const task = item[3] ? (/x/i.test(item[3]) ? "☑ " : "☐ ") : "";
|
|
258
275
|
const bullet = `${" ".repeat(depth)}${/\d/.test(item[2]) ? item[2] : "•"} ${task}`;
|
|
259
|
-
for (const line of wrapSpans([{ text: bullet }, ...
|
|
276
|
+
for (const line of wrapSpans([{ text: bullet }, ...spansOf(item[4])], width, bullet.length))
|
|
260
277
|
out.push({ kind: "text", spans: line });
|
|
261
278
|
continue;
|
|
262
279
|
}
|
|
263
|
-
for (const line of wrapSpans(
|
|
280
|
+
for (const line of wrapSpans(spansOf(raw), width))
|
|
264
281
|
out.push({ kind: "text", spans: line });
|
|
265
282
|
}
|
|
266
283
|
return out;
|
|
267
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* path, path:line or path:line-line; a path has a dot in its last part, so plain words don't match.
|
|
287
|
+
* Windows paths (C:\\dir\\file.ts, dir\\file.ts) count too.
|
|
288
|
+
*/
|
|
289
|
+
const FILE_REF = /(?<![\w/\\.@~-])((?:[A-Za-z]:[\\/]|\.{0,2}[\\/])?(?:[\w@.~+-]+[\\/])*[\w@-][\w@.~+-]*\.[A-Za-z0-9]{1,8})(?::(\d+)(?:[-–](\d+))?)?(?![\w/\\])/g;
|
|
290
|
+
/** Splits out the references to files that exist (per `isFile`) as link spans, keeping each span's style. */
|
|
291
|
+
export function linkFileRefs(spans, isFile) {
|
|
292
|
+
const out = [];
|
|
293
|
+
for (const span of spans) {
|
|
294
|
+
let pos = 0;
|
|
295
|
+
for (const m of span.text.matchAll(FILE_REF)) {
|
|
296
|
+
const file = m[1].replace(/^\.[\\/]/, "");
|
|
297
|
+
if (!isFile(file))
|
|
298
|
+
continue;
|
|
299
|
+
if (m.index > pos)
|
|
300
|
+
out.push({ ...span, text: span.text.slice(pos, m.index) });
|
|
301
|
+
const link = { path: file };
|
|
302
|
+
if (m[2])
|
|
303
|
+
link.line = Number(m[2]);
|
|
304
|
+
if (m[3])
|
|
305
|
+
link.to = Number(m[3]);
|
|
306
|
+
out.push({ ...span, text: m[0], link });
|
|
307
|
+
pos = m.index + m[0].length;
|
|
308
|
+
}
|
|
309
|
+
if (pos < span.text.length)
|
|
310
|
+
out.push(pos ? { ...span, text: span.text.slice(pos) } : span);
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
/** The link at column `col` of a line of spans, if any. */
|
|
315
|
+
export function linkAt(spans, col) {
|
|
316
|
+
let x = 0;
|
|
317
|
+
for (const span of spans) {
|
|
318
|
+
if (col >= x && col < x + span.text.length)
|
|
319
|
+
return span.link ?? null;
|
|
320
|
+
x += span.text.length;
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
// eslint-disable-next-line no-control-regex -- color codes start with ESC
|
|
325
|
+
const ESCAPE = /\x1b\[[\d;]*m/y;
|
|
326
|
+
/**
|
|
327
|
+
* Columns `from` to `from + width` of a highlighted line, keeping its colors: the color codes before
|
|
328
|
+
* `from` are kept (so the text still starts in the right color), the characters are dropped.
|
|
329
|
+
*/
|
|
330
|
+
export function sliceAnsi(line, from, width = Infinity) {
|
|
331
|
+
let out = "";
|
|
332
|
+
let column = 0;
|
|
333
|
+
for (let i = 0; i < line.length;) {
|
|
334
|
+
ESCAPE.lastIndex = i;
|
|
335
|
+
const code = ESCAPE.exec(line);
|
|
336
|
+
if (code) {
|
|
337
|
+
out += code[0];
|
|
338
|
+
i += code[0].length;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const ch = String.fromCodePoint(line.codePointAt(i));
|
|
342
|
+
if (column >= from && column < from + width)
|
|
343
|
+
out += ch;
|
|
344
|
+
column++;
|
|
345
|
+
i += ch.length;
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
/** Wraps visible columns `from`..`to` of a highlighted line in `open`/`close` codes (e.g. to mark a search match). */
|
|
350
|
+
export function markAnsi(line, from, to, open = "\x1b[7m", close = "\x1b[27m") {
|
|
351
|
+
let out = "";
|
|
352
|
+
let column = 0;
|
|
353
|
+
for (let i = 0; i < line.length;) {
|
|
354
|
+
ESCAPE.lastIndex = i;
|
|
355
|
+
const code = ESCAPE.exec(line);
|
|
356
|
+
if (code) {
|
|
357
|
+
out += code[0];
|
|
358
|
+
i += code[0].length;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const ch = String.fromCodePoint(line.codePointAt(i));
|
|
362
|
+
if (column === from)
|
|
363
|
+
out += open;
|
|
364
|
+
out += ch;
|
|
365
|
+
column++;
|
|
366
|
+
if (column === to)
|
|
367
|
+
out += close;
|
|
368
|
+
i += ch.length;
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* A reply still streaming, with its open markdown closed for now (a code block, **bold**, `code`), so it
|
|
374
|
+
* renders the way it will once finished instead of reflowing when the closing marker arrives.
|
|
375
|
+
*/
|
|
376
|
+
export function closeOpenMarkdown(text) {
|
|
377
|
+
const fences = text.match(/^\s*(`{3,}|~{3,})/gm)?.length ?? 0;
|
|
378
|
+
if (fences % 2)
|
|
379
|
+
return `${text}\n\`\`\``;
|
|
380
|
+
const last = text.slice(text.lastIndexOf("\n") + 1);
|
|
381
|
+
let out = text;
|
|
382
|
+
if ((last.match(/`/g)?.length ?? 0) % 2)
|
|
383
|
+
out += "`";
|
|
384
|
+
else if ((last.match(/\*\*/g)?.length ?? 0) % 2)
|
|
385
|
+
out += "**";
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Stands in for string-width (see fast-width.ts): the real one, named in this module's URL, made faster. */
|
|
2
|
+
import { fastStringWidth } from "./fast-width.js";
|
|
3
|
+
const of = new URL(import.meta.url).searchParams.get("of");
|
|
4
|
+
if (!of)
|
|
5
|
+
throw new Error("width-shim needs ?of=<string-width url>");
|
|
6
|
+
const real = (await import(of)).default;
|
|
7
|
+
export default fastStringWidth(real);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
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": {
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@anthropic-ai/sdk": "^0.133.0",
|
|
49
49
|
"@clack/prompts": "^1.1.0",
|
|
50
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
50
51
|
"chalk": "^5.6.2",
|
|
51
52
|
"cli-highlight": "^2.1.11",
|
|
52
53
|
"diff": "^9.0.0",
|