nou-cli 0.2.0 → 0.3.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 +12 -0
- package/README.md +42 -6
- package/dist/index.js +22 -2
- package/dist/src/agent/harness.js +54 -0
- package/dist/src/agent/index.js +159 -15
- package/dist/src/commands/index.js +54 -5
- package/dist/src/config/credentials.js +48 -0
- package/dist/src/config/index.js +3 -0
- package/dist/src/config/origins.js +14 -0
- package/dist/src/context/index.js +1 -1
- package/dist/src/engines/claude.js +147 -0
- package/dist/src/engines/codex.js +131 -0
- package/dist/src/engines/external.js +152 -0
- package/dist/src/engines/index.js +127 -0
- package/dist/src/engines/pi.js +161 -0
- package/dist/src/engines/types.js +1 -0
- package/dist/src/env.js +5 -1
- package/dist/src/git/index.js +14 -4
- package/dist/src/history/index.js +53 -1
- package/dist/src/plugins/index.js +109 -21
- package/dist/src/providers/anthropic.js +16 -1
- package/dist/src/providers/ollama.js +58 -1
- package/dist/src/providers/openai.js +22 -5
- package/dist/src/search/index.js +183 -0
- package/dist/src/setup/index.js +240 -0
- package/dist/src/tools/index.js +72 -2
- package/dist/src/ui/App.js +351 -72
- package/dist/src/ui/Chat.js +9 -3
- package/dist/src/ui/CodeView.js +5 -1
- package/dist/src/ui/Editor.js +5 -1
- package/dist/src/ui/FileTree.js +18 -1
- package/dist/src/ui/Header.js +3 -1
- package/dist/src/ui/ProblemsView.js +5 -1
- package/dist/src/ui/Settings.js +93 -14
- package/dist/src/ui/mouse.js +52 -0
- package/dist/src/ui/text.js +12 -0
- package/package.json +5 -3
- package/plugins/fetch-url.mjs +82 -0
- package/plugins/project-rules.mjs +43 -0
- package/plugins/run-tests.mjs +89 -0
- package/plugins/todos.mjs +81 -0
package/dist/src/ui/Chat.js
CHANGED
|
@@ -5,8 +5,8 @@ import { wrap } from "./text.js";
|
|
|
5
5
|
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
6
6
|
import { C, SPINNER } from "./theme.js";
|
|
7
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 }) {
|
|
8
|
+
export const COMMANDS = ["/model", "/models", "/settings", "/undo", "/rewind", "/history", "/problems", "/version", "/clear", "/help"];
|
|
9
|
+
export function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, onSubmit }) {
|
|
10
10
|
const [input, setInput] = useState("");
|
|
11
11
|
const [scrollBack, setScrollBack] = useState(0);
|
|
12
12
|
const { frame } = useAnimation({ interval: 80, isActive: !!busy });
|
|
@@ -74,6 +74,10 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
74
74
|
const maxBack = Math.max(0, lines.length - rows);
|
|
75
75
|
const back = Math.min(scrollBack, maxBack);
|
|
76
76
|
const visible = lines.slice(Math.max(0, lines.length - rows - back), lines.length - back);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (wheel)
|
|
79
|
+
setScrollBack((b) => Math.max(0, Math.min(maxBack, b - wheel.dir * 3)));
|
|
80
|
+
}, [wheel?.nonce]);
|
|
77
81
|
useInput((_input, key) => {
|
|
78
82
|
if (key.pageUp || (key.upArrow && key.shift))
|
|
79
83
|
setScrollBack((b) => Math.min(maxBack, b + Math.max(1, rows - 2)));
|
|
@@ -90,7 +94,9 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
90
94
|
};
|
|
91
95
|
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
96
|
const empty = entries.length === 0 && !streaming;
|
|
93
|
-
const title = _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
|
|
97
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
|
|
98
|
+
? _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines \u00B7 Shift+\u2193 / PgDn / wheel to go back"] })
|
|
99
|
+
: maxBack > 0 ? _jsx(Text, { color: C.dim, children: " Shift+\u2191 / PgUp / wheel to scroll" }) : null] }));
|
|
94
100
|
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
101
|
? _jsxs(Text, { color: C.accent, children: [suggestions.join(" "), " ", _jsx(Text, { color: C.dim, children: suggestions.length === 1 ? "Enter ↵" : "" })] })
|
|
96
102
|
: 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" })] }));
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -6,7 +6,7 @@ import { artWork } from "../util/index.js";
|
|
|
6
6
|
import { highlightLines, sideBySide } from "./text.js";
|
|
7
7
|
import { C, fileIcon, gradient } from "./theme.js";
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
9
|
-
export function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, onViewport }) {
|
|
9
|
+
export function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, onViewport }) {
|
|
10
10
|
const [top, setTop] = useState(0);
|
|
11
11
|
const rows = Math.max(1, height - 3);
|
|
12
12
|
const inner = width - 2;
|
|
@@ -32,6 +32,10 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
32
32
|
}, [focusLine]);
|
|
33
33
|
const maxTop = Math.max(0, total - rows);
|
|
34
34
|
const start = Math.min(top, maxTop);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (wheel)
|
|
37
|
+
setTop((t) => Math.max(0, Math.min(maxTop, Math.min(t, maxTop) + wheel.dir * 3)));
|
|
38
|
+
}, [wheel?.nonce]);
|
|
35
39
|
useEffect(() => {
|
|
36
40
|
if (filePath && !diff)
|
|
37
41
|
onViewport(start + 1, Math.min(total, start + rows));
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -7,7 +7,7 @@ import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
|
7
7
|
import { Pane } from "./Pane.js";
|
|
8
8
|
const expand = (s) => s.replace(/\t/g, " ");
|
|
9
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 }) {
|
|
10
|
+
export function Editor({ width, height, focused, filePath, initial, baseline, startLine, problems, autoSave, syntaxVersion, wheel, onSave, onExit, onState, onHover, onDefinition }) {
|
|
11
11
|
const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
|
|
12
12
|
const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
|
|
13
13
|
const [buf, setBuf] = useState(() => {
|
|
@@ -91,6 +91,10 @@ export function Editor({ width, height, focused, filePath, initial, baseline, st
|
|
|
91
91
|
}, [focused]);
|
|
92
92
|
const position = () => ({ text: buf.lines.join("\n"), line: buf.row + 1, col: buf.col + 1 });
|
|
93
93
|
const rows = Math.max(1, height - 4);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (wheel)
|
|
96
|
+
move((b) => ({ row: b.row + wheel.dir * 3 }));
|
|
97
|
+
}, [wheel?.nonce]);
|
|
94
98
|
usePaste((text) => insert(text), { isActive: focused });
|
|
95
99
|
useInput((input, key) => {
|
|
96
100
|
if (confirmExit) {
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -30,7 +30,7 @@ function flatten(expanded) {
|
|
|
30
30
|
walk(".", 0);
|
|
31
31
|
return nodes;
|
|
32
32
|
}
|
|
33
|
-
export function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, onOpen, onHover }) {
|
|
33
|
+
export function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, wheel, click, onOpen, onHover }) {
|
|
34
34
|
const [expanded, setExpanded] = useState(() => new Set(["src"]));
|
|
35
35
|
const [cursor, setCursor] = useState(0);
|
|
36
36
|
const [changesOnly, setChangesOnly] = useState(false);
|
|
@@ -118,6 +118,23 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
|
|
|
118
118
|
}, { isActive: focused });
|
|
119
119
|
const start = Math.max(0, Math.min(cursor - Math.floor(rows / 2), nodes.length - rows));
|
|
120
120
|
const visible = nodes.slice(start, start + rows);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (wheel)
|
|
123
|
+
setCursor((c) => Math.max(0, Math.min(nodes.length - 1, c + wheel.dir * 3)));
|
|
124
|
+
}, [wheel?.nonce]);
|
|
125
|
+
// Clicking a row selects it; a file opens, a folder expands or collapses.
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!click)
|
|
128
|
+
return;
|
|
129
|
+
const node = visible[click.row];
|
|
130
|
+
if (!node)
|
|
131
|
+
return;
|
|
132
|
+
setCursor(start + click.row);
|
|
133
|
+
if (node.isDir)
|
|
134
|
+
toggle(node.path);
|
|
135
|
+
else
|
|
136
|
+
onOpen(node.path);
|
|
137
|
+
}, [click?.nonce]);
|
|
121
138
|
const inner = width - 2;
|
|
122
139
|
const title = changesOnly
|
|
123
140
|
? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -2,8 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { Box, Text, useAnimation } from "ink";
|
|
4
4
|
import { C, SPINNER, gradient } from "./theme.js";
|
|
5
|
+
import { currentVersion } from "../update/index.js";
|
|
6
|
+
const VERSION = `v${currentVersion()}`;
|
|
5
7
|
export function Header({ width, branch, changes, model, local, busy, update }) {
|
|
6
8
|
const { frame } = useAnimation({ interval: 120, isActive: !!busy });
|
|
7
9
|
const project = path.basename(process.cwd());
|
|
8
|
-
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }), _jsxs(Text, { children: [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} ` }), " "] })] }));
|
|
10
|
+
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }), _jsxs(Text, { children: [update && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] })] }));
|
|
9
11
|
}
|
|
@@ -8,7 +8,7 @@ export const SEVERITY_STYLE = {
|
|
|
8
8
|
warning: { icon: "▲", color: C.yellow },
|
|
9
9
|
info: { icon: "ℹ", color: C.accent },
|
|
10
10
|
};
|
|
11
|
-
export function ProblemsView({ width, height, focused, problems, checking, onJump, onClose }) {
|
|
11
|
+
export function ProblemsView({ width, height, focused, problems, checking, wheel, onJump, onClose }) {
|
|
12
12
|
const [cursor, setCursor] = useState(0);
|
|
13
13
|
const [onlyErrors, setOnlyErrors] = useState(false);
|
|
14
14
|
const { frame } = useAnimation({ interval: 80, isActive: checking.length > 0 });
|
|
@@ -32,6 +32,10 @@ export function ProblemsView({ width, height, focused, problems, checking, onJum
|
|
|
32
32
|
return out;
|
|
33
33
|
}, [problems, onlyErrors]);
|
|
34
34
|
useEffect(() => setCursor((c) => Math.min(c, Math.max(0, rows.length - 1))), [rows.length]);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (wheel)
|
|
37
|
+
setCursor((c) => Math.max(0, Math.min(rows.length - 1, c + wheel.dir * 3)));
|
|
38
|
+
}, [wheel?.nonce]);
|
|
35
39
|
const visibleRows = Math.max(1, height - 4);
|
|
36
40
|
useInput((input, key) => {
|
|
37
41
|
if (key.upArrow || input === "k")
|
package/dist/src/ui/Settings.js
CHANGED
|
@@ -3,42 +3,82 @@ import { useEffect, useState } from "react";
|
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
5
|
import { AUTO_SAVE, EFFORTS } from "../config/index.js";
|
|
6
|
+
import { saveCredentials } from "../config/credentials.js";
|
|
7
|
+
import { originOf, setOutsideNou } from "../config/origins.js";
|
|
6
8
|
import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
|
|
7
9
|
import { currentVersion } from "../update/index.js";
|
|
8
10
|
import { describe, providers } from "../providers/index.js";
|
|
11
|
+
import { ENGINES, isInstalled } from "../engines/index.js";
|
|
9
12
|
import { C, SPINNER } from "./theme.js";
|
|
10
|
-
const TABS = ["Model", "Preferences", "Plugins"];
|
|
13
|
+
const TABS = ["Model", "Engine", "Preferences", "Plugins"];
|
|
11
14
|
const PREFS = [
|
|
12
15
|
{ key: "effort", label: "Reasoning effort", help: "How hard Claude thinks (low → max). Higher is smarter but slower and pricier." },
|
|
13
16
|
{ key: "autoSave", label: "Auto-save", help: "delay: a second after you stop typing · focus: when you leave the editor · off: only Ctrl+S." },
|
|
17
|
+
{ key: "mouse", label: "Mouse", help: "Wheel scrolls the pane under the pointer; click to focus or open files. Hold Shift to select text." },
|
|
14
18
|
{ key: "followMode", label: "Follow mode on start", help: "The code view jumps to files Nou reads and edits." },
|
|
19
|
+
{ key: "reuseToolConfig", label: "Use Claude Code / Codex keys", help: "Use the API keys from Claude Code's or Codex's settings when you haven't set one. Applies on restart." },
|
|
15
20
|
{ key: "gitContext", label: "Git context", help: "Give the model your branch, changes and commits, plus git tools." },
|
|
16
21
|
{ key: "autoApproveEdits", label: "Auto-approve edits", help: "Apply file edits without asking (diffs are still shown).", warn: true },
|
|
17
22
|
{ key: "autoApproveCommands", label: "Auto-approve commands", help: "Run shell commands without asking. Only turn on if you trust the model.", warn: true },
|
|
18
23
|
];
|
|
19
|
-
export function Settings({ width, height, settings, choice, plugins, onModel, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
|
|
24
|
+
export function Settings({ width, height, settings, choice, plugins, connectTo, onModel, onEngine, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
|
|
20
25
|
const [tab, setTab] = useState("Model");
|
|
21
|
-
const [row, setRow] = useState(0);
|
|
26
|
+
const [row, setRow] = useState(() => Math.max(0, providers.indexOf(connectTo ?? choice.provider)));
|
|
22
27
|
const [column, setColumn] = useState("providers");
|
|
23
28
|
const [modelRow, setModelRow] = useState(0);
|
|
24
29
|
const [typing, setTyping] = useState(false);
|
|
25
30
|
const [custom, setCustom] = useState("");
|
|
26
31
|
const [models, setModels] = useState({});
|
|
32
|
+
const [connecting, setConnecting] = useState(connectTo?.connect ? { step: 0, values: {} } : null);
|
|
33
|
+
const [field, setField] = useState(() => (connectTo?.connect ? initialValue(connectTo.connect[0]) : ""));
|
|
27
34
|
const { frame } = useAnimation({ interval: 80 });
|
|
28
35
|
const provider = providers[row] ?? providers[0];
|
|
29
36
|
const listed = models[provider.id];
|
|
30
37
|
// Load the model list for the highlighted provider.
|
|
31
38
|
useEffect(() => {
|
|
32
|
-
if (tab !== "Model" || provider.unavailable() || models[provider.id])
|
|
39
|
+
if (tab !== "Model" || connecting || provider.unavailable() || models[provider.id])
|
|
33
40
|
return;
|
|
34
41
|
setModels((m) => ({ ...m, [provider.id]: "loading" }));
|
|
35
42
|
provider.listModels()
|
|
36
43
|
.then((list) => setModels((m) => ({ ...m, [provider.id]: list })))
|
|
37
44
|
.catch((error) => setModels((m) => ({ ...m, [provider.id]: { error: String(error.message ?? error) } })));
|
|
38
|
-
}, [tab, provider.id]);
|
|
45
|
+
}, [tab, provider.id, connecting]);
|
|
46
|
+
const startConnect = () => {
|
|
47
|
+
if (!provider.connect)
|
|
48
|
+
return;
|
|
49
|
+
setConnecting({ step: 0, values: {} });
|
|
50
|
+
setField(initialValue(provider.connect[0]));
|
|
51
|
+
};
|
|
52
|
+
/** Takes the current field's answer; after the last one, saves everything and shows the provider's models. */
|
|
53
|
+
const submitField = (value) => {
|
|
54
|
+
const fields = provider.connect;
|
|
55
|
+
const current = fields[connecting.step];
|
|
56
|
+
// Required, and nothing set yet: ask again.
|
|
57
|
+
if (!value.trim() && !current.optional && !process.env[current.env])
|
|
58
|
+
return;
|
|
59
|
+
const values = { ...connecting.values, [current.env]: value };
|
|
60
|
+
const next = connecting.step + 1;
|
|
61
|
+
if (next < fields.length) {
|
|
62
|
+
setConnecting({ step: next, values });
|
|
63
|
+
setField(initialValue(fields[next]));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
saveCredentials(values);
|
|
67
|
+
setConnecting(null);
|
|
68
|
+
setModels((m) => Object.fromEntries(Object.entries(m).filter(([id]) => id !== provider.id)));
|
|
69
|
+
if (!provider.unavailable()) {
|
|
70
|
+
setColumn("models");
|
|
71
|
+
setModelRow(0);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
39
74
|
const modelItems = Array.isArray(listed) ? listed : [];
|
|
40
|
-
const rowsFor = (t) => (t === "Model" ? providers.length : t === "Preferences" ? PREFS.length : plugins.length);
|
|
75
|
+
const rowsFor = (t) => (t === "Model" ? providers.length : t === "Engine" ? ENGINES.length : t === "Preferences" ? PREFS.length : plugins.length);
|
|
41
76
|
useInput((input, key) => {
|
|
77
|
+
if (connecting) {
|
|
78
|
+
if (key.escape)
|
|
79
|
+
setConnecting(null);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
42
82
|
if (typing) {
|
|
43
83
|
if (key.escape)
|
|
44
84
|
setTyping(false);
|
|
@@ -47,7 +87,7 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
|
|
|
47
87
|
if (key.tab) {
|
|
48
88
|
const next = TABS[(TABS.indexOf(tab) + (key.shift ? TABS.length - 1 : 1)) % TABS.length];
|
|
49
89
|
setTab(next);
|
|
50
|
-
setRow(next === "Model" ? Math.max(0, providers.indexOf(choice.provider)) : 0);
|
|
90
|
+
setRow(next === "Model" ? Math.max(0, providers.indexOf(choice.provider)) : next === "Engine" ? Math.max(0, ENGINES.findIndex((e) => e.id === settings.engine)) : 0);
|
|
51
91
|
setColumn("providers");
|
|
52
92
|
return;
|
|
53
93
|
}
|
|
@@ -66,6 +106,8 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
|
|
|
66
106
|
setModelRow((r) => Math.min(count - 1, r + 1));
|
|
67
107
|
else if (key.leftArrow)
|
|
68
108
|
setColumn("providers");
|
|
109
|
+
else if (input === "k")
|
|
110
|
+
startConnect();
|
|
69
111
|
else if (key.return) {
|
|
70
112
|
if (modelRow === 0) {
|
|
71
113
|
setCustom("");
|
|
@@ -85,6 +127,14 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
|
|
|
85
127
|
setColumn("models");
|
|
86
128
|
setModelRow(0);
|
|
87
129
|
}
|
|
130
|
+
else
|
|
131
|
+
startConnect();
|
|
132
|
+
}
|
|
133
|
+
else if (tab === "Model" && input === "k") {
|
|
134
|
+
startConnect();
|
|
135
|
+
}
|
|
136
|
+
else if (tab === "Engine" && (key.return || input === " ")) {
|
|
137
|
+
onEngine(ENGINES[row].id);
|
|
88
138
|
}
|
|
89
139
|
else if (tab === "Preferences" && (key.return || input === " " || key.leftArrow || key.rightArrow)) {
|
|
90
140
|
const pref = PREFS[row];
|
|
@@ -111,16 +161,32 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
|
|
|
111
161
|
const body = () => {
|
|
112
162
|
if (tab === "Model") {
|
|
113
163
|
const leftWidth = Math.min(46, Math.floor(width * 0.45));
|
|
164
|
+
// Room for the label next to the marker, "local"/"cloud" and the lock (plus the border).
|
|
165
|
+
const labelWidth = Math.max(10, leftWidth - 14);
|
|
114
166
|
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
167
|
const problem = p.unavailable();
|
|
116
168
|
const selected = i === row;
|
|
117
169
|
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(
|
|
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.
|
|
170
|
+
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(labelWidth).slice(0, labelWidth) }), _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));
|
|
171
|
+
})] }), _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] }), connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
|
|
172
|
+
const entered = connecting.values[f.env];
|
|
173
|
+
const shown = (v) => (f.secret ? "•".repeat(Math.min(v.length, 12)) : v);
|
|
174
|
+
if (i < connecting.step) {
|
|
175
|
+
return _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["\u2713 ", f.label, ": ", entered ? shown(entered) : "unchanged"] }, f.env);
|
|
176
|
+
}
|
|
177
|
+
if (i > connecting.step)
|
|
178
|
+
return _jsxs(Text, { color: C.dim, children: [" ", f.label, f.optional ? " (optional)" : ""] }, f.env);
|
|
179
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { color: C.accent2, children: ["\u270E ", f.label, f.optional ? " (optional)" : "", ": "] }), _jsx(TextInput, { value: field, onChange: setField, onSubmit: submitField, ...(f.secret ? { mask: "•" } : {}), ...(f.placeholder ? { placeholder: f.placeholder } : {}) })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", f.env, process.env[f.env] ? (f.secret ? ` is set (from ${originOf(f.env)}); Enter keeps it` : "") : f.optional ? "; Enter skips" : ""] }), setOutsideNou(f.env) && (_jsxs(Text, { color: C.yellow, wrap: "wrap", children: [" A new one works now, but ", originOf(f.env), " sets ", f.env, " too and wins when Nou restarts: change or remove it there."] }))] }, f.env));
|
|
180
|
+
}), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Saved to ~/.nou/credentials.json (only you can read it). Your environment and .env still win." })] })) : provider.unavailable() ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.yellow, children: ["Not set up: ", provider.unavailable()] }), provider.connect
|
|
181
|
+
? _jsx(Text, { color: C.accent2, children: "Press Enter to connect it: paste your key here, no restart needed." })
|
|
182
|
+
: null, _jsx(Text, { color: C.dim, children: "Or add it to a .env file in your project (see .env.example) or your environment." })] })) : (_jsxs(_Fragment, { children: [typing ? (_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, children: "\u270E " }), _jsx(TextInput, { value: custom, onChange: setCustom, onSubmit: (v) => {
|
|
120
183
|
setTyping(false);
|
|
121
184
|
if (v.trim())
|
|
122
185
|
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] }), (() => {
|
|
186
|
+
}, 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(_Fragment, { children: [_jsxs(Text, { color: C.red, children: ["Couldn't list models: ", listed.error] }), rejectedKey(listed.error) && provider.connect?.some((f) => f.secret) && (() => {
|
|
187
|
+
const key = provider.connect.find((f) => f.secret).env;
|
|
188
|
+
return _jsxs(Text, { color: C.yellow, wrap: "wrap", children: ["The ", key, " ", originOf(key) ? `from ${originOf(key)} ` : "", "was rejected. Press k to enter a different key."] });
|
|
189
|
+
})()] })), (() => {
|
|
124
190
|
const visibleCount = Math.max(1, boxHeight - 6);
|
|
125
191
|
const offset = Math.max(0, Math.min(modelRow - 1 - Math.floor(visibleCount / 2), modelItems.length - visibleCount));
|
|
126
192
|
return modelItems.slice(offset, offset + visibleCount).map((m, i) => {
|
|
@@ -131,6 +197,13 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
|
|
|
131
197
|
});
|
|
132
198
|
})()] }))] })] }));
|
|
133
199
|
}
|
|
200
|
+
if (tab === "Engine") {
|
|
201
|
+
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [ENGINES.map((engine, i) => {
|
|
202
|
+
const selected = i === row;
|
|
203
|
+
const active = engine.id === settings.engine;
|
|
204
|
+
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: active ? C.green : C.dim, bold: true, children: active ? "● " : "○ " }), _jsx(Text, { color: C.text, bold: selected, children: engine.label.padEnd(14) }), _jsxs(Text, { color: C.dim, children: [active ? "in use · " : "", isInstalled(engine) ? "" : "installs on first use · ", "model: ", engine.model(choice)] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", engine.description] }), engine.approvals === "after" && _jsx(Text, { color: C.orange, wrap: "truncate-end", children: " Nou can't ask before its edits; it shows them afterwards so you can undo them." })] }, engine.id));
|
|
205
|
+
}), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "The engine is the agent that answers in the chat. Diffs, approvals, undo and problems work with each one." })] }));
|
|
206
|
+
}
|
|
134
207
|
if (tab === "Preferences") {
|
|
135
208
|
return (_jsx(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: PREFS.map((pref, i) => {
|
|
136
209
|
const selected = i === row;
|
|
@@ -151,15 +224,21 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
|
|
|
151
224
|
(p.plugin.diagnostics ?? []).length && "checker",
|
|
152
225
|
].filter(Boolean)
|
|
153
226
|
: [];
|
|
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"
|
|
227
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), p.scope === "starter" ? _jsx(Text, { color: C.accent2, bold: true, children: "+ ADD" }) : 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 === "starter" ? "starter, optional" : p.scope, parts.length ? ` · ${parts.join(", ")}` : ""] })] }), state === "changed"
|
|
155
228
|
? _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
229
|
: p.error
|
|
157
230
|
? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \u2716 ", p.error] })
|
|
158
231
|
: _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: ["
|
|
232
|
+
}), _jsx(Text, { children: " " }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Starters: Enter adds one to ", PLUGIN_DIRS.find((d) => d.scope === "user").dir, ", where you can edit it."] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Write your own: /plugins new <name>, or drop a .js/.mjs file into ", PLUGIN_DIRS.map((d) => d.dir).join(" or "), ", then press r."] })] }));
|
|
160
233
|
};
|
|
161
234
|
const hints = tab === "Model"
|
|
162
|
-
? column === "models" ?
|
|
163
|
-
: tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : "↑↓ move · Enter toggle · r rescan";
|
|
235
|
+
? connecting ? "Enter next · Esc cancel" : column === "models" ? `↑↓ choose · Enter use model · ← providers${provider.connect ? " · k change key" : ""}` : `↑↓ provider · Enter/→ ${provider.unavailable() ? "connect" : "pick a model"}${provider.connect ? " · k change key" : ""}`
|
|
236
|
+
: tab === "Engine" ? "↑↓ move · Enter use this engine" : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : "↑↓ move · Enter toggle · r rescan";
|
|
164
237
|
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
238
|
}
|
|
239
|
+
/** What a connect field starts with: the current URL to edit (keys stay hidden, so they start empty). */
|
|
240
|
+
function initialValue(f) {
|
|
241
|
+
return f.secret ? "" : process.env[f.env] ?? "";
|
|
242
|
+
}
|
|
243
|
+
/** Whether a provider error means the API key is wrong (rather than, say, the server being down). */
|
|
244
|
+
const rejectedKey = (error) => /\b(401|403)\b|api key|unauthori[sz]ed|authentication/i.test(error);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useStdin } from "ink";
|
|
3
|
+
// Report clicks and wheel (1000) using SGR coordinates (1006), which work past column 223.
|
|
4
|
+
const ENABLE = "\x1b[?1000h\x1b[?1006h";
|
|
5
|
+
const DISABLE = "\x1b[?1000l\x1b[?1006l";
|
|
6
|
+
/** Mouse events in a chunk of terminal input (SGR format: ESC [ < button ; x ; y M|m). */
|
|
7
|
+
export function parseMouse(chunk) {
|
|
8
|
+
const events = [];
|
|
9
|
+
// eslint-disable-next-line no-control-regex -- mouse reports start with ESC
|
|
10
|
+
for (const m of chunk.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
|
|
11
|
+
const code = Number(m[1]);
|
|
12
|
+
const x = Number(m[2]) - 1;
|
|
13
|
+
const y = Number(m[3]) - 1;
|
|
14
|
+
const button = code & 0b11000011; // drop the shift/alt/ctrl bits
|
|
15
|
+
if (button === 64 || button === 65)
|
|
16
|
+
events.push({ kind: "wheel", dir: button === 64 ? -1 : 1, x, y });
|
|
17
|
+
else if (button === 0 && m[4] === "M")
|
|
18
|
+
events.push({ kind: "click", x, y });
|
|
19
|
+
}
|
|
20
|
+
return events;
|
|
21
|
+
}
|
|
22
|
+
let exitHookInstalled = false;
|
|
23
|
+
/**
|
|
24
|
+
* Turns on mouse reporting while mounted and calls `handler` for wheel and click events.
|
|
25
|
+
* Ink keeps reading the keyboard as usual: a 'data' listener sees the same chunks Ink reads, and Ink
|
|
26
|
+
* already ignores mouse sequences, so they never show up as typed text.
|
|
27
|
+
*/
|
|
28
|
+
export function useMouse(handler, enabled) {
|
|
29
|
+
const { stdin } = useStdin();
|
|
30
|
+
const latest = useRef(handler);
|
|
31
|
+
latest.current = handler;
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!enabled || !stdin)
|
|
34
|
+
return;
|
|
35
|
+
const terminal = process.stdout.isTTY ? process.stdout : null;
|
|
36
|
+
terminal?.write(ENABLE);
|
|
37
|
+
if (terminal && !exitHookInstalled) {
|
|
38
|
+
// Leave the terminal clean even if Nou crashes; otherwise the shell gets mouse garbage.
|
|
39
|
+
process.on("exit", () => process.stdout.write(DISABLE));
|
|
40
|
+
exitHookInstalled = true;
|
|
41
|
+
}
|
|
42
|
+
const onData = (data) => {
|
|
43
|
+
for (const event of parseMouse(String(data)))
|
|
44
|
+
latest.current(event);
|
|
45
|
+
};
|
|
46
|
+
stdin.on("data", onData);
|
|
47
|
+
return () => {
|
|
48
|
+
stdin.off("data", onData);
|
|
49
|
+
terminal?.write(DISABLE);
|
|
50
|
+
};
|
|
51
|
+
}, [enabled, stdin]);
|
|
52
|
+
}
|
package/dist/src/ui/text.js
CHANGED
|
@@ -98,3 +98,15 @@ export function changedLines(before, after) {
|
|
|
98
98
|
}
|
|
99
99
|
return lines;
|
|
100
100
|
}
|
|
101
|
+
/** Lines added and removed, for summaries like "+12 -3". */
|
|
102
|
+
export function lineCounts(before, after) {
|
|
103
|
+
let added = 0;
|
|
104
|
+
let removed = 0;
|
|
105
|
+
for (const part of diffLines(before.replace(/\r\n/g, "\n"), after.replace(/\r\n/g, "\n"))) {
|
|
106
|
+
if (part.added)
|
|
107
|
+
added += part.count ?? 0;
|
|
108
|
+
else if (part.removed)
|
|
109
|
+
removed += part.count ?? 0;
|
|
110
|
+
}
|
|
111
|
+
return { added, removed };
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Nou: an IDE-style AI coding assistant for your terminal. Local (Ollama, LM Studio) and cloud (Claude, OpenAI, Gemini...) models, live diffs, git-aware, built-in editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
11
|
+
"plugins",
|
|
11
12
|
".env.example",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
@@ -19,7 +20,8 @@
|
|
|
19
20
|
"build": "tsc -p tsconfig.build.json && node scripts/copy-grammars.mjs",
|
|
20
21
|
"start": "node dist/index.js",
|
|
21
22
|
"typecheck": "tsc --noEmit -p .",
|
|
22
|
-
"lint": "eslint index.ts src",
|
|
23
|
+
"lint": "eslint index.ts src evals plugins",
|
|
24
|
+
"eval": "tsx evals/run.ts",
|
|
23
25
|
"prepublishOnly": "npm run build",
|
|
24
26
|
"screenshot": "node -e \"process.env.FORCE_COLOR='3';require('child_process').execSync('npx tsx scripts/screenshot.tsx',{stdio:'inherit'})\"",
|
|
25
27
|
"test": "tsx tests/unit.ts && tsx tests/e2e.tsx"
|
|
@@ -57,7 +59,7 @@
|
|
|
57
59
|
},
|
|
58
60
|
"devDependencies": {
|
|
59
61
|
"@eslint/js": "^10.0.1",
|
|
60
|
-
"@types/node": "^
|
|
62
|
+
"@types/node": "^22.20.5",
|
|
61
63
|
"@types/react": "^19.3.0",
|
|
62
64
|
"eslint": "^10.2.0",
|
|
63
65
|
"globals": "^17.4.0",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Starter plugin: lets the model read a web page (docs, an issue, a changelog) as plain text.
|
|
2
|
+
const MAX_CHARS = 20_000;
|
|
3
|
+
const TIMEOUT_MS = 15_000;
|
|
4
|
+
|
|
5
|
+
const ENTITIES = { nbsp: " ", lt: "<", gt: ">", quot: "\"", "#39": "'", amp: "&" };
|
|
6
|
+
|
|
7
|
+
/** Elements whose contents aren't page text. */
|
|
8
|
+
const SKIPPED = new Set(["script", "style", "noscript", "svg"]);
|
|
9
|
+
/** Tags that end a line of text. */
|
|
10
|
+
const BREAKS = new Set(["br", "/p", "/div", "/li", "/tr", "/h1", "/h2", "/h3", "/h4", "/h5", "/h6"]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Keeps the text between tags. Every "<" starts a tag (page text writes a literal one as <), which
|
|
14
|
+
* runs to the next ">"; skipped elements lose their contents too. Nothing tag-like can survive.
|
|
15
|
+
*/
|
|
16
|
+
function stripTags(html) {
|
|
17
|
+
const lower = html.toLowerCase();
|
|
18
|
+
let out = "";
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < html.length) {
|
|
21
|
+
const open = lower.indexOf("<", i);
|
|
22
|
+
if (open < 0) return out + html.slice(i);
|
|
23
|
+
out += html.slice(i, open);
|
|
24
|
+
const end = lower.indexOf(">", open);
|
|
25
|
+
if (end < 0) return out; // a tag that never closes: the rest is inside it
|
|
26
|
+
// "p" for <p class=x>, "/p" for </p>, "br" for <br/>.
|
|
27
|
+
const inner = lower.slice(open + 1, end);
|
|
28
|
+
const closing = inner.startsWith("/");
|
|
29
|
+
const name = `${closing ? "/" : ""}${inner.slice(closing ? 1 : 0).split(/[\s/]/)[0]}`;
|
|
30
|
+
if (BREAKS.has(name)) out += "\n";
|
|
31
|
+
i = end + 1;
|
|
32
|
+
if (SKIPPED.has(name)) {
|
|
33
|
+
const close = lower.indexOf(`</${name}`, i);
|
|
34
|
+
if (close < 0) return out; // never closed: the rest is its contents
|
|
35
|
+
const closeEnd = lower.indexOf(">", close);
|
|
36
|
+
i = closeEnd < 0 ? html.length : closeEnd + 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Rough HTML to text: drops scripts, styles and tags, keeps the words. */
|
|
43
|
+
export function htmlToText(html) {
|
|
44
|
+
// Entities after the tags, in one pass, so "&lt;" becomes "<", not "<".
|
|
45
|
+
return stripTags(html)
|
|
46
|
+
.replace(/&(nbsp|lt|gt|quot|#39|amp);/g, (_, name) => ENTITIES[name])
|
|
47
|
+
.replace(/[ \t]+/g, " ")
|
|
48
|
+
.replace(/\n\s*\n+/g, "\n\n")
|
|
49
|
+
.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function fetchText(url) {
|
|
53
|
+
if (!/^https?:\/\//i.test(url)) throw new Error("only http(s) URLs");
|
|
54
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "user-agent": "nou-fetch-url" } });
|
|
55
|
+
const type = response.headers.get("content-type") ?? "";
|
|
56
|
+
const body = await response.text();
|
|
57
|
+
const text = type.includes("html") ? htmlToText(body) : body;
|
|
58
|
+
const cut = text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}\n[... ${text.length - MAX_CHARS} more chars cut]` : text;
|
|
59
|
+
return `${response.status} ${response.statusText} · ${url}\n\n${cut}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default {
|
|
63
|
+
name: "Fetch URL",
|
|
64
|
+
version: "0.1.0",
|
|
65
|
+
description: "Adds a fetch_url tool so the model can read docs and web pages (asks first).",
|
|
66
|
+
|
|
67
|
+
tools: [
|
|
68
|
+
{
|
|
69
|
+
// Not read-only: it reaches the network, so you approve each URL.
|
|
70
|
+
definition: {
|
|
71
|
+
name: "fetch_url",
|
|
72
|
+
description: "Fetch a web page or file over http(s) and return it as text",
|
|
73
|
+
parameters: {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: { url: { type: "string", description: "The http(s) URL" } },
|
|
76
|
+
required: ["url"],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
run: (args) => fetchText(String(args.url)),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Starter plugin: gives the model the instructions you already wrote for other coding agents
|
|
2
|
+
// (AGENTS.md, CLAUDE.md, Cursor and Copilot rules), so you don't have to repeat them in NOU.md.
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import process from "process";
|
|
6
|
+
|
|
7
|
+
const FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", ".cursorrules", ".github/copilot-instructions.md"];
|
|
8
|
+
const MAX_CHARS = 8_000;
|
|
9
|
+
|
|
10
|
+
function readRules(cwd) {
|
|
11
|
+
return FILES.filter((file) => existsSync(path.join(cwd, file)))
|
|
12
|
+
.map((file) => {
|
|
13
|
+
const text = readFileSync(path.join(cwd, file), "utf-8").trim();
|
|
14
|
+
return { file, text: text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}\n[... cut]` : text };
|
|
15
|
+
})
|
|
16
|
+
.filter((rule) => rule.text);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
name: "Project rules",
|
|
21
|
+
version: "0.1.0",
|
|
22
|
+
description: "Reads AGENTS.md, CLAUDE.md, .cursorrules and Copilot instructions into the system prompt. /rules lists them.",
|
|
23
|
+
|
|
24
|
+
// A getter, so edits to those files are picked up whenever the prompt is rebuilt.
|
|
25
|
+
get systemPrompt() {
|
|
26
|
+
const rules = readRules(process.cwd());
|
|
27
|
+
if (!rules.length) return undefined;
|
|
28
|
+
return `Follow the project's own instructions:\n${rules.map((r) => `--- ${r.file} ---\n${r.text}`).join("\n\n")}`;
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
commands: [
|
|
32
|
+
{
|
|
33
|
+
name: "rules",
|
|
34
|
+
description: "Show which instruction files are in use",
|
|
35
|
+
run(args, ctx) {
|
|
36
|
+
const rules = readRules(ctx.cwd);
|
|
37
|
+
return rules.length
|
|
38
|
+
? `Using: ${rules.map((r) => `${r.file} (${r.text.length} chars)`).join(", ")}`
|
|
39
|
+
: `None found. Looked for ${FILES.join(", ")}.`;
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|