@nanhara/hara 0.89.0 → 0.95.2
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 +45 -8
- package/dist/config.js +25 -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 +820 -71
- 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/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- 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 +241 -30
- 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);
|
|
55
143
|
}
|
|
56
|
-
|
|
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`;
|
|
169
|
+
}
|
|
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))}` })] }));
|
|
178
|
+
}
|
|
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] }));
|
|
64
211
|
}
|
|
65
|
-
export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim }) {
|
|
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([]);
|
|
@@ -71,6 +218,14 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
71
218
|
const [prompt, setPrompt] = useState(null);
|
|
72
219
|
const [promptSel, setPromptSel] = useState(0);
|
|
73
220
|
const [reasoningOpen, setReasoningOpen] = useState(false);
|
|
221
|
+
const [showTranscript, setShowTranscript] = useState(false); // Ctrl+T full-transcript overlay
|
|
222
|
+
// Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
|
|
223
|
+
// in_progress item. The tool emits on every todo_write — keeps the UI in lockstep with the agent.
|
|
224
|
+
const [todos, setTodos] = useState(() => currentTodos());
|
|
225
|
+
// Collapse-after-turn: once a turn ends, leave the panel visible briefly (so the user sees the
|
|
226
|
+
// final state) then fold it into a single-line "Todos: N/M done" notice in history. Cleared if
|
|
227
|
+
// a new turn starts before the timer fires.
|
|
228
|
+
const collapseTimerRef = useRef(null);
|
|
74
229
|
const ctrlRef = useRef(null);
|
|
75
230
|
const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
|
|
76
231
|
const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
|
|
@@ -84,6 +239,22 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
84
239
|
activity.onChange(fn);
|
|
85
240
|
return () => activity.onChange(null);
|
|
86
241
|
}, []);
|
|
242
|
+
// Subscribe to todo_write updates so the panel re-renders when the agent edits the checklist.
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const unsub = onTodosChange((list) => {
|
|
245
|
+
setTodos([...list]); // copy so React sees a new array (the tool reuses one)
|
|
246
|
+
// A change mid-turn cancels any pending collapse — the user is still working with this list.
|
|
247
|
+
if (collapseTimerRef.current) {
|
|
248
|
+
clearTimeout(collapseTimerRef.current);
|
|
249
|
+
collapseTimerRef.current = null;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
return () => {
|
|
253
|
+
unsub();
|
|
254
|
+
if (collapseTimerRef.current)
|
|
255
|
+
clearTimeout(collapseTimerRef.current);
|
|
256
|
+
};
|
|
257
|
+
}, []);
|
|
87
258
|
const pushCurrent = useCallback((kind, text, merge = false) => {
|
|
88
259
|
setCurrent((cur) => {
|
|
89
260
|
const last = cur[cur.length - 1];
|
|
@@ -92,6 +263,16 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
92
263
|
return [...cur, { id: nid(), kind, text }];
|
|
93
264
|
});
|
|
94
265
|
}, []);
|
|
266
|
+
// Lazy vision notice: 顾雅 spec — the header no longer carries an always-on "👁 …" line.
|
|
267
|
+
// Instead, the first time an image attachment shows up in this session, we print the
|
|
268
|
+
// routing notice once (inline). `visionShownRef` is the session-scoped flag.
|
|
269
|
+
const visionShownRef = useRef(false);
|
|
270
|
+
const noteVisionIfNeeded = useCallback(() => {
|
|
271
|
+
if (visionShownRef.current || !visionNotice)
|
|
272
|
+
return;
|
|
273
|
+
visionShownRef.current = true;
|
|
274
|
+
setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ⓘ ${visionNotice}` }]);
|
|
275
|
+
}, [visionNotice]);
|
|
95
276
|
// Type-ahead steering: hand the runner everything queued while the turn ran, showing each message
|
|
96
277
|
// inline (as a user block) at the point it gets folded into the conversation. Drained mid-turn so an
|
|
97
278
|
// addition reaches the model on its next call; whatever's still queued at turn end is the effect below.
|
|
@@ -101,10 +282,12 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
101
282
|
const batch = queueRef.current;
|
|
102
283
|
queueRef.current = [];
|
|
103
284
|
setPool([]);
|
|
285
|
+
if (batch.some((b) => b.images?.length))
|
|
286
|
+
noteVisionIfNeeded();
|
|
104
287
|
for (const b of batch)
|
|
105
288
|
pushCurrent("user", b.line.trim() || "🖼 (image)");
|
|
106
289
|
return batch;
|
|
107
|
-
}, [pushCurrent]);
|
|
290
|
+
}, [pushCurrent, noteVisionIfNeeded]);
|
|
108
291
|
const handleSubmit = useCallback(async (line, images) => {
|
|
109
292
|
const t = line.trim();
|
|
110
293
|
if ((!t && !images?.length) || prompt)
|
|
@@ -115,6 +298,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
115
298
|
setPool(queueRef.current.map((q) => q.line.trim() || "🖼 (image)"));
|
|
116
299
|
return;
|
|
117
300
|
}
|
|
301
|
+
if (images?.length)
|
|
302
|
+
noteVisionIfNeeded(); // one-shot inline notice on first image of the session
|
|
118
303
|
setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]); // t already carries any [Image #N] tokens
|
|
119
304
|
const ctrl = new AbortController();
|
|
120
305
|
ctrlRef.current = ctrl;
|
|
@@ -145,14 +330,34 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
145
330
|
catch (e) {
|
|
146
331
|
pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
|
|
147
332
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
333
|
+
// Commit this turn's items to scrollback. Read the LIVE current via the updater — currentRef only
|
|
334
|
+
// syncs on render (line ~225), so a fast slash-only turn (/design, /help, /skills…) that pushes a
|
|
335
|
+
// notice and returns before any re-render would otherwise commit nothing and lose the notice.
|
|
336
|
+
setCurrent((cur) => {
|
|
337
|
+
const committed = cur.map((it) => it.kind === "reasoning"
|
|
338
|
+
? { ...it, kind: "notice", text: `✻ thought · ${it.text.split("\n").filter((l) => l.trim()).length} lines`, full: it.text }
|
|
339
|
+
: it);
|
|
340
|
+
if (committed.length)
|
|
341
|
+
setHistory((h) => [...h, ...committed]);
|
|
342
|
+
return [];
|
|
343
|
+
});
|
|
153
344
|
setWorking(false);
|
|
154
345
|
ctrlRef.current = null;
|
|
155
|
-
|
|
346
|
+
// Schedule a panel collapse: if there was a checklist this turn, fold it to a one-line summary
|
|
347
|
+
// in scrollback after ~30s of quiet (i.e. no new todo_write or new turn).
|
|
348
|
+
if (collapseTimerRef.current)
|
|
349
|
+
clearTimeout(collapseTimerRef.current);
|
|
350
|
+
if (currentTodos().length) {
|
|
351
|
+
collapseTimerRef.current = setTimeout(() => {
|
|
352
|
+
const list = currentTodos();
|
|
353
|
+
if (!list.length)
|
|
354
|
+
return;
|
|
355
|
+
const done = list.filter((t) => t.status === "done").length;
|
|
356
|
+
setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ✓ Todos: ${done}/${list.length} done` }]);
|
|
357
|
+
collapseTimerRef.current = null;
|
|
358
|
+
}, 30_000);
|
|
359
|
+
}
|
|
360
|
+
}, [working, prompt, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
|
|
156
361
|
// Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
|
|
157
362
|
// every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
|
|
158
363
|
useEffect(() => {
|
|
@@ -169,6 +374,10 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
169
374
|
});
|
|
170
375
|
}, [working, prompt, handleSubmit]);
|
|
171
376
|
useInput((input, key) => {
|
|
377
|
+
if (key.ctrl && input === "t")
|
|
378
|
+
return setShowTranscript((x) => !x); // open/close the full-transcript overlay
|
|
379
|
+
if (showTranscript)
|
|
380
|
+
return; // while open, the overlay's own useInput owns every key (scroll / esc)
|
|
172
381
|
if (prompt) {
|
|
173
382
|
const opts = prompt.options;
|
|
174
383
|
if (key.upArrow)
|
|
@@ -209,5 +418,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
209
418
|
else if (key.tab && key.shift && cycleApproval)
|
|
210
419
|
setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
|
|
211
420
|
});
|
|
212
|
-
|
|
421
|
+
if (showTranscript)
|
|
422
|
+
return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
|
|
423
|
+
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` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
213
424
|
}
|
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
|
+
}
|