nou-cli 0.1.0 → 0.2.0

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 (44) hide show
  1. package/README.md +140 -31
  2. package/dist/grammars/tree-sitter-bash.wasm +0 -0
  3. package/dist/grammars/tree-sitter-c.wasm +0 -0
  4. package/dist/grammars/tree-sitter-css.wasm +0 -0
  5. package/dist/grammars/tree-sitter-go.wasm +0 -0
  6. package/dist/grammars/tree-sitter-html.wasm +0 -0
  7. package/dist/grammars/tree-sitter-java.wasm +0 -0
  8. package/dist/grammars/tree-sitter-javascript.wasm +0 -0
  9. package/dist/grammars/tree-sitter-json.wasm +0 -0
  10. package/dist/grammars/tree-sitter-python.wasm +0 -0
  11. package/dist/grammars/tree-sitter-rust.wasm +0 -0
  12. package/dist/grammars/tree-sitter-toml.wasm +0 -0
  13. package/dist/grammars/tree-sitter-tsx.wasm +0 -0
  14. package/dist/grammars/tree-sitter-typescript.wasm +0 -0
  15. package/dist/grammars/tree-sitter-yaml.wasm +0 -0
  16. package/dist/index.js +55 -6
  17. package/dist/src/agent/index.js +37 -5
  18. package/dist/src/commands/index.js +16 -6
  19. package/dist/src/config/index.js +5 -2
  20. package/dist/src/diagnostics/index.js +235 -0
  21. package/dist/src/diagnostics/process.js +64 -0
  22. package/dist/src/diagnostics/providers.js +103 -0
  23. package/dist/src/diagnostics/types.js +1 -0
  24. package/dist/src/env.js +12 -0
  25. package/dist/src/history/index.js +113 -0
  26. package/dist/src/lsp/client.js +169 -0
  27. package/dist/src/lsp/index.js +169 -0
  28. package/dist/src/plugins/index.js +94 -3
  29. package/dist/src/providers/ids.js +3 -0
  30. package/dist/src/providers/ollama.js +2 -1
  31. package/dist/src/providers/openai.js +3 -2
  32. package/dist/src/syntax/index.js +240 -0
  33. package/dist/src/ui/App.js +268 -29
  34. package/dist/src/ui/Chat.js +11 -4
  35. package/dist/src/ui/ChatInput.js +128 -0
  36. package/dist/src/ui/CodeView.js +21 -5
  37. package/dist/src/ui/Editor.js +90 -21
  38. package/dist/src/ui/FileTree.js +6 -4
  39. package/dist/src/ui/Header.js +2 -2
  40. package/dist/src/ui/ProblemsView.js +71 -0
  41. package/dist/src/ui/Settings.js +30 -15
  42. package/dist/src/ui/text.js +6 -1
  43. package/dist/src/update/index.js +102 -0
  44. package/package.json +9 -4
@@ -1,15 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
+ import { SEVERITY_STYLE } from "./ProblemsView.js";
4
5
  import { artWork } from "../util/index.js";
5
6
  import { highlightLines, sideBySide } from "./text.js";
6
7
  import { C, fileIcon, gradient } from "./theme.js";
7
8
  import { Pane } from "./Pane.js";
8
- export function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, onViewport }) {
9
+ export function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, onViewport }) {
9
10
  const [top, setTop] = useState(0);
10
11
  const rows = Math.max(1, height - 3);
11
12
  const inner = width - 2;
12
- const lines = useMemo(() => (filePath ? highlightLines(filePath, content) : []), [filePath, content]);
13
+ const lines = useMemo(() => (filePath ? highlightLines(filePath, content) : []), [filePath, content, syntaxVersion]);
14
+ const problemsByLine = useMemo(() => {
15
+ const map = new Map();
16
+ for (const d of problems)
17
+ map.set(d.line, [...(map.get(d.line) ?? []), d]);
18
+ return map;
19
+ }, [problems]);
13
20
  const diffRows = useMemo(() => (diff ? sideBySide(diff.before, diff.after) : []), [diff]);
14
21
  const total = diff ? diffRows.length : lines.length;
15
22
  // Jump to the first change whenever a new file or diff is shown.
@@ -19,6 +26,10 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
19
26
  : marked.size ? Math.min(...marked) - 1 : 0;
20
27
  setTop(Math.max(0, first - 3));
21
28
  }, [filePath, diff, diffRows, marked]);
29
+ useEffect(() => {
30
+ if (focusLine && !diff)
31
+ setTop(Math.max(0, focusLine.line - Math.floor(rows / 3)));
32
+ }, [focusLine]);
22
33
  const maxTop = Math.max(0, total - rows);
23
34
  const start = Math.min(top, maxTop);
