nou-cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,165 @@
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 { Pane } from "./Pane.js";
7
+ const expand = (s) => s.replace(/\t/g, " ");
8
+ /** 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
+ const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
11
+ const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
12
+ const [buf, setBuf] = useState(() => ({ lines: initial.replace(/\r\n/g, "\n").split("\n"), row: 0, col: 0 }));
13
+ const [saved, setSaved] = useState(() => baseline.replace(/\r\n/g, "\n"));
14
+ const [confirmExit, setConfirmExit] = useState(false);
15
+ const [flash, setFlash] = useState(null);
16
+ const undo = useRef([]);
17
+ const view = useRef({ top: 0, left: 0 });
18
+ const dirty = useMemo(() => buf.lines.join("\n") !== saved, [buf.lines, saved]);
19
+ useEffect(() => onState({ line: buf.row + 1, col: buf.col + 1, dirty, lines: buf.lines }), [buf, dirty]);
20
+ useEffect(() => {
21
+ if (!flash)
22
+ return;
23
+ const t = setTimeout(() => setFlash(null), 1500);
24
+ return () => clearTimeout(t);
25
+ }, [flash]);
26
+ const edit = (fn) => setBuf((b) => {
27
+ undo.current.push(b);
28
+ if (undo.current.length > 300)
29
+ undo.current.shift();
30
+ return fn(b);
31
+ });
32
+ const insert = (text) => edit((b) => {
33
+ const pieces = text.replace(/\r\n?/g, "\n").split("\n");
34
+ const line = b.lines[b.row];
35
+ const before = line.slice(0, b.col);
36
+ const after = line.slice(b.col);
37
+ const lines = [...b.lines];
38
+ if (pieces.length === 1) {
39
+ lines[b.row] = before + text + after;
40
+ return { lines, row: b.row, col: b.col + text.length };
41
+ }
42
+ const last = pieces[pieces.length - 1];
43
+ lines.splice(b.row, 1, before + pieces[0], ...pieces.slice(1, -1), last + after);
44
+ return { lines, row: b.row + pieces.length - 1, col: last.length };
45
+ });
46
+ const move = (fn) => setBuf((b) => {
47
+ const next = { ...b, ...fn(b) };
48
+ next.row = Math.max(0, Math.min(next.lines.length - 1, next.row));
49
+ next.col = Math.max(0, Math.min(next.lines[next.row].length, next.col));
50
+ return next;
51
+ });
52
+ const save = () => {
53
+ const text = buf.lines.join("\n");
54
+ onSave(text.replace(/\n/g, eol));
55
+ setSaved(text);
56
+ setFlash("Saved ✓");
57
+ };
58
+ const rows = Math.max(1, height - 4);
59
+ usePaste((text) => insert(text), { isActive: focused });
60
+ useInput((input, key) => {
61
+ if (confirmExit) {
62
+ if (key.escape)
63
+ onExit();
64
+ else if (key.ctrl && input === "s") {
65
+ save();
66
+ onExit();
67
+ }
68
+ else
69
+ setConfirmExit(false);
70
+ return;
71
+ }
72
+ if (key.ctrl && input === "s")
73
+ return save();
74
+ if (key.ctrl && input === "z") {
75
+ const prev = undo.current.pop();
76
+ if (prev)
77
+ setBuf(prev);
78
+ return;
79
+ }
80
+ if (key.escape)
81
+ return dirty ? setConfirmExit(true) : onExit();
82
+ if (key.upArrow)
83
+ return move((b) => ({ row: b.row - 1 }));
84
+ if (key.downArrow)
85
+ return move((b) => ({ row: b.row + 1 }));
86
+ if (key.leftArrow) {
87
+ return move((b) => (b.col > 0 ? { col: b.col - 1 } : b.row > 0 ? { row: b.row - 1, col: b.lines[b.row - 1].length } : {}));
88
+ }
89
+ if (key.rightArrow) {
90
+ 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 } : {}));
91
+ }
92
+ if (key.home)
93
+ return move((b) => ({ col: b.lines[b.row].match(/^\s*/)[0].length === b.col ? 0 : b.lines[b.row].match(/^\s*/)[0].length }));
94
+ if (key.end)
95
+ return move((b) => ({ col: b.lines[b.row].length }));
96
+ if (key.pageUp)
97
+ return move((b) => ({ row: b.row - rows }));
98
+ if (key.pageDown)
99
+ return move((b) => ({ row: b.row + rows }));
100
+ if (key.return) {
101
+ const indent = buf.lines[buf.row].match(/^\s*/)[0];
102
+ return insert(`\n${indent}`);
103
+ }
104
+ if (key.tab)
105
+ return insert(indentUnit);
106
+ if (key.backspace) {
107
+ return edit((b) => {
108
+ const lines = [...b.lines];
109
+ if (b.col > 0) {
110
+ const line = lines[b.row];
111
+ lines[b.row] = line.slice(0, b.col - 1) + line.slice(b.col);
112
+ return { lines, row: b.row, col: b.col - 1 };
113
+ }
114
+ if (b.row === 0)
115
+ return b;
116
+ const prev = lines[b.row - 1];
117
+ lines.splice(b.row - 1, 2, prev + lines[b.row]);
118
+ return { lines, row: b.row - 1, col: prev.length };
119
+ });
120
+ }
121
+ if (key.delete) {
122
+ return edit((b) => {
123
+ const lines = [...b.lines];
124
+ const line = lines[b.row];
125
+ if (b.col < line.length)
126
+ lines[b.row] = line.slice(0, b.col) + line.slice(b.col + 1);
127
+ else if (b.row < lines.length - 1)
128
+ lines.splice(b.row, 2, line + lines[b.row + 1]);
129
+ return { ...b, lines };
130
+ });
131
+ }
132
+ if (input && !key.ctrl && !key.meta)
133
+ insert(input);
134
+ }, { isActive: focused });
135
+ // Keep the cursor on screen.
136
+ const gutter = String(buf.lines.length).length + 2;
137
+ const textWidth = Math.max(10, width - 2 - gutter);
138
+ const v = view.current;
139
+ if (buf.row < v.top)
140
+ v.top = buf.row;
141
+ if (buf.row >= v.top + rows)
142
+ v.top = buf.row - rows + 1;
143
+ const cursorX = expand(buf.lines[buf.row].slice(0, buf.col)).length;
144
+ if (cursorX < v.left)
145
+ v.left = Math.max(0, cursorX - 8);
146
+ if (cursorX >= v.left + textWidth)
147
+ v.left = cursorX - textWidth + 8;
148
+ const visible = buf.lines.slice(v.top, v.top + rows);
149
+ const colored = useMemo(() => (v.left === 0 ? highlightLines(filePath, visible.join("\n")) : visible.map(expand)), [filePath, visible.join("\n"), v.left]);
150
+ const { icon, color } = fileIcon(filePath);
151
+ const status = confirmExit
152
+ ? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
153
+ : flash ?? `Ln ${buf.row + 1}, Col ${buf.col + 1}${dirty ? " ● modified" : ""}`;
154
+ 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
+ const n = v.top + i;
156
+ const isCursorLine = n === buf.row;
157
+ const num = _jsxs(Text, { color: isCursorLine ? C.green : C.dim, children: [String(n + 1).padStart(gutter - 2), isCursorLine ? " ▸" : " "] });
158
+ if (!isCursorLine) {
159
+ return _jsxs(Text, { wrap: "truncate-end", children: [num, colored[i] || " "] }, n);
160
+ }
161
+ const shown = expand(line).slice(v.left, v.left + textWidth);
162
+ const at = cursorX - v.left;
163
+ return (_jsxs(Text, { wrap: "truncate-end", children: [num, _jsx(Text, { color: C.text, children: shown.slice(0, at) }), _jsx(Text, { inverse: true, color: C.green, children: shown[at] ?? " " }), _jsx(Text, { color: C.text, children: shown.slice(at + 1) })] }, n));
164
+ }) }), _jsxs(Text, { wrap: "truncate-end", color: confirmExit ? C.red : flash ? C.green : C.dim, children: [` ${status}`, _jsx(Text, { color: C.dim, children: confirmExit ? "" : " Ctrl+S save · Ctrl+Z undo · Esc close" })] })] }));
165
+ }
@@ -0,0 +1,141 @@
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, 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 marker = code ?? (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
133
+ const markerColor = code ? GIT_COLORS[code] ?? C.yellow : changed.has(node.path) ? C.green : C.yellow;
134
+ const label = `${" ".repeat(node.depth)}${icon} ${node.name}`;
135
+ const nameColor = isOpen ? C.cyan : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
136
+ const room = Math.max(1, inner - marker.length - 1);
137
+ const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
138
+ const iconAt = shown.indexOf(icon);
139
+ 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));
140
+ })] }) }));
141
+ }
@@ -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 }) {
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: [_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,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,150 @@
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 { EFFORTS } from "../config/index.js";
6
+ import { PLUGIN_DIRS } from "../plugins/index.js";
7
+ import { describe, providers } from "../providers/index.js";
8
+ import { C, SPINNER } from "./theme.js";
9
+ const TABS = ["Model", "Preferences", "Plugins"];
10
+ const PREFS = [
11
+ { key: "effort", label: "Reasoning effort", help: "How hard Claude thinks (low → max). Higher is smarter but slower and pricier." },
12
+ { key: "followMode", label: "Follow mode on start", help: "The code view jumps to files Nou reads and edits." },
13
+ { key: "gitContext", label: "Git context", help: "Give the model your branch, changes and commits, plus git tools." },
14
+ { key: "autoApproveEdits", label: "Auto-approve edits", help: "Apply file edits without asking (diffs are still shown).", warn: true },
15
+ { key: "autoApproveCommands", label: "Auto-approve commands", help: "Run shell commands without asking. Only turn on if you trust the model.", warn: true },
16
+ ];
17
+ export function Settings({ width, height, settings, choice, plugins, onModel, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
18
+ const [tab, setTab] = useState("Model");
19
+ const [row, setRow] = useState(0);
20
+ const [column, setColumn] = useState("providers");
21
+ const [modelRow, setModelRow] = useState(0);
22
+ const [typing, setTyping] = useState(false);
23
+ const [custom, setCustom] = useState("");
24
+ const [models, setModels] = useState({});
25
+ const { frame } = useAnimation({ interval: 80 });
26
+ const provider = providers[row] ?? providers[0];
27
+ const listed = models[provider.id];
28
+ // Load the model list for the highlighted provider.
29
+ useEffect(() => {
30
+ if (tab !== "Model" || provider.unavailable() || models[provider.id])
31
+ return;
32
+ setModels((m) => ({ ...m, [provider.id]: "loading" }));
33
+ provider.listModels()
34
+ .then((list) => setModels((m) => ({ ...m, [provider.id]: list })))
35
+ .catch((error) => setModels((m) => ({ ...m, [provider.id]: { error: String(error.message ?? error) } })));
36
+ }, [tab, provider.id]);
37
+ const modelItems = Array.isArray(listed) ? listed : [];
38
+ const rowsFor = (t) => (t === "Model" ? providers.length : t === "Preferences" ? PREFS.length : plugins.length);
39
+ useInput((input, key) => {
40
+ if (typing) {
41
+ if (key.escape)
42
+ setTyping(false);
43
+ return;
44
+ }
45
+ if (key.tab) {
46
+ const next = TABS[(TABS.indexOf(tab) + (key.shift ? TABS.length - 1 : 1)) % TABS.length];
47
+ setTab(next);
48
+ setRow(next === "Model" ? Math.max(0, providers.indexOf(choice.provider)) : 0);
49
+ setColumn("providers");
50
+ return;
51
+ }
52
+ if (key.escape) {
53
+ if (tab === "Model" && column === "models")
54
+ setColumn("providers");
55
+ else
56
+ onClose();
57
+ return;
58
+ }
59
+ if (tab === "Model" && column === "models") {
60
+ const count = modelItems.length + 1;
61
+ if (key.upArrow)
62
+ setModelRow((r) => Math.max(0, r - 1));
63
+ else if (key.downArrow)
64
+ setModelRow((r) => Math.min(count - 1, r + 1));
65
+ else if (key.leftArrow)
66
+ setColumn("providers");
67
+ else if (key.return) {
68
+ if (modelRow === 0) {
69
+ setCustom("");
70
+ setTyping(true);
71
+ }
72
+ else
73
+ onModel({ provider, model: modelItems[modelRow - 1] });
74
+ }
75
+ return;
76
+ }
77
+ if (key.upArrow)
78
+ setRow((r) => Math.max(0, r - 1));
79
+ else if (key.downArrow)
80
+ setRow((r) => Math.min(rowsFor(tab) - 1, r + 1));
81
+ else if (tab === "Model" && (key.return || key.rightArrow)) {
82
+ if (!provider.unavailable()) {
83
+ setColumn("models");
84
+ setModelRow(0);
85
+ }
86
+ }
87
+ else if (tab === "Preferences" && (key.return || input === " " || key.leftArrow || key.rightArrow)) {
88
+ const pref = PREFS[row];
89
+ if (pref.key === "effort") {
90
+ const step = key.leftArrow ? -1 : 1;
91
+ onChange({ effort: EFFORTS[(EFFORTS.indexOf(settings.effort) + step + EFFORTS.length) % EFFORTS.length] });
92
+ }
93
+ else {
94
+ onChange({ [pref.key]: !settings[pref.key] });
95
+ }
96
+ }
97
+ else if (tab === "Plugins") {
98
+ if (input === "r")
99
+ onRescanPlugins();
100
+ else if ((key.return || input === " ") && plugins[row])
101
+ onTogglePlugin(plugins[row].id, !settings.plugins[plugins[row].id]);
102
+ }
103
+ });
104
+ const boxHeight = height - 6;
105
+ const toggle = (on, warn) => (_jsx(Text, { color: on ? (warn ? C.orange : C.green) : C.dim, bold: true, children: on ? "● ON " : "○ OFF" }));
106
+ const body = () => {
107
+ if (tab === "Model") {
108
+ const leftWidth = Math.min(46, Math.floor(width * 0.45));
109
+ 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) => {
110
+ const problem = p.unavailable();
111
+ const selected = i === row;
112
+ const current = p.id === choice.provider.id;
113
+ 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));
114
+ })] }), _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) => {
115
+ setTyping(false);
116
+ if (v.trim())
117
+ onModel({ provider, model: v.trim() });
118
+ }, 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] }), (() => {
119
+ const visibleCount = Math.max(1, boxHeight - 6);
120
+ const offset = Math.max(0, Math.min(modelRow - 1 - Math.floor(visibleCount / 2), modelItems.length - visibleCount));
121
+ return modelItems.slice(offset, offset + visibleCount).map((m, i) => {
122
+ const index = offset + i + 1;
123
+ const selected = column === "models" && modelRow === index;
124
+ const current = provider.id === choice.provider.id && m === choice.model;
125
+ 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));
126
+ });
127
+ })()] }))] })] }));
128
+ }
129
+ if (tab === "Preferences") {
130
+ return (_jsx(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: PREFS.map((pref, i) => {
131
+ const selected = i === row;
132
+ const value = settings[pref.key];
133
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: selected, children: pref.label.padEnd(26) }), pref.key === "effort"
134
+ ? _jsx(Text, { children: EFFORTS.map((e) => _jsx(Text, { color: e === value ? C.accent2 : C.dim, bold: e === value, children: e === value ? `[${e}]` : ` ${e} ` }, e)) })
135
+ : toggle(value, pref.warn)] }), _jsxs(Text, { color: C.dim, children: [" ", pref.help] })] }, pref.key));
136
+ }) }));
137
+ }
138
+ return (_jsx(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: plugins.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.text, children: "No plugins found." }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "Drop a .js/.mjs/.ts file (or a folder with index.*) into one of:" }), PLUGIN_DIRS.map((d) => _jsxs(Text, { color: C.cyan, children: [" ", d.dir, " ", _jsxs(Text, { color: C.dim, children: ["(", d.scope, ")"] })] }, d.dir)), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "See examples/plugins/word-count.mjs for the shape. Press r to rescan." })] })) : plugins.map((p, i) => {
139
+ const selected = i === row;
140
+ const enabled = !!settings.plugins[p.id];
141
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope] })] }), p.error
142
+ ? _jsxs(Text, { color: C.red, children: [" \u2716 ", p.error] })
143
+ : _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.description ?? (enabled ? "" : "Enable to load it."), p.plugin ? ` [${(p.plugin.tools ?? []).length} tools, ${(p.plugin.commands ?? []).length} commands]` : ""] })] }, p.id));
144
+ }) }));
145
+ };
146
+ const hints = tab === "Model"
147
+ ? column === "models" ? "↑↓ choose · Enter use model · ← providers" : "↑↓ provider · Enter/→ pick a model"
148
+ : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change effort" : "↑↓ move · Enter toggle · r rescan";
149
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u2699 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
150
+ }