nou-cli 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +142 -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 +6 -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 +299 -30
  34. package/dist/src/ui/Chat.js +19 -6
  35. package/dist/src/ui/ChatInput.js +128 -0
  36. package/dist/src/ui/CodeView.js +25 -5
  37. package/dist/src/ui/Editor.js +94 -21
  38. package/dist/src/ui/FileTree.js +23 -4
  39. package/dist/src/ui/Header.js +4 -2
  40. package/dist/src/ui/ProblemsView.js +75 -0
  41. package/dist/src/ui/Settings.js +31 -15
  42. package/dist/src/ui/mouse.js +52 -0
  43. package/dist/src/ui/text.js +6 -1
  44. package/dist/src/update/index.js +102 -0
  45. package/package.json +10 -5
@@ -0,0 +1,128 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import { Box, Text, useInput, usePaste } from "ink";
4
+ import { C } from "./theme.js";
5
+ /** Word-wraps `text` into segments of at most `width` characters, honoring newlines. */
6
+ export function layout(text, width) {
7
+ const w = Math.max(1, width);
8
+ const segments = [];
9
+ let pos = 0;
10
+ while (pos <= text.length) {
11
+ const newline = text.indexOf("\n", pos);
12
+ const lineEnd = newline === -1 ? text.length : newline;
13
+ if (lineEnd - pos <= w) {
14
+ segments.push({ start: pos, end: lineEnd });
15
+ if (newline === -1)
16
+ break;
17
+ pos = newline + 1;
18
+ continue;
19
+ }
20
+ // Break after the last space that fits, or hard-break a long word.
21
+ const space = text.lastIndexOf(" ", pos + w - 1);
22
+ const end = space >= pos ? space + 1 : pos + w;
23
+ segments.push({ start: pos, end });
24
+ pos = end;
25
+ }
26
+ return segments;
27
+ }
28
+ /** The segment the cursor sits in; a cursor at a wrap boundary belongs to the next line. */
29
+ function cursorLine(segments, cursor) {
30
+ for (let i = 0; i < segments.length; i++) {
31
+ const s = segments[i];
32
+ const next = segments[i + 1];
33
+ if (cursor >= s.start && (cursor < s.end || (cursor === s.end && (!next || next.start > s.end))))
34
+ return i;
35
+ }
36
+ return segments.length - 1;
37
+ }
38
+ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6 }) {
39
+ const [cursor, setCursor] = useState(value.length);
40
+ const [recall, setRecall] = useState(null);
41
+ const stash = useRef("");
42
+ const textWidth = Math.max(4, width - 2);
43
+ const pos = Math.min(cursor, value.length);
44
+ const segments = layout(value, textWidth);
45
+ const line = cursorLine(segments, pos);
46
+ const set = (next, nextCursor) => {
47
+ onChange(next);
48
+ setCursor(Math.max(0, Math.min(next.length, nextCursor)));
49
+ };
50
+ const insert = (text) => set(value.slice(0, pos) + text + value.slice(pos), pos + text.length);
51
+ const recallHistory = (step) => {
52
+ if (!history.length)
53
+ return false;
54
+ const current = recall ?? history.length;
55
+ const next = current + step;
56
+ if (next < 0 || next > history.length)
57
+ return false;
58
+ if (recall === null)
59
+ stash.current = value;
60
+ const text = next === history.length ? stash.current : history[next];
61
+ setRecall(next === history.length ? null : next);
62
+ set(text, text.length);
63
+ return true;
64
+ };
65
+ usePaste((text) => insert(text.replace(/\r\n?/g, "\n")), { isActive: focus });
66
+ useInput((input, key) => {
67
+ if (key.tab || key.escape || key.pageUp || key.pageDown || (key.shift && (key.upArrow || key.downArrow)))
68
+ return;
69
+ if (key.return) {
70
+ setRecall(null);
71
+ onSubmit(value);
72
+ return;
73
+ }
74
+ if (key.backspace)
75
+ return pos > 0 && set(value.slice(0, pos - 1) + value.slice(pos), pos - 1);
76
+ if (key.delete)
77
+ return pos < value.length && set(value.slice(0, pos) + value.slice(pos + 1), pos);
78
+ if (key.leftArrow)
79
+ return setCursor(key.ctrl || key.meta ? value.lastIndexOf(" ", pos - 2) + 1 : pos - 1 < 0 ? 0 : pos - 1);
80
+ if (key.rightArrow) {
81
+ if (key.ctrl || key.meta) {
82
+ const next = value.indexOf(" ", pos + 1);
83
+ return setCursor(next === -1 ? value.length : next);
84
+ }
85
+ return setCursor(Math.min(value.length, pos + 1));
86
+ }
87
+ if (key.home || (key.ctrl && input === "a"))
88
+ return setCursor(segments[line].start);
89
+ if (key.end || (key.ctrl && input === "e"))
90
+ return setCursor(segments[line].end);
91
+ if (key.ctrl && input === "u")
92
+ return set("", 0);
93
+ if (key.upArrow || key.downArrow) {
94
+ const target = line + (key.upArrow ? -1 : 1);
95
+ if (target >= 0 && target < segments.length) {
96
+ // Move between wrapped lines, keeping the column.
97
+ const col = pos - segments[line].start;
98
+ const s = segments[target];
99
+ return setCursor(Math.min(s.start + col, s.end));
100
+ }
101
+ recallHistory(key.upArrow ? -1 : 1);
102
+ return;
103
+ }
104
+ if (key.ctrl || key.meta || !input)
105
+ return;
106
+ insert(input.replace(/\r\n?/g, "\n"));
107
+ }, { isActive: focus });
108
+ // Show a window of lines around the cursor once the message gets long.
109
+ const first = Math.max(0, Math.min(line - maxLines + 1, segments.length - maxLines));
110
+ const shown = segments.slice(first, first + maxLines);
111
+ const prompt = (i) => (_jsx(Text, { color: focus ? C.accent2 : C.dim, bold: true, children: first + i === 0 ? "❯ " : first > 0 && i === 0 ? "↑ " : " " }));
112
+ if (!value) {
113
+ return (_jsxs(Box, { children: [prompt(0), focus ? _jsx(Text, { inverse: true, children: " " }) : null, _jsx(Text, { color: C.dim, wrap: "truncate-end", children: placeholder })] }));
114
+ }
115
+ return (_jsx(Box, { flexDirection: "column", children: shown.map((s, i) => {
116
+ const text = value.slice(s.start, s.end).replace(/\n/g, "");
117
+ const isCursor = focus && first + i === line;
118
+ if (!isCursor) {
119
+ return _jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text || " " })] }, first + i);
120
+ }
121
+ const at = pos - s.start;
122
+ return (_jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text.slice(0, at) }), _jsx(Text, { inverse: true, children: text[at] ?? " " }), _jsx(Text, { color: C.text, children: text.slice(at + 1) })] }, first + i));
123
+ }) }));
124
+ }
125
+ /** How many rows the input will take, so the chat can size its history above it. */
126
+ export function inputHeight(value, width, maxLines = 6) {
127
+ return value ? Math.min(maxLines, layout(value, Math.max(4, width - 2)).length) : 1;
128
+ }
@@ -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, wheel, 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,8 +26,16 @@ 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);
35
+ useEffect(() => {
36
+ if (wheel)
37
+ setTop((t) => Math.max(0, Math.min(maxTop, Math.min(t, maxTop) + wheel.dir * 3)));
38
+ }, [wheel?.nonce]);
24
39
  useEffect(() => {
25
40
  if (filePath && !diff)
26
41
  onViewport(start + 1, Math.min(total, start + rows));
@@ -60,7 +75,7 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
60
75
  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
76
  };
62
77
  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] })] }));