24
35
  useEffect(() => {
@@ -60,7 +71,7 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
60
71
  return (_jsx(Box, { width: w, children: _jsxs(Text, { wrap: "truncate-end", ...(color ? { color } : { color: C.dim }), children: [String(c.n).padStart(gutter - 1), changed ? side : " ", c.text || " "] }) }));
61
72
  };
62
73
  const { icon, color } = fileIcon(diff.path);
63
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: "black", children: pending ? (diff.isNew ? " NEW FILE " : " PROPOSED ") : ` ${diffLabel.toUpperCase()} ` }), " ", _jsxs(Text, { color: C.green, children: ["+", added] }), " ", _jsxs(Text, { color: C.red, children: ["-", removed] }), _jsxs(Text, { color: C.dim, children: [pending ? " y accept · n reject" : " n/N next/prev change", scroll] })] }));
74
+ const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: "black", children: pending ? (diff.isNew ? " NEW FILE " : " PROPOSED ") : ` ${diffLabel.toUpperCase()} ` }), " ", _jsxs(Text, { color: C.green, children: ["+", added] }), " ", _jsxs(Text, { color: C.red, children: ["-", removed] }), diffNote ? _jsxs(Text, { color: diffNote.startsWith("✓") ? C.green : C.red, children: [" ", diffNote] }) : null, _jsxs(Text, { color: C.dim, children: [pending ? " y accept · n reject" : " n/N next/prev change", scroll] })] }));
64
75
  return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, ...(pending ? { accent: C.yellow } : {}), children: _jsx(Box, { flexDirection: "column", children: diffRows.slice(start, start + rows).map((row, i) => (_jsxs(Box, { flexDirection: "row", children: [cell(row.left, "-", row.changed, half), _jsx(Text, { color: C.dim, children: "\u2502" }), cell(row.right, "+", row.changed, inner - half - 1)] }, start + i))) }) }));
65
76
  }
66
77
  if (!filePath) {
@@ -75,10 +86,15 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
75
86
  }
76
87
  const gutter = String(lines.length).length;
77
88
  const { icon, color } = fileIcon(filePath);
78
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused ? " e edit · v diff" : ""] })] }));
89
+ const errorCount = problems.filter((d) => d.severity === "error").length;
90
+ const warningCount = problems.filter((d) => d.severity === "warning").length;
91
+ const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused ? " e edit · v diff" : ""] })] }));
79
92
  return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, children: _jsx(Box, { flexDirection: "column", children: lines.slice(start, start + rows).map((line, i) => {
80
93
  const n = start + i + 1;
81
94
  const isMarked = marked.has(n);
82
- return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: isMarked ? C.green : C.dim, children: String(n).padStart(gutter) }), _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), line || " "] }, n));
95
+ const here = problemsByLine.get(n);
96
+ const worst = here?.[0];
97
+ const style = worst ? SEVERITY_STYLE[worst.severity] : null;
98
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: style?.color ?? (isMarked ? C.green : C.dim), children: String(n).padStart(gutter) }), style ? _jsx(Text, { color: style.color, children: style.icon }) : _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), line || " ", worst && style ? _jsxs(Text, { color: style.color, dimColor: true, children: [" ", style.icon, " ", worst.message, here.length > 1 ? ` (+${here.length - 1})` : ""] }) : null] }, n));
83
99
  }) }) }));
84
100
  }
@@ -3,17 +3,26 @@ import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useInput, usePaste } from "ink";
4
4
  import { highlightLines } from "./text.js";
5
5
  import { C, fileIcon } from "./theme.js";
6
+ import { SEVERITY_STYLE } from "./ProblemsView.js";
6
7
  import { Pane } from "./Pane.js";
7
8
  const expand = (s) => s.replace(/\t/g, " ");
8
9
  /** A small modal text editor: arrows to move, type to insert, Ctrl+S save, Ctrl+Z undo, Esc leave. */
9
- export function Editor({ width, height, focused, filePath, initial, baseline, onSave, onExit, onState }) {
10
+ export function Editor({ width, height, focused, filePath, initial, baseline, startLine, problems, autoSave, syntaxVersion, onSave, onExit, onState, onHover, onDefinition }) {
10
11
  const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
11
12
  const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
12
- const [buf, setBuf] = useState(() => ({ lines: initial.replace(/\r\n/g, "\n").split("\n"), row: 0, col: 0 }));
13
+ const [buf, setBuf] = useState(() => {
14
+ const lines = initial.replace(/\r\n/g, "\n").split("\n");
15
+ const row = Math.max(0, Math.min(lines.length - 1, (startLine ?? 1) - 1));
16
+ return { lines, row, col: lines[row].match(/^\s*/)[0].length };
17
+ });
18
+ const [hover, setHover] = useState(null);
13
19
  const [saved, setSaved] = useState(() => baseline.replace(/\r\n/g, "\n"));
14
20
  const [confirmExit, setConfirmExit] = useState(false);
15
21
  const [flash, setFlash] = useState(null);
16
22
  const undo = useRef([]);
23
+ const redo = useRef([]);
24
+ // Consecutive typing within a word is one undo step, like VS Code.
25
+ const lastEdit = useRef({ kind: "", time: 0 });
17
26
  const view = useRef({ top: 0, left: 0 });
18
27
  const dirty = useMemo(() => buf.lines.join("\n") !== saved, [buf.lines, saved]);
19
28
  useEffect(() => onState({ line: buf.row + 1, col: buf.col + 1, dirty, lines: buf.lines }), [buf, dirty]);
@@ -23,13 +32,27 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
23
32
  const t = setTimeout(() => setFlash(null), 1500);
24
33
  return () => clearTimeout(t);
25
34
  }, [flash]);
