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.
- package/README.md +142 -31
- package/dist/grammars/tree-sitter-bash.wasm +0 -0
- package/dist/grammars/tree-sitter-c.wasm +0 -0
- package/dist/grammars/tree-sitter-css.wasm +0 -0
- package/dist/grammars/tree-sitter-go.wasm +0 -0
- package/dist/grammars/tree-sitter-html.wasm +0 -0
- package/dist/grammars/tree-sitter-java.wasm +0 -0
- package/dist/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/grammars/tree-sitter-json.wasm +0 -0
- package/dist/grammars/tree-sitter-python.wasm +0 -0
- package/dist/grammars/tree-sitter-rust.wasm +0 -0
- package/dist/grammars/tree-sitter-toml.wasm +0 -0
- package/dist/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/grammars/tree-sitter-yaml.wasm +0 -0
- package/dist/index.js +55 -6
- package/dist/src/agent/index.js +37 -5
- package/dist/src/commands/index.js +16 -6
- package/dist/src/config/index.js +6 -2
- package/dist/src/diagnostics/index.js +235 -0
- package/dist/src/diagnostics/process.js +64 -0
- package/dist/src/diagnostics/providers.js +103 -0
- package/dist/src/diagnostics/types.js +1 -0
- package/dist/src/env.js +12 -0
- package/dist/src/history/index.js +113 -0
- package/dist/src/lsp/client.js +169 -0
- package/dist/src/lsp/index.js +169 -0
- package/dist/src/plugins/index.js +94 -3
- package/dist/src/providers/ids.js +3 -0
- package/dist/src/providers/ollama.js +2 -1
- package/dist/src/providers/openai.js +3 -2
- package/dist/src/syntax/index.js +240 -0
- package/dist/src/ui/App.js +299 -30
- package/dist/src/ui/Chat.js +19 -6
- package/dist/src/ui/ChatInput.js +128 -0
- package/dist/src/ui/CodeView.js +25 -5
- package/dist/src/ui/Editor.js +94 -21
- package/dist/src/ui/FileTree.js +23 -4
- package/dist/src/ui/Header.js +4 -2
- package/dist/src/ui/ProblemsView.js +75 -0
- package/dist/src/ui/Settings.js +31 -15
- package/dist/src/ui/mouse.js +52 -0
- package/dist/src/ui/text.js +6 -1
- package/dist/src/update/index.js +102 -0
- 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
|
+
}
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -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(() =>
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
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(
|
|
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 ??
|
|
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
|
|
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
|
-
}) }),
|
|
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
|
}
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -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
|
|
133
|
-
const
|
|
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);
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|