78
+ 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
79
  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
80
  }
66
81
  if (!filePath) {
@@ -75,10 +90,15 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
75
90
  }
76
91
  const gutter = String(lines.length).length;
77
92
  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" : ""] })] }));
93
+ const errorCount = problems.filter((d) => d.severity === "error").length;
94
+ const warningCount = problems.filter((d) => d.severity === "warning").length;
95
+ 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
96
  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
97
  const n = start + i + 1;
81
98
  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));
99
+ const here = problemsByLine.get(n);
100
+ const worst = here?.[0];
101
+ const style = worst ? SEVERITY_STYLE[worst.severity] : null;
102
+ 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
103
  }) }) }));
84
104
  }
@@ -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, wheel, 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,20 +65,36 @@ 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);
94
+ useEffect(() => {
95
+ if (wheel)
96
+ move((b) => ({ row: b.row + wheel.dir * 3 }));
97
+ }, [wheel?.nonce]);
59
98
  usePaste((text) => insert(text), { isActive: focused });
60
99
  useInput((input, key) => {
61
100
  if (confirmExit) {
@@ -69,16 +108,36 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
69
108
  setConfirmExit(false);
70
109
  return;
71
110
  }
111
+ if (hover)
112
+ setHover(null);
113
+ if (key.ctrl && input === "c")
114
+ return; // the app handles Ctrl+C (press twice to quit)
72
115
  if (key.ctrl && input === "s")
73
116
  return save();
74
- if (key.ctrl && input === "z") {
75
- const prev = undo.current.pop();
76
- if (prev)
77
- setBuf(prev);
117
+ if (key.ctrl && input === "z")
118
+ return step(undo, redo);
119
+ if (key.ctrl && input === "y")
120
+ return step(redo, undo);
121
+ if (key.ctrl && input === "k") {
122
+ const { text, line, col } = position();
123
+ setFlash("…");
124
+ void onHover(text, line, col).then((info) => {
125
+ setFlash(null);
126
+ setHover(info ?? "No info here (needs a language server for this file type).");
127
+ });
78
128
  return;
79
129
  }
80
- if (key.escape)
130
+ if (key.ctrl && input === "g") {
131
+ const { text, line, col } = position();
132
+ return onDefinition(text, line, col);
133
+ }
134
+ if (key.escape) {
135
+ if (dirty && autoSave !== "off") {
136
+ save(true);
137
+ return onExit();
138
+ }
81
139
  return dirty ? setConfirmExit(true) : onExit();
140
+ }
82
141
  if (key.upArrow)
83
142
  return move((b) => ({ row: b.row - 1 }));
84
143
  if (key.downArrow)
@@ -116,7 +175,7 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
116
175
  const prev = lines[b.row - 1];
117
176
  lines.splice(b.row - 1, 2, prev + lines[b.row]);
118
177
  return { lines, row: b.row - 1, col: prev.length };
119
- });
178
+ }, "delete");
120
179
  }
