@nanhara/hara 0.48.0 → 0.62.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/CHANGELOG.md +245 -0
- package/README.md +28 -7
- package/SECURITY.md +54 -0
- package/dist/agent/loop.js +21 -4
- package/dist/completions.js +49 -0
- package/dist/config.js +19 -7
- package/dist/cron/install.js +112 -0
- package/dist/cron/runner.js +109 -0
- package/dist/cron/schedule.js +147 -0
- package/dist/cron/store.js +87 -0
- package/dist/hooks.js +64 -0
- package/dist/index.js +410 -13
- package/dist/mcp/server.js +56 -0
- package/dist/memory/store.js +44 -6
- package/dist/notify.js +42 -0
- package/dist/org/review-chain.js +91 -0
- package/dist/org/roles.js +11 -0
- package/dist/plugins/plugins.js +14 -0
- package/dist/providers/anthropic.js +21 -11
- package/dist/providers/qwen-oauth.js +9 -2
- package/dist/sandbox.js +25 -3
- package/dist/search/semindex.js +9 -2
- package/dist/session/store.js +12 -2
- package/dist/tools/computer.js +9 -4
- package/dist/tools/patch.js +31 -12
- package/dist/tools/todo.js +51 -0
- package/dist/tools/web.js +178 -8
- package/dist/tui/App.js +17 -4
- package/dist/tui/InputBox.js +37 -3
- package/dist/tui/vim.js +115 -0
- package/package.json +6 -2
package/dist/tools/web.js
CHANGED
|
@@ -1,7 +1,67 @@
|
|
|
1
1
|
// web_fetch — fetch an http(s) URL and return readable text (HTML reduced to text). Read-only.
|
|
2
|
-
// Uses Node's global fetch (Node >=20). NOT sandboxed (network egress is in-process, not via bash)
|
|
2
|
+
// Uses Node's global fetch (Node >=20). NOT sandboxed (network egress is in-process, not via bash) —
|
|
3
|
+
// so it carries an SSRF guard: private/loopback/link-local targets are refused, re-checked on every
|
|
4
|
+
// redirect hop, and the body is read under a hard byte ceiling.
|
|
3
5
|
import { registerTool } from "./registry.js";
|
|
6
|
+
import { lookup } from "node:dns/promises";
|
|
7
|
+
import { isIP } from "node:net";
|
|
4
8
|
const MAX = 60_000;
|
|
9
|
+
/** True for loopback / private / link-local / ULA / CGNAT addresses we must not let web_fetch reach. */
|
|
10
|
+
export function isPrivateIp(ip) {
|
|
11
|
+
const host = ip.replace(/^\[|\]$/g, "");
|
|
12
|
+
if (isIP(host) === 4) {
|
|
13
|
+
const p = host.split(".").map(Number);
|
|
14
|
+
return p[0] === 0 || p[0] === 10 || p[0] === 127 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168) || (p[0] === 169 && p[1] === 254) || (p[0] === 100 && p[1] >= 64 && p[1] <= 127);
|
|
15
|
+
}
|
|
16
|
+
const l = host.toLowerCase();
|
|
17
|
+
if (l === "::1" || l === "::")
|
|
18
|
+
return true;
|
|
19
|
+
if (l.startsWith("fe80") || l.startsWith("fc") || l.startsWith("fd"))
|
|
20
|
+
return true; // link-local + unique-local
|
|
21
|
+
const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(l); // IPv4-mapped IPv6
|
|
22
|
+
return m ? isPrivateIp(m[1]) : false;
|
|
23
|
+
}
|
|
24
|
+
/** Refuse to fetch a host that is (or resolves to) a private/internal address — defeats metadata-endpoint
|
|
25
|
+
* / localhost SSRF. Throws (caught by the caller) on a blocked or unresolvable host. */
|
|
26
|
+
async function assertPublicHost(hostname) {
|
|
27
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
28
|
+
if (isIP(host)) {
|
|
29
|
+
if (isPrivateIp(host))
|
|
30
|
+
throw new Error(`refusing to fetch ${host} (private/loopback address)`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const addrs = await lookup(host, { all: true });
|
|
34
|
+
for (const a of addrs)
|
|
35
|
+
if (isPrivateIp(a.address))
|
|
36
|
+
throw new Error(`refusing to fetch ${host} — resolves to a private/internal address (${a.address})`);
|
|
37
|
+
}
|
|
38
|
+
/** Read a fetch Response body up to `maxBytes`, then stop (avoids materializing a huge / bomb body). */
|
|
39
|
+
async function readCapped(res, maxBytes) {
|
|
40
|
+
if (!res.body)
|
|
41
|
+
return res.text();
|
|
42
|
+
const reader = res.body.getReader();
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let total = 0;
|
|
45
|
+
for (;;) {
|
|
46
|
+
const { done, value } = await reader.read();
|
|
47
|
+
if (done)
|
|
48
|
+
break;
|
|
49
|
+
if (value) {
|
|
50
|
+
chunks.push(value);
|
|
51
|
+
total += value.length;
|
|
52
|
+
}
|
|
53
|
+
if (total >= maxBytes) {
|
|
54
|
+
try {
|
|
55
|
+
await reader.cancel();
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* already closing */
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
64
|
+
}
|
|
5
65
|
/** Strip HTML to a readable-ish plain-text approximation (no dependency). */
|
|
6
66
|
export function htmlToText(html) {
|
|
7
67
|
return html
|
|
@@ -24,6 +84,103 @@ export function htmlToText(html) {
|
|
|
24
84
|
.replace(/\n{3,}/g, "\n\n")
|
|
25
85
|
.trim();
|
|
26
86
|
}
|
|
87
|
+
/** Parse DuckDuckGo HTML results → [{title, url, snippet}]. Best-effort HTML scrape (no key, no dependency). */
|
|
88
|
+
export function parseSearchResults(html, limit) {
|
|
89
|
+
const strip = (s) => s
|
|
90
|
+
.replace(/<[^>]+>/g, "")
|
|
91
|
+
.replace(/&/g, "&")
|
|
92
|
+
.replace(/</g, "<")
|
|
93
|
+
.replace(/>/g, ">")
|
|
94
|
+
.replace(/"/g, '"')
|
|
95
|
+
.replace(/'|'/g, "'")
|
|
96
|
+
.replace(/\s+/g, " ")
|
|
97
|
+
.trim();
|
|
98
|
+
const snippets = [];
|
|
99
|
+
const snipRe = /class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
|
|
100
|
+
let m;
|
|
101
|
+
while ((m = snipRe.exec(html)))
|
|
102
|
+
snippets.push(strip(m[1]));
|
|
103
|
+
const out = [];
|
|
104
|
+
const linkRe = /class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
|
|
105
|
+
let i = 0;
|
|
106
|
+
while ((m = linkRe.exec(html)) && out.length < limit) {
|
|
107
|
+
let href = m[1].replace(/&/g, "&");
|
|
108
|
+
const uddg = /[?&]uddg=([^&]+)/.exec(href); // DuckDuckGo wraps results in a /l/?uddg=<real-url> redirect
|
|
109
|
+
if (uddg)
|
|
110
|
+
href = decodeURIComponent(uddg[1]);
|
|
111
|
+
else if (href.startsWith("//"))
|
|
112
|
+
href = "https:" + href;
|
|
113
|
+
out.push({ title: strip(m[2]), url: href, snippet: snippets[i++] ?? "" });
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
registerTool({
|
|
118
|
+
name: "web_search",
|
|
119
|
+
description: "Search the web and return the top results (title, URL, snippet). Use it to FIND information or pages you " +
|
|
120
|
+
"don't already have a URL for, then `web_fetch` a result to read it. Read-only. Reliable with a Tavily key " +
|
|
121
|
+
"(env HARA_SEARCH_API_KEY); otherwise a best-effort keyless fallback that may be rate-limited.",
|
|
122
|
+
input_schema: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
query: { type: "string" },
|
|
126
|
+
limit: { type: "number", description: "max results (default 6, max 10)" },
|
|
127
|
+
},
|
|
128
|
+
required: ["query"],
|
|
129
|
+
},
|
|
130
|
+
kind: "read",
|
|
131
|
+
async run(input) {
|
|
132
|
+
const q = String(input.query ?? "").trim();
|
|
133
|
+
if (!q)
|
|
134
|
+
return "(empty query)";
|
|
135
|
+
const limit = Math.min(Math.max(1, Number(input.limit) || 6), 10);
|
|
136
|
+
const fmt = (rs) => rs.map((r, n) => `${n + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n\n");
|
|
137
|
+
const ctrl = new AbortController();
|
|
138
|
+
const timer = setTimeout(() => ctrl.abort(), 20_000);
|
|
139
|
+
try {
|
|
140
|
+
// Reliable path: Tavily (designed for agents, free tier) when a key is configured.
|
|
141
|
+
const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
|
|
142
|
+
if (key) {
|
|
143
|
+
const res = await fetch("https://api.tavily.com/search", {
|
|
144
|
+
method: "POST",
|
|
145
|
+
signal: ctrl.signal,
|
|
146
|
+
headers: { "content-type": "application/json" },
|
|
147
|
+
body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
|
|
148
|
+
});
|
|
149
|
+
if (res.ok) {
|
|
150
|
+
const j = (await res.json());
|
|
151
|
+
const rs = (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
|
|
152
|
+
if (rs.length)
|
|
153
|
+
return fmt(rs);
|
|
154
|
+
}
|
|
155
|
+
// Tavily failed → fall through to the keyless best-effort path.
|
|
156
|
+
}
|
|
157
|
+
// Keyless fallback: DuckDuckGo HTML (POST — GET returns a 202 challenge). May be rate-limited.
|
|
158
|
+
const res = await fetch("https://html.duckduckgo.com/html/", {
|
|
159
|
+
method: "POST",
|
|
160
|
+
signal: ctrl.signal,
|
|
161
|
+
redirect: "follow",
|
|
162
|
+
headers: {
|
|
163
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
|
|
164
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
165
|
+
accept: "text/html",
|
|
166
|
+
},
|
|
167
|
+
body: `q=${encodeURIComponent(q)}`,
|
|
168
|
+
});
|
|
169
|
+
if (!res.ok)
|
|
170
|
+
return `Search failed: HTTP ${res.status}. Keyless search is rate-limited — set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.`;
|
|
171
|
+
const results = parseSearchResults(await res.text(), limit);
|
|
172
|
+
if (!results.length)
|
|
173
|
+
return "(no results — the keyless endpoint is rate-limited or changed. Set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.)";
|
|
174
|
+
return fmt(results);
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
return `Search failed: ${e?.name === "AbortError" ? "timed out (20s)" : (e?.message ?? e)}`;
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
});
|
|
27
184
|
registerTool({
|
|
28
185
|
name: "web_fetch",
|
|
29
186
|
description: "Fetch an http(s) URL and return its text content (HTML is reduced to readable text). Read-only. " +
|
|
@@ -51,17 +208,30 @@ registerTool({
|
|
|
51
208
|
const ctrl = new AbortController();
|
|
52
209
|
const timer = setTimeout(() => ctrl.abort(), 30_000);
|
|
53
210
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
211
|
+
// Follow redirects manually so the SSRF guard runs on EVERY hop (a public URL can 30x to 169.254…).
|
|
212
|
+
let current = url;
|
|
213
|
+
let res;
|
|
214
|
+
for (let hop = 0;; hop++) {
|
|
215
|
+
await assertPublicHost(current.hostname);
|
|
216
|
+
res = await fetch(current, {
|
|
217
|
+
signal: ctrl.signal,
|
|
218
|
+
redirect: "manual",
|
|
219
|
+
headers: { "user-agent": "hara-cli", accept: "text/html,text/plain,application/json,*/*" },
|
|
220
|
+
});
|
|
221
|
+
const loc = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
|
|
222
|
+
if (!loc || hop >= 5)
|
|
223
|
+
break;
|
|
224
|
+
const next = new URL(loc, current);
|
|
225
|
+
if (next.protocol !== "http:" && next.protocol !== "https:")
|
|
226
|
+
return "Error: redirect to a non-http(s) URL was blocked.";
|
|
227
|
+
current = next;
|
|
228
|
+
}
|
|
59
229
|
const ct = res.headers.get("content-type") ?? "";
|
|
60
|
-
const raw = await res
|
|
230
|
+
const raw = await readCapped(res, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
|
|
61
231
|
let text = /html/i.test(ct) ? htmlToText(raw) : raw;
|
|
62
232
|
if (text.length > cap)
|
|
63
233
|
text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
|
|
64
|
-
return `# ${
|
|
234
|
+
return `# ${current.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
|
|
65
235
|
}
|
|
66
236
|
catch (e) {
|
|
67
237
|
return `Error fetching ${url.href}: ${e?.name === "AbortError" ? "timed out (30s)" : (e?.message ?? e)}`;
|
package/dist/tui/App.js
CHANGED
|
@@ -62,7 +62,7 @@ function Working() {
|
|
|
62
62
|
const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
63
63
|
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[n % frames.length] }), _jsx(Text, { dimColor: true, children: ` working ${Math.floor(n / 10)}s · esc to interrupt` })] }));
|
|
64
64
|
}
|
|
65
|
-
export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage }) {
|
|
65
|
+
export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim }) {
|
|
66
66
|
const { exit } = useApp();
|
|
67
67
|
const [history, setHistory] = useState([]);
|
|
68
68
|
const [current, setCurrent] = useState([]);
|
|
@@ -92,6 +92,19 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
92
92
|
return [...cur, { id: nid(), kind, text }];
|
|
93
93
|
});
|
|
94
94
|
}, []);
|
|
95
|
+
// Type-ahead steering: hand the runner everything queued while the turn ran, showing each message
|
|
96
|
+
// inline (as a user block) at the point it gets folded into the conversation. Drained mid-turn so an
|
|
97
|
+
// addition reaches the model on its next call; whatever's still queued at turn end is the effect below.
|
|
98
|
+
const drainQueue = useCallback(() => {
|
|
99
|
+
if (!queueRef.current.length)
|
|
100
|
+
return [];
|
|
101
|
+
const batch = queueRef.current;
|
|
102
|
+
queueRef.current = [];
|
|
103
|
+
setPool([]);
|
|
104
|
+
for (const b of batch)
|
|
105
|
+
pushCurrent("user", b.line.trim() || "🖼 (image)");
|
|
106
|
+
return batch;
|
|
107
|
+
}, [pushCurrent]);
|
|
95
108
|
const handleSubmit = useCallback(async (line, images) => {
|
|
96
109
|
const t = line.trim();
|
|
97
110
|
if ((!t && !images?.length) || prompt)
|
|
@@ -127,7 +140,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
127
140
|
const selectFn = (title, options) => openPrompt(title, options);
|
|
128
141
|
const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
|
|
129
142
|
try {
|
|
130
|
-
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval }, images);
|
|
143
|
+
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
|
|
131
144
|
}
|
|
132
145
|
catch (e) {
|
|
133
146
|
pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -139,7 +152,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
139
152
|
setCurrent([]);
|
|
140
153
|
setWorking(false);
|
|
141
154
|
ctrlRef.current = null;
|
|
142
|
-
}, [working, prompt, onSubmit, pushCurrent, model, exit]);
|
|
155
|
+
}, [working, prompt, onSubmit, pushCurrent, model, exit, drainQueue]);
|
|
143
156
|
// Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
|
|
144
157
|
// every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
|
|
145
158
|
useEffect(() => {
|
|
@@ -196,5 +209,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
196
209
|
else if (key.tab && key.shift && cycleApproval)
|
|
197
210
|
setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
|
|
198
211
|
});
|
|
199
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), working && !prompt && _jsx(Working, {}), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
212
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), working && !prompt && _jsx(Working, {}), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
200
213
|
}
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Box, Text, useInput, useStdout } from "ink";
|
|
|
7
7
|
import { useMemo, useState } from "react";
|
|
8
8
|
import { fileCandidates } from "../context/mentions.js";
|
|
9
9
|
import { imagePathFromPaste } from "../images.js";
|
|
10
|
+
import { vimNormal } from "./vim.js";
|
|
10
11
|
export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
|
|
11
12
|
export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
|
|
12
13
|
const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
|
@@ -84,7 +85,7 @@ function InputLine({ value, cursor }) {
|
|
|
84
85
|
return _jsx(Text, { children: nodes });
|
|
85
86
|
}
|
|
86
87
|
/** Top border (session) + prompt line + bottom border (usage) + ModeBar, with an @path popup. */
|
|
87
|
-
export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
|
|
88
|
+
export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, vim = false, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
|
|
88
89
|
const { stdout } = useStdout();
|
|
89
90
|
const w = width ?? stdout?.columns ?? 80;
|
|
90
91
|
const [value, setValue] = useState("");
|
|
@@ -92,6 +93,9 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
|
|
|
92
93
|
const [sel, setSel] = useState(0);
|
|
93
94
|
const [dismissed, setDismissed] = useState(false);
|
|
94
95
|
const [images, setImages] = useState([]);
|
|
96
|
+
const [mode, setMode] = useState("insert"); // vim only
|
|
97
|
+
const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
|
|
98
|
+
const [register, setRegister] = useState(""); // vim yank/delete register
|
|
95
99
|
const set = (v, c) => {
|
|
96
100
|
setValue(v);
|
|
97
101
|
setCursor(c);
|
|
@@ -116,6 +120,8 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
|
|
|
116
120
|
onSubmit?.(text, images.length ? images : undefined);
|
|
117
121
|
set("", 0);
|
|
118
122
|
setImages([]);
|
|
123
|
+
setMode("insert"); // a fresh prompt starts in insert
|
|
124
|
+
setPending("");
|
|
119
125
|
};
|
|
120
126
|
const mention = activeMention(value, cursor);
|
|
121
127
|
const candidates = useMemo(() => (isActive && mention && !dismissed ? fileCandidates(cwd, mention.query, 8) : []), [cwd, isActive, dismissed, mention?.query, mention?.start]);
|
|
@@ -143,8 +149,36 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
|
|
|
143
149
|
return;
|
|
144
150
|
}
|
|
145
151
|
if (key.escape) {
|
|
146
|
-
if (popupOpen)
|
|
152
|
+
if (popupOpen) {
|
|
147
153
|
setDismissed(true);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (vim && mode === "insert") {
|
|
157
|
+
setMode("normal");
|
|
158
|
+
setPending("");
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// vim NORMAL mode: printable keys are commands, not text (Enter/arrows/backspace still navigate/submit)
|
|
163
|
+
if (vim && mode === "normal") {
|
|
164
|
+
if (key.return)
|
|
165
|
+
return submit(value);
|
|
166
|
+
if (key.leftArrow)
|
|
167
|
+
return setCursor((c) => Math.max(0, c - 1));
|
|
168
|
+
if (key.rightArrow)
|
|
169
|
+
return setCursor((c) => Math.min(value.length, c + 1));
|
|
170
|
+
if (key.backspace || key.delete)
|
|
171
|
+
return setCursor((c) => Math.max(0, c - 1));
|
|
172
|
+
if (input && !key.ctrl && !key.meta) {
|
|
173
|
+
const st = vimNormal({ value, cursor, mode, pending, register }, input);
|
|
174
|
+
setValue(st.value);
|
|
175
|
+
setCursor(st.cursor);
|
|
176
|
+
setMode(st.mode);
|
|
177
|
+
setPending(st.pending);
|
|
178
|
+
setRegister(st.register);
|
|
179
|
+
setSel(0);
|
|
180
|
+
setDismissed(false);
|
|
181
|
+
}
|
|
148
182
|
return;
|
|
149
183
|
}
|
|
150
184
|
if (key.return) {
|
|
@@ -204,5 +238,5 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
|
|
|
204
238
|
set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
|
|
205
239
|
}
|
|
206
240
|
}, { isActive });
|
|
207
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "› " }), value.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsx(InputLine, { value: value, cursor: cursor }))] }), _jsx(BottomBorder, { s: status, width: w }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, _jsx(ModeBar, { approval: status.approval })] }));
|
|
241
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsxs(Box, { children: [_jsx(Text, { color: vim ? (mode === "normal" ? "yellow" : "green") : "cyan", children: vim && mode === "normal" ? "◆ " : "› " }), value.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsx(InputLine, { value: value, cursor: cursor }))] }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(BottomBorder, { s: status, width: w }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, _jsx(ModeBar, { approval: status.approval })] }));
|
|
208
242
|
}
|
package/dist/tui/vim.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const isSpace = (ch) => ch === " " || ch === "\t";
|
|
2
|
+
const clamp = (c, n) => Math.max(0, Math.min(n, c));
|
|
3
|
+
/** Start of the next word (run of non-space), vim `w`. */
|
|
4
|
+
function nextWord(v, c) {
|
|
5
|
+
const n = v.length;
|
|
6
|
+
let i = c;
|
|
7
|
+
if (i < n && !isSpace(v[i]))
|
|
8
|
+
while (i < n && !isSpace(v[i]))
|
|
9
|
+
i++;
|
|
10
|
+
while (i < n && isSpace(v[i]))
|
|
11
|
+
i++;
|
|
12
|
+
return i;
|
|
13
|
+
}
|
|
14
|
+
/** Start of the previous word, vim `b`. */
|
|
15
|
+
function prevWord(v, c) {
|
|
16
|
+
let i = c - 1;
|
|
17
|
+
while (i > 0 && isSpace(v[i]))
|
|
18
|
+
i--;
|
|
19
|
+
while (i > 0 && !isSpace(v[i - 1]))
|
|
20
|
+
i--;
|
|
21
|
+
return Math.max(0, i);
|
|
22
|
+
}
|
|
23
|
+
/** End index of the current/next word, vim `e`. */
|
|
24
|
+
function wordEnd(v, c) {
|
|
25
|
+
const n = v.length;
|
|
26
|
+
let i = c + 1;
|
|
27
|
+
while (i < n && isSpace(v[i]))
|
|
28
|
+
i++;
|
|
29
|
+
while (i < n - 1 && !isSpace(v[i + 1]))
|
|
30
|
+
i++;
|
|
31
|
+
return Math.min(n, i);
|
|
32
|
+
}
|
|
33
|
+
/** Apply one printable key in NORMAL mode, returning the next state. Special keys (Esc/Enter/arrows/
|
|
34
|
+
* backspace) are handled by the caller. Unknown keys are no-ops. */
|
|
35
|
+
export function vimNormal(st, input) {
|
|
36
|
+
const { value, cursor } = st;
|
|
37
|
+
const n = value.length;
|
|
38
|
+
const move = (c) => ({ ...st, cursor: clamp(c, n), pending: "" });
|
|
39
|
+
const toInsert = (v, c, register = st.register) => ({ value: v, cursor: clamp(c, v.length), mode: "insert", pending: "", register });
|
|
40
|
+
const toNormal = (patch) => ({ ...st, pending: "", ...patch });
|
|
41
|
+
// operator-pending: d{motion} / c{motion}
|
|
42
|
+
if (st.pending === "d" || st.pending === "c") {
|
|
43
|
+
const op = st.pending;
|
|
44
|
+
if (input === op)
|
|
45
|
+
return op === "c" ? toInsert("", 0, value) : toNormal({ value: "", cursor: 0, register: value }); // dd / cc
|
|
46
|
+
const ranges = {
|
|
47
|
+
w: [cursor, nextWord(value, cursor)],
|
|
48
|
+
e: [cursor, Math.min(n, wordEnd(value, cursor) + 1)],
|
|
49
|
+
b: [prevWord(value, cursor), cursor],
|
|
50
|
+
$: [cursor, n],
|
|
51
|
+
"0": [0, cursor],
|
|
52
|
+
};
|
|
53
|
+
const r = ranges[op === "c" && input === "w" ? "e" : input]; // cw acts like ce (vim quirk)
|
|
54
|
+
if (!r)
|
|
55
|
+
return toNormal({}); // unknown motion → cancel the operator
|
|
56
|
+
const [from, to] = r;
|
|
57
|
+
const deleted = value.slice(from, to);
|
|
58
|
+
const next = value.slice(0, from) + value.slice(to);
|
|
59
|
+
return op === "c" ? toInsert(next, from, deleted) : toNormal({ value: next, cursor: clamp(from, next.length), register: deleted });
|
|
60
|
+
}
|
|
61
|
+
if (st.pending === "g")
|
|
62
|
+
return input === "g" ? move(0) : toNormal({});
|
|
63
|
+
switch (input) {
|
|
64
|
+
// motions
|
|
65
|
+
case "h":
|
|
66
|
+
return move(cursor - 1);
|
|
67
|
+
case "l":
|
|
68
|
+
return move(cursor + 1);
|
|
69
|
+
case "0":
|
|
70
|
+
return move(0);
|
|
71
|
+
case "$":
|
|
72
|
+
return move(n);
|
|
73
|
+
case "w":
|
|
74
|
+
return move(nextWord(value, cursor));
|
|
75
|
+
case "b":
|
|
76
|
+
return move(prevWord(value, cursor));
|
|
77
|
+
case "e":
|
|
78
|
+
return move(wordEnd(value, cursor));
|
|
79
|
+
case "G":
|
|
80
|
+
return move(n);
|
|
81
|
+
// enter insert mode
|
|
82
|
+
case "i":
|
|
83
|
+
return toInsert(value, cursor);
|
|
84
|
+
case "a":
|
|
85
|
+
return toInsert(value, cursor + 1);
|
|
86
|
+
case "I":
|
|
87
|
+
return toInsert(value, value.length - value.trimStart().length);
|
|
88
|
+
case "A":
|
|
89
|
+
case "o": // single-line: open ≈ append at end
|
|
90
|
+
return toInsert(value, n);
|
|
91
|
+
// edits
|
|
92
|
+
case "x": {
|
|
93
|
+
if (!n)
|
|
94
|
+
return toNormal({});
|
|
95
|
+
return toNormal({ value: value.slice(0, cursor) + value.slice(cursor + 1), cursor: clamp(cursor, n - 1), register: value[cursor] ?? "" });
|
|
96
|
+
}
|
|
97
|
+
case "D":
|
|
98
|
+
return toNormal({ value: value.slice(0, cursor), cursor: clamp(cursor, cursor), register: value.slice(cursor) });
|
|
99
|
+
case "C":
|
|
100
|
+
return toInsert(value.slice(0, cursor), cursor, value.slice(cursor));
|
|
101
|
+
case "p":
|
|
102
|
+
return toNormal({ value: value.slice(0, cursor + 1) + st.register + value.slice(cursor + 1), cursor: cursor + st.register.length });
|
|
103
|
+
case "P":
|
|
104
|
+
return toNormal({ value: value.slice(0, cursor) + st.register + value.slice(cursor), cursor: cursor + Math.max(0, st.register.length - 1) });
|
|
105
|
+
// operators
|
|
106
|
+
case "d":
|
|
107
|
+
return { ...st, pending: "d" };
|
|
108
|
+
case "c":
|
|
109
|
+
return { ...st, pending: "c" };
|
|
110
|
+
case "g":
|
|
111
|
+
return { ...st, pending: "g" };
|
|
112
|
+
default:
|
|
113
|
+
return toNormal({}); // ignore everything else (printable keys don't insert in normal mode)
|
|
114
|
+
}
|
|
115
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanhara/hara",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"description": "hara — a coding agent CLI that runs like an engineering org.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"hara": "dist/index.js"
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
"type": "module",
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
11
|
+
"!dist/bin",
|
|
11
12
|
"README.md",
|
|
12
13
|
"CHANGELOG.md",
|
|
14
|
+
"SECURITY.md",
|
|
13
15
|
"LICENSE",
|
|
14
16
|
"CLA.md",
|
|
15
17
|
"plugins"
|
|
@@ -40,7 +42,9 @@
|
|
|
40
42
|
"prepare": "tsc",
|
|
41
43
|
"dev": "tsx src/index.ts",
|
|
42
44
|
"start": "node dist/index.js",
|
|
43
|
-
"test": "tsc && node --test test/*.test.mjs"
|
|
45
|
+
"test": "tsc && node --test test/*.test.mjs",
|
|
46
|
+
"build:binary": "tsc && bun scripts/build-binary.ts dist/bin/hara",
|
|
47
|
+
"build:binaries": "tsc && bun scripts/build-binary.ts dist/bin/hara-darwin-arm64 bun-darwin-arm64 && bun scripts/build-binary.ts dist/bin/hara-darwin-x64 bun-darwin-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-x64 bun-linux-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-arm64 bun-linux-arm64"
|
|
44
48
|
},
|
|
45
49
|
"publishConfig": {
|
|
46
50
|
"access": "public"
|