@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 +17 -0
- package/dist/tui/App.js +32 -11
- package/dist/tui/InputBox.js +51 -16
- package/package.json +1 -1
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
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
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
}
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -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
|
-
/**
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
58
|
-
//
|
|
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
|
|
373
|
-
//
|
|
374
|
-
|
|
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
|
}
|