26
- const edit = (fn) => setBuf((b) => {
27
- undo.current.push(b);
28
- if (undo.current.length > 300)
29
- undo.current.shift();
35
+ const edit = (fn, kind = "other") => setBuf((b) => {
36
+ const now = Date.now();
37
+ const grouped = kind !== "other" && kind === lastEdit.current.kind && now - lastEdit.current.time < 1000;
38
+ if (!grouped) {
39
+ undo.current.push(b);
40
+ if (undo.current.length > 500)
41
+ undo.current.shift();
42
+ }
43
+ redo.current = [];
44
+ lastEdit.current = { kind, time: now };
30
45
  return fn(b);
31
46
  });
32
- const insert = (text) => edit((b) => {
47
+ const step = (from, to) => setBuf((b) => {
48
+ const target = from.current.pop();
49
+ if (!target)
50
+ return b;
51
+ to.current.push(b);
52
+ lastEdit.current = { kind: "", time: 0 };
53
+ return target;
54
+ });
55
+ const insert = (text, kind = "other") => edit((b) => {
33
56
  const pieces = text.replace(/\r\n?/g, "\n").split("\n");
34
57
  const line = b.lines[b.row];
35
58
  const before = line.slice(0, b.col);
@@ -42,19 +65,31 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
42
65
  const last = pieces[pieces.length - 1];
43
66
  lines.splice(b.row, 1, before + pieces[0], ...pieces.slice(1, -1), last + after);
44
67
  return { lines, row: b.row + pieces.length - 1, col: last.length };
45
- });
68
+ }, kind);
46
69
  const move = (fn) => setBuf((b) => {
47
70
  const next = { ...b, ...fn(b) };
48
71
  next.row = Math.max(0, Math.min(next.lines.length - 1, next.row));
49
72
  next.col = Math.max(0, Math.min(next.lines[next.row].length, next.col));
50
73
  return next;
51
74
  });
52
- const save = () => {
75
+ const save = (auto = false) => {
53
76
  const text = buf.lines.join("\n");
54
77
  onSave(text.replace(/\n/g, eol));
55
78
  setSaved(text);
56
- setFlash("Saved ✓");
79
+ setFlash(auto ? "Auto-saved ✓" : "Saved ✓");
57
80
  };
81
+ // Auto-save: a second after you stop typing, or when you leave the editor.
82
+ useEffect(() => {
83
+ if (autoSave !== "delay" || !dirty)
84
+ return;
85
+ const timer = setTimeout(() => save(true), 1000);
86
+ return () => clearTimeout(timer);
87
+ }, [buf.lines, dirty, autoSave]);
88
+ useEffect(() => {
89
+ if (autoSave !== "off" && !focused && dirty)
90
+ save(true);
91
+ }, [focused]);
92
+ const position = () => ({ text: buf.lines.join("\n"), line: buf.row + 1, col: buf.col + 1 });
58
93
  const rows = Math.max(1, height - 4);
59
94
  usePaste((text) => insert(text), { isActive: focused });
60
95
  useInput((input, key) => {
@@ -69,16 +104,36 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
69
104
  setConfirmExit(false);
70
105
  return;
71
106
  }
107
+ if (hover)
108
+ setHover(null);
109
+ if (key.ctrl && input === "c")
110
+ return; // the app handles Ctrl+C (press twice to quit)
72
111
  if (key.ctrl && input === "s")
73
112
  return save();
74
- if (key.ctrl && input === "z") {
75
- const prev = undo.current.pop();
76
- if (prev)
77
- setBuf(prev);
113
+ if (key.ctrl && input === "z")
114
+ return step(undo, redo);
115
+ if (key.ctrl && input === "y")
116
+ return step(redo, undo);
117
+ if (key.ctrl && input === "k") {
118
+ const { text, line, col } = position();
119
+ setFlash("…");
120
+ void onHover(text, line, col).then((info) => {
121
+ setFlash(null);
122
+ setHover(info ?? "No info here (needs a language server for this file type).");
123
+ });
78
124
  return;
79
125
  }
80
- if (key.escape)
126
+ if (key.ctrl && input === "g") {
127
+ const { text, line, col } = position();
128
+ return onDefinition(text, line, col);
129
+ }
130
+ if (key.escape) {
131
+ if (dirty && autoSave !== "off") {
132
+ save(true);
133
+ return onExit();
134
+ }
81
135
  return dirty ? setConfirmExit(true) : onExit();
136
+ }
82
137
  if (key.upArrow)
83
138
  return move((b) => ({ row: b.row - 1 }));
84
139
  if (key.downArrow)
@@ -116,7 +171,7 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
116
171
  const prev = lines[b.row - 1];
117
172
  lines.splice(b.row - 1, 2, prev + lines[b.row]);
118
173
  return { lines, row: b.row - 1, col: prev.length };
119
- });
174
+ }, "delete");
120
175
  }
