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.
- package/.env.example +24 -0
- package/README.md +163 -2
- 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 +84 -0
- package/dist/src/agent/index.js +246 -0
- package/dist/src/commands/index.js +125 -0
- package/dist/src/config/index.js +39 -0
- package/dist/src/context/index.js +53 -0
- 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/git/index.js +94 -0
- package/dist/src/git/tools.js +52 -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 +134 -0
- package/dist/src/providers/anthropic.js +109 -0
- package/dist/src/providers/ids.js +3 -0
- package/dist/src/providers/index.js +46 -0
- package/dist/src/providers/ollama.js +58 -0
- package/dist/src/providers/openai.js +107 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/syntax/index.js +240 -0
- package/dist/src/tools/index.js +100 -0
- package/dist/src/ui/App.js +671 -0
- package/dist/src/ui/Chat.js +97 -0
- package/dist/src/ui/ChatInput.js +128 -0
- package/dist/src/ui/CodeView.js +100 -0
- package/dist/src/ui/Editor.js +234 -0
- package/dist/src/ui/FileTree.js +143 -0
- package/dist/src/ui/Header.js +9 -0
- package/dist/src/ui/Pane.js +8 -0
- package/dist/src/ui/ProblemsView.js +71 -0
- package/dist/src/ui/QuickOpen.js +91 -0
- package/dist/src/ui/Settings.js +165 -0
- package/dist/src/ui/text.js +100 -0
- package/dist/src/ui/theme.js +41 -0
- package/dist/src/update/index.js +102 -0
- package/dist/src/util/index.js +9 -0
- package/package.json +69 -4
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { readdirSync } from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { Box, Text, useInput } from "ink";
|
|
6
|
+
import { IGNORED } from "../commands/index.js";
|
|
7
|
+
import { C, GIT_COLORS, fileIcon } from "./theme.js";
|
|
8
|
+
import { Pane } from "./Pane.js";
|
|
9
|
+
function listDir(dir) {
|
|
10
|
+
try {
|
|
11
|
+
return readdirSync(path.resolve(process.cwd(), dir), { withFileTypes: true })
|
|
12
|
+
.filter((e) => !IGNORED.has(e.name))
|
|
13
|
+
.map((e) => ({ name: e.name, isDir: e.isDirectory() }))
|
|
14
|
+
.sort((a, b) => Number(b.isDir) - Number(a.isDir) || a.name.localeCompare(b.name));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function flatten(expanded) {
|
|
21
|
+
const nodes = [];
|
|
22
|
+
const walk = (dir, depth) => {
|
|
23
|
+
for (const entry of listDir(dir)) {
|
|
24
|
+
const rel = dir === "." ? entry.name : `${dir}/${entry.name}`;
|
|
25
|
+
nodes.push({ path: rel, name: entry.name, depth, isDir: entry.isDir });
|
|
26
|
+
if (entry.isDir && expanded.has(rel))
|
|
27
|
+
walk(rel, depth + 1);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
walk(".", 0);
|
|
31
|
+
return nodes;
|
|
32
|
+
}
|
|
33
|
+
export function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, onOpen, onHover }) {
|
|
34
|
+
const [expanded, setExpanded] = useState(() => new Set(["src"]));
|
|
35
|
+
const [cursor, setCursor] = useState(0);
|
|
36
|
+
const [changesOnly, setChangesOnly] = useState(false);
|
|
37
|
+
const nodes = useMemo(() => {
|
|
38
|
+
if (!changesOnly)
|
|
39
|
+
return flatten(expanded);
|
|
40
|
+
return [...new Set([...git.keys(), ...changed])].sort().map((p) => ({ path: p, name: p, depth: 0, isDir: false }));
|
|
41
|
+
}, [expanded, version, changesOnly, git, changed]);
|
|
42
|
+
const rows = Math.max(1, height - 3);
|
|
43
|
+
// Folders that contain git changes get a dot.
|
|
44
|
+
const dirtyDirs = useMemo(() => {
|
|
45
|
+
const dirs = new Set();
|
|
46
|
+
for (const file of git.keys()) {
|
|
47
|
+
const parts = file.split("/");
|
|
48
|
+
for (let i = 1; i < parts.length; i++)
|
|
49
|
+
dirs.add(parts.slice(0, i).join("/"));
|
|
50
|
+
}
|
|
51
|
+
return dirs;
|
|
52
|
+
}, [git]);
|
|
53
|
+
// Expand the folders leading to whatever the agent is touching and move to it.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!reveal)
|
|
56
|
+
return;
|
|
57
|
+
const parts = reveal.split("/");
|
|
58
|
+
setExpanded((prev) => {
|
|
59
|
+
const next = new Set(prev);
|
|
60
|
+
for (let i = 1; i < parts.length; i++)
|
|
61
|
+
next.add(parts.slice(0, i).join("/"));
|
|
62
|
+
return next;
|
|
63
|
+
});
|
|
64
|
+
}, [reveal, version]);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!reveal)
|
|
67
|
+
return;
|
|
68
|
+
const index = nodes.findIndex((n) => n.path === reveal);
|
|
69
|
+
if (index >= 0)
|
|
70
|
+
setCursor(index);
|
|
71
|
+
}, [nodes, reveal]);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
setCursor((c) => Math.min(c, Math.max(0, nodes.length - 1)));
|
|
74
|
+
}, [nodes.length]);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
onHover(focused ? nodes[cursor]?.path ?? null : null);
|
|
77
|
+
}, [focused, cursor, nodes]);
|
|
78
|
+
const toggle = (dir, open) => setExpanded((prev) => {
|
|
79
|
+
const next = new Set(prev);
|
|
80
|
+
if (open ?? !next.has(dir))
|
|
81
|
+
next.add(dir);
|
|
82
|
+
else
|
|
83
|
+
next.delete(dir);
|
|
84
|
+
return next;
|
|
85
|
+
});
|
|
86
|
+
useInput((input, key) => {
|
|
87
|
+
const node = nodes[cursor];
|
|
88
|
+
if (input === "c") {
|
|
89
|
+
setChangesOnly((v) => !v);
|
|
90
|
+
setCursor(0);
|
|
91
|
+
}
|
|
92
|
+
else if (key.upArrow || input === "k")
|
|
93
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
94
|
+
else if (key.downArrow || input === "j")
|
|
95
|
+
setCursor((c) => Math.min(nodes.length - 1, c + 1));
|
|
96
|
+
else if (key.pageUp)
|
|
97
|
+
setCursor((c) => Math.max(0, c - rows));
|
|
98
|
+
else if (key.pageDown)
|
|
99
|
+
setCursor((c) => Math.min(nodes.length - 1, c + rows));
|
|
100
|
+
else if (!node)
|
|
101
|
+
return;
|
|
102
|
+
else if (key.return || key.rightArrow || input === "l") {
|
|
103
|
+
if (node.isDir)
|
|
104
|
+
toggle(node.path, key.return ? undefined : true);
|
|
105
|
+
else
|
|
106
|
+
onOpen(node.path);
|
|
107
|
+
}
|
|
108
|
+
else if (key.leftArrow || input === "h") {
|
|
109
|
+
if (node.isDir && expanded.has(node.path))
|
|
110
|
+
toggle(node.path, false);
|
|
111
|
+
else {
|
|
112
|
+
const parent = node.path.split("/").slice(0, -1).join("/");
|
|
113
|
+
const index = nodes.findIndex((n) => n.path === parent);
|
|
114
|
+
if (index >= 0)
|
|
115
|
+
setCursor(index);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}, { isActive: focused });
|
|
119
|
+
const start = Math.max(0, Math.min(cursor - Math.floor(rows / 2), nodes.length - rows));
|
|
120
|
+
const visible = nodes.slice(start, start + rows);
|
|
121
|
+
const inner = width - 2;
|
|
122
|
+
const title = changesOnly
|
|
123
|
+
? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
|
|
124
|
+
: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent, children: "\u25A4" }), " Explorer", git.size ? _jsxs(Text, { color: C.dim, children: [" c: changes (", git.size, ")"] }) : null] });
|
|
125
|
+
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", children: [visible.length === 0 && _jsxs(Text, { color: C.dim, children: [" ", changesOnly ? "No changes ✓" : "(empty)"] }), visible.map((node, i) => {
|
|
126
|
+
const selected = start + i === cursor;
|
|
127
|
+
const code = git.get(node.path);
|
|
128
|
+
const isOpen = node.path === openFile;
|
|
129
|
+
const { icon, color } = node.isDir
|
|
130
|
+
? { icon: expanded.has(node.path) ? "▾" : "▸", color: C.accent }
|
|
131
|
+
: fileIcon(node.path);
|
|
132
|
+
const counts = problemCounts.get(node.path);
|
|
133
|
+
const problemMark = counts?.errors ? "✖" : counts?.warnings ? "▲" : "";
|
|
134
|
+
const marker = problemMark || code || (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
|
|
135
|
+
const markerColor = counts?.errors ? C.red : counts?.warnings ? C.yellow : code ? GIT_COLORS[code] ?? C.yellow : changed.has(node.path) ? C.green : C.yellow;
|
|
136
|
+
const label = `${" ".repeat(node.depth)}${icon} ${node.name}`;
|
|
137
|
+
const nameColor = isOpen ? C.cyan : counts?.errors ? C.red : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
|
|
138
|
+
const room = Math.max(1, inner - marker.length - 1);
|
|
139
|
+
const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
|
|
140
|
+
const iconAt = shown.indexOf(icon);
|
|
141
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), _jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) }), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
|
|
142
|
+
})] }) }));
|
|
143
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { Box, Text, useAnimation } from "ink";
|
|
4
|
+
import { C, SPINNER, gradient } from "./theme.js";
|
|
5
|
+
export function Header({ width, branch, changes, model, local, busy, update }) {
|
|
6
|
+
const { frame } = useAnimation({ interval: 120, isActive: !!busy });
|
|
7
|
+
const project = path.basename(process.cwd());
|
|
8
|
+
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }), _jsxs(Text, { children: [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
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { C } from "./theme.js";
|
|
4
|
+
/** A bordered panel with a title row. Content gets `width - 2` columns and `height - 3` rows. */
|
|
5
|
+
export function Pane({ title, width, height, focused, accent, children }) {
|
|
6
|
+
const color = accent ?? (focused ? C.accent : C.dim);
|
|
7
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, borderStyle: focused || accent ? "round" : "single", borderColor: color, overflow: "hidden", children: [_jsx(Text, { bold: true, color: focused || accent ? color : C.dim, wrap: "truncate-end", children: title }), children] }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
|
+
import { C, SPINNER, fileIcon } from "./theme.js";
|
|
5
|
+
import { Pane } from "./Pane.js";
|
|
6
|
+
export const SEVERITY_STYLE = {
|
|
7
|
+
error: { icon: "✖", color: C.red },
|
|
8
|
+
warning: { icon: "▲", color: C.yellow },
|
|
9
|
+
info: { icon: "ℹ", color: C.accent },
|
|
10
|
+
};
|
|
11
|
+
export function ProblemsView({ width, height, focused, problems, checking, onJump, onClose }) {
|
|
12
|
+
const [cursor, setCursor] = useState(0);
|
|
13
|
+
const [onlyErrors, setOnlyErrors] = useState(false);
|
|
14
|
+
const { frame } = useAnimation({ interval: 80, isActive: checking.length > 0 });
|
|
15
|
+
const rows = useMemo(() => {
|
|
16
|
+
const list = onlyErrors ? problems.filter((d) => d.severity === "error") : problems;
|
|
17
|
+
const out = [];
|
|
18
|
+
let current = "";
|
|
19
|
+
for (const d of list) {
|
|
20
|
+
if (d.file !== current) {
|
|
21
|
+
current = d.file;
|
|
22
|
+
const inFile = list.filter((x) => x.file === d.file);
|
|
23
|
+
out.push({
|
|
24
|
+
kind: "file",
|
|
25
|
+
file: d.file,
|
|
26
|
+
errors: inFile.filter((x) => x.severity === "error").length,
|
|
27
|
+
warnings: inFile.filter((x) => x.severity === "warning").length,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
out.push({ kind: "problem", d });
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}, [problems, onlyErrors]);
|
|
34
|
+
useEffect(() => setCursor((c) => Math.min(c, Math.max(0, rows.length - 1))), [rows.length]);
|
|
35
|
+
const visibleRows = Math.max(1, height - 4);
|
|
36
|
+
useInput((input, key) => {
|
|
37
|
+
if (key.upArrow || input === "k")
|
|
38
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
39
|
+
else if (key.downArrow || input === "j")
|
|
40
|
+
setCursor((c) => Math.min(rows.length - 1, c + 1));
|
|
41
|
+
else if (key.pageUp)
|
|
42
|
+
setCursor((c) => Math.max(0, c - visibleRows));
|
|
43
|
+
else if (key.pageDown)
|
|
44
|
+
setCursor((c) => Math.min(rows.length - 1, c + visibleRows));
|
|
45
|
+
else if (input === "e")
|
|
46
|
+
setOnlyErrors((v) => !v);
|
|
47
|
+
else if (input === "p" || key.escape)
|
|
48
|
+
onClose();
|
|
49
|
+
else if (key.return) {
|
|
50
|
+
const row = rows[cursor];
|
|
51
|
+
if (row?.kind === "problem")
|
|
52
|
+
onJump(row.d.file, row.d.line);
|
|
53
|
+
else if (row?.kind === "file")
|
|
54
|
+
onJump(row.file, problems.find((d) => d.file === row.file)?.line ?? 1);
|
|
55
|
+
}
|
|
56
|
+
}, { isActive: focused });
|
|
57
|
+
const errors = problems.filter((d) => d.severity === "error").length;
|
|
58
|
+
const warnings = problems.filter((d) => d.severity === "warning").length;
|
|
59
|
+
const start = Math.max(0, Math.min(cursor - Math.floor(visibleRows / 2), rows.length - visibleRows));
|
|
60
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsxs(Text, { color: C.red, children: ["\u2716 ", errors] }), " ", _jsxs(Text, { color: C.yellow, children: ["\u25B2 ", warnings] }), " Problems", checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " checking ", checking.join(", ")] }), _jsx(Text, { color: C.dim, children: onlyErrors ? " (errors only)" : "" })] }));
|
|
61
|
+
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, ...(focused ? { accent: C.red } : {}), children: [_jsxs(Box, { flexDirection: "column", height: visibleRows, children: [rows.length === 0 && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.green, children: checking.length ? "Checking…" : "✓ No problems found" }), _jsx(Text, { color: C.dim, children: "Checked by the syntax parser, plus ESLint, TypeScript, Ruff and language servers when your project has them." })] })), rows.slice(start, start + visibleRows).map((row, i) => {
|
|
62
|
+
const selected = focused && start + i === cursor;
|
|
63
|
+
const bg = selected ? { backgroundColor: C.panel } : {};
|
|
64
|
+
if (row.kind === "file") {
|
|
65
|
+
const { icon, color } = fileIcon(row.file);
|
|
66
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [_jsx(Text, { color: color, children: icon }), " ", _jsx(Text, { bold: true, color: C.text, children: row.file }), row.errors > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", row.errors] }), row.warnings > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", row.warnings] })] }, `f${row.file}`));
|
|
67
|
+
}
|
|
68
|
+
const style = SEVERITY_STYLE[row.d.severity];
|
|
69
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [" ", _jsx(Text, { color: style.color, children: style.icon }), _jsxs(Text, { color: C.dim, children: [" ", String(row.d.line).padStart(4), ":", String(row.d.col).padEnd(3), " "] }), _jsx(Text, { ...(selected ? { color: C.text } : {}), children: row.d.message }), _jsxs(Text, { color: C.dim, children: [" ", row.d.source, row.d.code ? ` ${row.d.code}` : ""] })] }, `${row.d.file}:${row.d.line}:${row.d.col}:${i}`));
|
|
70
|
+
})] }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: " \u2191\u2193 move \u00B7 Enter go to line \u00B7 e errors only \u00B7 p/Esc close" })] }));
|
|
71
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { readdirSync } from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { Box, Text, useInput } from "ink";
|
|
6
|
+
import TextInput from "ink-text-input";
|
|
7
|
+
import { IGNORED } from "../commands/index.js";
|
|
8
|
+
import { C, fileIcon } from "./theme.js";
|
|
9
|
+
export function listAllFiles(limit = 5000) {
|
|
10
|
+
const files = [];
|
|
11
|
+
const walk = (dir) => {
|
|
12
|
+
if (files.length >= limit)
|
|
13
|
+
return;
|
|
14
|
+
let entries;
|
|
15
|
+
try {
|
|
16
|
+
entries = readdirSync(path.resolve(process.cwd(), dir), { withFileTypes: true });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (const e of entries) {
|
|
22
|
+
if (IGNORED.has(e.name))
|
|
23
|
+
continue;
|
|
24
|
+
const rel = dir === "." ? e.name : `${dir}/${e.name}`;
|
|
25
|
+
if (e.isDirectory())
|
|
26
|
+
walk(rel);
|
|
27
|
+
else
|
|
28
|
+
files.push(rel);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
walk(".");
|
|
32
|
+
return files;
|
|
33
|
+
}
|
|
34
|
+
/** Subsequence fuzzy match; higher is better, null if no match. Returns matched indexes for highlighting. */
|
|
35
|
+
function score(file, query) {
|
|
36
|
+
if (!query)
|
|
37
|
+
return { score: 0, hits: [] };
|
|
38
|
+
const lower = file.toLowerCase();
|
|
39
|
+
const q = query.toLowerCase();
|
|
40
|
+
const baseStart = lower.lastIndexOf("/") + 1;
|
|
41
|
+
const hits = [];
|
|
42
|
+
let total = 0;
|
|
43
|
+
let from = 0;
|
|
44
|
+
for (const ch of q) {
|
|
45
|
+
const i = lower.indexOf(ch, from);
|
|
46
|
+
if (i < 0)
|
|
47
|
+
return null;
|
|
48
|
+
total += 1;
|
|
49
|
+
if (hits.length && hits[hits.length - 1] === i - 1)
|
|
50
|
+
total += 5; // consecutive
|
|
51
|
+
if (i >= baseStart)
|
|
52
|
+
total += 3; // in the file name
|
|
53
|
+
if (i === baseStart || "/._-".includes(lower[i - 1] ?? "/"))
|
|
54
|
+
total += 4; // word start
|
|
55
|
+
hits.push(i);
|
|
56
|
+
from = i + 1;
|
|
57
|
+
}
|
|
58
|
+
return { score: total - file.length * 0.05, hits };
|
|
59
|
+
}
|
|
60
|
+
export function QuickOpen({ width, top, files, onPick, onClose }) {
|
|
61
|
+
const [query, setQuery] = useState("");
|
|
62
|
+
const [cursor, setCursor] = useState(0);
|
|
63
|
+
const boxWidth = Math.min(80, Math.max(40, width - 10));
|
|
64
|
+
const inner = boxWidth - 2; // fill every cell so the screen underneath doesn't show through
|
|
65
|
+
const results = useMemo(() => {
|
|
66
|
+
return files
|
|
67
|
+
.map((f) => ({ file: f, m: score(f, query) }))
|
|
68
|
+
.filter((r) => r.m !== null)
|
|
69
|
+
.sort((a, b) => b.m.score - a.m.score)
|
|
70
|
+
.slice(0, 12);
|
|
71
|
+
}, [files, query]);
|
|
72
|
+
useInput((_input, key) => {
|
|
73
|
+
if (key.escape)
|
|
74
|
+
onClose();
|
|
75
|
+
else if (key.upArrow)
|
|
76
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
77
|
+
else if (key.downArrow)
|
|
78
|
+
setCursor((c) => Math.min(results.length - 1, c + 1));
|
|
79
|
+
});
|
|
80
|
+
const pad = (s) => (s.length > inner ? `…${s.slice(-(inner - 1))}` : s.padEnd(inner));
|
|
81
|
+
return (_jsxs(Box, { position: "absolute", top: top, left: Math.floor((width - boxWidth) / 2), width: boxWidth, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, children: [_jsxs(Text, { backgroundColor: C.panel, children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(TextInput, { value: query, onChange: (v) => {
|
|
82
|
+
setQuery(v);
|
|
83
|
+
setCursor(0);
|
|
84
|
+
}, onSubmit: () => results[cursor] && onPick(results[cursor].file), placeholder: "Go to file\u2026" }), _jsx(Text, { children: " ".repeat(Math.max(0, inner - 4 - Math.max(query.length, 11))) })] }), _jsx(Text, { color: C.dim, children: "─".repeat(inner) }), results.length === 0 && _jsx(Text, { color: C.dim, children: pad(" No matching files") }), results.map((r, i) => {
|
|
85
|
+
const selected = i === cursor;
|
|
86
|
+
const { icon, color } = fileIcon(r.file);
|
|
87
|
+
const label = pad(` ${r.file}`).slice(0, inner - 2);
|
|
88
|
+
const hits = new Set(r.m.hits.map((h) => h + 1));
|
|
89
|
+
return (_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▌" : " " }), _jsx(Text, { color: color, children: icon }), [...label].map((ch, j) => (_jsx(Text, { color: hits.has(j) ? C.yellow : selected ? C.text : C.dim, bold: hits.has(j), children: ch }, j)))] }, r.file));
|
|
90
|
+
}), _jsx(Text, { color: C.dim, children: pad(" ↑↓ select · Enter open · Esc close") })] }));
|
|
91
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
import { AUTO_SAVE, EFFORTS } from "../config/index.js";
|
|
6
|
+
import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
|
|
7
|
+
import { currentVersion } from "../update/index.js";
|
|
8
|
+
import { describe, providers } from "../providers/index.js";
|
|
9
|
+
import { C, SPINNER } from "./theme.js";
|
|
10
|
+
const TABS = ["Model", "Preferences", "Plugins"];
|
|
11
|
+
const PREFS = [
|
|
12
|
+
{ key: "effort", label: "Reasoning effort", help: "How hard Claude thinks (low → max). Higher is smarter but slower and pricier." },
|
|
13
|
+
{ key: "autoSave", label: "Auto-save", help: "delay: a second after you stop typing · focus: when you leave the editor · off: only Ctrl+S." },
|
|
14
|
+
{ key: "followMode", label: "Follow mode on start", help: "The code view jumps to files Nou reads and edits." },
|
|
15
|
+
{ key: "gitContext", label: "Git context", help: "Give the model your branch, changes and commits, plus git tools." },
|
|
16
|
+
{ key: "autoApproveEdits", label: "Auto-approve edits", help: "Apply file edits without asking (diffs are still shown).", warn: true },
|
|
17
|
+
{ key: "autoApproveCommands", label: "Auto-approve commands", help: "Run shell commands without asking. Only turn on if you trust the model.", warn: true },
|
|
18
|
+
];
|
|
19
|
+
export function Settings({ width, height, settings, choice, plugins, onModel, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
|
|
20
|
+
const [tab, setTab] = useState("Model");
|
|
21
|
+
const [row, setRow] = useState(0);
|
|
22
|
+
const [column, setColumn] = useState("providers");
|
|
23
|
+
const [modelRow, setModelRow] = useState(0);
|
|
24
|
+
const [typing, setTyping] = useState(false);
|
|
25
|
+
const [custom, setCustom] = useState("");
|
|
26
|
+
const [models, setModels] = useState({});
|
|
27
|
+
const { frame } = useAnimation({ interval: 80 });
|
|
28
|
+
const provider = providers[row] ?? providers[0];
|
|
29
|
+
const listed = models[provider.id];
|
|
30
|
+
// Load the model list for the highlighted provider.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (tab !== "Model" || provider.unavailable() || models[provider.id])
|
|
33
|
+
return;
|
|
34
|
+
setModels((m) => ({ ...m, [provider.id]: "loading" }));
|
|
35
|
+
provider.listModels()
|
|
36
|
+
.then((list) => setModels((m) => ({ ...m, [provider.id]: list })))
|
|
37
|
+
.catch((error) => setModels((m) => ({ ...m, [provider.id]: { error: String(error.message ?? error) } })));
|
|
38
|
+
}, [tab, provider.id]);
|
|
39
|
+
const modelItems = Array.isArray(listed) ? listed : [];
|
|
40
|
+
const rowsFor = (t) => (t === "Model" ? providers.length : t === "Preferences" ? PREFS.length : plugins.length);
|
|
41
|
+
useInput((input, key) => {
|
|
42
|
+
if (typing) {
|
|
43
|
+
if (key.escape)
|
|
44
|
+
setTyping(false);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (key.tab) {
|
|
48
|
+
const next = TABS[(TABS.indexOf(tab) + (key.shift ? TABS.length - 1 : 1)) % TABS.length];
|
|
49
|
+
setTab(next);
|
|
50
|
+
setRow(next === "Model" ? Math.max(0, providers.indexOf(choice.provider)) : 0);
|
|
51
|
+
setColumn("providers");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (key.escape) {
|
|
55
|
+
if (tab === "Model" && column === "models")
|
|
56
|
+
setColumn("providers");
|
|
57
|
+
else
|
|
58
|
+
onClose();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (tab === "Model" && column === "models") {
|
|
62
|
+
const count = modelItems.length + 1;
|
|
63
|
+
if (key.upArrow)
|
|
64
|
+
setModelRow((r) => Math.max(0, r - 1));
|
|
65
|
+
else if (key.downArrow)
|
|
66
|
+
setModelRow((r) => Math.min(count - 1, r + 1));
|
|
67
|
+
else if (key.leftArrow)
|
|
68
|
+
setColumn("providers");
|
|
69
|
+
else if (key.return) {
|
|
70
|
+
if (modelRow === 0) {
|
|
71
|
+
setCustom("");
|
|
72
|
+
setTyping(true);
|
|
73
|
+
}
|
|
74
|
+
else
|
|
75
|
+
onModel({ provider, model: modelItems[modelRow - 1] });
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (key.upArrow)
|
|
80
|
+
setRow((r) => Math.max(0, r - 1));
|
|
81
|
+
else if (key.downArrow)
|
|
82
|
+
setRow((r) => Math.min(rowsFor(tab) - 1, r + 1));
|
|
83
|
+
else if (tab === "Model" && (key.return || key.rightArrow)) {
|
|
84
|
+
if (!provider.unavailable()) {
|
|
85
|
+
setColumn("models");
|
|
86
|
+
setModelRow(0);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (tab === "Preferences" && (key.return || input === " " || key.leftArrow || key.rightArrow)) {
|
|
90
|
+
const pref = PREFS[row];
|
|
91
|
+
const step = key.leftArrow ? -1 : 1;
|
|
92
|
+
if (pref.key === "effort") {
|
|
93
|
+
onChange({ effort: EFFORTS[(EFFORTS.indexOf(settings.effort) + step + EFFORTS.length) % EFFORTS.length] });
|
|
94
|
+
}
|
|
95
|
+
else if (pref.key === "autoSave") {
|
|
96
|
+
onChange({ autoSave: AUTO_SAVE[(AUTO_SAVE.indexOf(settings.autoSave) + step + AUTO_SAVE.length) % AUTO_SAVE.length] });
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
onChange({ [pref.key]: !settings[pref.key] });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (tab === "Plugins") {
|
|
103
|
+
if (input === "r")
|
|
104
|
+
onRescanPlugins();
|
|
105
|
+
else if ((key.return || input === " ") && plugins[row])
|
|
106
|
+
onTogglePlugin(plugins[row].id, !isEnabled(plugins[row], settings));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const boxHeight = height - 6;
|
|
110
|
+
const toggle = (on, warn) => (_jsx(Text, { color: on ? (warn ? C.orange : C.green) : C.dim, bold: true, children: on ? "● ON " : "○ OFF" }));
|
|
111
|
+
const body = () => {
|
|
112
|
+
if (tab === "Model") {
|
|
113
|
+
const leftWidth = Math.min(46, Math.floor(width * 0.45));
|
|
114
|
+
return (_jsxs(Box, { flexDirection: "row", height: boxHeight, children: [_jsxs(Box, { flexDirection: "column", width: leftWidth, borderStyle: "round", borderColor: column === "providers" ? C.accent : C.dim, children: [_jsx(Text, { color: C.dim, bold: true, children: " PROVIDER" }), providers.map((p, i) => {
|
|
115
|
+
const problem = p.unavailable();
|
|
116
|
+
const selected = i === row;
|
|
117
|
+
const current = p.id === choice.provider.id;
|
|
118
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...(selected && column === "providers" ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? " ● " : " " }), _jsx(Text, { color: problem ? C.dim : C.text, bold: selected, children: p.label.padEnd(26).slice(0, 26) }), _jsx(Text, { color: p.local ? C.green : C.accent2, children: p.local ? " local" : " cloud" }), problem ? _jsx(Text, { color: C.dim, children: " \uD83D\uDD12" }) : null] }, p.id));
|
|
119
|
+
})] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: column === "models" ? C.accent : C.dim, paddingX: 1, children: [_jsxs(Text, { color: C.dim, bold: true, children: ["MODELS \u00B7 ", provider.label] }), provider.unavailable() ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.yellow, children: ["Not set up: ", provider.unavailable()] }), _jsx(Text, { color: C.dim, children: "Add it to a .env file in your project (see .env.example) or your environment, then restart Nou." })] })) : (_jsxs(_Fragment, { children: [typing ? (_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, children: "\u270E " }), _jsx(TextInput, { value: custom, onChange: setCustom, onSubmit: (v) => {
|
|
120
|
+
setTyping(false);
|
|
121
|
+
if (v.trim())
|
|
122
|
+
onModel({ provider, model: v.trim() });
|
|
123
|
+
}, placeholder: `e.g. ${provider.defaultModel}` })] })) : (_jsx(Text, { ...(column === "models" && modelRow === 0 ? { backgroundColor: C.panel } : {}), color: C.accent2, children: "\u270E Type a model name\u2026" })), listed === "loading" && _jsxs(Text, { color: C.accent2, children: [SPINNER[frame % SPINNER.length], " loading models\u2026"] }), listed && typeof listed === "object" && !Array.isArray(listed) && _jsxs(Text, { color: C.red, children: ["Couldn't list models: ", listed.error] }), (() => {
|
|
124
|
+
const visibleCount = Math.max(1, boxHeight - 6);
|
|
125
|
+
const offset = Math.max(0, Math.min(modelRow - 1 - Math.floor(visibleCount / 2), modelItems.length - visibleCount));
|
|
126
|
+
return modelItems.slice(offset, offset + visibleCount).map((m, i) => {
|
|
127
|
+
const index = offset + i + 1;
|
|
128
|
+
const selected = column === "models" && modelRow === index;
|
|
129
|
+
const current = provider.id === choice.provider.id && m === choice.model;
|
|
130
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? "● " : " " }), _jsx(Text, { color: selected ? C.text : C.dim, bold: selected, children: m })] }, m));
|
|
131
|
+
});
|
|
132
|
+
})()] }))] })] }));
|
|
133
|
+
}
|
|
134
|
+
if (tab === "Preferences") {
|
|
135
|
+
return (_jsx(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: PREFS.map((pref, i) => {
|
|
136
|
+
const selected = i === row;
|
|
137
|
+
const value = settings[pref.key];
|
|
138
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: selected, children: pref.label.padEnd(26) }), pref.key === "effort" || pref.key === "autoSave"
|
|
139
|
+
? _jsx(Text, { children: (pref.key === "effort" ? EFFORTS : AUTO_SAVE).map((e) => _jsx(Text, { color: e === value ? C.accent2 : C.dim, bold: e === value, children: e === value ? `[${e}]` : ` ${e} ` }, e)) })
|
|
140
|
+
: toggle(value, pref.warn)] }), _jsxs(Text, { color: C.dim, children: [" ", pref.help] })] }, pref.key));
|
|
141
|
+
}) }));
|
|
142
|
+
}
|
|
143
|
+
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [plugins.map((p, i) => {
|
|
144
|
+
const selected = i === row;
|
|
145
|
+
const state = trustState(p, settings);
|
|
146
|
+
const enabled = state === "on";
|
|
147
|
+
const parts = p.plugin
|
|
148
|
+
? [
|
|
149
|
+
(p.plugin.tools ?? []).length && `${p.plugin.tools.length} tools`,
|
|
150
|
+
(p.plugin.commands ?? []).length && `${p.plugin.commands.length} commands`,
|
|
151
|
+
(p.plugin.diagnostics ?? []).length && "checker",
|
|
152
|
+
].filter(Boolean)
|
|
153
|
+
: [];
|
|
154
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope, parts.length ? ` · ${parts.join(", ")}` : ""] })] }), state === "changed"
|
|
155
|
+
? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u26A0 Changed since you enabled it. Review the code, then press Enter to enable again." })
|
|
156
|
+
: p.error
|
|
157
|
+
? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \u2716 ", p.error] })
|
|
158
|
+
: _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", p.plugin?.description ?? (enabled ? "" : "Enable to load it.")] })] }, p.id));
|
|
159
|
+
}), _jsx(Text, { children: " " }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Add your own: drop a .js/.mjs file into ", PLUGIN_DIRS.map((d) => d.dir).join(" or "), ", then press r."] }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Example: examples/plugins/word-count.mjs in the Nou repo." })] }));
|
|
160
|
+
};
|
|
161
|
+
const hints = tab === "Model"
|
|
162
|
+
? column === "models" ? "↑↓ choose · Enter use model · ← providers" : "↑↓ provider · Enter/→ pick a model"
|
|
163
|
+
: tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : "↑↓ move · Enter toggle · r rescan";
|
|
164
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u2699 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) }), _jsxs(Text, { color: C.dim, children: [" nou v", currentVersion()] })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
|
|
165
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { highlight, supportsLanguage } from "cli-highlight";
|
|
3
|
+
import { diffLines } from "diff";
|
|
4
|
+
import { highlightTree } from "../syntax/index.js";
|
|
5
|
+
const LANGUAGES = {
|
|
6
|
+
".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
|
|
7
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
8
|
+
".py": "python", ".json": "json", ".md": "markdown", ".css": "css", ".html": "xml",
|
|
9
|
+
".sh": "bash", ".ps1": "powershell", ".yml": "yaml", ".yaml": "yaml", ".go": "go",
|
|
10
|
+
".rs": "rust", ".java": "java", ".c": "c", ".cpp": "cpp", ".cs": "csharp", ".sql": "sql",
|
|
11
|
+
};
|
|
12
|
+
/** Splits file text into display lines: normalizes CRLF and expands tabs. */
|
|
13
|
+
export function splitLines(text) {
|
|
14
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\t/g, " ").split("\n");
|
|
15
|
+
if (lines.length > 1 && lines[lines.length - 1] === "")
|
|
16
|
+
lines.pop();
|
|
17
|
+
return lines;
|
|
18
|
+
}
|
|
19
|
+
export function highlightLines(filePath, text) {
|
|
20
|
+
const plain = splitLines(text);
|
|
21
|
+
// tree-sitter when the grammar is loaded (accurate); highlight.js otherwise.
|
|
22
|
+
const tree = highlightTree(filePath, plain.join("\n"));
|
|
23
|
+
if (tree)
|
|
24
|
+
return tree;
|
|
25
|
+
const language = LANGUAGES[path.extname(filePath).toLowerCase()];
|
|
26
|
+
if (!language || !supportsLanguage(language) || text.length > 200_000)
|
|
27
|
+
return plain;
|
|
28
|
+
try {
|
|
29
|
+
return highlight(plain.join("\n"), { language, ignoreIllegals: true }).split("\n");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return plain;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Word-wraps plain text to the given width. */
|
|
36
|
+
export function wrap(text, width) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const max = Math.max(1, width);
|
|
39
|
+
for (const raw of splitLines(text)) {
|
|
40
|
+
if (raw.length <= max) {
|
|
41
|
+
out.push(raw);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
let line = "";
|
|
45
|
+
for (const word of raw.split(/(\s+)/)) {
|
|
46
|
+
if ((line + word).length <= max) {
|
|
47
|
+
line += word;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (line.trim())
|
|
51
|
+
out.push(line.trimEnd());
|
|
52
|
+
line = word.trimStart();
|
|
53
|
+
while (line.length > max) {
|
|
54
|
+
out.push(line.slice(0, max));
|
|
55
|
+
line = line.slice(max);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
out.push(line);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Lines up before/after text into rows for a side by side diff. */
|
|
63
|
+
export function sideBySide(before, after) {
|
|
64
|
+
const parts = diffLines(before.replace(/\r\n/g, "\n"), after.replace(/\r\n/g, "\n"));
|
|
65
|
+
const rows = [];
|
|
66
|
+
let ln = 1;
|
|
67
|
+
let rn = 1;
|
|
68
|
+
const cells = (value) => (value ? splitLines(value) : []);
|
|
69
|
+
for (let i = 0; i < parts.length; i++) {
|
|
70
|
+
const part = parts[i];
|
|
71
|
+
if (!part.added && !part.removed) {
|
|
72
|
+
for (const text of cells(part.value))
|
|
73
|
+
rows.push({ left: { n: ln++, text }, right: { n: rn++, text }, changed: false });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const removed = part.removed ? cells(part.value) : [];
|
|
77
|
+
let added = part.added ? cells(part.value) : [];
|
|
78
|
+
if (part.removed && parts[i + 1]?.added) {
|
|
79
|
+
added = cells(parts[++i].value);
|
|
80
|
+
}
|
|
81
|
+
for (let k = 0; k < Math.max(removed.length, added.length); k++) {
|
|
82
|
+
const row = { changed: true };
|
|
83
|
+
if (k < removed.length)
|
|
84
|
+
row.left = { n: ln++, text: removed[k] };
|
|
85
|
+
if (k < added.length)
|
|
86
|
+
row.right = { n: rn++, text: added[k] };
|
|
87
|
+
rows.push(row);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return rows;
|
|
91
|
+
}
|
|
92
|
+
/** 1-based line numbers in `after` that were added or changed. */
|
|
93
|
+
export function changedLines(before, after) {
|
|
94
|
+
const lines = new Set();
|
|
95
|
+
for (const row of sideBySide(before, after)) {
|
|
96
|
+
if (row.changed && row.right)
|
|
97
|
+
lines.add(row.right.n);
|
|
98
|
+
}
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
/** Tokyo Night inspired palette (truecolor; Windows Terminal supports it). */
|
|
3
|
+
export const C = {
|
|
4
|
+
accent: "#7aa2f7",
|
|
5
|
+
accent2: "#bb9af7",
|
|
6
|
+
cyan: "#7dcfff",
|
|
7
|
+
green: "#9ece6a",
|
|
8
|
+
yellow: "#e0af68",
|
|
9
|
+
orange: "#ff9e64",
|
|
10
|
+
red: "#f7768e",
|
|
11
|
+
text: "#c0caf5",
|
|
12
|
+
dim: "#565f89",
|
|
13
|
+
panel: "#1f2335",
|
|
14
|
+
};
|
|
15
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
16
|
+
const GRADIENT = [C.accent, "#8c9cf7", C.accent2, "#c39af2", "#e08ad6", C.red, C.orange, C.yellow, C.green, C.cyan];
|
|
17
|
+
/** Colors for each character of a label, shifted by `frame` so it shimmers when animated. */
|
|
18
|
+
export function gradient(text, frame = 0) {
|
|
19
|
+
return [...text].map((ch, i) => ({ ch, color: GRADIENT[(i + frame) % GRADIENT.length] }));
|
|
20
|
+
}
|
|
21
|
+
export const GIT_COLORS = {
|
|
22
|
+
M: C.yellow, A: C.green, "?": C.green, D: C.red, R: C.cyan, U: C.orange,
|
|
23
|
+
};
|
|
24
|
+
const ICONS = {
|
|
25
|
+
".ts": ["◆", "#3178c6"], ".tsx": ["◆", C.cyan], ".mts": ["◆", "#3178c6"], ".cts": ["◆", "#3178c6"],
|
|
26
|
+
".js": ["◆", "#f1e05a"], ".jsx": ["◆", C.cyan], ".mjs": ["◆", "#f1e05a"], ".cjs": ["◆", "#f1e05a"],
|
|
27
|
+
".py": ["◆", "#3572a5"], ".json": ["◇", C.yellow], ".md": ["≡", C.text], ".css": ["◆", C.accent2],
|
|
28
|
+
".html": ["◆", C.orange], ".yml": ["◇", C.red], ".yaml": ["◇", C.red], ".go": ["◆", C.cyan],
|
|
29
|
+
".rs": ["◆", C.orange], ".sh": ["$", C.green], ".ps1": ["$", C.accent], ".lock": ["◌", C.dim],
|
|
30
|
+
};
|
|
31
|
+
export function fileIcon(file) {
|
|
32
|
+
const base = path.basename(file).toLowerCase();
|
|
33
|
+
if (base.startsWith(".env"))
|
|
34
|
+
return { icon: "⚿", color: C.yellow };
|
|
35
|
+
if (base.startsWith(".git"))
|
|
36
|
+
return { icon: "◌", color: C.orange };
|
|
37
|
+
if (base === "package.json")
|
|
38
|
+
return { icon: "⬢", color: C.green };
|
|
39
|
+
const [icon, color] = ICONS[path.extname(base)] ?? ["•", C.dim];
|
|
40
|
+
return { icon, color };
|
|
41
|
+
}
|