nou-cli 0.0.0-stage → 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 (56) hide show
  1. package/.env.example +24 -0
  2. package/README.md +163 -2
  3. package/dist/grammars/tree-sitter-bash.wasm +0 -0
  4. package/dist/grammars/tree-sitter-c.wasm +0 -0
  5. package/dist/grammars/tree-sitter-css.wasm +0 -0
  6. package/dist/grammars/tree-sitter-go.wasm +0 -0
  7. package/dist/grammars/tree-sitter-html.wasm +0 -0
  8. package/dist/grammars/tree-sitter-java.wasm +0 -0
  9. package/dist/grammars/tree-sitter-javascript.wasm +0 -0
  10. package/dist/grammars/tree-sitter-json.wasm +0 -0
  11. package/dist/grammars/tree-sitter-python.wasm +0 -0
  12. package/dist/grammars/tree-sitter-rust.wasm +0 -0
  13. package/dist/grammars/tree-sitter-toml.wasm +0 -0
  14. package/dist/grammars/tree-sitter-tsx.wasm +0 -0
  15. package/dist/grammars/tree-sitter-typescript.wasm +0 -0
  16. package/dist/grammars/tree-sitter-yaml.wasm +0 -0
  17. package/dist/index.js +84 -0
  18. package/dist/src/agent/index.js +246 -0
  19. package/dist/src/commands/index.js +125 -0
  20. package/dist/src/config/index.js +39 -0
  21. package/dist/src/context/index.js +53 -0
  22. package/dist/src/diagnostics/index.js +235 -0
  23. package/dist/src/diagnostics/process.js +64 -0
  24. package/dist/src/diagnostics/providers.js +103 -0
  25. package/dist/src/diagnostics/types.js +1 -0
  26. package/dist/src/env.js +12 -0
  27. package/dist/src/git/index.js +94 -0
  28. package/dist/src/git/tools.js +52 -0
  29. package/dist/src/history/index.js +113 -0
  30. package/dist/src/lsp/client.js +169 -0
  31. package/dist/src/lsp/index.js +169 -0
  32. package/dist/src/plugins/index.js +134 -0
  33. package/dist/src/providers/anthropic.js +109 -0
  34. package/dist/src/providers/ids.js +3 -0
  35. package/dist/src/providers/index.js +46 -0
  36. package/dist/src/providers/ollama.js +58 -0
  37. package/dist/src/providers/openai.js +107 -0
  38. package/dist/src/providers/types.js +2 -0
  39. package/dist/src/syntax/index.js +240 -0
  40. package/dist/src/tools/index.js +100 -0
  41. package/dist/src/ui/App.js +671 -0
  42. package/dist/src/ui/Chat.js +97 -0
  43. package/dist/src/ui/ChatInput.js +128 -0
  44. package/dist/src/ui/CodeView.js +100 -0
  45. package/dist/src/ui/Editor.js +234 -0
  46. package/dist/src/ui/FileTree.js +143 -0
  47. package/dist/src/ui/Header.js +9 -0
  48. package/dist/src/ui/Pane.js +8 -0
  49. package/dist/src/ui/ProblemsView.js +71 -0
  50. package/dist/src/ui/QuickOpen.js +91 -0
  51. package/dist/src/ui/Settings.js +165 -0
  52. package/dist/src/ui/text.js +100 -0
  53. package/dist/src/ui/theme.js +41 -0
  54. package/dist/src/update/index.js +102 -0
  55. package/dist/src/util/index.js +9 -0
  56. package/package.json +69 -4