121
176
  if (key.delete) {
122
177
  return edit((b) => {
@@ -129,8 +184,9 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
129
184
  return { ...b, lines };
130
185
  });
131
186
  }
187
+ // Letters/digits group into one undo step per word; spaces and punctuation start a new one.
132
188
  if (input && !key.ctrl && !key.meta)
133
- insert(input);
189
+ insert(input, /^\w+$/.test(input) ? "type" : "other");
134
190
  }, { isActive: focused });
135
191
  // Keep the cursor on screen.
136
192
  const gutter = String(buf.lines.length).length + 2;
@@ -146,20 +202,33 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
146
202
  if (cursorX >= v.left + textWidth)
147
203
  v.left = cursorX - textWidth + 8;
148
204
  const visible = buf.lines.slice(v.top, v.top + rows);
149
- const colored = useMemo(() => (v.left === 0 ? highlightLines(filePath, visible.join("\n")) : visible.map(expand)), [filePath, visible.join("\n"), v.left]);
205
+ const colored = useMemo(
206
+ // Scrolled sideways: every line shifts by the same amount as the cursor line (plain text, since
207
+ // slicing highlighted text could cut a color code in half).
208
+ () => (v.left === 0 ? highlightLines(filePath, visible.join("\n")) : visible.map((l) => expand(l).slice(v.left, v.left + textWidth))), [filePath, visible.join("\n"), v.left, textWidth, syntaxVersion]);
150
209
  const { icon, color } = fileIcon(filePath);
210
+ const byLine = new Map();
211
+ for (const d of problems)
212
+ byLine.set(d.line, [...(byLine.get(d.line) ?? []), d]);
213
+ const cursorProblem = byLine.get(buf.row + 1)?.[0];
151
214
  const status = confirmExit
152
215
  ? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
153
- : flash ?? `Ln ${buf.row + 1}, Col ${buf.col + 1}${dirty ? " ● modified" : ""}`;
216
+ : flash ?? (cursorProblem
217
+ ? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
218
+ : `Ln ${buf.row + 1}, Col ${buf.col + 1}${dirty ? " ● modified" : ""}`);
219
+ const statusColor = confirmExit ? C.red : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
220
+ const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
154
221
  return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : "", " ", _jsx(Text, { backgroundColor: C.green, color: "black", children: " \u270E EDITING " })] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsx(Box, { flexDirection: "column", height: rows, children: visible.map((line, i) => {
155
222
  const n = v.top + i;
156
223
  const isCursorLine = n === buf.row;
157
- const num = _jsxs(Text, { color: isCursorLine ? C.green : C.dim, children: [String(n + 1).padStart(gutter - 2), isCursorLine ? " ▸" : " "] });
224
+ const problem = byLine.get(n + 1)?.[0];
225
+ const mark = problem ? SEVERITY_STYLE[problem.severity] : null;
226
+ const num = (_jsxs(Text, { color: mark?.color ?? (isCursorLine ? C.green : C.dim), children: [String(n + 1).padStart(gutter - 2), mark ? ` ${mark.icon}` : isCursorLine ? " ▸" : " "] }));
158
227
  if (!isCursorLine) {
159
228
  return _jsxs(Text, { wrap: "truncate-end", children: [num, colored[i] || " "] }, n);
160
229
  }
161
230
  const shown = expand(line).slice(v.left, v.left + textWidth);
162
231
  const at = cursorX - v.left;
163
232
  return (_jsxs(Text, { wrap: "truncate-end", children: [num, _jsx(Text, { color: C.text, children: shown.slice(0, at) }), _jsx(Text, { inverse: true, color: C.green, children: shown[at] ?? " " }), _jsx(Text, { color: C.text, children: shown.slice(at + 1) })] }, n));
164
- }) }), _jsxs(Text, { wrap: "truncate-end", color: confirmExit ? C.red : flash ? C.green : C.dim, children: [` ${status}`, _jsx(Text, { color: C.dim, children: confirmExit ? "" : " Ctrl+S save · Ctrl+Z undo · Esc close" })] })] }));
233
+ }) }), hoverLines.length > 0 && (_jsx(Box, { position: "absolute", top: Math.max(1, rows - hoverLines.length - 1), left: gutter + 1, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, width: Math.min(width - gutter - 3, 2 + Math.max(...hoverLines.map((l) => l.length))), children: hoverLines.map((l, i) => _jsx(Text, { color: C.text, wrap: "truncate-end", children: l.padEnd(width) }, i)) })), _jsxs(Text, { wrap: "truncate-end", color: statusColor, children: [` ${status}`, _jsx(Text, { color: C.dim, children: confirmExit ? "" : ` Ctrl+S save${autoSave === "off" ? "" : " (auto)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close` })] })] }));
165
234
  }
