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.
Files changed (42) hide show
  1. package/README.md +48 -9
  2. package/dist/index.js +9 -99
  3. package/dist/src/agent/index.js +199 -33
  4. package/dist/src/cli.js +117 -0
  5. package/dist/src/commands/index.js +233 -25
  6. package/dist/src/config/index.js +14 -0
  7. package/dist/src/context/index.js +33 -10
  8. package/dist/src/conversations/external.js +165 -0
  9. package/dist/src/conversations/index.js +71 -0
  10. package/dist/src/engines/claude.js +10 -0
  11. package/dist/src/engines/codex.js +60 -5
  12. package/dist/src/games/index.js +217 -0
  13. package/dist/src/mcp/import.js +106 -0
  14. package/dist/src/mcp/index.js +184 -0
  15. package/dist/src/providers/anthropic.js +23 -3
  16. package/dist/src/providers/index.js +13 -0
  17. package/dist/src/providers/openai.js +6 -2
  18. package/dist/src/syntax/index.js +10 -20
  19. package/dist/src/syntax/themes.js +57 -0
  20. package/dist/src/terminal/index.js +127 -0
  21. package/dist/src/tools/index.js +86 -2
  22. package/dist/src/ui/App.js +900 -118
  23. package/dist/src/ui/Chat.js +143 -22
  24. package/dist/src/ui/ChatInput.js +9 -2
  25. package/dist/src/ui/CodeView.js +111 -20
  26. package/dist/src/ui/Editor.js +13 -6
  27. package/dist/src/ui/FileTree.js +6 -5
  28. package/dist/src/ui/GamePanel.js +99 -0
  29. package/dist/src/ui/Header.js +1 -1
  30. package/dist/src/ui/QuickOpen.js +51 -12
  31. package/dist/src/ui/Settings.js +58 -13
  32. package/dist/src/ui/Tabs.js +56 -0
  33. package/dist/src/ui/Terminal.js +84 -0
  34. package/dist/src/ui/clipboard.js +9 -7
  35. package/dist/src/ui/commands.js +36 -0
  36. package/dist/src/ui/definition.js +135 -0
  37. package/dist/src/ui/fast-width.js +62 -0
  38. package/dist/src/ui/mouse.js +4 -1
  39. package/dist/src/ui/render-options.js +4 -0
  40. package/dist/src/ui/text.js +132 -12
  41. package/dist/src/ui/width-shim.js +7 -0
  42. 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
+ }
@@ -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({ kind: m[4] === "M" ? "click" : "release", x, y });
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
  };
@@ -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 || !supportsLanguage(language) || text.length > 200_000)
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) => inlineSpans(c).map((s) => s.text).join("")));
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(...inlineSpans(cell, r === 0 ? { text: "", bold: true } : { text: "" }));
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(inlineSpans(heading[2], { text: "", bold: true }), width))
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(inlineSpans(quote[1], { text: "", italic: true }), width - 2))
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 }, ...inlineSpans(item[4])], width, bullet.length))
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(inlineSpans(raw), width))
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.1",
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",