@nanhara/hara 0.89.0 → 0.98.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 +74 -0
- package/README.md +4 -1
- package/dist/agent/loop.js +113 -8
- package/dist/config.js +51 -9
- package/dist/gateway/serve.js +58 -1
- package/dist/gateway/signal.js +220 -0
- package/dist/gateway/tmux-routes.js +135 -0
- package/dist/gateway/wecom.js +383 -0
- package/dist/index.js +1021 -82
- package/dist/org-fleet/enroll.js +28 -5
- package/dist/plugins/plugins.js +49 -1
- package/dist/profile/profile.js +436 -0
- package/dist/providers/anthropic.js +28 -1
- package/dist/providers/openai.js +16 -1
- package/dist/security/guardian.js +261 -0
- package/dist/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- package/dist/tools/ask_user.js +64 -0
- package/dist/tools/external_agent.js +118 -0
- package/dist/tools/skill.js +6 -2
- package/dist/tools/todo.js +65 -8
- package/dist/tui/App.js +280 -33
- package/dist/tui/run.js +36 -1
- package/package.json +1 -1
package/dist/tui/App.js
CHANGED
|
@@ -7,13 +7,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
7
7
|
//
|
|
8
8
|
// The agent machinery is injected via `onSubmit` (a turn runner) so this view is testable with
|
|
9
9
|
// ink-testing-library against a fake runner — no provider/network needed.
|
|
10
|
-
import { Box, Static, Text, useApp, useInput } from "ink";
|
|
10
|
+
import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
|
|
11
11
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
12
12
|
import { InputBox } from "./InputBox.js";
|
|
13
13
|
import { activity } from "../activity.js";
|
|
14
14
|
import { ctxPctFor } from "../statusbar.js";
|
|
15
15
|
import { accent } from "./theme.js";
|
|
16
16
|
import { renderMarkdown } from "../md.js";
|
|
17
|
+
import { currentTodos, onTodosChange } from "../tools/todo.js";
|
|
17
18
|
let _id = 0;
|
|
18
19
|
const nid = () => ++_id;
|
|
19
20
|
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
@@ -24,12 +25,15 @@ function Block({ item, open }) {
|
|
|
24
25
|
case "assistant":
|
|
25
26
|
return _jsx(Text, { children: renderMarkdown(item.text) }); // headers/bold/inline-code/bullets + verbatim fences
|
|
26
27
|
case "reasoning": {
|
|
27
|
-
//
|
|
28
|
+
// Codex-style: stream the reasoning dim + italic with a leading "• " bullet; show the full text up to
|
|
29
|
+
// MAX lines, fold longer to the live tail with a "… +N lines" summary. ctrl-r expands/collapses.
|
|
30
|
+
const MAX = 10;
|
|
28
31
|
const lines = item.text.replace(/\n+$/, "").split("\n");
|
|
29
|
-
const long = lines.length >
|
|
30
|
-
const shown = open || !long ? lines : lines.slice(-
|
|
31
|
-
const
|
|
32
|
-
|
|
32
|
+
const long = lines.length > MAX;
|
|
33
|
+
const shown = open || !long ? lines : lines.slice(-MAX); // short or expanded → all; long & collapsed → live tail
|
|
34
|
+
const omitted = long && !open ? lines.length - MAX : 0;
|
|
35
|
+
const hint = long ? (open ? " · ctrl-r collapse" : ` · … +${omitted} lines · ctrl-r expand`) : "";
|
|
36
|
+
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, italic: true, children: `${i === 0 ? "• " : " "}${l}` }, i)))] }));
|
|
33
37
|
}
|
|
34
38
|
case "tool":
|
|
35
39
|
return _jsx(Text, { dimColor: true, children: " " + item.text });
|
|
@@ -39,30 +43,173 @@ function Block({ item, open }) {
|
|
|
39
43
|
return _jsx(Text, { dimColor: true, children: item.text });
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
function flattenTranscript(items) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const it of items) {
|
|
49
|
+
const body = (it.full ?? it.text).replace(/\n+$/, "");
|
|
50
|
+
if (!body && it.kind !== "user")
|
|
51
|
+
continue;
|
|
52
|
+
out.push({ t: "" }); // blank line between blocks
|
|
53
|
+
if (it.kind === "user") {
|
|
54
|
+
body.split("\n").forEach((l, i) => out.push({ t: (i === 0 ? "› " : " ") + l, color: "cyan" }));
|
|
55
|
+
}
|
|
56
|
+
else if (it.kind === "reasoning" || (it.kind === "notice" && it.full !== undefined)) {
|
|
57
|
+
const lines = body.split("\n");
|
|
58
|
+
out.push({ t: `✻ thinking (${lines.length} line${lines.length === 1 ? "" : "s"})`, dim: true, color: accent() });
|
|
59
|
+
lines.forEach((l, i) => out.push({ t: (i === 0 ? "• " : " ") + l, dim: true, italic: true }));
|
|
60
|
+
}
|
|
61
|
+
else if (it.kind === "tool") {
|
|
62
|
+
out.push({ t: it.text, dim: true });
|
|
63
|
+
if (it.full && it.full !== it.text)
|
|
64
|
+
it.full.replace(/\n+$/, "").split("\n").forEach((l) => out.push({ t: " " + l, dim: true }));
|
|
65
|
+
}
|
|
66
|
+
else if (it.kind === "diff") {
|
|
67
|
+
body.split("\n").forEach((l) => out.push({ t: l }));
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
body.split("\n").forEach((l) => out.push({ t: l, dim: it.kind === "notice" }));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
function Transcript({ items, onClose }) {
|
|
76
|
+
const { stdout } = useStdout();
|
|
77
|
+
const rows = Math.max(6, (stdout?.rows ?? 30) - 2); // leave a row for the header
|
|
78
|
+
const lines = flattenTranscript(items);
|
|
79
|
+
const maxScroll = Math.max(0, lines.length - rows);
|
|
80
|
+
const [scroll, setScroll] = useState(1e9); // open at the bottom (latest); clamped to maxScroll below
|
|
81
|
+
const at = Math.min(scroll, maxScroll);
|
|
82
|
+
useInput((input, key) => {
|
|
83
|
+
if (key.escape || (key.ctrl && input === "t"))
|
|
84
|
+
return onClose();
|
|
85
|
+
if (key.upArrow)
|
|
86
|
+
setScroll(Math.max(0, at - 1));
|
|
87
|
+
else if (key.downArrow)
|
|
88
|
+
setScroll(Math.min(maxScroll, at + 1));
|
|
89
|
+
else if (key.pageUp)
|
|
90
|
+
setScroll(Math.max(0, at - rows));
|
|
91
|
+
else if (key.pageDown)
|
|
92
|
+
setScroll(Math.min(maxScroll, at + rows));
|
|
93
|
+
else if (input === "g")
|
|
94
|
+
setScroll(0);
|
|
95
|
+
else if (input === "G")
|
|
96
|
+
setScroll(maxScroll);
|
|
97
|
+
});
|
|
98
|
+
const view = lines.slice(at, at + rows);
|
|
99
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: accent(), bold: true, children: ` TRANSCRIPT · full, nothing folded · ↑↓/PgUp·PgDn/g·G scroll · esc or ctrl+t closes · ${lines.length ? at + 1 : 0}–${Math.min(at + rows, lines.length)}/${lines.length}` }), view.map((l, i) => (_jsx(Text, { color: l.color, dimColor: l.dim, italic: l.italic, children: l.t || " " }, i)))] }));
|
|
100
|
+
}
|
|
101
|
+
// ─── header helpers ────────────────────────────────────────────────────────────
|
|
102
|
+
// Pure functions so the view stays declarative and tests can pin the formatting
|
|
103
|
+
// without rendering ink. Exported for unit tests (see test/tui-header.test.mjs).
|
|
104
|
+
/** Extract the URL host (no scheme, no path, no query). Falls back to the raw
|
|
105
|
+
* string when `url` isn't parseable — better to surface something than swallow it. */
|
|
106
|
+
export function extractHost(url) {
|
|
107
|
+
if (!url)
|
|
108
|
+
return "";
|
|
109
|
+
try {
|
|
110
|
+
return new URL(url).host;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Strip a leading scheme + leading // if present (handles "git@host:..." style which URL() rejects).
|
|
114
|
+
const noScheme = url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
|
|
115
|
+
return noScheme.split("/")[0].split("?")[0];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Tilde-collapse the user's home directory. If the path is too long, keep the
|
|
119
|
+
* TAIL (most-specific segments) — the project name reads better than `~/work/…`.
|
|
120
|
+
* `maxLen` clamps the displayed length; default 60 fits in a 80-col terminal next
|
|
121
|
+
* to the "cwd " label + an optional " · AGENTS.md" suffix. */
|
|
122
|
+
export function shortenHome(abs, home = process.env.HOME ?? "", maxLen = 60) {
|
|
123
|
+
let p = abs;
|
|
124
|
+
if (home && (p === home || p.startsWith(home + "/"))) {
|
|
125
|
+
p = "~" + p.slice(home.length);
|
|
126
|
+
}
|
|
127
|
+
if (p.length <= maxLen)
|
|
128
|
+
return p;
|
|
129
|
+
// Keep the last `maxLen - 2` chars, prefixed with `…/` to signal truncation.
|
|
130
|
+
const tail = p.slice(-(maxLen - 2));
|
|
131
|
+
// If the truncation lands inside a segment, advance to the next `/` for a clean break.
|
|
132
|
+
const firstSlash = tail.indexOf("/");
|
|
133
|
+
const clean = firstSlash > 0 ? tail.slice(firstSlash) : tail;
|
|
134
|
+
return "…" + clean;
|
|
135
|
+
}
|
|
136
|
+
/** Render a session uuid (or any id) as its first 8 chars — same convention as `shortId`
|
|
137
|
+
* in src/session/store.ts. A second helper here so the view never reaches into the session
|
|
138
|
+
* module + so headers in tests can pass any string and get a stable display. */
|
|
139
|
+
export function shortenSession(uuid) {
|
|
140
|
+
if (!uuid)
|
|
141
|
+
return "";
|
|
142
|
+
return uuid.slice(0, 8);
|
|
143
|
+
}
|
|
144
|
+
/** Layout constants for the field grid. Field names live in a 10-char column;
|
|
145
|
+
* values start at column 12 (after 2 spaces). The view uses `padField` to
|
|
146
|
+
* pad a label so the values align vertically across rows. */
|
|
147
|
+
const FIELD_PAD = 10;
|
|
148
|
+
const padField = (name) => name.padEnd(FIELD_PAD, " ");
|
|
149
|
+
function HeaderCard(props) {
|
|
150
|
+
const { version, modelLabel, cwd, agentsMdLoaded, session, kind } = props;
|
|
151
|
+
const home = process.env.HOME ?? "";
|
|
152
|
+
const cwdShort = shortenHome(cwd, home);
|
|
153
|
+
const sessionShort = shortenSession(session);
|
|
154
|
+
// Identity line — branches on kind. Personal collapses kind+model into one line;
|
|
155
|
+
// org splits identity (org/label/id/route) from model (with its source).
|
|
156
|
+
const identity = kind === "org" ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("org")}` }), _jsx(Text, { children: props.orgLabel ?? props.orgId ?? "(unnamed)" }), props.orgId && props.orgLabel ? _jsx(Text, { dimColor: true, children: ` · ${props.orgId}` }) : null, props.routeHost ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: " → " }), _jsx(Text, { dimColor: true, children: props.routeHost })] })) : null] })) : (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField(props.profileId ? `personal:${props.profileId}` : "personal")}` }), _jsx(Text, { children: modelLabel }), props.routeHost ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: " → " }), _jsx(Text, { dimColor: true, children: props.routeHost })] })) : null] }));
|
|
157
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: accent(), children: "> " }), _jsx(Text, { color: accent(), bold: true, children: `hara` }), _jsx(Text, { dimColor: true, children: ` · v${version} — the coding agent that runs like an org` })] }), _jsx(Text, { children: " " }), identity, kind === "org" ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("model")}` }), _jsx(Text, { children: modelLabel }), props.modelSource ? _jsx(Text, { dimColor: true, children: ` · from ${props.modelSource}` }) : null] })) : null, _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("cwd")}` }), _jsx(Text, { dimColor: true, children: cwdShort }), agentsMdLoaded ? _jsx(Text, { dimColor: true, children: " · AGENTS.md" }) : null] }), sessionShort ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("session")}` }), _jsx(Text, { dimColor: true, children: sessionShort })] })) : null, _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: " /help · @file · shift+tab · esc" })] }));
|
|
158
|
+
}
|
|
159
|
+
// Spinner verb: while a turn is running, prefer the in_progress todo's activeForm (or its text),
|
|
160
|
+
// so the bottom line reads "▶ updating tests…" instead of an abstract "working". Falls back to
|
|
161
|
+
// the elapsed-seconds form when no checklist is active. Exported for unit testing.
|
|
162
|
+
export function spinnerVerb(list, elapsedSec) {
|
|
163
|
+
const active = list.find((t) => t.status === "in_progress");
|
|
164
|
+
if (active) {
|
|
165
|
+
const phrase = active.activeForm?.trim() || active.text;
|
|
166
|
+
return `${phrase}… ${elapsedSec}s · esc to interrupt`;
|
|
167
|
+
}
|
|
168
|
+
return `working ${elapsedSec}s · esc to interrupt`;
|
|
55
169
|
}
|
|
56
|
-
function Working() {
|
|
170
|
+
function Working({ todos }) {
|
|
57
171
|
const [n, setN] = useState(0);
|
|
58
172
|
useEffect(() => {
|
|
59
173
|
const id = setInterval(() => setN((x) => x + 1), 100);
|
|
60
174
|
return () => clearInterval(id);
|
|
61
175
|
}, []);
|
|
62
176
|
const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
63
|
-
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[n % frames.length] }), _jsx(Text, { dimColor: true, children: `
|
|
177
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[n % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${spinnerVerb(todos, Math.floor(n / 10))}` })] }));
|
|
64
178
|
}
|
|
65
|
-
|
|
179
|
+
// Live task panel: renders the current todo_write checklist between the in-progress turn output
|
|
180
|
+
// and the input box. Highlights the in_progress item; caps at 8 rows and folds the rest into
|
|
181
|
+
// `… +N pending/done`. Hidden when the list is empty.
|
|
182
|
+
const PANEL_MAX_ROWS = 8;
|
|
183
|
+
const TODO_MARK = { pending: "☐", in_progress: "▶", done: "☑" };
|
|
184
|
+
function TodoPanel({ todos }) {
|
|
185
|
+
if (!todos.length)
|
|
186
|
+
return null;
|
|
187
|
+
const doneCount = todos.filter((t) => t.status === "done").length;
|
|
188
|
+
// Prioritize visible rows: in_progress first, then pending, then done — show the most informative
|
|
189
|
+
// slice when the list outgrows the cap. Stable order within each group via the original index.
|
|
190
|
+
const indexed = todos.map((t, i) => ({ t, i }));
|
|
191
|
+
const rank = (s) => (s === "in_progress" ? 0 : s === "pending" ? 1 : 2);
|
|
192
|
+
const prioritized = [...indexed].sort((a, b) => rank(a.t.status) - rank(b.t.status) || a.i - b.i);
|
|
193
|
+
const visible = prioritized.slice(0, PANEL_MAX_ROWS).sort((a, b) => a.i - b.i).map((x) => x.t);
|
|
194
|
+
const hidden = todos.length - visible.length;
|
|
195
|
+
const hiddenSummary = hidden > 0 ? (() => {
|
|
196
|
+
const remaining = prioritized.slice(PANEL_MAX_ROWS).map((x) => x.t);
|
|
197
|
+
const p = remaining.filter((t) => t.status === "pending").length;
|
|
198
|
+
const d = remaining.filter((t) => t.status === "done").length;
|
|
199
|
+
const parts = [];
|
|
200
|
+
if (p)
|
|
201
|
+
parts.push(`${p} pending`);
|
|
202
|
+
if (d)
|
|
203
|
+
parts.push(`${d} done`);
|
|
204
|
+
return ` … +${parts.join(", ")}`;
|
|
205
|
+
})() : "";
|
|
206
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: accent(), children: ` Todos (${doneCount}/${todos.length} done)` }), visible.map((t, i) => {
|
|
207
|
+
const inProg = t.status === "in_progress";
|
|
208
|
+
const done = t.status === "done";
|
|
209
|
+
return (_jsx(Text, { color: inProg ? accent() : undefined, bold: inProg, dimColor: done, children: ` ${TODO_MARK[t.status]} ${t.text}` }, i));
|
|
210
|
+
}), hiddenSummary ? _jsx(Text, { dimColor: true, children: hiddenSummary }) : null] }));
|
|
211
|
+
}
|
|
212
|
+
export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim, visionNotice }) {
|
|
66
213
|
const { exit } = useApp();
|
|
67
214
|
const [history, setHistory] = useState([]);
|
|
68
215
|
const [current, setCurrent] = useState([]);
|
|
@@ -70,7 +217,18 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
70
217
|
const [status, setStatus] = useState({ ...initialStatus, agents: 0 });
|
|
71
218
|
const [prompt, setPrompt] = useState(null);
|
|
72
219
|
const [promptSel, setPromptSel] = useState(0);
|
|
220
|
+
// Free-text question prompt (ask_user with no/declined options): re-enables the InputBox to capture one
|
|
221
|
+
// line, then resolves the awaiting tool with that text. Separate from `prompt` (the select-only path).
|
|
222
|
+
const [askText, setAskText] = useState(null);
|
|
73
223
|
const [reasoningOpen, setReasoningOpen] = useState(false);
|
|
224
|
+
const [showTranscript, setShowTranscript] = useState(false); // Ctrl+T full-transcript overlay
|
|
225
|
+
// Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
|
|
226
|
+
// in_progress item. The tool emits on every todo_write — keeps the UI in lockstep with the agent.
|
|
227
|
+
const [todos, setTodos] = useState(() => currentTodos());
|
|
228
|
+
// Collapse-after-turn: once a turn ends, leave the panel visible briefly (so the user sees the
|
|
229
|
+
// final state) then fold it into a single-line "Todos: N/M done" notice in history. Cleared if
|
|
230
|
+
// a new turn starts before the timer fires.
|
|
231
|
+
const collapseTimerRef = useRef(null);
|
|
74
232
|
const ctrlRef = useRef(null);
|
|
75
233
|
const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
|
|
76
234
|
const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
|
|
@@ -84,6 +242,22 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
84
242
|
activity.onChange(fn);
|
|
85
243
|
return () => activity.onChange(null);
|
|
86
244
|
}, []);
|
|
245
|
+
// Subscribe to todo_write updates so the panel re-renders when the agent edits the checklist.
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
const unsub = onTodosChange((list) => {
|
|
248
|
+
setTodos([...list]); // copy so React sees a new array (the tool reuses one)
|
|
249
|
+
// A change mid-turn cancels any pending collapse — the user is still working with this list.
|
|
250
|
+
if (collapseTimerRef.current) {
|
|
251
|
+
clearTimeout(collapseTimerRef.current);
|
|
252
|
+
collapseTimerRef.current = null;
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return () => {
|
|
256
|
+
unsub();
|
|
257
|
+
if (collapseTimerRef.current)
|
|
258
|
+
clearTimeout(collapseTimerRef.current);
|
|
259
|
+
};
|
|
260
|
+
}, []);
|
|
87
261
|
const pushCurrent = useCallback((kind, text, merge = false) => {
|
|
88
262
|
setCurrent((cur) => {
|
|
89
263
|
const last = cur[cur.length - 1];
|
|
@@ -92,6 +266,16 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
92
266
|
return [...cur, { id: nid(), kind, text }];
|
|
93
267
|
});
|
|
94
268
|
}, []);
|
|
269
|
+
// Lazy vision notice: 顾雅 spec — the header no longer carries an always-on "👁 …" line.
|
|
270
|
+
// Instead, the first time an image attachment shows up in this session, we print the
|
|
271
|
+
// routing notice once (inline). `visionShownRef` is the session-scoped flag.
|
|
272
|
+
const visionShownRef = useRef(false);
|
|
273
|
+
const noteVisionIfNeeded = useCallback(() => {
|
|
274
|
+
if (visionShownRef.current || !visionNotice)
|
|
275
|
+
return;
|
|
276
|
+
visionShownRef.current = true;
|
|
277
|
+
setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ⓘ ${visionNotice}` }]);
|
|
278
|
+
}, [visionNotice]);
|
|
95
279
|
// Type-ahead steering: hand the runner everything queued while the turn ran, showing each message
|
|
96
280
|
// inline (as a user block) at the point it gets folded into the conversation. Drained mid-turn so an
|
|
97
281
|
// addition reaches the model on its next call; whatever's still queued at turn end is the effect below.
|
|
@@ -101,12 +285,22 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
101
285
|
const batch = queueRef.current;
|
|
102
286
|
queueRef.current = [];
|
|
103
287
|
setPool([]);
|
|
288
|
+
if (batch.some((b) => b.images?.length))
|
|
289
|
+
noteVisionIfNeeded();
|
|
104
290
|
for (const b of batch)
|
|
105
291
|
pushCurrent("user", b.line.trim() || "🖼 (image)");
|
|
106
292
|
return batch;
|
|
107
|
-
}, [pushCurrent]);
|
|
293
|
+
}, [pushCurrent, noteVisionIfNeeded]);
|
|
108
294
|
const handleSubmit = useCallback(async (line, images) => {
|
|
109
295
|
const t = line.trim();
|
|
296
|
+
// A free-text question (ask_user) is awaiting an answer: this submission IS the answer, not a new turn.
|
|
297
|
+
if (askText) {
|
|
298
|
+
const r = askText.resolve;
|
|
299
|
+
setAskText(null);
|
|
300
|
+
setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]);
|
|
301
|
+
r(t);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
110
304
|
if ((!t && !images?.length) || prompt)
|
|
111
305
|
return; // nothing to send, or a choice is pending
|
|
112
306
|
if (working) {
|
|
@@ -115,6 +309,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
115
309
|
setPool(queueRef.current.map((q) => q.line.trim() || "🖼 (image)"));
|
|
116
310
|
return;
|
|
117
311
|
}
|
|
312
|
+
if (images?.length)
|
|
313
|
+
noteVisionIfNeeded(); // one-shot inline notice on first image of the session
|
|
118
314
|
setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]); // t already carries any [Image #N] tokens
|
|
119
315
|
const ctrl = new AbortController();
|
|
120
316
|
ctrlRef.current = ctrl;
|
|
@@ -138,25 +334,61 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
138
334
|
{ label: "No (esc)", value: false, key: "n" },
|
|
139
335
|
]);
|
|
140
336
|
const selectFn = (title, options) => openPrompt(title, options);
|
|
337
|
+
// Free-text question: re-enable the InputBox to read one line (resolves via handleSubmit's askText branch).
|
|
338
|
+
const askTextFn = (title) => new Promise((resolve) => setAskText({ title, resolve }));
|
|
339
|
+
// ask_user: when options are given, offer them as a select + a "type my own" escape hatch; otherwise (or
|
|
340
|
+
// when the user chooses to type their own) capture a free-text line. Returns the chosen/typed answer.
|
|
341
|
+
const OTHER = "__ask_other__"; // sentinel value for the "type my own" option
|
|
342
|
+
const askFn = async (question, options) => {
|
|
343
|
+
if (options && options.length) {
|
|
344
|
+
const choice = await openPrompt(question, [
|
|
345
|
+
...options.map((o) => ({ label: o, value: o })),
|
|
346
|
+
{ label: "✎ Type my own answer", value: OTHER },
|
|
347
|
+
]);
|
|
348
|
+
if (choice !== OTHER)
|
|
349
|
+
return choice;
|
|
350
|
+
}
|
|
351
|
+
return askTextFn(question);
|
|
352
|
+
};
|
|
141
353
|
const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
|
|
142
354
|
try {
|
|
143
|
-
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
|
|
355
|
+
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
|
|
144
356
|
}
|
|
145
357
|
catch (e) {
|
|
146
358
|
pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
|
|
147
359
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
360
|
+
// Commit this turn's items to scrollback. Read the LIVE current via the updater — currentRef only
|
|
361
|
+
// syncs on render (line ~225), so a fast slash-only turn (/design, /help, /skills…) that pushes a
|
|
362
|
+
// notice and returns before any re-render would otherwise commit nothing and lose the notice.
|
|
363
|
+
setCurrent((cur) => {
|
|
364
|
+
const committed = cur.map((it) => it.kind === "reasoning"
|
|
365
|
+
? { ...it, kind: "notice", text: `✻ thought · ${it.text.split("\n").filter((l) => l.trim()).length} lines`, full: it.text }
|
|
366
|
+
: it);
|
|
367
|
+
if (committed.length)
|
|
368
|
+
setHistory((h) => [...h, ...committed]);
|
|
369
|
+
return [];
|
|
370
|
+
});
|
|
153
371
|
setWorking(false);
|
|
154
372
|
ctrlRef.current = null;
|
|
155
|
-
|
|
373
|
+
// Schedule a panel collapse: if there was a checklist this turn, fold it to a one-line summary
|
|
374
|
+
// in scrollback after ~30s of quiet (i.e. no new todo_write or new turn).
|
|
375
|
+
if (collapseTimerRef.current)
|
|
376
|
+
clearTimeout(collapseTimerRef.current);
|
|
377
|
+
if (currentTodos().length) {
|
|
378
|
+
collapseTimerRef.current = setTimeout(() => {
|
|
379
|
+
const list = currentTodos();
|
|
380
|
+
if (!list.length)
|
|
381
|
+
return;
|
|
382
|
+
const done = list.filter((t) => t.status === "done").length;
|
|
383
|
+
setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ✓ Todos: ${done}/${list.length} done` }]);
|
|
384
|
+
collapseTimerRef.current = null;
|
|
385
|
+
}, 30_000);
|
|
386
|
+
}
|
|
387
|
+
}, [working, prompt, askText, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
|
|
156
388
|
// Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
|
|
157
389
|
// every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
|
|
158
390
|
useEffect(() => {
|
|
159
|
-
if (working || prompt || drainingRef.current || !queueRef.current.length)
|
|
391
|
+
if (working || prompt || askText || drainingRef.current || !queueRef.current.length)
|
|
160
392
|
return;
|
|
161
393
|
drainingRef.current = true;
|
|
162
394
|
const batch = queueRef.current;
|
|
@@ -167,8 +399,21 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
167
399
|
void Promise.resolve(handleSubmit(line, images.length ? images : undefined)).finally(() => {
|
|
168
400
|
drainingRef.current = false;
|
|
169
401
|
});
|
|
170
|
-
}, [working, prompt, handleSubmit]);
|
|
402
|
+
}, [working, prompt, askText, handleSubmit]);
|
|
171
403
|
useInput((input, key) => {
|
|
404
|
+
if (key.ctrl && input === "t")
|
|
405
|
+
return setShowTranscript((x) => !x); // open/close the full-transcript overlay
|
|
406
|
+
if (showTranscript)
|
|
407
|
+
return; // while open, the overlay's own useInput owns every key (scroll / esc)
|
|
408
|
+
// Free-text question awaiting an answer: Esc cancels (empty answer); all other keys belong to the InputBox.
|
|
409
|
+
if (askText) {
|
|
410
|
+
if (key.escape) {
|
|
411
|
+
const r = askText.resolve;
|
|
412
|
+
setAskText(null);
|
|
413
|
+
r("");
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
172
417
|
if (prompt) {
|
|
173
418
|
const opts = prompt.options;
|
|
174
419
|
if (key.upArrow)
|
|
@@ -209,5 +454,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
209
454
|
else if (key.tab && key.shift && cycleApproval)
|
|
210
455
|
setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
|
|
211
456
|
});
|
|
212
|
-
|
|
457
|
+
if (showTranscript)
|
|
458
|
+
return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
|
|
459
|
+
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))), !prompt && _jsx(TodoPanel, { todos: todos }), working && !prompt && _jsx(Working, { todos: todos }), 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` })] })), askText && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ? ${stripAnsi(askText.title)}` }), _jsx(Text, { dimColor: true, children: " type your answer below · Enter to send · Esc cancels" })] })), pool.length > 0 && !prompt && !askText && (_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 && !askText, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
213
460
|
}
|
package/dist/tui/run.js
CHANGED
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
// Mounts the hara TUI (ink) and resolves when the user exits. Thin shell — all agent wiring
|
|
2
2
|
// (provider, session history, slash commands, turn execution) is passed in via AppProps.onSubmit
|
|
3
3
|
// from index.ts, which owns that state.
|
|
4
|
-
import { render } from "ink";
|
|
4
|
+
import { render, Box, Text, useApp, useInput } from "ink";
|
|
5
5
|
import { createElement } from "react";
|
|
6
6
|
import { App } from "./App.js";
|
|
7
7
|
export async function runTui(props) {
|
|
8
8
|
const instance = render(createElement(App, props));
|
|
9
9
|
await instance.waitUntilExit();
|
|
10
10
|
}
|
|
11
|
+
// A tiny ink yes/no prompt for pre-TUI confirms (e.g. the first-run "create AGENTS.md?" offer).
|
|
12
|
+
// MUST be ink, NOT readline: a readline question before the main TUI leaves stdin in a state ink
|
|
13
|
+
// can't read from, which kills the input box. ink restores stdin cleanly on unmount, so the main
|
|
14
|
+
// TUI mounted right after still gets working input. Resolves true on y/Enter, false on n/Esc.
|
|
15
|
+
export async function askConfirm(question, def = true) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
let done = false;
|
|
18
|
+
const finish = (v) => {
|
|
19
|
+
if (!done) {
|
|
20
|
+
done = true;
|
|
21
|
+
resolve(v);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
function Prompt() {
|
|
25
|
+
const { exit } = useApp();
|
|
26
|
+
useInput((input, key) => {
|
|
27
|
+
const v = input.toLowerCase();
|
|
28
|
+
let ans = null;
|
|
29
|
+
if (key.return)
|
|
30
|
+
ans = def;
|
|
31
|
+
else if (v === "y")
|
|
32
|
+
ans = true;
|
|
33
|
+
else if (v === "n" || key.escape)
|
|
34
|
+
ans = false;
|
|
35
|
+
if (ans !== null) {
|
|
36
|
+
finish(ans);
|
|
37
|
+
exit();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return createElement(Box, { marginY: 1 }, createElement(Text, { color: "yellow" }, ` ${question} `), createElement(Text, { dimColor: true }, def ? "[Y/n] " : "[y/N] "));
|
|
41
|
+
}
|
|
42
|
+
const inst = render(createElement(Prompt));
|
|
43
|
+
void inst.waitUntilExit().then(() => finish(def));
|
|
44
|
+
});
|
|
45
|
+
}
|