@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/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
- // fixed-height window: show the last 5 lines while thinking; ctrl-r toggles the full text.
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 > 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)))] }));
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
- // 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] }));
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: ` working ${Math.floor(n / 10)}s · esc to interrupt` })] }));
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
- export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim }) {
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
- const committed = currentRef.current.map((it) => it.kind === "reasoning"
149
- ? { ...it, kind: "notice", text: `✻ thought · ${it.text.split("\n").filter((l) => l.trim()).length} lines` }
150
- : it);
151
- setHistory((h) => [...h, ...committed]);
152
- setCurrent([]);
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
- }, [working, prompt, onSubmit, pushCurrent, model, exit, drainQueue]);
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
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), working && !prompt && _jsx(Working, {}), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.89.0",
3
+ "version": "0.98.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"