@nanhara/hara 0.99.0 → 0.99.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 CHANGED
@@ -5,6 +5,23 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.99.2 — TUI: steadier input box + transient approval selector
9
+
10
+ - **Approval-mode selector is now transient, not always-on chrome.** The persistent two-row ModeBar under every
11
+ frame is gone; the current mode reads inline in the status footer (colored: red=full-auto, cyan=plan,
12
+ green=edit), and **shift+tab** pops the full picker + descriptions, which auto-hides after ~2.5s. Reclaims two
13
+ rows and cuts per-frame redraw during streaming — matching codex (transient approval overlay + compact status
14
+ line) and Claude Code.
15
+ - `tui/InputBox.tsx`: `footerParts`/`approvalColor` (colored mode in the footer); `ModeBar` gated on a new
16
+ `showModeSelector` prop. `tui/App.tsx`: shift+tab arms a 2.5s auto-hide timer.
17
+ - **Input box no longer bobs up/down while the model thinks.** A streaming reasoning block used to render up to
18
+ ~11 live rows above the input, then fold to a single "✻ thought · N lines" line on finalize — that N→1
19
+ collapse yanked the box up every time. Reasoning now shows a **compact 1-line header by default** (same height
20
+ as the folded form → zero jump); **ctrl+r** expands the full streaming body, and **ctrl+t** always has the full
21
+ text. (ink can't bottom-pin the composer the way codex's ratatui viewport does, so this removes the dominant
22
+ jump; minor spinner/panel shifts at turn boundaries remain.)
23
+ - `tui/App.tsx`: the reasoning `Block` renders header-only unless `open` (ctrl+r).
24
+
8
25
  ## 0.94.1 — unreleased (gateway: relay is on-inbound, not a noisy push)
9
26
 
10
27
  - **Fix the bind output relay to be quiet + platform-correct.** 0.94.0's continuous 3s timer-push flooded chat
package/dist/tui/App.js CHANGED
@@ -34,6 +34,10 @@ function foldForHistory(it) {
34
34
  * block on every token, which over a laggy link leaves stale duplicate lines + jitter. 33ms clamps
35
35
  * the live re-render rate while state itself stays exact (nothing is dropped, only coalesced). */
36
36
  const LIVE_FRAME_MS = 33;
37
+ // How long the transient approval-mode selector stays up after a shift+tab before it auto-hides. Long
38
+ // enough to read the descriptions + tap shift+tab again to keep cycling; short enough that it isn't
39
+ // always-on chrome (codex/Claude-Code keep the mode compact in the status line, not a permanent bar).
40
+ const MODE_SELECTOR_MS = 2500;
37
41
  // Memoized: a live block only re-renders when its own `item` (a fresh object when its text grows) or
38
42
  // `open` changes. So a spinner tick or an unrelated flush doesn't re-run `renderMarkdown` for every
39
43
  // live block, and non-tail live blocks stay put while only the streaming tail grows.
@@ -44,15 +48,18 @@ const Block = memo(function Block({ item, open }) {
44
48
  case "assistant":
45
49
  return _jsx(Text, { children: renderMarkdown(item.text) }); // headers/bold/inline-code/bullets + verbatim fences
46
50
  case "reasoning": {
47
- // Codex-style: stream the reasoning dim + italic with a leading "• " bullet; show the full text up to
48
- // MAX lines, fold longer to the live tail with a "… +N lines" summary. ctrl-r expands/collapses.
49
- const MAX = 10;
51
+ // A streaming reasoning block lives in the dynamic region ABOVE the input box, and the instant the
52
+ // model stops thinking it FOLDS to a single "✻ thought · N lines" notice in scrollback. If we streamed
53
+ // the body live (up to ~11 rows) and then folded to 1, the input box would jump UP by that many rows
54
+ // every time — the "bobbing" you saw. So by default we show only the compact 1-line header (same height
55
+ // as the folded form → the box holds still). ctrl+r opts into the full streaming body (its own taller
56
+ // view), and the FULL text is always in the ctrl+t transcript regardless — nothing is lost.
50
57
  const lines = item.text.replace(/\n+$/, "").split("\n");
51
- const long = lines.length > MAX;
52
- const shown = open || !long ? lines : lines.slice(-MAX); // short or expanded all; long & collapsed → live tail
53
- const omitted = long && !open ? lines.length - MAX : 0;
54
- const hint = long ? (open ? " · ctrl-r collapse" : ` · +${omitted} lines · ctrl-r expand`) : "";
55
- 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)))] }));
58
+ const n = lines.length;
59
+ const hint = open ? " · ctrl-r collapse" : " · ctrl-r expand";
60
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: accent(), dimColor: true, children: `✻ thinking … ${n} line${n === 1 ? "" : "s"}${hint}` }), open
61
+ ? lines.map((l, i) => (_jsx(Text, { dimColor: true, italic: true, children: `${i === 0 ? "• " : " "}${l}` }, i)))
62
+ : null] }));
56
63
  }