@@ -30,7 +30,7 @@ function flatten(expanded) {
30
30
  walk(".", 0);
31
31
  return nodes;
32
32
  }
33
- export function FileTree({ width, height, focused, openFile, reveal, version, changed, git, onOpen, onHover }) {
33
+ export function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, onOpen, onHover }) {
34
34
  const [expanded, setExpanded] = useState(() => new Set(["src"]));
35
35
  const [cursor, setCursor] = useState(0);
36
36
  const [changesOnly, setChangesOnly] = useState(false);
@@ -129,10 +129,12 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
129
129
  const { icon, color } = node.isDir
130
130
  ? { icon: expanded.has(node.path) ? "▾" : "▸", color: C.accent }
131
131
  : fileIcon(node.path);
132
- const marker = code ?? (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
133
- const markerColor = code ? GIT_COLORS[code] ?? C.yellow : changed.has(node.path) ? C.green : C.yellow;
132
+ const counts = problemCounts.get(node.path);
133
+ const problemMark = counts?.errors ? "✖" : counts?.warnings ? "▲" : "";
134
+ const marker = problemMark || code || (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
135
+ const markerColor = counts?.errors ? C.red : counts?.warnings ? C.yellow : code ? GIT_COLORS[code] ?? C.yellow : changed.has(node.path) ? C.green : C.yellow;
134
136
  const label = `${" ".repeat(node.depth)}${icon} ${node.name}`;
135
- const nameColor = isOpen ? C.cyan : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
137
+ const nameColor = isOpen ? C.cyan : counts?.errors ? C.red : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
136
138
  const room = Math.max(1, inner - marker.length - 1);
137
139
  const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
138
140
  const iconAt = shown.indexOf(icon);
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import path from "path";
3
3
  import { Box, Text, useAnimation } from "ink";
4
4
  import { C, SPINNER, gradient } from "./theme.js";
5
- export function Header({ width, branch, changes, model, local, busy }) {
5
+ export function Header({ width, branch, changes, model, local, busy, update }) {
6
6
  const { frame } = useAnimation({ interval: 120, isActive: !!busy });
7
7
  const project = path.basename(process.cwd());
8
- 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)), _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: [_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} ` }), " "] })] }));
8
+ 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)), _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} ` }), " "] })] }));
9
9
  }