@@ -0,0 +1,97 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Box, Text, useAnimation, useInput } from "ink";
4
+ import { wrap } from "./text.js";
5
+ import { ChatInput, inputHeight } from "./ChatInput.js";
6
+ import { C, SPINNER } from "./theme.js";
7
+ import { Pane } from "./Pane.js";
8
+ export const COMMANDS = ["/model", "/models", "/settings", "/clear", "/help"];
9
+ export function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, onSubmit }) {
10
+ const [input, setInput] = useState("");
11
+ const [scrollBack, setScrollBack] = useState(0);
12
+ const { frame } = useAnimation({ interval: 80, isActive: !!busy });
13
+ const inner = width - 2;
14
+ const lines = useMemo(() => {
15
+ const out = [];
16
+ const all = streaming ? [...entries, { kind: "assistant", text: streaming }] : entries;
17
+ for (const entry of all) {
18
+ if (entry.kind === "user") {
19
+ out.push({ text: "you", color: C.cyan, bold: true, bar: C.cyan });
20
+ for (const line of wrap(entry.text, inner - 3))
21
+ out.push({ text: line, color: C.text, bar: C.cyan });
22
+ out.push({ text: "" });
23
+ }
24
+ else if (entry.kind === "assistant") {
25
+ out.push({ text: "◆ nou", color: C.accent2, bold: true, bar: C.accent2 });
26
+ for (const line of wrap(entry.text, inner - 3)) {
27
+ const isCode = /^\s{2,}|^```|^\s*[{}();]/.test(line);
28
+ out.push({ text: line, ...(isCode ? { color: C.yellow } : {}), bar: C.accent2 });
29
+ }
30
+ out.push({ text: "" });
31
+ }
32
+ else {
33
+ const style = entry.kind === "tool"
34
+ ? { icon: "⚙", color: C.dim }
35
+ : entry.kind === "error" ? { icon: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
36
+ wrap(entry.text, inner - 4).forEach((line, i) => out.push({ text: `${i === 0 ? ` ${style.icon}` : " "} ${line}`, color: style.color }));
37
+ }
38
+ }
39
+ return out;
40
+ }, [entries, streaming, inner]);
41
+ // New output snaps the view back to the bottom.
42
+ useEffect(() => setScrollBack(0), [entries.length, streaming]);
43
+ const footer = [];
44
+ if (approval?.kind === "command") {
45
+ footer.push({ text: "⚡ Run this command?", color: C.yellow, bold: true });
46
+ for (const line of wrap(`$ ${approval.command}`, inner - 2))
47
+ footer.push({ text: ` ${line}`, color: C.text });
48
+ footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
49
+ }
50
+ else if (approval?.kind === "tool") {
51
+ footer.push({ text: `🔌 Plugin tool: ${approval.name}`, color: C.yellow, bold: true });
52
+ for (const line of wrap(JSON.stringify(approval.args), inner - 2).slice(0, 4))
53
+ footer.push({ text: ` ${line}`, color: C.text });
54
+ footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
55
+ }
56
+ else if (approval?.kind === "change") {
57
+ footer.push({ text: `✎ Review ${approval.change.path} →`, color: C.yellow, bold: true });
58
+ if (approvalNote) {
59
+ footer.push({ text: "⚠ This edit adds problems:", color: C.red, bold: true });
60
+ for (const line of wrap(approvalNote, inner - 2).slice(0, 6))
61
+ footer.push({ text: ` ${line}`, color: C.red });
62
+ }
63
+ footer.push({ text: " [y] accept [n] reject [Esc] stop", color: C.yellow });
64
+ }
65
+ else if (busy) {
66
+ footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
67
+ }
68
+ const suggestions = input.startsWith("/") && !input.includes(" ")
69
+ ? COMMANDS.filter((c) => c.startsWith(input) && c !== input)
70
+ : [];
71
+ const inputRows = inputHeight(input, inner);
72
+ const rows = Math.max(1, height - 3 - footer.length - 2 - inputRows);
73
+ const history = useMemo(() => entries.filter((e) => e.kind === "user").map((e) => e.text), [entries]);
74
+ const maxBack = Math.max(0, lines.length - rows);
75
+ const back = Math.min(scrollBack, maxBack);
76
+ const visible = lines.slice(Math.max(0, lines.length - rows - back), lines.length - back);
77
+ useInput((_input, key) => {
78
+ if (key.pageUp || (key.upArrow && key.shift))
79
+ setScrollBack((b) => Math.min(maxBack, b + Math.max(1, rows - 2)));
80
+ else if (key.pageDown || (key.downArrow && key.shift))
81
+ setScrollBack((b) => Math.max(0, b - Math.max(1, rows - 2)));
82
+ }, { isActive: focused });
83
+ const submit = (value) => {
84
+ if (!value.trim() || busy || approval)
85
+ return;
86
+ // "/se" + Enter runs /settings when only one command matches.
87
+ const command = !COMMANDS.includes(value.trim()) && suggestions.length === 1 ? suggestions[0] : value.trim();
88
+ setInput("");
89
+ onSubmit(command);
90
+ };
91
+ const render = (line, key) => (_jsxs(Text, { wrap: "truncate-end", children: [line.bar ? _jsx(Text, { color: line.bar, children: "\u258C " }) : null, _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
92
+ const empty = entries.length === 0 && !streaming;
93
+ const title = _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back ? _jsxs(Text, { color: C.dim, children: [" \u2191", back, " more"] }) : null] });
94
+ return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsx(Box, { flexDirection: "column", height: rows, children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : visible.map((line, i) => render(line, i)) }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), _jsx(Text, { wrap: "truncate-end", color: C.dim, children: suggestions.length
95
+ ? _jsxs(Text, { color: C.accent, children: [suggestions.join(" "), " ", _jsx(Text, { color: C.dim, children: suggestions.length === 1 ? "Enter ↵" : "" })] })
96
+ : contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " }), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !approval, width: inner, history: history, placeholder: busy ? "working… Esc to stop" : "Ask anything · / for commands · ↑ history" })] }));
97
+ }
@@ -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
+ }
@@ -0,0 +1,100 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { SEVERITY_STYLE } from "./ProblemsView.js";
5
+ import { artWork } from "../util/index.js";
6
+ import { highlightLines, sideBySide } from "./text.js";
7
+ import { C, fileIcon, gradient } from "./theme.js";
8
+ import { Pane } from "./Pane.js";
9
+ export function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, onViewport }) {
10
+ const [top, setTop] = useState(0);
11
+ const rows = Math.max(1, height - 3);
12
+ const inner = width - 2;
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]);
20
+ const diffRows = useMemo(() => (diff ? sideBySide(diff.before, diff.after) : []), [diff]);
21
+ const total = diff ? diffRows.length : lines.length;
22
+ // Jump to the first change whenever a new file or diff is shown.
23
+ useEffect(() => {
24
+ const first = diff
25
+ ? diffRows.findIndex((r) => r.changed)
26
+ : marked.size ? Math.min(...marked) - 1 : 0;
27
+ setTop(Math.max(0, first - 3));
28
+ }, [filePath, diff, diffRows, marked]);
29
+ useEffect(() => {
30
+ if (focusLine && !diff)
31
+ setTop(Math.max(0, focusLine.line - Math.floor(rows / 3)));
32
+ }, [focusLine]);
33
+ const maxTop = Math.max(0, total - rows);
34
+ const start = Math.min(top, maxTop);
35
+ useEffect(() => {
36
+ if (filePath && !diff)
37
+ onViewport(start + 1, Math.min(total, start + rows));
38
+ }, [filePath, diff, start, rows, total]);
39
+ useInput((input, key) => {
40
+ if (key.upArrow || input === "k")
41
+ setTop((t) => Math.max(0, Math.min(t, maxTop) - 1));
42
+ else if (key.downArrow || input === "j")
43
+ setTop((t) => Math.min(maxTop, t + 1));
44
+ else if (key.pageUp || input === "u")
45
+ setTop((t) => Math.max(0, Math.min(t, maxTop) - rows));
46
+ else if (key.pageDown || input === "d" || input === " ")
47
+ setTop((t) => Math.min(maxTop, t + rows));
48
+ else if (input === "g")
49
+ setTop(0);
50
+ else if (input === "G")
51
+ setTop(maxTop);
52
+ else if (diff && (input === "n" || input === "N")) {
53
+ // Jump between change hunks.
54
+ const starts = diffRows.flatMap((r, i) => (r.changed && !diffRows[i - 1]?.changed ? [i] : []));
55
+ const target = input === "n" ? starts.find((s) => s - 3 > start) : [...starts].reverse().find((s) => s - 3 < start);
56
+ if (target !== undefined)
57
+ setTop(Math.max(0, Math.min(maxTop, target - 3)));
58
+ }
59
+ }, { isActive: focused });
60
+ const scroll = total > rows ? ` ${start + 1}-${Math.min(total, start + rows)}/${total}` : "";
61
+ const percent = total > rows ? Math.round(((start + rows) / total) * 100) : 100;
62
+ if (diff) {
63
+ const half = Math.floor((inner - 1) / 2);
64
+ const gutter = String(diffRows.length).length + 1;
65
+ const added = diffRows.filter((r) => r.changed && r.right).length;
66
+ const removed = diffRows.filter((r) => r.changed && r.left).length;
67
+ const cell = (c, side, changed, w) => {
68
+ if (!c)
69
+ return _jsx(Box, { width: w, children: _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "╱".repeat(Math.max(0, w)) }) });
70
+ const color = changed ? (side === "-" ? C.red : C.green) : undefined;
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 || " "] }) }));
72
+ };
73
+ const { icon, color } = fileIcon(diff.path);
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] })] }));
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))) }) }));
76
+ }
77
+ if (!filePath) {
78
+ const tips = [
79
+ ["Ctrl+P", "find a file"],
80
+ ["Tab", "switch panes"],
81
+ ["e", "edit the open file"],
82
+ ["v", "diff vs git HEAD"],
83
+ ["/settings", "models, preferences, plugins"],
84
+ ];
85
+ return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! \uD83C\uDF1E Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
86
+ }
87
+ const gutter = String(lines.length).length;
88
+ const { icon, color } = fileIcon(filePath);
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" : ""] })] }));
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) => {
93
+ const n = start + i + 1;
94
+ const isMarked = marked.has(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));
99
+ }) }) }));
100
+ }
@@ -0,0 +1,234 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { Box, Text, useInput, usePaste } from "ink";
4
+ import { highlightLines } from "./text.js";
5
+ import { C, fileIcon } from "./theme.js";
6
+ import { SEVERITY_STYLE } from "./ProblemsView.js";
7
+ import { Pane } from "./Pane.js";
8
+ const expand = (s) => s.replace(/\t/g, " ");
9
+ /** A small modal text editor: arrows to move, type to insert, Ctrl+S save, Ctrl+Z undo, Esc leave. */
10
+ export function Editor({ width, height, focused, filePath, initial, baseline, startLine, problems, autoSave, syntaxVersion, onSave, onExit, onState, onHover, onDefinition }) {
11
+ const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
12
+ const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
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);
19
+ const [saved, setSaved] = useState(() => baseline.replace(/\r\n/g, "\n"));
20
+ const [confirmExit, setConfirmExit] = useState(false);
21
+ const [flash, setFlash] = useState(null);
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 });
26
+ const view = useRef({ top: 0, left: 0 });
27
+ const dirty = useMemo(() => buf.lines.join("\n") !== saved, [buf.lines, saved]);
28
+ useEffect(() => onState({ line: buf.row + 1, col: buf.col + 1, dirty, lines: buf.lines }), [buf, dirty]);
29
+ useEffect(() => {
30
+ if (!flash)
31
+ return;
32
+ const t = setTimeout(() => setFlash(null), 1500);
33
+ return () => clearTimeout(t);
34
+ }, [flash]);
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 };
45
+ return fn(b);
46
+ });
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) => {
56
+ const pieces = text.replace(/\r\n?/g, "\n").split("\n");
57
+ const line = b.lines[b.row];
58
+ const before = line.slice(0, b.col);
59
+ const after = line.slice(b.col);
60
+ const lines = [...b.lines];
61
+ if (pieces.length === 1) {
62
+ lines[b.row] = before + text + after;
63
+ return { lines, row: b.row, col: b.col + text.length };
64
+ }
65
+ const last = pieces[pieces.length - 1];
66
+ lines.splice(b.row, 1, before + pieces[0], ...pieces.slice(1, -1), last + after);
67
+ return { lines, row: b.row + pieces.length - 1, col: last.length };
68
+ }, kind);
69
+ const move = (fn) => setBuf((b) => {
70
+ const next = { ...b, ...fn(b) };
71
+ next.row = Math.max(0, Math.min(next.lines.length - 1, next.row));
72
+ next.col = Math.max(0, Math.min(next.lines[next.row].length, next.col));
73
+ return next;
74
+ });
75
+ const save = (auto = false) => {
76
+ const text = buf.lines.join("\n");
77
+ onSave(text.replace(/\n/g, eol));
78
+ setSaved(text);
79
+ setFlash(auto ? "Auto-saved ✓" : "Saved ✓");
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 });
93
+ const rows = Math.max(1, height - 4);
94
+ usePaste((text) => insert(text), { isActive: focused });
95
+ useInput((input, key) => {
96
+ if (confirmExit) {
97
+ if (key.escape)
98
+ onExit();
99
+ else if (key.ctrl && input === "s") {
100
+ save();
101
+ onExit();
102
+ }
103
+ else
104
+ setConfirmExit(false);
105
+ return;
106
+ }
107
+ if (hover)
108
+ setHover(null);
109
+ if (key.ctrl && input === "c")
110
+ return; // the app handles Ctrl+C (press twice to quit)
111
+ if (key.ctrl && input === "s")
112
+ return save();
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
+ });
124
+ return;
125
+ }
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
+ }
135
+ return dirty ? setConfirmExit(true) : onExit();
136
+ }
137
+ if (key.upArrow)
138
+ return move((b) => ({ row: b.row - 1 }));
139
+ if (key.downArrow)
140
+ return move((b) => ({ row: b.row + 1 }));
141
+ if (key.leftArrow) {
142
+ return move((b) => (b.col > 0 ? { col: b.col - 1 } : b.row > 0 ? { row: b.row - 1, col: b.lines[b.row - 1].length } : {}));
143
+ }
144
+ if (key.rightArrow) {
145
+ return move((b) => (b.col < b.lines[b.row].length ? { col: b.col + 1 } : b.row < b.lines.length - 1 ? { row: b.row + 1, col: 0 } : {}));
146
+ }
147
+ if (key.home)
148
+ return move((b) => ({ col: b.lines[b.row].match(/^\s*/)[0].length === b.col ? 0 : b.lines[b.row].match(/^\s*/)[0].length }));
149
+ if (key.end)
150
+ return move((b) => ({ col: b.lines[b.row].length }));
151
+ if (key.pageUp)
152
+ return move((b) => ({ row: b.row - rows }));
153
+ if (key.pageDown)
154
+ return move((b) => ({ row: b.row + rows }));
155
+ if (key.return) {
156
+ const indent = buf.lines[buf.row].match(/^\s*/)[0];
157
+ return insert(`\n${indent}`);
158
+ }
159
+ if (key.tab)
160
+ return insert(indentUnit);
161
+ if (key.backspace) {
162
+ return edit((b) => {
163
+ const lines = [...b.lines];
164
+ if (b.col > 0) {
165
+ const line = lines[b.row];
166
+ lines[b.row] = line.slice(0, b.col - 1) + line.slice(b.col);
167
+ return { lines, row: b.row, col: b.col - 1 };
168
+ }
169
+ if (b.row === 0)
170
+ return b;
171
+ const prev = lines[b.row - 1];
172
+ lines.splice(b.row - 1, 2, prev + lines[b.row]);
173
+ return { lines, row: b.row - 1, col: prev.length };
174
+ }, "delete");
175
+ }
176
+ if (key.delete) {
177
+ return edit((b) => {
178
+ const lines = [...b.lines];
179
+ const line = lines[b.row];
180
+ if (b.col < line.length)
181
+ lines[b.row] = line.slice(0, b.col) + line.slice(b.col + 1);
182
+ else if (b.row < lines.length - 1)
183
+ lines.splice(b.row, 2, line + lines[b.row + 1]);
184
+ return { ...b, lines };
185
+ });
186
+ }
187
+ // Letters/digits group into one undo step per word; spaces and punctuation start a new one.
188
+ if (input && !key.ctrl && !key.meta)
189
+ insert(input, /^\w+$/.test(input) ? "type" : "other");
190
+ }, { isActive: focused });
191
+ // Keep the cursor on screen.
192
+ const gutter = String(buf.lines.length).length + 2;
193
+ const textWidth = Math.max(10, width - 2 - gutter);
194
+ const v = view.current;
195
+ if (buf.row < v.top)
196
+ v.top = buf.row;
197
+ if (buf.row >= v.top + rows)
198
+ v.top = buf.row - rows + 1;
199
+ const cursorX = expand(buf.lines[buf.row].slice(0, buf.col)).length;
200
+ if (cursorX < v.left)
201
+ v.left = Math.max(0, cursorX - 8);
202
+ if (cursorX >= v.left + textWidth)
203
+ v.left = cursorX - textWidth + 8;
204
+ const visible = buf.lines.slice(v.top, v.top + rows);
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]);
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];
214
+ const status = confirmExit
215
+ ? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
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) : [];
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) => {
222
+ const n = v.top + i;
223
+ const isCursorLine = n === buf.row;
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 ? " ▸" : " "] }));
227
+ if (!isCursorLine) {
228
+ return _jsxs(Text, { wrap: "truncate-end", children: [num, colored[i] || " "] }, n);
229
+ }
230
+ const shown = expand(line).slice(v.left, v.left + textWidth);
231
+ const at = cursorX - v.left;
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));
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` })] })] }));
234
+ }