@nanhara/hara 0.0.1 → 0.33.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 (55) hide show
  1. package/CHANGELOG.md +431 -0
  2. package/CLA.md +51 -0
  3. package/LICENSE +201 -21
  4. package/README.md +203 -7
  5. package/dist/activity.js +30 -0
  6. package/dist/agent/loop.js +184 -0
  7. package/dist/config.js +114 -0
  8. package/dist/context/agents-md.js +64 -0
  9. package/dist/context/mentions.js +90 -0
  10. package/dist/diff.js +103 -0
  11. package/dist/fs-walk.js +103 -0
  12. package/dist/fuzzy.js +62 -0
  13. package/dist/images.js +146 -0
  14. package/dist/index.js +1362 -0
  15. package/dist/mcp/client.js +54 -0
  16. package/dist/md.js +52 -0
  17. package/dist/memory/guard.js +51 -0
  18. package/dist/memory/store.js +93 -0
  19. package/dist/org/planner.js +155 -0
  20. package/dist/org/roles.js +140 -0
  21. package/dist/org/router.js +39 -0
  22. package/dist/plugins/plugins.js +124 -0
  23. package/dist/providers/anthropic.js +83 -0
  24. package/dist/providers/openai.js +125 -0
  25. package/dist/providers/qwen-oauth.js +139 -0
  26. package/dist/providers/types.js +2 -0
  27. package/dist/recall.js +76 -0
  28. package/dist/sandbox.js +78 -0
  29. package/dist/search/embed.js +42 -0
  30. package/dist/search/hybrid.js +38 -0
  31. package/dist/search/semindex.js +141 -0
  32. package/dist/session/store.js +95 -0
  33. package/dist/skills/skills.js +141 -0
  34. package/dist/statusbar.js +69 -0
  35. package/dist/tools/agent.js +26 -0
  36. package/dist/tools/apply-core.js +63 -0
  37. package/dist/tools/builtin.js +106 -0
  38. package/dist/tools/codebase.js +102 -0
  39. package/dist/tools/computer.js +236 -0
  40. package/dist/tools/edit.js +62 -0
  41. package/dist/tools/memory.js +147 -0
  42. package/dist/tools/patch.js +123 -0
  43. package/dist/tools/registry.js +18 -0
  44. package/dist/tools/search.js +176 -0
  45. package/dist/tools/skill.js +30 -0
  46. package/dist/tools/web.js +73 -0
  47. package/dist/tui/App.js +165 -0
  48. package/dist/tui/InputBox.js +208 -0
  49. package/dist/tui/run.js +10 -0
  50. package/dist/tui/theme.js +11 -0
  51. package/dist/ui.js +17 -0
  52. package/dist/undo.js +40 -0
  53. package/dist/vision.js +81 -0
  54. package/package.json +33 -7
  55. package/bin/hara.mjs +0 -25