@@ -0,0 +1,71 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Box, Text, useAnimation, useInput } from "ink";
4
+ import { C, SPINNER, fileIcon } from "./theme.js";
5
+ import { Pane } from "./Pane.js";
6
+ export const SEVERITY_STYLE = {
7
+ error: { icon: "✖", color: C.red },
8
+ warning: { icon: "▲", color: C.yellow },
9
+ info: { icon: "ℹ", color: C.accent },
10
+ };
11
+ export function ProblemsView({ width, height, focused, problems, checking, onJump, onClose }) {
12
+ const [cursor, setCursor] = useState(0);
13
+ const [onlyErrors, setOnlyErrors] = useState(false);
14
+ const { frame } = useAnimation({ interval: 80, isActive: checking.length > 0 });
15
+ const rows = useMemo(() => {
16
+ const list = onlyErrors ? problems.filter((d) => d.severity === "error") : problems;
17
+ const out = [];
18
+ let current = "";
19
+ for (const d of list) {
20
+ if (d.file !== current) {
21
+ current = d.file;
22
+ const inFile = list.filter((x) => x.file === d.file);
23
+ out.push({
24
+ kind: "file",
25
+ file: d.file,
26
+ errors: inFile.filter((x) => x.severity === "error").length,
27
+ warnings: inFile.filter((x) => x.severity === "warning").length,
28
+ });
29
+ }
30
+ out.push({ kind: "problem", d });
31
+ }
32
+ return out;
33
+ }, [problems, onlyErrors]);
34
+ useEffect(() => setCursor((c) => Math.min(c, Math.max(0, rows.length - 1))), [rows.length]);
35
+ const visibleRows = Math.max(1, height - 4);
36
+ useInput((input, key) => {
37
+ if (key.upArrow || input === "k")
38
+ setCursor((c) => Math.max(0, c - 1));
39
+ else if (key.downArrow || input === "j")
40
+ setCursor((c) => Math.min(rows.length - 1, c + 1));
41
+ else if (key.pageUp)
42
+ setCursor((c) => Math.max(0, c - visibleRows));
43
+ else if (key.pageDown)
44
+ setCursor((c) => Math.min(rows.length - 1, c + visibleRows));
45
+ else if (input === "e")
46
+ setOnlyErrors((v) => !v);
47
+ else if (input === "p" || key.escape)
48
+ onClose();
49
+ else if (key.return) {
50
+ const row = rows[cursor];
51
+ if (row?.kind === "problem")
52
+ onJump(row.d.file, row.d.line);
53
+ else if (row?.kind === "file")
54
+ onJump(row.file, problems.find((d) => d.file === row.file)?.line ?? 1);
55
+ }
56
+ }, { isActive: focused });
57
+ const errors = problems.filter((d) => d.severity === "error").length;
58
+ const warnings = problems.filter((d) => d.severity === "warning").length;
59
+ const start = Math.max(0, Math.min(cursor - Math.floor(visibleRows / 2), rows.length - visibleRows));
60
+ const title = (_jsxs(_Fragment, { children: [" ", _jsxs(Text, { color: C.red, children: ["\u2716 ", errors] }), " ", _jsxs(Text, { color: C.yellow, children: ["\u25B2 ", warnings] }), " Problems", checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " checking ", checking.join(", ")] }), _jsx(Text, { color: C.dim, children: onlyErrors ? " (errors only)" : "" })] }));
61
+ return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, ...(focused ? { accent: C.red } : {}), children: [_jsxs(Box, { flexDirection: "column", height: visibleRows, children: [rows.length === 0 && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.green, children: checking.length ? "Checking…" : "✓ No problems found" }), _jsx(Text, { color: C.dim, children: "Checked by the syntax parser, plus ESLint, TypeScript, Ruff and language servers when your project has them." })] })), rows.slice(start, start + visibleRows).map((row, i) => {
62
+ const selected = focused && start + i === cursor;
63
+ const bg = selected ? { backgroundColor: C.panel } : {};
64
+ if (row.kind === "file") {
65
+ const { icon, color } = fileIcon(row.file);
66
+ return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [_jsx(Text, { color: color, children: icon }), " ", _jsx(Text, { bold: true, color: C.text, children: row.file }), row.errors > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", row.errors] }), row.warnings > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", row.warnings] })] }, `f${row.file}`));
67
+ }
68
+ const style = SEVERITY_STYLE[row.d.severity];
69
+ return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [" ", _jsx(Text, { color: style.color, children: style.icon }), _jsxs(Text, { color: C.dim, children: [" ", String(row.d.line).padStart(4), ":", String(row.d.col).padEnd(3), " "] }), _jsx(Text, { ...(selected ? { color: C.text } : {}), children: row.d.message }), _jsxs(Text, { color: C.dim, children: [" ", row.d.source, row.d.code ? ` ${row.d.code}` : ""] })] }, `${row.d.file}:${row.d.line}:${row.d.col}:${i}`));
70
+ })] }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: " \u2191\u2193 move \u00B7 Enter go to line \u00B7 e errors only \u00B7 p/Esc close" })] }));
71
+ }
@@ -2,13 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useEffect, useState } from "react";
3
3
  import { Box, Text, useAnimation, useInput } from "ink";
4
4
  import TextInput from "ink-text-input";
5
- import { EFFORTS } from "../config/index.js";
6
- import { PLUGIN_DIRS } from "../plugins/index.js";
5
+ import { AUTO_SAVE, EFFORTS } from "../config/index.js";
6
+ import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
7
+ import { currentVersion } from "../update/index.js";
7
8
  import { describe, providers } from "../providers/index.js";
8
9
  import { C, SPINNER } from "./theme.js";
9
10
  const TABS = ["Model", "Preferences", "Plugins"];
10
11
  const PREFS = [
11
12
  { key: "effort", label: "Reasoning effort", help: "How hard Claude thinks (low → max). Higher is smarter but slower and pricier." },
13
+ { key: "autoSave", label: "Auto-save", help: "delay: a second after you stop typing · focus: when you leave the editor · off: only Ctrl+S." },
12
14
  { key: "followMode", label: "Follow mode on start", help: "The code view jumps to files Nou reads and edits." },
13
15
  { key: "gitContext", label: "Git context", help: "Give the model your branch, changes and commits, plus git tools." },
14
16
  { key: "autoApproveEdits", label: "Auto-approve edits", help: "Apply file edits without asking (diffs are still shown).", warn: true },
@@ -86,10 +88,13 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
86
88
  }
87
89
  else if (tab === "Preferences" && (key.return || input === " " || key.leftArrow || key.rightArrow)) {
88
90
  const pref = PREFS[row];
91
+ const step = key.leftArrow ? -1 : 1;
89
92
  if (pref.key === "effort") {
90
- const step = key.leftArrow ? -1 : 1;
91
93
  onChange({ effort: EFFORTS[(EFFORTS.indexOf(settings.effort) + step + EFFORTS.length) % EFFORTS.length] });
92
94
  }
95
+ else if (pref.key === "autoSave") {
96
+ onChange({ autoSave: AUTO_SAVE[(AUTO_SAVE.indexOf(settings.autoSave) + step + AUTO_SAVE.length) % AUTO_SAVE.length] });
97
+ }
93
98
  else {
94
99
  onChange({ [pref.key]: !settings[pref.key] });
95
100
  }
@@ -98,7 +103,7 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
98
103
  if (input === "r")
99
104
  onRescanPlugins();
100
105
  else if ((key.return || input === " ") && plugins[row])
101
- onTogglePlugin(plugins[row].id, !settings.plugins[plugins[row].id]);
106
+ onTogglePlugin(plugins[row].id, !isEnabled(plugins[row], settings));
102
107
  }