57
64
  case "tool":
58
65
  return _jsx(Text, { dimColor: true, children: " " + item.text });
@@ -278,6 +285,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
278
285
  const [askText, setAskText] = useState(null);
279
286
  const [reasoningOpen, setReasoningOpen] = useState(false);
280
287
  const [showTranscript, setShowTranscript] = useState(false); // Ctrl+T full-transcript overlay
288
+ const [modeSelector, setModeSelector] = useState(false); // transient approval selector: shift+tab pops it, auto-hides
289
+ const modeSelectorTimerRef = useRef(null);
281
290
  // Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
282
291
  // in_progress item. The tool emits on every todo_write — keeps the UI in lockstep with the agent.
283
292
  const [todos, setTodos] = useState(() => currentTodos());
@@ -303,10 +312,12 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
303
312
  activity.onChange(fn);
304
313
  return () => activity.onChange(null);
305
314
  }, []);
306
- // Cancel any pending live-region flush on unmount so a timer can't fire after teardown.
315
+ // Cancel any pending timers on unmount so they can't fire after teardown.
307
316
  useEffect(() => () => {
308
317
  if (flushTimerRef.current)
309
318
  clearTimeout(flushTimerRef.current);
319
+ if (modeSelectorTimerRef.current)
320
+ clearTimeout(modeSelectorTimerRef.current);
310
321
  }, []);
311
322
  // Subscribe to todo_write updates so the panel re-renders when the agent edits the checklist.
312
323
  useEffect(() => {
@@ -552,10 +563,20 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
552
563
  }
553
564
  ctrlRef.current?.abort();
554
565
  }
555
- else if (key.tab && key.shift && cycleApproval)
566
+ else if (key.tab && key.shift && cycleApproval) {
556
567
  setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
568
+ // Pop the approval selector transiently (codex-style) and (re)arm the auto-hide — tapping shift+tab
569
+ // again keeps it up while cycling; it folds away on its own so it isn't permanent chrome.
570
+ setModeSelector(true);
571
+ if (modeSelectorTimerRef.current)
572
+ clearTimeout(modeSelectorTimerRef.current);
573
+ modeSelectorTimerRef.current = setTimeout(() => {
574
+ setModeSelector(false);
575
+ modeSelectorTimerRef.current = null;
576
+ }, MODE_SELECTOR_MS);
577
+ }
557
578
  });
558
579
  if (showTranscript)
559
580
  return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
560
- 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, model: model, route: header?.routeHost, isActive: !prompt, working: working && !askText, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
581
+ 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, model: model, route: header?.routeHost, isActive: !prompt, working: working && !askText, queued: pool.length, vim: vim, showModeSelector: modeSelector, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
561
582
  }
@@ -35,18 +35,49 @@ export function footerCwd(abs, home = process.env.HOME ?? "", maxLen = 28) {
35
35
  const slash = tail.indexOf("/");
36
36
  return "…" + (slash > 0 ? tail.slice(slash) : tail);
37
37
  }