@@ -0,0 +1,123 @@
1
+ // apply_patch — change MULTIPLE files atomically (all-or-nothing). Everything is validated and
2
+ // computed in memory first; nothing is written unless every change applies cleanly.
3
+ import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
4
+ import { isAbsolute, resolve, dirname } from "node:path";
5
+ import { registerTool } from "./registry.js";
6
+ import { applyEdits } from "./apply-core.js";
7
+ import { emitDiff } from "../diff.js";
8
+ import { recordEdit } from "../undo.js";
9
+ registerTool({
10
+ name: "apply_patch",
11
+ description: "Change SEVERAL files in one atomic step (all-or-nothing). `changes` is an array of " +
12
+ "{path, type:'update'|'create'|'delete', edits?:[{old_string,new_string,replace_all?}], content?}. " +
13
+ "update applies edits (or replaces the whole file with content); create writes a new file; delete removes it. " +
14
+ "If ANY change fails to apply, nothing is written. Prefer this over multiple edit_file calls for multi-file changes.",
15
+ input_schema: {
16
+ type: "object",
17
+ properties: {
18
+ changes: {
19
+ type: "array",
20
+ description: "the file changes to apply together",
21
+ items: {
22
+ type: "object",
23
+ properties: {
24
+ path: { type: "string" },
25
+ type: { type: "string", enum: ["update", "create", "delete"] },
26
+ content: { type: "string", description: "full file content (for create, or whole-file update)" },
27
+ edits: {
28
+ type: "array",
29
+ items: {
30
+ type: "object",
31
+ properties: {
32
+ old_string: { type: "string" },
33
+ new_string: { type: "string" },
34
+ replace_all: { type: "boolean" },
35
+ },
36
+ required: ["old_string", "new_string"],
37
+ },
38
+ },
39
+ },
40
+ required: ["path"],
41
+ },
42
+ },
43
+ },
44
+ required: ["changes"],
45
+ },
46
+ kind: "edit",
47
+ async run(input, ctx) {
48
+ const changes = Array.isArray(input.changes) ? input.changes : [];
49
+ if (!changes.length)
50
+ return "Error: apply_patch needs a non-empty `changes` array.";
51
+ const abs = (pth) => (isAbsolute(pth) ? pth : resolve(ctx.cwd, pth));
52
+ // PHASE 1 — validate + compute every change in memory; bail before writing anything.
53
+ const plans = [];
54
+ for (let i = 0; i < changes.length; i++) {
55
+ const ch = changes[i];
56
+ const tag = `change ${i + 1}/${changes.length}`;
57
+ if (typeof ch.path !== "string" || !ch.path)
58
+ return `Error: ${tag} is missing a path. Nothing written.`;
59
+ const p = abs(ch.path);
60
+ const type = ch.type ?? (ch.edits ? "update" : "create");
61
+ if (type === "delete") {
62
+ let before;
63
+ try {
64
+ before = await readFile(p, "utf8");
65
+ }
66
+ catch {
67
+ return `Error: ${tag} delete ${ch.path}: file not found. Nothing written.`;
68
+ }
69
+ plans.push({ path: ch.path, abs: p, type, before, after: null, existed: true });
70
+ }
71
+ else if (type === "create") {
72
+ if (typeof ch.content !== "string")
73
+ return `Error: ${tag} create ${ch.path} needs \`content\`. Nothing written.`;
74
+ let before = "";
75
+ let existed = false;
76
+ try {
77
+ before = await readFile(p, "utf8");
78
+ existed = true;
79
+ }
80
+ catch {
81
+ /* new file */
82
+ }
83
+ plans.push({ path: ch.path, abs: p, type, before, after: ch.content, existed });
84
+ }
85
+ else {
86
+ // update
87
+ let before;
88
+ try {
89
+ before = await readFile(p, "utf8");
90
+ }
91
+ catch {
92
+ return `Error: ${tag} update ${ch.path}: cannot read (use type:create for a new file). Nothing written.`;
93
+ }
94
+ if (typeof ch.content === "string" && !ch.edits) {
95
+ plans.push({ path: ch.path, abs: p, type, before, after: ch.content, existed: true });
96
+ }
97
+ else {
98
+ const res = applyEdits(before, ch.edits ?? []);
99
+ if ("error" in res)
100
+ return `Error: ${tag} ${ch.path} — ${res.error}. Nothing written.`;
101
+ plans.push({ path: ch.path, abs: p, type, before, after: res.text, existed: true });
102
+ }
103
+ }
104
+ }
105
+ // PHASE 2 — commit all changes + show each diff.
106
+ const summary = [];
107
+ for (const pl of plans) {
108
+ if (pl.type === "delete") {
109
+ await unlink(pl.abs);
110
+ emitDiff(pl.path, pl.before, "", ctx.ui);
111
+ summary.push(`deleted ${pl.path}`);
112
+ }
113
+ else {
114
+ await mkdir(dirname(pl.abs), { recursive: true });
115
+ await writeFile(pl.abs, pl.after, "utf8");
116
+ emitDiff(pl.path, pl.before, pl.after, ctx.ui);
117
+ summary.push(`${pl.type === "create" ? "created" : "updated"} ${pl.path}`);
118
+ }
119
+ }
120
+ recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
121
+ return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
122
+ },
123
+ });
@@ -0,0 +1,18 @@
1
+ const registry = new Map();
2
+ export function registerTool(t) {
3
+ registry.set(t.name, t);
4
+ }
5
+ export function getTool(name) {
6
+ return registry.get(name);
7
+ }
8
+ export function getTools() {
9
+ return [...registry.values()];
10
+ }
11
+ /** Provider-neutral tool specs derived from the registry. */
12
+ export function toolSpecs() {
13
+ return getTools().map((t) => ({
14
+ name: t.name,
15
+ description: t.description,
16
+ input_schema: t.input_schema,
17
+ }));
18
+ }
@@ -0,0 +1,176 @@
1
+ // Search/listing tools — grep (regex across files), glob (path patterns), ls (one directory).
2
+ // All read-only (kind: "read"), so they never hit the approval gate and run in parallel.
3
+ import { readFileSync, readdirSync, statSync } from "node:fs";
4
+ import { isAbsolute, resolve, join, relative, sep } from "node:path";
5
+ import { registerTool } from "./registry.js";
6
+ import { walkFiles, isProbablyBinary } from "../fs-walk.js";
7
+ const MAX_OUT = 60_000;
8
+ const MAX_MATCHES = 300;
9
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
10
+ const toPosix = (p) => (sep === "/" ? p : p.split(sep).join("/"));
11
+ const absOf = (p, cwd) => (p ? (isAbsolute(p) ? p : resolve(cwd, p)) : cwd);
12
+ /** Convert a glob (supports **, *, ?) to an anchored RegExp over POSIX paths. */
13
+ function globToRegExp(glob) {
14
+ let re = "";
15
+ for (let i = 0; i < glob.length; i++) {
16
+ const ch = glob[i];
17
+ if (ch === "*") {
18
+ if (glob[i + 1] === "*") {
19
+ // ** matches across path separators (optionally swallow a trailing slash)
20
+ i++;
21
+ if (glob[i + 1] === "/")
22
+ i++;
23
+ re += "(?:.*/)?";
24
+ }
25
+ else {
26
+ re += "[^/]*";
27
+ }
28
+ }
29
+ else if (ch === "?")
30
+ re += "[^/]";
31
+ else if (".+^${}()|[]\\".includes(ch))
32
+ re += "\\" + ch;
33
+ else
34
+ re += ch;
35
+ }
36
+ return new RegExp("^" + re + "$");
37
+ }
38
+ registerTool({
39
+ name: "grep",
40
+ description: "Search file contents by regular expression. Returns matching `path:line: text`. " +
41
+ "Scopes to `path` (dir or file, default cwd); optional `glob` filters which files; `ignore_case`.",
42
+ input_schema: {
43
+ type: "object",
44
+ properties: {
45
+ pattern: { type: "string", description: "JavaScript regular expression" },
46
+ path: { type: "string", description: "directory or file to search (default: cwd)" },
47
+ glob: { type: "string", description: "only search files whose path matches this glob (e.g. **/*.ts)" },
48
+ ignore_case: { type: "boolean" },
49
+ },
50
+ required: ["pattern"],
51
+ },
52
+ kind: "read",
53
+ async run(input, ctx) {
54
+ let re;
55
+ try {
56
+ re = new RegExp(input.pattern, input.ignore_case ? "i" : "");
57
+ }
58
+ catch (e) {
59
+ return `Error: invalid regex: ${e.message}`;
60
+ }
61
+ const root = absOf(input.path, ctx.cwd);
62
+ let isFile = false;
63
+ try {
64
+ isFile = statSync(root).isFile();
65
+ }
66
+ catch {
67
+ return `Error: no such path: ${input.path ?? "."}`;
68
+ }
69
+ const rel = (abs) => toPosix(relative(ctx.cwd, abs)) || toPosix(relative(root, abs));
70
+ const files = isFile ? [root] : walkFiles(root).map((f) => join(root, f));
71
+ const globRe = input.glob ? globToRegExp(input.glob) : null;
72
+ const lines = [];
73
+ let matches = 0;
74
+ let scanned = 0;
75
+ for (const abs of files) {
76
+ if (matches >= MAX_MATCHES)
77
+ break;
78
+ const r = toPosix(relative(ctx.cwd, abs));
79
+ if (globRe && !globRe.test(toPosix(relative(isFile ? ctx.cwd : root, abs))))
80
+ continue;
81
+ let buf;
82
+ try {
83
+ if (statSync(abs).size > MAX_FILE_BYTES)
84
+ continue;
85
+ buf = readFileSync(abs);
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ if (isProbablyBinary(buf))
91
+ continue;
92
+ scanned++;
93
+ const text = buf.toString("utf8");
94
+ const fileLines = text.split("\n");
95
+ for (let i = 0; i < fileLines.length; i++) {
96
+ if (re.test(fileLines[i])) {
97
+ lines.push(`${r}:${i + 1}: ${fileLines[i].trim().slice(0, 300)}`);
98
+ if (++matches >= MAX_MATCHES)
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ if (!lines.length)
104
+ return `No matches for /${input.pattern}/ (scanned ${scanned} files).`;
105
+ let body = lines.join("\n");
106
+ if (body.length > MAX_OUT)
107
+ body = body.slice(0, MAX_OUT) + "\n…[truncated]";
108
+ const head = matches >= MAX_MATCHES ? `(showing first ${MAX_MATCHES} matches)\n` : "";
109
+ return head + body;
110
+ },
111
+ });
112
+ registerTool({
113
+ name: "glob",
114
+ description: "List files whose path matches a glob pattern (supports **, *, ?). Scopes to `path` (default cwd).",
115
+ input_schema: {
116
+ type: "object",
117
+ properties: {
118
+ pattern: { type: "string", description: "e.g. **/*.ts, src/**/index.*" },
119
+ path: { type: "string", description: "base directory (default: cwd)" },
120
+ },
121
+ required: ["pattern"],
122
+ },
123
+ kind: "read",
124
+ async run(input, ctx) {
125
+ const root = absOf(input.path, ctx.cwd);
126
+ const re = globToRegExp(input.pattern);
127
+ const hits = walkFiles(root).filter((f) => re.test(f));
128
+ if (!hits.length)
129
+ return `No files match ${input.pattern}.`;
130
+ const shown = hits.slice(0, 400);
131
+ const head = hits.length > shown.length ? `(${hits.length} matches, showing 400)\n` : "";
132
+ return head + shown.join("\n");
133
+ },
134
+ });
135
+ registerTool({
136
+ name: "ls",
137
+ description: "List the entries of one directory (name, type, size). Non-recursive; use glob/grep to search deeper.",
138
+ input_schema: {
139
+ type: "object",
140
+ properties: { path: { type: "string", description: "directory (default: cwd)" } },
141
+ },
142
+ kind: "read",
143
+ async run(input, ctx) {
144
+ const dir = absOf(input.path, ctx.cwd);
145
+ let entries;
146
+ try {
147
+ entries = readdirSync(dir, { withFileTypes: true });
148
+ }
149
+ catch (e) {
150
+ return `Error: cannot list ${input.path ?? "."}: ${e.message}`;
151
+ }
152
+ const rows = entries
153
+ .filter((e) => !(e.isDirectory() && e.name === ".git"))
154
+ .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
155
+ .map((e) => {
156
+ if (e.isDirectory())
157
+ return ` ${e.name}/`;
158
+ let size = 0;
159
+ try {
160
+ size = statSync(join(dir, e.name)).size;
161
+ }
162
+ catch {
163
+ /* ignore */
164
+ }
165
+ return ` ${e.name} ${c_size(size)}`;
166
+ });
167
+ return rows.length ? rows.join("\n") : "(empty directory)";
168
+ },
169
+ });
170
+ function c_size(n) {
171
+ if (n < 1024)
172
+ return `${n}B`;
173
+ if (n < 1024 * 1024)
174
+ return `${(n / 1024).toFixed(1)}K`;
175
+ return `${(n / 1024 / 1024).toFixed(1)}M`;
176
+ }
@@ -0,0 +1,30 @@
1
+ // The `skill` tool — load a skill's full instructions on demand. The system prompt lists available
2
+ // skills (id + description); the model calls this to pull the body before doing a task the skill covers.
3
+ // Returning the body as a tool RESULT (not editing the system prompt) keeps the cached prefix stable.
4
+ import { registerTool } from "./registry.js";
5
+ import { loadSkillIndex, loadSkillBody } from "../skills/skills.js";
6
+ import { scanMemory } from "../memory/guard.js";
7
+ registerTool({
8
+ name: "skill",
9
+ description: "Load the full instructions for a skill by id. The system prompt's Skills list shows what's available; " +
10
+ "call this to get a skill's steps before performing a task it covers, then follow them.",
11
+ input_schema: { type: "object", properties: { id: { type: "string", description: "the skill id from the Skills list" } }, required: ["id"] },
12
+ kind: "read",
13
+ async run(input, ctx) {
14
+ const id = String(input.id ?? "").trim();
15
+ const sk = loadSkillIndex(ctx.cwd).find((s) => s.id === id);
16
+ if (!sk)
17
+ return `No skill '${id}'. See the Skills list in the system prompt for available ids.`;
18
+ const body = loadSkillBody(sk);
19
+ if (!body)
20
+ return `Skill '${id}' has no instructions.`;
21
+ const scan = scanMemory(body); // skills may come from plugins (untrusted) — guard at load time
22
+ if (!scan.ok)
23
+ return `Skill '${id}' blocked: its content looks unsafe (${scan.hits.join(", ")}).`;
24
+ if (sk.context === "fork" && ctx.spawn) {
25
+ // fork: run the skill as a delegated sub-agent rather than inlining it into this turn
26
+ return await ctx.spawn(`Follow this skill to complete the current task:\n\n${body}`);
27
+ }
28
+ return body; // inline (default): the body enters the conversation as this tool's result
29
+ },
30
+ });
@@ -0,0 +1,73 @@
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).
3
+ import { registerTool } from "./registry.js";
4
+ const MAX = 60_000;
5
+ /** Strip HTML to a readable-ish plain-text approximation (no dependency). */
6
+ export function htmlToText(html) {
7
+ return html
8
+ .replace(/<head[\s\S]*?<\/head>/gi, " ")
9
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
10
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
11
+ .replace(/<!--[\s\S]*?-->/g, " ")
12
+ .replace(/<li[^>]*>/gi, "\n- ")
13
+ .replace(/<\/(p|div|h[1-6]|li|tr|section|article|header|footer|ul|ol|blockquote)>/gi, "\n")
14
+ .replace(/<br\s*\/?>/gi, "\n")
15
+ .replace(/<[^>]+>/g, " ")
16
+ .replace(/&nbsp;/g, " ")
17
+ .replace(/&amp;/g, "&")
18
+ .replace(/&lt;/g, "<")
19
+ .replace(/&gt;/g, ">")
20
+ .replace(/&quot;/g, '"')
21
+ .replace(/&#39;/g, "'")
22
+ .replace(/[ \t]+/g, " ")
23
+ .replace(/ *\n */g, "\n")
24
+ .replace(/\n{3,}/g, "\n\n")
25
+ .trim();
26
+ }
27
+ registerTool({
28
+ name: "web_fetch",
29
+ description: "Fetch an http(s) URL and return its text content (HTML is reduced to readable text). Read-only. " +
30
+ "Use for docs, references, or pages the user mentions. Not sandboxed.",
31
+ input_schema: {
32
+ type: "object",
33
+ properties: {
34
+ url: { type: "string", description: "http:// or https:// URL" },
35
+ max_chars: { type: "number", description: "cap on returned text (default 60000)" },
36
+ },
37
+ required: ["url"],
38
+ },
39
+ kind: "read",
40
+ async run(input) {
41
+ let url;
42
+ try {
43
+ url = new URL(input.url);
44
+ }
45
+ catch {
46
+ return `Error: invalid URL: ${input.url}`;
47
+ }
48
+ if (url.protocol !== "http:" && url.protocol !== "https:")
49
+ return "Error: only http/https URLs are supported.";
50
+ const cap = Math.min(Math.max(1000, input.max_chars ?? MAX), 200_000);
51
+ const ctrl = new AbortController();
52
+ const timer = setTimeout(() => ctrl.abort(), 30_000);
53
+ try {
54
+ const res = await fetch(url, {
55
+ signal: ctrl.signal,
56
+ redirect: "follow",
57
+ headers: { "user-agent": "hara-cli", accept: "text/html,text/plain,application/json,*/*" },
58
+ });
59
+ const ct = res.headers.get("content-type") ?? "";
60
+ const raw = await res.text();
61
+ let text = /html/i.test(ct) ? htmlToText(raw) : raw;
62
+ if (text.length > cap)
63
+ text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
64
+ return `# ${url.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
65
+ }
66
+ catch (e) {
67
+ return `Error fetching ${url.href}: ${e?.name === "AbortError" ? "timed out (30s)" : (e?.message ?? e)}`;
68
+ }
69
+ finally {
70
+ clearTimeout(timer);
71
+ }
72
+ },
73
+ });
@@ -0,0 +1,165 @@
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
+ let _id = 0;
17
+ const nid = () => ++_id;
18
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
19
+ function Block({ item, open }) {
20
+ switch (item.kind) {
21
+ case "user":
22
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { children: item.text })] }));
23
+ case "assistant":
24
+ return _jsx(Text, { children: item.text });
25
+ case "reasoning": {
26
+ // fixed-height window: show the last 5 lines while thinking; ctrl-r toggles the full text.
27
+ const lines = item.text.replace(/\n+$/, "").split("\n");
28
+ const long = lines.length > 5;
29
+ const shown = open || !long ? lines : lines.slice(-5);
30
+ const hint = long ? (open ? " · ctrl-r collapse" : " · ctrl-r expand") : "";
31
+ 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)))] }));
32
+ }
33
+ case "tool":
34
+ return _jsx(Text, { dimColor: true, children: " " + item.text });
35
+ case "diff":
36
+ return _jsx(Text, { children: item.text });
37
+ case "notice":
38
+ return _jsx(Text, { dimColor: true, children: item.text });
39
+ }
40
+ }
41
+ // ASCII rendering of the nanhara "Λi" mark (small peak + big peak + italic i), in the brand violet.
42
+ // hara wordmark — FIGlet "ANSI Shadow". A recognizable banner reads better in a terminal than a
43
+ // pixel-faithful logo. Printed once at the top of the session; scrolls away with the transcript.
44
+ const BANNER = [
45
+ "██╗ ██╗ █████╗ ██████╗ █████╗",
46
+ "██║ ██║██╔══██╗██╔══██╗██╔══██╗",
47
+ "███████║███████║██████╔╝███████║",
48
+ "██╔══██║██╔══██║██╔══██╗██╔══██║",
49
+ "██║ ██║██║ ██║██║ ██║██║ ██║",
50
+ "╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝",
51
+ ];
52
+ function HeaderCard({ version, model, cwd, tip, vision, session }) {
53
+ 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] }));
54
+ }
55
+ function Working() {
56
+ const [n, setN] = useState(0);
57
+ useEffect(() => {
58
+ const id = setInterval(() => setN((x) => x + 1), 100);
59
+ return () => clearInterval(id);
60
+ }, []);
61
+ const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
62
+ 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` })] }));
63
+ }
64
+ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage }) {
65
+ const { exit } = useApp();
66
+ const [history, setHistory] = useState([]);
67
+ const [current, setCurrent] = useState([]);
68
+ const [working, setWorking] = useState(false);
69
+ const [status, setStatus] = useState({ ...initialStatus, agents: 0 });
70
+ const [prompt, setPrompt] = useState(null);
71
+ const [promptSel, setPromptSel] = useState(0);
72
+ const [reasoningOpen, setReasoningOpen] = useState(false);
73
+ const ctrlRef = useRef(null);
74
+ const currentRef = useRef([]);
75
+ currentRef.current = current;
76
+ const statusRef = useRef(status);
77
+ statusRef.current = status;
78
+ useEffect(() => {
79
+ const fn = () => setStatus((s) => ({ ...s, agents: activity.running }));
80
+ activity.onChange(fn);
81
+ return () => activity.onChange(null);
82
+ }, []);
83
+ const pushCurrent = useCallback((kind, text, merge = false) => {
84
+ setCurrent((cur) => {
85
+ const last = cur[cur.length - 1];
86
+ if (merge && last && last.kind === kind)
87
+ return [...cur.slice(0, -1), { ...last, text: last.text + text }];
88
+ return [...cur, { id: nid(), kind, text }];
89
+ });
90
+ }, []);
91
+ const handleSubmit = useCallback(async (line, images) => {
92
+ const t = line.trim();
93
+ if ((!t && !images?.length) || working || prompt)
94
+ return; // allow image-only turns
95
+ setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]); // t already carries any [Image #N] tokens
96
+ const ctrl = new AbortController();
97
+ ctrlRef.current = ctrl;
98
+ setWorking(true);
99
+ const sink = {
100
+ assistantDelta: (d) => pushCurrent("assistant", d, true),
101
+ reasoningDelta: (d) => pushCurrent("reasoning", d, true),
102
+ tool: (name, preview) => pushCurrent("tool", `↳ ${name}${preview ? " " + preview : ""}`),
103
+ diff: (text) => pushCurrent("diff", text),
104
+ notice: (text) => pushCurrent("notice", text),
105
+ usage: (input, output) => setStatus((s) => ({ ...s, input: s.input + input, output: s.output + output, ctxPct: ctxPctFor(model, input) })),
106
+ session: (name) => setStatus((s) => ({ ...s, sessionName: name })),
107
+ };
108
+ const openPrompt = (title, options) => new Promise((resolve) => {
109
+ setPromptSel(0);
110
+ setPrompt({ title, options: options, resolve: resolve });
111
+ });
112
+ const confirmFn = (q) => openPrompt(q, [
113
+ { label: "Yes", value: true, key: "y" },
114
+ { label: "Yes, and don't ask again this session", value: "always", key: "a" },
115
+ { label: "No (esc)", value: false, key: "n" },
116
+ ]);
117
+ const selectFn = (title, options) => openPrompt(title, options);
118
+ const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
119
+ try {
120
+ await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval }, images);
121
+ }
122
+ catch (e) {
123
+ pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
124
+ }
125
+ const committed = currentRef.current.map((it) => it.kind === "reasoning"
126
+ ? { ...it, kind: "notice", text: `✻ thought · ${it.text.split("\n").filter((l) => l.trim()).length} lines` }
127
+ : it);
128
+ setHistory((h) => [...h, ...committed]);
129
+ setCurrent([]);
130
+ setWorking(false);
131
+ ctrlRef.current = null;
132
+ }, [working, prompt, onSubmit, pushCurrent, model, exit]);
133
+ useInput((input, key) => {
134
+ if (prompt) {
135
+ const opts = prompt.options;
136
+ if (key.upArrow)
137
+ setPromptSel((s) => (s - 1 + opts.length) % opts.length);
138
+ else if (key.downArrow)
139
+ setPromptSel((s) => (s + 1) % opts.length);
140
+ else if (key.return) {
141
+ prompt.resolve(opts[Math.min(promptSel, opts.length - 1)].value);
142
+ setPrompt(null);
143
+ }
144
+ else if (key.escape) {
145
+ prompt.resolve(opts[opts.length - 1].value); // last option = cancel/no
146
+ setPrompt(null);
147
+ }
148
+ else if (input) {
149
+ const hit = opts.find((o) => o.key && o.key === input.toLowerCase());
150
+ if (hit) {
151
+ prompt.resolve(hit.value);
152
+ setPrompt(null);
153
+ }
154
+ }
155
+ return;
156
+ }
157
+ if (key.ctrl && input === "r")
158
+ return setReasoningOpen((x) => !x);
159
+ if (key.escape && working)
160
+ ctrlRef.current?.abort();
161
+ else if (key.tab && key.shift && cycleApproval)
162
+ setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
163
+ });
164
+ 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 ? " ❯ " : " ") + o.label }, i)))] })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !working && !prompt, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
165
+ }