@nanhara/hara 0.0.2 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CLA.md +1 -1
  3. package/README.md +207 -10
  4. package/dist/activity.js +30 -0
  5. package/dist/agent/loop.js +184 -0
  6. package/dist/config.js +114 -0
  7. package/dist/context/agents-md.js +64 -0
  8. package/dist/context/mentions.js +90 -0
  9. package/dist/diff.js +103 -0
  10. package/dist/fs-walk.js +103 -0
  11. package/dist/fuzzy.js +62 -0
  12. package/dist/images.js +146 -0
  13. package/dist/index.js +1589 -0
  14. package/dist/mcp/client.js +54 -0
  15. package/dist/md.js +52 -0
  16. package/dist/memory/guard.js +51 -0
  17. package/dist/memory/store.js +93 -0
  18. package/dist/org/planner.js +174 -0
  19. package/dist/org/roles.js +140 -0
  20. package/dist/org/router.js +39 -0
  21. package/dist/plugins/plugins.js +124 -0
  22. package/dist/providers/anthropic.js +83 -0
  23. package/dist/providers/openai.js +125 -0
  24. package/dist/providers/qwen-oauth.js +139 -0
  25. package/dist/providers/types.js +2 -0
  26. package/dist/recall.js +76 -0
  27. package/dist/sandbox.js +78 -0
  28. package/dist/search/embed.js +42 -0
  29. package/dist/search/hybrid.js +38 -0
  30. package/dist/search/semindex.js +192 -0
  31. package/dist/session/store.js +109 -0
  32. package/dist/skills/skills.js +141 -0
  33. package/dist/statusbar.js +69 -0
  34. package/dist/tools/agent.js +26 -0
  35. package/dist/tools/apply-core.js +63 -0
  36. package/dist/tools/builtin.js +106 -0
  37. package/dist/tools/codebase.js +102 -0
  38. package/dist/tools/computer.js +376 -0
  39. package/dist/tools/edit.js +62 -0
  40. package/dist/tools/memory.js +147 -0
  41. package/dist/tools/patch.js +123 -0
  42. package/dist/tools/registry.js +18 -0
  43. package/dist/tools/search.js +176 -0
  44. package/dist/tools/skill.js +30 -0
  45. package/dist/tools/web.js +73 -0
  46. package/dist/tui/App.js +200 -0
  47. package/dist/tui/InputBox.js +208 -0
  48. package/dist/tui/run.js +10 -0
  49. package/dist/tui/theme.js +11 -0
  50. package/dist/ui.js +17 -0
  51. package/dist/undo.js +40 -0
  52. package/dist/vision.js +130 -0
  53. package/package.json +34 -9
  54. package/plugins/browser/.hara-plugin/plugin.json +9 -0
  55. package/plugins/browser/skills/web/SKILL.md +27 -0
  56. package/plugins/chrome/.hara-plugin/plugin.json +9 -0
  57. package/plugins/chrome/skills/chrome/SKILL.md +26 -0
  58. package/LICENSE-MIT +0 -21
  59. package/bin/hara.mjs +0 -25
  60. /package/{LICENSE-APACHE → LICENSE} +0 -0