121
180
  if (key.delete) {
122
181
  return edit((b) => {
@@ -129,8 +188,9 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
129
188
  return { ...b, lines };
130
189
  });
131
190
  }
191
+ // Letters/digits group into one undo step per word; spaces and punctuation start a new one.
132
192
  if (input && !key.ctrl && !key.meta)
133
- insert(input);
193
+ insert(input, /^\w+$/.test(input) ? "type" : "other");
134
194
  }, { isActive: focused });
135
195
  // Keep the cursor on screen.
136
196
  const gutter = String(buf.lines.length).length + 2;
@@ -146,20 +206,33 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
146
206
  if (cursorX >= v.left + textWidth)
147
207
  v.left = cursorX - textWidth + 8;
148
208
  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]);
209
+ const colored = useMemo(
210
+ // Scrolled sideways: every line shifts by the same amount as the cursor line (plain text, since
211
+ // slicing highlighted text could cut a color code in half).
212
+ () => (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
213
  const { icon, color } = fileIcon(filePath);
214
+ const byLine = new Map();
215
+ for (const d of problems)
216
+ byLine.set(d.line, [...(byLine.get(d.line) ?? []), d]);
217
+ const cursorProblem = byLine.get(buf.row + 1)?.[0];
151
218
  const status = confirmExit
152
219
  ? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
153
- : flash ?? `Ln ${buf.row + 1}, Col ${buf.col + 1}${dirty ? " ● modified" : ""}`;
220
+ : flash ?? (cursorProblem
221
+ ? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
222
+ : `Ln ${buf.row + 1}, Col ${buf.col + 1}${dirty ? " ● modified" : ""}`);
223
+ const statusColor = confirmExit ? C.red : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
224
+ const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
154
225
  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
226
  const n = v.top + i;
156
227
  const isCursorLine = n === buf.row;
157
- const num = _jsxs(Text, { color: isCursorLine ? C.green : C.dim, children: [String(n + 1).padStart(gutter - 2), isCursorLine ? " ▸" : " "] });
228
+ const problem = byLine.get(n + 1)?.[0];
229
+ const mark = problem ? SEVERITY_STYLE[problem.severity] : null;
230
+ const num = (_jsxs(Text, { color: mark?.color ?? (isCursorLine ? C.green : C.dim), children: [String(n + 1).padStart(gutter - 2), mark ? ` ${mark.icon}` : isCursorLine ? " ▸" : " "] }));
158
231
  if (!isCursorLine) {
159
232
  return _jsxs(Text, { wrap: "truncate-end", children: [num, colored[i] || " "] }, n);
160
233
  }
161
234
  const shown = expand(line).slice(v.left, v.left + textWidth);
162
235
  const at = cursorX - v.left;
163
236
  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" })] })] }));
237
+ }) }), 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
238
  }
