nou-cli 0.5.1 → 0.5.2

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.
@@ -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,99 @@ 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
+ }
@@ -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.2",
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",