@@ -0,0 +1,200 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The hara TUI (ink). Layout, top to bottom:
3
+ // <Static> committed transcript — rendered once each, scrolls into native scrollback
4
+ // current the in-progress turn's blocks (assistant text / reasoning / tool / diff), live
5
+ // <Working> spinner while a turn runs (Esc interrupts)
6
+ // <InputBox> the pinned, bordered prompt (or a confirm prompt when a tool needs approval)
7
+ //
8
+ // The agent machinery is injected via `onSubmit` (a turn runner) so this view is testable with
9
+ // ink-testing-library against a fake runner — no provider/network needed.
10
+ import { Box, Static, Text, useApp, useInput } from "ink";
11
+ import { useCallback, useEffect, useRef, useState } from "react";
12
+ import { InputBox } from "./InputBox.js";
13
+ import { activity } from "../activity.js";
14
+ import { ctxPctFor } from "../statusbar.js";
15
+ import { accent } from "./theme.js";
16
+ import { renderMarkdown } from "../md.js";
17
+ let _id = 0;
18
+ const nid = () => ++_id;
19
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
20
+ function Block({ item, open }) {
21
+ switch (item.kind) {
22
+ case "user":
23
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { children: item.text })] }));
24
+ case "assistant":
25
+ return _jsx(Text, { children: renderMarkdown(item.text) }); // headers/bold/inline-code/bullets + verbatim fences
26
+ case "reasoning": {
27
+ // fixed-height window: show the last 5 lines while thinking; ctrl-r toggles the full text.
28
+ const lines = item.text.replace(/\n+$/, "").split("\n");
29
+ const long = lines.length > 5;
30
+ const shown = open || !long ? lines : lines.slice(-5);
31
+ const hint = long ? (open ? " · ctrl-r collapse" : " · ctrl-r expand") : "";
32
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: accent(), dimColor: true, children: `✻ thinking … ${lines.length} line${lines.length === 1 ? "" : "s"}${hint}` }), shown.map((l, i) => (_jsx(Text, { dimColor: true, children: `│ ${l}` }, i)))] }));
33
+ }
34
+ case "tool":
35
+ return _jsx(Text, { dimColor: true, children: " " + item.text });
36
+ case "diff":
37
+ return _jsx(Text, { children: item.text });
38
+ case "notice":
39
+ return _jsx(Text, { dimColor: true, children: item.text });
40
+ }
41
+ }
42
+ // ASCII rendering of the nanhara "Λi" mark (small peak + big peak + italic i), in the brand violet.
43
+ // hara wordmark — FIGlet "ANSI Shadow". A recognizable banner reads better in a terminal than a
44
+ // pixel-faithful logo. Printed once at the top of the session; scrolls away with the transcript.
45
+ const BANNER = [
46
+ "██╗ ██╗ █████╗ ██████╗ █████╗",
47
+ "██║ ██║██╔══██╗██╔══██╗██╔══██╗",
48
+ "███████║███████║██████╔╝███████║",
49
+ "██╔══██║██╔══██║██╔══██╗██╔══██║",
50
+ "██║ ██║██║ ██║██║ ██║██║ ██║",
51
+ "╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝",
52
+ ];
53
+ function HeaderCard({ version, model, cwd, tip, vision, session }) {
54
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [BANNER.map((row, i) => (_jsx(Text, { color: accent(), children: row }, i))), _jsx(Text, { dimColor: true, children: ` the coding agent that runs like an org · v${version}` }), _jsx(Text, { dimColor: true, children: ` ${model} · ${cwd}` }), session ? _jsx(Text, { dimColor: true, children: ` session ${session}` }) : null, vision ? (_jsxs(Text, { children: [_jsx(Text, { color: accent(), children: " 👁 " }), _jsx(Text, { dimColor: true, children: vision })] })) : null, tip ? _jsx(Text, { dimColor: true, children: ` ${tip}` }) : null] }));
55
+ }
56
+ function Working() {
57
+ const [n, setN] = useState(0);
58
+ useEffect(() => {
59
+ const id = setInterval(() => setN((x) => x + 1), 100);
60
+ return () => clearInterval(id);
61
+ }, []);
62
+ const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
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
+ }
65
+ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage }) {
66
+ const { exit } = useApp();
67
+ const [history, setHistory] = useState([]);
68
+ const [current, setCurrent] = useState([]);
69
+ const [working, setWorking] = useState(false);
70
+ const [status, setStatus] = useState({ ...initialStatus, agents: 0 });
71
+ const [prompt, setPrompt] = useState(null);
72
+ const [promptSel, setPromptSel] = useState(0);
73
+ const [reasoningOpen, setReasoningOpen] = useState(false);
74
+ const ctrlRef = useRef(null);
75
+ const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
76
+ const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
77
+ const drainingRef = useRef(false); // idempotency guard so the drain effect can't double-send one item
78
+ const currentRef = useRef([]);
79
+ currentRef.current = current;
80
+ const statusRef = useRef(status);
81
+ statusRef.current = status;
82
+ useEffect(() => {
83
+ const fn = () => setStatus((s) => ({ ...s, agents: activity.running }));
84
+ activity.onChange(fn);
85
+ return () => activity.onChange(null);
86
+ }, []);
87
+ const pushCurrent = useCallback((kind, text, merge = false) => {
88
+ setCurrent((cur) => {
89
+ const last = cur[cur.length - 1];
90
+ if (merge && last && last.kind === kind)
91
+ return [...cur.slice(0, -1), { ...last, text: last.text + text }];
92
+ return [...cur, { id: nid(), kind, text }];
93
+ });
94
+ }, []);
95
+ const handleSubmit = useCallback(async (line, images) => {
96
+ const t = line.trim();
97
+ if ((!t && !images?.length) || prompt)
98
+ return; // nothing to send, or a choice is pending
99
+ if (working) {
100
+ // type-ahead: hold the message in the pool; all pooled messages are sent together when the turn ends
101
+ queueRef.current.push({ line, images });
102
+ setPool(queueRef.current.map((q) => q.line.trim() || "🖼 (image)"));
103
+ return;
104
+ }
105
+ setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]); // t already carries any [Image #N] tokens
106
+ const ctrl = new AbortController();
107
+ ctrlRef.current = ctrl;
108
+ setWorking(true);
109
+ const sink = {
110
+ assistantDelta: (d) => pushCurrent("assistant", d, true),
111
+ reasoningDelta: (d) => pushCurrent("reasoning", d, true),
112
+ tool: (name, preview) => pushCurrent("tool", `↳ ${name}${preview ? " " + preview : ""}`),
113
+ diff: (text) => pushCurrent("diff", text),
114
+ notice: (text) => pushCurrent("notice", text),
115
+ usage: (input, output) => setStatus((s) => ({ ...s, input: s.input + input, output: s.output + output, ctxPct: ctxPctFor(model, input) })),
116
+ session: (name) => setStatus((s) => ({ ...s, sessionName: name })),
117
+ };
118
+ const openPrompt = (title, options) => new Promise((resolve) => {
119
+ setPromptSel(0);
120
+ setPrompt({ title, options: options, resolve: resolve });
121
+ });
122
+ const confirmFn = (q) => openPrompt(q, [
123
+ { label: "Yes", value: true, key: "y" },
124
+ { label: "Yes, and don't ask again this session", value: "always", key: "a" },
125
+ { label: "No (esc)", value: false, key: "n" },
126
+ ]);
127
+ const selectFn = (title, options) => openPrompt(title, options);
128
+ const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
129
+ try {
130
+ await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval }, images);
131
+ }
132
+ catch (e) {
133
+ pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
134
+ }
135
+ const committed = currentRef.current.map((it) => it.kind === "reasoning"
136
+ ? { ...it, kind: "notice", text: `✻ thought · ${it.text.split("\n").filter((l) => l.trim()).length} lines` }
137
+ : it);
138
+ setHistory((h) => [...h, ...committed]);
139
+ setCurrent([]);
140
+ setWorking(false);
141
+ ctrlRef.current = null;
142
+ }, [working, prompt, onSubmit, pushCurrent, model, exit]);
143
+ // Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
144
+ // every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
145
+ useEffect(() => {
146
+ if (working || prompt || drainingRef.current || !queueRef.current.length)
147
+ return;
148
+ drainingRef.current = true;
149
+ const batch = queueRef.current;
150
+ queueRef.current = [];
151
+ setPool([]);
152
+ const line = batch.map((b) => b.line).join("\n\n");
153
+ const images = batch.flatMap((b) => b.images ?? []);
154
+ void Promise.resolve(handleSubmit(line, images.length ? images : undefined)).finally(() => {
155
+ drainingRef.current = false;
156
+ });
157
+ }, [working, prompt, handleSubmit]);
158
+ useInput((input, key) => {
159
+ if (prompt) {
160
+ const opts = prompt.options;
161
+ if (key.upArrow)
162
+ setPromptSel((s) => (s - 1 + opts.length) % opts.length);
163
+ else if (key.downArrow)
164
+ setPromptSel((s) => (s + 1) % opts.length);
165
+ else if (key.return) {
166
+ prompt.resolve(opts[Math.min(promptSel, opts.length - 1)].value);
167
+ setPrompt(null);
168
+ }
169
+ else if (key.escape) {
170
+ prompt.resolve(opts[opts.length - 1].value); // last option = cancel/no
171
+ setPrompt(null);
172
+ }
173
+ else if (/^[1-9]$/.test(input) && Number(input) <= opts.length) {
174
+ prompt.resolve(opts[Number(input) - 1].value); // type a number to pick directly
175
+ setPrompt(null);
176
+ }
177
+ else if (input) {
178
+ const hit = opts.find((o) => o.key && o.key === input.toLowerCase());
179
+ if (hit) {
180
+ prompt.resolve(hit.value);
181
+ setPrompt(null);
182
+ }
183
+ }
184
+ return;
185
+ }
186
+ if (key.ctrl && input === "r")
187
+ return setReasoningOpen((x) => !x);
188
+ if (key.escape && working) {
189
+ // Esc = stop everything: abort the turn AND drop any type-ahead (a stopped turn shouldn't fire queued msgs)
190
+ if (queueRef.current.length) {
191
+ queueRef.current = [];
192
+ setPool([]);
193
+ }
194
+ ctrlRef.current?.abort();
195
+ }
196
+ else if (key.tab && key.shift && cycleApproval)
197
+ setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
198
+ });
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 })] }));
200
+ }
@@ -0,0 +1,208 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ // The framed input box (ink): a top border carrying the session name in the right corner, the
3
+ // prompt line in the middle, and a bottom border carrying the approval modes + token usage +
4
+ // concurrency. Composed from <Text> rows (no ink border fork needed) so the title sits exactly
5
+ // where we want it. Pure-ish: pass `width` to make rendering deterministic in tests.
6
+ import { Box, Text, useInput, useStdout } from "ink";
7
+ import { useMemo, useState } from "react";
8
+ import { fileCandidates } from "../context/mentions.js";
9
+ import { imagePathFromPaste } from "../images.js";
10
+ export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
11
+ export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
12
+ const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
13
+ function TopBorder({ name, width }) {
14
+ const labelLen = name.length + 2; // "⏺ " + name
15
+ const left = Math.max(2, width - labelLen - 3);
16
+ return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["─".repeat(left), " "] }), _jsx(Text, { color: "cyan", children: "\u23FA" }), _jsxs(Text, { bold: true, children: [" ", name] }), _jsx(Text, { dimColor: true, children: " \u2500" })] }));
17
+ }
18
+ // Bottom border carries token usage + concurrency at the right corner (modes moved to ModeBar below).
19
+ function BottomBorder({ s, width }) {
20
+ const usage = `↑${tok(s.input)} ↓${tok(s.output)}${s.ctxPct > 0 ? ` · ctx ${s.ctxPct}%` : ""}`;
21
+ const label = s.agents > 0 ? `${usage} · ⛁${s.agents}` : `${usage} · ⛁ idle`;
22
+ const left = Math.max(2, width - label.length - 3);
23
+ return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${"─".repeat(left)} ${label} ─` }) }));
24
+ }
25
+ const MODE_DESC = {
26
+ suggest: "confirms edits & commands",
27
+ "auto-edit": "auto-applies edits · asks before commands",
28
+ "full-auto": "runs everything — no prompts ⚠",
29
+ plan: "investigate read-only, then propose a plan to approve",
30
+ };
31
+ // Prominent approval-mode selector below the box: all three listed, the active one highlighted (red
32
+ // for the dangerous full-auto) with a one-line description and the shift+tab hint.
33
+ function ModeBar({ approval }) {
34
+ const warn = approval === "full-auto";
35
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: MODES.map((m, i) => (_jsxs(Text, { children: [i > 0 ? " " : " ", m === approval ? _jsx(Text, { color: warn ? "red" : m === "plan" ? "cyan" : "green", bold: true, children: `◆ ${m}` }) : _jsx(Text, { dimColor: true, children: m })] }, m))) }), _jsx(Text, { dimColor: true, children: ` ${MODE_DESC[approval]} · shift+tab ⇄` })] }));
36
+ }
37
+ /** The active `@mention` token immediately left of the cursor (for the file popup), or null. */
38
+ function activeMention(value, cursor) {
39
+ const m = /(?:^|\s)@([^\s@]*)$/.exec(value.slice(0, cursor));
40
+ return m ? { query: m[1], start: cursor - m[1].length } : null;
41
+ }
42
+ // Dropdown of fuzzy @path matches, shown above the input as you type `@…` (codex / Claude-Code style).
43
+ function MentionPopup({ items, selected, query }) {
44
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: ` @${query} · ${items.length} match${items.length === 1 ? "" : "es"} — ↑↓ select · Tab/Enter insert · Esc dismiss` }), items.map((it, i) => (_jsxs(Text, { children: [i === selected ? _jsx(Text, { color: "cyan", children: " ▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: it.endsWith("/") ? "blue" : undefined, dimColor: i !== selected, bold: i === selected, children: it })] }, it)))] }));
45
+ }
46
+ const TOKEN_RE = /\[Image #\d+\]/g;
47
+ /** Render the prompt line: plain text + the cursor, with any `[Image #N]` attachment tokens highlighted
48
+ * (codex / Claude-Code style — the image lives inline in the text, visibly distinct from what you typed). */
49
+ function InputLine({ value, cursor }) {
50
+ const parts = [];
51
+ let last = 0;
52
+ let m;
53
+ TOKEN_RE.lastIndex = 0;
54
+ while ((m = TOKEN_RE.exec(value))) {
55
+ if (m.index > last)
56
+ parts.push({ text: value.slice(last, m.index), token: false });
57
+ parts.push({ text: m[0], token: true });
58
+ last = m.index + m[0].length;
59
+ }
60
+ if (last < value.length)
61
+ parts.push({ text: value.slice(last), token: false });
62
+ const seg = (token, text, k) => token ? (_jsx(Text, { backgroundColor: "magenta", color: "white", children: text }, k)) : (_jsx(Text, { children: text }, k));
63
+ const nodes = [];
64
+ let pos = 0;
65
+ let ki = 0;
66
+ for (const p of parts) {
67
+ const start = pos;
68
+ const end = pos + p.text.length;
69
+ if (cursor >= start && cursor < end) {
70
+ const rel = cursor - start;
71
+ if (rel > 0)
72
+ nodes.push(seg(p.token, p.text.slice(0, rel), `s${ki++}`));
73
+ nodes.push(_jsx(Text, { inverse: true, children: p.text[rel] }, `c${ki++}`));
74
+ if (rel + 1 < p.text.length)
75
+ nodes.push(seg(p.token, p.text.slice(rel + 1), `e${ki++}`));
76
+ }
77
+ else {
78
+ nodes.push(seg(p.token, p.text, `p${ki++}`));
79
+ }
80
+ pos = end;
81
+ }
82
+ if (cursor >= value.length)
83
+ nodes.push(_jsx(Text, { inverse: true, children: " " }, "end"));
84
+ return _jsx(Text, { children: nodes });
85
+ }
86
+ /** 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
+ const { stdout } = useStdout();
89
+ const w = width ?? stdout?.columns ?? 80;
90
+ const [value, setValue] = useState("");
91
+ const [cursor, setCursor] = useState(0);
92
+ const [sel, setSel] = useState(0);
93
+ const [dismissed, setDismissed] = useState(false);
94
+ const [images, setImages] = useState([]);
95
+ const set = (v, c) => {
96
+ setValue(v);
97
+ setCursor(c);
98
+ setSel(0);
99
+ setDismissed(false);
100
+ };
101
+ // Attach an image: drop a highlighted `[Image #N]` token inline at the cursor and track the file
102
+ // (codex / Claude-Code style). Backspace over the token removes both.
103
+ const addImage = (img) => {
104
+ const tok = `[Image #${images.length + 1}]`;
105
+ const before = value.slice(0, cursor);
106
+ const ins = (before && !/\s$/.test(before) ? " " : "") + tok + " ";
107
+ setValue(before + ins + value.slice(cursor));
108
+ setCursor((before + ins).length);
109
+ setImages((xs) => [...xs, img]);
110
+ setSel(0);
111
+ setDismissed(false);
112
+ };
113
+ const submit = (text) => {
114
+ if (!text.trim() && images.length === 0)
115
+ return; // nothing to send
116
+ onSubmit?.(text, images.length ? images : undefined);
117
+ set("", 0);
118
+ setImages([]);
119
+ };
120
+ const mention = activeMention(value, cursor);
121
+ const candidates = useMemo(() => (isActive && mention && !dismissed ? fileCandidates(cwd, mention.query, 8) : []), [cwd, isActive, dismissed, mention?.query, mention?.start]);
122
+ const popupOpen = candidates.length > 0;
123
+ const selIdx = popupOpen ? Math.min(sel, candidates.length - 1) : 0;
124
+ const complete = (cand) => {
125
+ if (!mention)
126
+ return;
127
+ const before = value.slice(0, mention.start); // includes the leading '@'
128
+ const after = value.slice(cursor);
129
+ const insert = cand.endsWith("/") ? cand : cand + " "; // dirs keep drilling; files end the mention
130
+ setValue(before + insert + after);
131
+ setCursor((before + insert).length);
132
+ setSel(0);
133
+ setDismissed(false);
134
+ };
135
+ useInput((input, key) => {
136
+ if (popupOpen && (key.upArrow || key.downArrow)) {
137
+ const n = candidates.length;
138
+ setSel((s) => (key.downArrow ? (s + 1) % n : (s - 1 + n) % n));
139
+ return;
140
+ }
141
+ if (popupOpen && (key.tab || key.return)) {
142
+ complete(candidates[selIdx]);
143
+ return;
144
+ }
145
+ if (key.escape) {
146
+ if (popupOpen)
147
+ setDismissed(true);
148
+ return;
149
+ }
150
+ if (key.return) {
151
+ submit(value);
152
+ return;
153
+ }
154
+ if (key.leftArrow)
155
+ return setCursor((c) => Math.max(0, c - 1));
156
+ if (key.rightArrow)
157
+ return setCursor((c) => Math.min(value.length, c + 1));
158
+ if (key.ctrl && input === "a")
159
+ return setCursor(0);
160
+ if (key.ctrl && input === "e")
161
+ return setCursor(value.length);
162
+ if (key.ctrl && input === "u")
163
+ return set(value.slice(cursor), 0);
164
+ if (key.ctrl && input === "v") {
165
+ // paste a screenshot / image from the OS clipboard
166
+ const img = onClipboardImage?.();
167
+ if (img)
168
+ addImage(img);
169
+ return;
170
+ }
171
+ if (key.backspace || key.delete) {
172
+ if (cursor > 0) {
173
+ const head = value.slice(0, cursor);
174
+ const tm = /\[Image #(\d+)\]\s?$/.exec(head); // backspacing over an attachment token removes it whole
175
+ if (tm) {
176
+ const n = Number(tm[1]);
177
+ const kept = head.slice(0, tm.index) + value.slice(cursor);
178
+ const renumbered = kept.replace(/\[Image #(\d+)\]/g, (m2, d) => (Number(d) > n ? `[Image #${Number(d) - 1}]` : m2));
179
+ setImages((xs) => xs.filter((_, i) => i !== n - 1));
180
+ setValue(renumbered);
181
+ setCursor(tm.index);
182
+ setSel(0);
183
+ setDismissed(false);
184
+ return;
185
+ }
186
+ set(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
187
+ }
188
+ return;
189
+ }
190
+ if (input && !key.ctrl && !key.meta) {
191
+ const nl = input.search(/[\r\n]/); // a chunk carrying a newline (paste / fed input) submits
192
+ if (nl >= 0) {
193
+ submit(value.slice(0, cursor) + input.slice(0, nl) + value.slice(cursor));
194
+ return;
195
+ }
196
+ // a dragged-in / pasted image file path attaches instead of inserting literal text
197
+ if (input.length > 3) {
198
+ const img = imagePathFromPaste(input, cwd);
199
+ if (img) {
200
+ addImage(img);
201
+ return;
202
+ }
203
+ }
204
+ set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
205
+ }
206
+ }, { 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 })] }));
208
+ }
@@ -0,0 +1,10 @@
1
+ // Mounts the hara TUI (ink) and resolves when the user exits. Thin shell — all agent wiring
2
+ // (provider, session history, slash commands, turn execution) is passed in via AppProps.onSubmit
3
+ // from index.ts, which owns that state.
4
+ import { render } from "ink";
5
+ import { createElement } from "react";
6
+ import { App } from "./App.js";
7
+ export async function runTui(props) {
8
+ const instance = render(createElement(App, props));
9
+ await instance.waitUntilExit();
10
+ }
@@ -0,0 +1,11 @@
1
+ let current = "dark";
2
+ export function setTheme(name) {
3
+ current = name === "light" ? "light" : "dark";
4
+ }
5
+ export function themeName() {
6
+ return current;
7
+ }
8
+ /** Brand accent (warm vermilion · 朱印). */
9
+ export function accent() {
10
+ return current === "light" ? "#C0392B" : "#FF6B5C";
11
+ }
package/dist/ui.js ADDED
@@ -0,0 +1,17 @@
1
+ import { stdout } from "node:process";
2
+ const useColor = stdout.isTTY && process.env.NO_COLOR === undefined;
3
+ const wrap = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
4
+ export const c = {
5
+ dim: wrap("2"),
6
+ bold: wrap("1"),
7
+ cyan: wrap("36"),
8
+ green: wrap("32"),
9
+ yellow: wrap("33"),
10
+ red: wrap("31"),
11
+ };
12
+ export function out(s) {
13
+ stdout.write(s);
14
+ }
15
+ export function statusLine(model, inTok, outTok) {
16
+ return c.dim(` ${model} · ↑${inTok} ↓${outTok} tok`);
17
+ }
package/dist/undo.js ADDED
@@ -0,0 +1,40 @@
1
+ // In-session undo stack for file changes. Each edit tool records the prior state of the files it
2
+ // touched; `/undo` pops the last group and restores it. Process-scoped (one REPL session).
3
+ import { writeFile, unlink, mkdir } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ const stack = [];
6
+ const MAX = 50;
7
+ /** Record a group of file changes (one tool call = one undo step). */
8
+ export function recordEdit(group) {
9
+ if (!group.length)
10
+ return;
11
+ stack.push(group);
12
+ if (stack.length > MAX)
13
+ stack.shift();
14
+ }
15
+ export function undoDepth() {
16
+ return stack.length;
17
+ }
18
+ /** Restore the most recent edit group. Returns the files reverted, or an error. */
19
+ export async function undoLast() {
20
+ const group = stack.pop();
21
+ if (!group)
22
+ return { error: "nothing to undo" };
23
+ const files = [];
24
+ for (const s of group) {
25
+ try {
26
+ if (s.before === null) {
27
+ await unlink(s.absPath).catch(() => { }); // was newly created → remove
28
+ }
29
+ else {
30
+ await mkdir(dirname(s.absPath), { recursive: true });
31
+ await writeFile(s.absPath, s.before, "utf8");
32
+ }
33
+ files.push(s.path);
34
+ }
35
+ catch {
36
+ /* skip a file we can't restore */
37
+ }
38
+ }
39
+ return { files };
40
+ }
package/dist/vision.js ADDED
@@ -0,0 +1,130 @@
1
+ // Built-in capability map for the major model families. First matching rule wins, so each family's
2
+ // vision pattern is listed BEFORE its text catch-all. Anything that matches nothing → "unknown"
3
+ // (we ask the user once and remember). Easy to extend — add a rule near the right family.
4
+ const MODEL_VISION_MAP = [
5
+ // OpenAI
6
+ { rx: /gpt-4o|gpt-4\.1|gpt-4-turbo|chatgpt-4o|gpt-5|(?:^|[-_/])o[134](?:[-_/]|$)/i, cap: "vision" },
7
+ { rx: /gpt-4(\b|-0|-1)|gpt-3\.5|davinci|babbage|text-(?:embedding|davinci)/i, cap: "text" },
8
+ // Qwen — Alibaba Coding Plan: qwen3.x-plus see images (verified qwen3.7-plus); max/coder are text-only.
9
+ { rx: /qwen.*vl|qwen.*omni|qvq/i, cap: "vision" },
10
+ { rx: /qwen-?3[.\d]*-plus/i, cap: "vision" }, // qwen3.5-plus / qwen3.6-plus / qwen3.7-plus
11
+ { rx: /qwen.*(?:coder|plus|max|turbo|long|math)|qwq|qwen[\d.]*-?\d+b\b|qwen-?\d/i, cap: "text" },
12
+ // GLM / Zhipu — 4v/4.5v see images; glm-5, glm-4.7, glm-4-flash are text-only.
13
+ { rx: /glm-?\d(?:\.\d+)?v|cogvlm|glm.*vision/i, cap: "vision" },
14
+ { rx: /glm-?\d(?:\.\d+)?(?:-(?:air|flash|plus|long|x|0520))?\b|glm-z|chatglm/i, cap: "text" },
15
+ // DeepSeek (VL first, then the text families)
16
+ { rx: /deepseek.*vl/i, cap: "vision" },
17
+ { rx: /deepseek/i, cap: "text" },
18
+ // Google
19
+ { rx: /gemini|gemma-3/i, cap: "vision" },
20
+ { rx: /gemma/i, cap: "text" },
21
+ // Mistral (Pixtral/small-3 see; the rest text)
22
+ { rx: /pixtral|mistral-small-3|mistral.*vision/i, cap: "vision" },
23
+ { rx: /mistral|mixtral|codestral|ministral/i, cap: "text" },
24
+ // Meta Llama (3.2-11B/90B + 4 see; the rest text)
25
+ { rx: /llama-?3\.2-(?:11|90)b|llama.*vision|llama-?4/i, cap: "vision" },
26
+ { rx: /llama|codellama/i, cap: "text" },
27
+ // Moonshot / Kimi — kimi-k2.5 sees images (Coding Plan); older Kimi text.
28
+ { rx: /kimi-?k?2\.5|kimi.*vl|moonshot.*(?:vl|vision)/i, cap: "vision" },
29
+ { rx: /kimi|moonshot/i, cap: "text" },
30
+ // xAI Grok
31
+ { rx: /grok.*vision|grok-[\d.]*v\b|grok-4/i, cap: "vision" },
32
+ { rx: /grok/i, cap: "text" },
33
+ // MiniMax — VL models see images; the M-series chat (e.g. MiniMax-M2.5) is text-only.
34
+ { rx: /minimax.*(?:vl|vision)|abab.*vl/i, cap: "vision" },
35
+ { rx: /minimax|abab/i, cap: "text" },
36
+ // Other well-known vision families
37
+ { rx: /(?:^|[-_/])vl(?:[-_/]|$)|internvl|llava|minicpm-?v|yi-vl|step-1[vo]|doubao.*(?:vl|vision)|ernie.*vl/i, cap: "vision" },
38
+ ];
39
+ /**
40
+ * Resolve a model's vision capability: explicit per-model override → Anthropic (all modern Claude see
41
+ * images) → built-in family map → "unknown" (caller asks the user). Pure + table-driven so it's testable.
42
+ */
43
+ export function classifyVision(provider, model, overrides = {}) {
44
+ const o = overrides[model];
45
+ if (o === "yes")
46
+ return "vision";
47
+ if (o === "no")
48
+ return "text";
49
+ if (provider === "anthropic")
50
+ return "vision";
51
+ const m = model || "";
52
+ for (const r of MODEL_VISION_MAP)
53
+ if (r.rx.test(m))
54
+ return r.cap;
55
+ return "unknown";
56
+ }
57
+ export const DESCRIBE_SYSTEM = [
58
+ "You are the eyes of a coding assistant that cannot see images. Transcribe and describe the attached",
59
+ "image(s) completely and precisely so the assistant can act on them without seeing them.",
60
+ "Rules:",
61
+ "1. Transcribe ALL visible text and code VERBATIM, preserving line breaks and indentation — put code,",
62
+ " terminal output, and logs in fenced code blocks.",
63
+ "2. For UI / screenshots: describe the layout, components, labels, states, and notable colors.",
64
+ "3. For diagrams / charts: describe the structure — nodes, edges, axes, and data.",
65
+ "4. Quote any error or warning messages exactly.",
66
+ "5. Be thorough and factual; do not speculate beyond what is visible.",
67
+ ].join("\n");
68
+ // Screenshot variant — tuned for driving the desktop (RPA) rather than transcription. A text-only main
69
+ // model can't see, so it needs *actionable* output: where things are, so it can issue clicks.
70
+ export const SCREENSHOT_SYSTEM = [
71
+ "You are the eyes of an assistant operating this computer; it cannot see the screen and acts only on your",
72
+ "words. Describe the screenshot so it can ACT. Prioritise, in order:",
73
+ "1. INTERACTIVE elements — buttons, links, text fields, checkboxes, menus, tabs, icons — each with its",
74
+ " visible label and an approximate location: a region (e.g. top-right) AND a rough pixel x,y if you can.",
75
+ "2. The currently focused/active element or selection, and any open dialog/modal/popup.",
76
+ "3. Errors, warnings, and key visible text/headings — quote them exactly.",
77
+ "4. One line on what app/screen this appears to be.",
78
+ "Positions guide clicks, so always estimate them. Be concise and factual; never invent elements.",
79
+ ].join("\n");
80
+ // Grounding — ask a vision model WHERE a UI element is (for accurate RPA clicking), as resolution-independent
81
+ // fractions so it works regardless of Retina/DPI scaling.
82
+ export const LOCATE_SYSTEM = [
83
+ "You are given a screenshot. The user names ONE UI element (button, field, icon, menu item, link).",
84
+ "Return ONLY its CENTER as JSON: {\"x\": <0-1000>, \"y\": <0-1000>}, where x is the position as per-mille of",
85
+ "the image WIDTH (0=left, 1000=right) and y as per-mille of the HEIGHT (0=top, 1000=bottom).",
86
+ "If the element is not visible, return {\"x\": -1, \"y\": -1}. Output ONLY the JSON, nothing else.",
87
+ ].join("\n");
88
+ /** Parse a grounding reply → {x,y} as 0..1 fractions (accepts per-mille / percent / fraction), or null. */
89
+ export function parseLocate(text) {
90
+ const m = text.match(/"x"\s*:\s*(-?\d+(?:\.\d+)?)[\s,}]+.*?"y"\s*:\s*(-?\d+(?:\.\d+)?)/s) || text.match(/(-?\d+(?:\.\d+)?)\s*[,\s]\s*(-?\d+(?:\.\d+)?)/);
91
+ if (!m)
92
+ return null;
93
+ let x = Number(m[1]);
94
+ let y = Number(m[2]);
95
+ if (x < 0 || y < 0 || Number.isNaN(x) || Number.isNaN(y))
96
+ return null; // not found / unparseable
97
+ const norm = (v) => (v > 100 ? v / 1000 : v > 1.5 ? v / 100 : v); // per-mille | percent | fraction → 0..1
98
+ x = Math.min(1, Math.max(0, norm(x)));
99
+ y = Math.min(1, Math.max(0, norm(y)));
100
+ return { x, y };
101
+ }
102
+ /** Send a screenshot to a (grounding-capable) vision model and get the target's center as 0..1 fractions. */
103
+ export async function locateImage(provider, image, target, opts = {}) {
104
+ const r = await provider.turn({
105
+ system: LOCATE_SYSTEM,
106
+ history: [{ role: "user", content: `Locate this element: ${target}`, images: [image] }],
107
+ tools: [],
108
+ onText: () => { },
109
+ signal: opts.signal,
110
+ });
111
+ if (r.stop === "error")
112
+ return null;
113
+ return parseLocate(r.text);
114
+ }
115
+ const PROMPT = "Describe the attached image(s) per your instructions.";
116
+ /** Send images to the vision provider and return its textual description. Throws on a provider error.
117
+ * `system` overrides the default prompt (e.g. SCREENSHOT_SYSTEM); `hint` focuses it on a specific goal. */
118
+ export async function describeImages(provider, images, opts = {}) {
119
+ const content = opts.hint ? `${PROMPT}\nFocus especially on: ${opts.hint}` : PROMPT;
120
+ const r = await provider.turn({
121
+ system: opts.system ?? DESCRIBE_SYSTEM,
122
+ history: [{ role: "user", content, images }],
123
+ tools: [],
124
+ onText: () => { },
125
+ signal: opts.signal,
126
+ });
127
+ if (r.stop === "error")
128
+ throw new Error(r.errorMsg || "vision provider error");
129
+ return r.text.trim();
130
+ }