38
- /** Compose the single dim footer line rendered below the box. Pure so the ordering/spacing (and the
39
- * optional route segment) can be pinned without rendering React. Shape (codex-style status line):
40
- * `<model> · <approval>[ · <route>] · <cwd> · ↑<in> ↓<out> · ctx <pct>%`. `ctx N%` is always present
41
- * (from 0 on) so the field never pops in mid-session and shifts the layout. */
42
- export function footerLine(model, s, cwdShort, route) {
38
+ /** Approval-mode accent color, shared by the footer indicator + the transient ModeBar. full-auto is
39
+ * the dangerous one (red), plan is read-only (cyan), the edit modes are green. */
40
+ export function approvalColor(a) {
41
+ return a === "full-auto" ? "red" : a === "plan" ? "cyan" : "green";
42
+ }
43
+ /** The footer split into three parts so the ACTIVE approval mode can be colored inline. The always-on
44
+ * ModeBar was removed — the mode now lives, glanceable and colored, right here in the footer, and
45
+ * shift+tab pops a transient selector. `prefix`+`mode`+`suffix` concatenates to exactly the old
46
+ * `footerLine` (same text, same spacing) — only the color of the `mode` token differs. Pure so the
47
+ * ordering/spacing can be pinned without rendering React. Shape (codex-style status line):
48
+ * `<model> · <approval>[ · <route>] · <cwd> · ↑<in> ↓<out> · ctx <pct>%`. The session name is NOT here —
49
+ * it rides the input box's top-right border (see TopBorder). `ctx N%` is always present (from 0 on) so
50
+ * the field never pops in mid-session and shifts the layout. */
51
+ export function footerParts(model, s, cwdShort, route) {
43
52
  const routeSeg = route ? ` · ${route}` : "";
44
- return ` ${model} · ${s.approval}${routeSeg} · ${cwdShort} · ↑${tok(s.input)} ↓${tok(s.output)} · ctx ${s.ctxPct}%`;
53
+ return {
54
+ prefix: ` ${model} · `,
55
+ mode: s.approval,
56
+ suffix: `${routeSeg} · ${cwdShort} · ↑${tok(s.input)} ↓${tok(s.output)} · ctx ${s.ctxPct}%`,
57
+ };
45
58
  }
46
- // The merged status footer (was split across the old top/bottom dash-rules): model · approval ·
47
- // route · cwd · usage · ctx. Memoized so a prompt keystroke doesn't reconcile it.
59
+ /** Back-compat: the full footer as one string (prefix+mode+suffix). Kept for any pure consumer/test. */
60
+ export function footerLine(model, s, cwdShort, route) {
61
+ const p = footerParts(model, s, cwdShort, route);
62
+ return p.prefix + p.mode + p.suffix;
63
+ }
64
+ // The merged status footer: model · approval · route · cwd · usage · ctx. The active approval mode is
65
+ // colored inline (the always-on ModeBar is gone — shift+tab now pops a transient selector instead), so
66
+ // the outer <Text> is NOT dim: only the prefix/suffix are dimmed and the mode token stays bright.
67
+ // Memoized so a prompt keystroke doesn't reconcile it.
48
68
  const Footer = memo(function Footer({ model, s, cwdShort, route }) {
49
- return _jsx(Text, { dimColor: true, children: footerLine(model, s, cwdShort, route) });
69
+ const { prefix, mode, suffix } = footerParts(model, s, cwdShort, route);
70
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: prefix }), _jsx(Text, { color: approvalColor(s.approval), bold: true, children: mode }), _jsx(Text, { dimColor: true, children: suffix })] }));
71
+ });
72
+ // The rounded TOP edge of the input box, carrying the session name in the right corner (it "rides"
73
+ // the border — codex-style titled panel, and where hara has always shown it). Drawn by hand because
74
+ // ink's <Box borderStyle> has no title slot; the box below it renders with `borderTop={false}` and a
75
+ // fixed `width`, so this line supplies the top with its two corners and everything lines up exactly.
76
+ // Layout (total = width): `╭` + left dashes + ` ● <name> ` + `─╮`. `●` (U+25CF) is a hard 1-cell glyph
77
+ // (unlike the emoji-presentation `⏺`, which some terminals render 2 cells wide and would skew the corner).
78
+ const TopBorder = memo(function TopBorder({ name, width }) {
79
+ const left = Math.max(2, width - name.length - 7); // ╭(1)+ " "(1)+●(1)+" name "(len+2)+"─╮"(2)
80
+ return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "╭" + "─".repeat(left) + " " }), _jsx(Text, { color: "cyan", children: "\u25CF" }), _jsx(Text, { bold: true, children: ` ${name} ` }), _jsx(Text, { dimColor: true, children: "─╮" })] }));
50
81
  });