@@ -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, wheel, click, 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);
@@ -118,6 +118,23 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
118
118
  }, { isActive: focused });
119
119
  const start = Math.max(0, Math.min(cursor - Math.floor(rows / 2), nodes.length - rows));
120
120
  const visible = nodes.slice(start, start + rows);
121
+ useEffect(() => {
122
+ if (wheel)
123
+ setCursor((c) => Math.max(0, Math.min(nodes.length - 1, c + wheel.dir * 3)));
124
+ }, [wheel?.nonce]);
125
+ // Clicking a row selects it; a file opens, a folder expands or collapses.
126
+ useEffect(() => {
127
+ if (!click)
128
+ return;
129
+ const node = visible[click.row];
130
+ if (!node)
131
+ return;
132
+ setCursor(start + click.row);
133
+ if (node.isDir)
134
+ toggle(node.path);
135
+ else
136
+ onOpen(node.path);
137
+ }, [click?.nonce]);
121
138
  const inner = width - 2;
122
139
  const title = changesOnly
123
140
  ? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
@@ -129,10 +146,12 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
129
146
  const { icon, color } = node.isDir
130
147
  ? { icon: expanded.has(node.path) ? "▾" : "▸", color: C.accent }
131
148
  : 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;
149
+ const counts = problemCounts.get(node.path);
150
+ const problemMark = counts?.errors ? "✖" : counts?.warnings ? "▲" : "";
151
+ const marker = problemMark || code || (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
152
+ 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
153
  const label = `${" ".repeat(node.depth)}${icon} ${node.name}`;
135
- const nameColor = isOpen ? C.cyan : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
154
+ const nameColor = isOpen ? C.cyan : counts?.errors ? C.red : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
136
155
  const room = Math.max(1, inner - marker.length - 1);
137
156
  const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
138
157
  const iconAt = shown.indexOf(icon);
@@ -2,8 +2,10 @@ 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
+ import { currentVersion } from "../update/index.js";
6
+ const VERSION = `v${currentVersion()}`;
7
+ export function Header({ width, branch, changes, model, local, busy, update }) {
6
8
  const { frame } = useAnimation({ interval: 120, isActive: !!busy });
7
9
  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} ` }), " "] })] }));
10
+ return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }), _jsxs(Text, { children: [update && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] })] }));
9
11
  }
@@ -0,0 +1,75 @@
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, wheel, 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
+ useEffect(() => {
36
+ if (wheel)
37
+ setCursor((c) => Math.max(0, Math.min(rows.length - 1, c + wheel.dir * 3)));
38
+ }, [wheel?.nonce]);
39
+ const visibleRows = Math.max(1, height - 4);
40
+ useInput((input, key) => {
41
+ if (key.upArrow || input === "k")
42
+ setCursor((c) => Math.max(0, c - 1));
43
+ else if (key.downArrow || input === "j")
44
+ setCursor((c) => Math.min(rows.length - 1, c + 1));
45
+ else if (key.pageUp)
46
+ setCursor((c) => Math.max(0, c - visibleRows));
47
+ else if (key.pageDown)
48
+ setCursor((c) => Math.min(rows.length - 1, c + visibleRows));
49
+ else if (input === "e")
50
+ setOnlyErrors((v) => !v);
51
+ else if (input === "p" || key.escape)
52
+ onClose();
53
+ else if (key.return) {
54
+ const row = rows[cursor];
55
+ if (row?.kind === "problem")
56
+ onJump(row.d.file, row.d.line);
57
+ else if (row?.kind === "file")
58
+ onJump(row.file, problems.find((d) => d.file === row.file)?.line ?? 1);
59
+ }
60
+ }, { isActive: focused });
61
+ const errors = problems.filter((d) => d.severity === "error").length;
62
+ const warnings = problems.filter((d) => d.severity === "warning").length;
63
+ const start = Math.max(0, Math.min(cursor - Math.floor(visibleRows / 2), rows.length - visibleRows));
64
+ 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)" : "" })] }));
65
+ 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) => {
66
+ const selected = focused && start + i === cursor;
67
+ const bg = selected ? { backgroundColor: C.panel } : {};
68
+ if (row.kind === "file") {
69
+ const { icon, color } = fileIcon(row.file);
70
+ 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}`));
71
+ }
72
+ const style = SEVERITY_STYLE[row.d.severity];
73
+ 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}`));
74
+ })] }), _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" })] }));
75
+ }