103
108
  });
104
109
  const boxHeight = height - 6;
@@ -130,21 +135,31 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
130
135
  return (_jsx(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: PREFS.map((pref, i) => {
131
136
  const selected = i === row;
132
137
  const value = settings[pref.key];
133
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: selected, children: pref.label.padEnd(26) }), pref.key === "effort"
134
- ? _jsx(Text, { children: EFFORTS.map((e) => _jsx(Text, { color: e === value ? C.accent2 : C.dim, bold: e === value, children: e === value ? `[${e}]` : ` ${e} ` }, e)) })
138
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: selected, children: pref.label.padEnd(26) }), pref.key === "effort" || pref.key === "autoSave"
139
+ ? _jsx(Text, { children: (pref.key === "effort" ? EFFORTS : AUTO_SAVE).map((e) => _jsx(Text, { color: e === value ? C.accent2 : C.dim, bold: e === value, children: e === value ? `[${e}]` : ` ${e} ` }, e)) })
135
140
  : toggle(value, pref.warn)] }), _jsxs(Text, { color: C.dim, children: [" ", pref.help] })] }, pref.key));
136
141
  }) }));
137
142
  }
138
- return (_jsx(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: plugins.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.text, children: "No plugins found." }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "Drop a .js/.mjs/.ts file (or a folder with index.*) into one of:" }), PLUGIN_DIRS.map((d) => _jsxs(Text, { color: C.cyan, children: [" ", d.dir, " ", _jsxs(Text, { color: C.dim, children: ["(", d.scope, ")"] })] }, d.dir)), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "See examples/plugins/word-count.mjs for the shape. Press r to rescan." })] })) : plugins.map((p, i) => {
139
- const selected = i === row;
140
- const enabled = !!settings.plugins[p.id];
141
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope] })] }), p.error
142
- ? _jsxs(Text, { color: C.red, children: [" \u2716 ", p.error] })
143
- : _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.description ?? (enabled ? "" : "Enable to load it."), p.plugin ? ` [${(p.plugin.tools ?? []).length} tools, ${(p.plugin.commands ?? []).length} commands]` : ""] })] }, p.id));
144
- }) }));
143
+ return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [plugins.map((p, i) => {
144
+ const selected = i === row;
145
+ const state = trustState(p, settings);
146
+ const enabled = state === "on";
147
+ const parts = p.plugin
148
+ ? [
149
+ (p.plugin.tools ?? []).length && `${p.plugin.tools.length} tools`,
150
+ (p.plugin.commands ?? []).length && `${p.plugin.commands.length} commands`,
151
+ (p.plugin.diagnostics ?? []).length && "checker",
152
+ ].filter(Boolean)
153
+ : [];
154
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope, parts.length ? ` · ${parts.join(", ")}` : ""] })] }), state === "changed"
155
+ ? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u26A0 Changed since you enabled it. Review the code, then press Enter to enable again." })
156
+ : p.error
157
+ ? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \u2716 ", p.error] })
158
+ : _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", p.plugin?.description ?? (enabled ? "" : "Enable to load it.")] })] }, p.id));
159
+ }), _jsx(Text, { children: " " }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Add your own: drop a .js/.mjs file into ", PLUGIN_DIRS.map((d) => d.dir).join(" or "), ", then press r."] }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Example: examples/plugins/word-count.mjs in the Nou repo." })] }));
145
160
  };
146
161
  const hints = tab === "Model"
147
162
  ? column === "models" ? "↑↓ choose · Enter use model · ← providers" : "↑↓ provider · Enter/→ pick a model"
148
- : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change effort" : "↑↓ move · Enter toggle · r rescan";
149
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u2699 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
163
+ : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : "↑↓ move · Enter toggle · r rescan";
164
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u2699 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) }), _jsxs(Text, { color: C.dim, children: [" nou v", currentVersion()] })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
150
165
  }
@@ -1,6 +1,7 @@
1
1
  import path from "path";
2
2
  import { highlight, supportsLanguage } from "cli-highlight";
3
3
  import { diffLines } from "diff";