51
82
  const MODE_DESC = {
52
83
  suggest: "confirms edits & commands",
@@ -54,8 +85,10 @@ const MODE_DESC = {
54
85
  "full-auto": "runs everything — no prompts ⚠",
55
86
  plan: "investigate read-only, then propose a plan to approve",
56
87
  };
57
- // Prominent approval-mode selector below the box: all three listed, the active one highlighted (red
58
- // for the dangerous full-auto) with a one-line description and the shift+tab hint.
88
+ // Transient approval-mode selector: popped by shift+tab and auto-hidden after a beat (App owns the
89
+ // timer) so it isn't always-on chrome. All modes listed, the active one highlighted (red for the
90
+ // dangerous full-auto) with a one-line description and the shift+tab hint. When hidden, the current
91
+ // mode still reads (colored) from the footer line — this just adds the full picker + descriptions.
59
92
  const ModeBar = memo(function ModeBar({ approval }) {
60
93
  const warn = approval === "full-auto";
61
94
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: MODES.map((m, i) => (_jsxs(Text, { children: [i > 0 ? " " : " ", m === approval ? _jsx(Text, { color: warn ? "red" : m === "plan" ? "cyan" : "green", bold: true, children: `◆ ${m}` }) : _jsx(Text, { dimColor: true, children: m })] }, m))) }), _jsx(Text, { dimColor: true, children: ` ${MODE_DESC[approval]} · shift+tab ⇄` })] }));
@@ -211,7 +244,7 @@ const InputLine = memo(function InputLine({ value, cursor, width, gutter, gutter
211
244
  });
212
245
  /** Bordered prompt box + one dim status footer (model · approval · route · cwd · usage · ctx) +
213
246
  * ModeBar, with an @path popup. */
214
- export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, vim = false, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
247
+ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, vim = false, showModeSelector = false, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
215
248
  const { stdout } = useStdout();
216
249
  const w = width ?? stdout?.columns ?? 80;
217
250
  const [value, setValue] = useState("");
@@ -369,9 +402,11 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
369
402
  }, { isActive });
370
403
  const gutter = vim && mode === "normal" ? "◆ " : "› ";
371
404
  const gutterColor = vim ? (mode === "normal" ? "yellow" : "green") : "cyan";
372
- // The prompt box borders subtract 2 cells (1 each side) from the usable text width; InputLine's
373
- // deterministic wrap needs the INNER width so the cursor/continuation gutter stay exact.
374
- const innerW = Math.max(1, w - 2);
405
+ // The box borders (1 each side) + paddingX (1 each side) subtract 4 cells; InputLine's deterministic
406
+ // wrap needs the INNER width so the cursor/continuation gutter stay exact. The box is a fixed `width={w}`
407
+ // with `borderTop={false}` so the hand-drawn TopBorder (with the session title) supplies the top edge
408
+ // + corners and everything aligns column-for-column.
409
+ const innerW = Math.max(1, w - 4);
375
410
  const cwdShort = footerCwd(cwd);
376
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { borderStyle: "round", borderColor: "gray", borderDimColor: true, paddingX: 1, children: _jsx(InputLine, { value: value, cursor: cursor, width: innerW, gutter: gutter, gutterColor: gutterColor, placeholder: placeholder }) }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(Footer, { model: model, s: status, cwdShort: cwdShort, route: route }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, _jsx(ModeBar, { approval: status.approval })] }));
411
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsx(Box, { borderStyle: "round", borderTop: false, borderColor: "gray", borderDimColor: true, paddingX: 1, width: w, children: _jsx(InputLine, { value: value, cursor: cursor, width: innerW, gutter: gutter, gutterColor: gutterColor, placeholder: placeholder }) }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(Footer, { model: model, s: status, cwdShort: cwdShort, route: route }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, showModeSelector ? _jsx(ModeBar, { approval: status.approval }) : null] }));
377
412
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.99.0",
3
+ "version": "0.99.2",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"