4
+ import { highlightTree } from "../syntax/index.js";
4
5
  const LANGUAGES = {
5
6
  ".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
6
7
  ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
@@ -16,8 +17,12 @@ export function splitLines(text) {
16
17
  return lines;
17
18
  }
18
19
  export function highlightLines(filePath, text) {
19
- const language = LANGUAGES[path.extname(filePath).toLowerCase()];
20
20
  const plain = splitLines(text);
21
+ // tree-sitter when the grammar is loaded (accurate); highlight.js otherwise.
22
+ const tree = highlightTree(filePath, plain.join("\n"));
23
+ if (tree)
24
+ return tree;
25
+ const language = LANGUAGES[path.extname(filePath).toLowerCase()];
21
26
  if (!language || !supportsLanguage(language) || text.length > 200_000)
22
27
  return plain;
23
28
  try {
@@ -0,0 +1,102 @@
1
+ import { spawn } from "child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { NOU_HOME } from "../config/index.js";
6
+ export const PACKAGE_NAME = "nou-cli";
7
+ const CACHE = path.join(NOU_HOME, "update.json");
8
+ const DAY = 24 * 60 * 60 * 1000;
9
+ /** Nou's own package.json (works from source and from the installed dist build). */
10
+ function ownPackage() {
11
+ let dir = path.dirname(fileURLToPath(import.meta.url));
12
+ for (let i = 0; i < 5; i++) {
13
+ const file = path.join(dir, "package.json");
14
+ if (existsSync(file)) {
15
+ const pkg = JSON.parse(readFileSync(file, "utf-8"));
16
+ if (pkg.name === PACKAGE_NAME)
17
+ return { version: pkg.version, dir };
18
+ }
19
+ dir = path.dirname(dir);
20
+ }
21
+ return { version: "0.0.0", dir };
22
+ }
23
+ export const currentVersion = () => ownPackage().version;
24
+ /** Running from a git checkout (npm run dev) rather than an npm install: never nag about updates. */
25
+ export const isDevCheckout = () => existsSync(path.join(ownPackage().dir, ".git"));
26
+ /** -1, 0 or 1. Plain x.y.z comparison; pre-release tags sort before the release. */
27
+ export function compareVersions(a, b) {
28
+ const parse = (v) => {
29
+ const [main, pre] = v.replace(/^v/, "").split("-", 2);
30
+ return { nums: main.split(".").map((n) => Number(n) || 0), pre };
31
+ };
32
+ const x = parse(a);
33
+ const y = parse(b);
34
+ for (let i = 0; i < 3; i++) {
35
+ const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
36
+ if (d)
37
+ return Math.sign(d);
38
+ }
39
+ if (x.pre && !y.pre)
40
+ return -1;
41
+ if (!x.pre && y.pre)
42
+ return 1;
43
+ return 0;
44
+ }
45
+ async function fetchLatest(timeout) {
46
+ try {
47
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { signal: AbortSignal.timeout(timeout) });
48
+ if (!res.ok)
49
+ return null;
50
+ return (await res.json()).version ?? null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /**
57
+ * The newer version available on npm, or null. Asks npm at most once a day (cached in ~/.nou/update.json)
58
+ * and never waits more than `timeout` ms, so startup stays fast offline.
59
+ */
60
+ export async function checkForUpdate({ force = false, timeout = 1500 } = {}) {
61
+ if (!force && (process.env.NOU_NO_UPDATE_CHECK || isDevCheckout()))
62
+ return null;
63
+ let latest = null;
64
+ try {
65
+ const cached = JSON.parse(readFileSync(CACHE, "utf-8"));
66
+ if (!force && Date.now() - cached.checkedAt < DAY)
67
+ latest = cached.latest;
68
+ }
69
+ catch {
70
+ // no cache yet
71
+ }
72
+ if (!latest) {
73
+ latest = await fetchLatest(timeout);
74
+ if (latest) {
75
+ try {
76
+ mkdirSync(NOU_HOME, { recursive: true });
77
+ writeFileSync(CACHE, JSON.stringify({ latest, checkedAt: Date.now() }));
78
+ }
79
+ catch {
80
+ // caching is optional
81
+ }
82
+ }
83
+ }
84
+ return latest && compareVersions(latest, currentVersion()) > 0 ? latest : null;
85
+ }
86
+ /** Runs `npm install -g nou-cli@<version>`, showing npm's output. Resolves to success. */
87
+ export function installUpdate(version = "latest") {
88
+ return new Promise((resolve) => {
89
+ // npm is npm.cmd on Windows, which needs a shell; the arguments are fixed, not user input.
90
+ const child = spawn("npm", ["install", "-g", `${PACKAGE_NAME}@${version}`], {
91
+ stdio: "inherit",
92
+ shell: process.platform === "win32",
93
+ });
94
+ child.on("error", () => resolve(false));
95
+ child.on("close", (code) => resolve(code === 0));
96
+ });
97
+ }
98
+ /** Starts the freshly installed Nou in place of this process. */
99
+ export function relaunch() {
100
+ const child = spawn(process.execPath, process.argv.slice(1).filter((a) => a !== "update"), { stdio: "inherit" });
101
+ child.on("close", (code) => process.exit(code ?? 0));
102
+ }