@nanhara/hara 0.98.0 → 0.98.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/dist/index.js CHANGED
File without changes
package/dist/tui/App.js CHANGED
@@ -8,7 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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
10
  import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
11
- import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { memo, 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";
@@ -18,7 +18,26 @@ import { currentTodos, onTodosChange } from "../tools/todo.js";
18
18
  let _id = 0;
19
19
  const nid = () => ++_id;
20
20
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
21
- function Block({ item, open }) {
21
+ /** Prepare a finalized turn item for the append-only `<Static>` scrollback. A completed reasoning
22
+ * block collapses to a single-line "✻ thought · N lines" notice (its full text preserved in `full`
23
+ * for the Ctrl+T overlay) — mirroring codex writing finalized rows to scrollback ONCE. Every other
24
+ * kind passes through unchanged. Used both mid-turn (as blocks finalize) and at turn end so a given
25
+ * turn's reasoning is emitted to Static exactly once and never re-rendered/re-emitted. */
26
+ function foldForHistory(it) {
27
+ if (it.kind !== "reasoning")
28
+ return it;
29
+ const lines = it.text.split("\n").filter((l) => l.trim()).length;
30
+ return { ...it, kind: "notice", text: `✻ thought · ${lines} lines`, full: it.text };
31
+ }
32
+ /** Redraw throttle for the LIVE region (~30fps). A fast token stream or a slow/remote terminal can
33
+ * push deltas far faster than a human perceives; without a cap ink re-diffs the growing dynamic
34
+ * block on every token, which over a laggy link leaves stale duplicate lines + jitter. 33ms clamps
35
+ * the live re-render rate while state itself stays exact (nothing is dropped, only coalesced). */
36
+ const LIVE_FRAME_MS = 33;
37
+ // Memoized: a live block only re-renders when its own `item` (a fresh object when its text grows) or
38
+ // `open` changes. So a spinner tick or an unrelated flush doesn't re-run `renderMarkdown` for every
39
+ // live block, and non-tail live blocks stay put while only the streaming tail grows.
40
+ const Block = memo(function Block({ item, open }) {
22
41
  switch (item.kind) {
23
42
  case "user":
24
43
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { children: item.text })] }));
@@ -42,7 +61,7 @@ function Block({ item, open }) {
42
61
  case "notice":
43
62
  return _jsx(Text, { dimColor: true, children: item.text });
44
63
  }
45
- }
64
+ });
46
65
  function flattenTranscript(items) {
47
66
  const out = [];
48
67
  for (const it of items) {
@@ -167,21 +186,33 @@ export function spinnerVerb(list, elapsedSec) {
167
186
  }
168
187
  return `working ${elapsedSec}s · esc to interrupt`;
169
188
  }
189
+ // The spinner is the only element that animates continuously while a turn runs — and because ink
190
+ // redraws the WHOLE dynamic region (input box + mode bar included) on any change, its tick rate sets
191
+ // a floor on full-frame redraws over the life of a turn. At ~8fps (125ms) the braille glyph still
192
+ // reads as smooth motion but cuts those forced redraws ~20% vs 100ms — meaningfully calmer over a
193
+ // slow/remote link. Elapsed seconds come from a wall-clock start, so the "Ns" text stays exact and
194
+ // stable (it only changes once per second, not coupled to the glyph frame).
195
+ const SPINNER_FRAME_MS = 125;
170
196
  function Working({ todos }) {
171
- const [n, setN] = useState(0);
197
+ const [frame, setFrame] = useState(0);
198
+ const startRef = useRef(Date.now());
172
199
  useEffect(() => {
173
- const id = setInterval(() => setN((x) => x + 1), 100);
200
+ startRef.current = Date.now();
201
+ const id = setInterval(() => setFrame((x) => x + 1), SPINNER_FRAME_MS);
174
202
  return () => clearInterval(id);
175
203
  }, []);
176
204
  const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
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))}` })] }));
205
+ const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
206
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${spinnerVerb(todos, elapsedSec)}` })] }));
178
207
  }
179
208
  // Live task panel: renders the current todo_write checklist between the in-progress turn output
180
209
  // and the input box. Highlights the in_progress item; caps at 8 rows and folds the rest into
181
210
  // `… +N pending/done`. Hidden when the list is empty.
182
211
  const PANEL_MAX_ROWS = 8;
183
212
  const TODO_MARK = { pending: "☐", in_progress: "▶", done: "☑" };
184
- function TodoPanel({ todos }) {
213
+ // Memoized on the `todos` array reference (stable between todo_write updates), so spinner ticks and
214
+ // streaming flushes don't re-run the sort/slice every frame.
215
+ const TodoPanel = memo(function TodoPanel({ todos }) {
185
216
  if (!todos.length)
186
217
  return null;
187
218
  const doneCount = todos.filter((t) => t.status === "done").length;
@@ -208,7 +239,7 @@ function TodoPanel({ todos }) {
208
239
  const done = t.status === "done";
209
240
  return (_jsx(Text, { color: inProg ? accent() : undefined, bold: inProg, dimColor: done, children: ` ${TODO_MARK[t.status]} ${t.text}` }, i));
210
241
  }), hiddenSummary ? _jsx(Text, { dimColor: true, children: hiddenSummary }) : null] }));
211
- }
242
+ });
212
243
  export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim, visionNotice }) {
213
244
  const { exit } = useApp();
214
245
  const [history, setHistory] = useState([]);
@@ -233,15 +264,25 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
233
264
  const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
234
265
  const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
235
266
  const drainingRef = useRef(false); // idempotency guard so the drain effect can't double-send one item
236
- const currentRef = useRef([]);
237
- currentRef.current = current;
238
267
  const statusRef = useRef(status);
239
268
  statusRef.current = status;
269
+ // Live-region write path (codex-style): deltas mutate `liveRef` synchronously (exact, never dropped),
270
+ // and a throttled flush (~30fps) reconciles it into the `current` React state that actually renders.
271
+ // As each block finalizes (a new block kind begins) its predecessors are appended to `history`
272
+ // (`<Static>`, rendered once) and dropped from the live buffer — so only the tail block stays dynamic.
273
+ const liveRef = useRef([]);
274
+ const flushTimerRef = useRef(null);
275
+ const toStaticRef = useRef([]); // finalized blocks awaiting append to <Static> on the next flush
240
276
  useEffect(() => {
241
277
  const fn = () => setStatus((s) => ({ ...s, agents: activity.running }));
242
278
  activity.onChange(fn);
243
279
  return () => activity.onChange(null);
244
280
  }, []);
281
+ // Cancel any pending live-region flush on unmount so a timer can't fire after teardown.
282
+ useEffect(() => () => {
283
+ if (flushTimerRef.current)
284
+ clearTimeout(flushTimerRef.current);
285
+ }, []);
245
286
  // Subscribe to todo_write updates so the panel re-renders when the agent edits the checklist.
246
287
  useEffect(() => {
247
288
  const unsub = onTodosChange((list) => {
@@ -258,14 +299,46 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
258
299
  clearTimeout(collapseTimerRef.current);
259
300
  };
260
301
  }, []);
261
- const pushCurrent = useCallback((kind, text, merge = false) => {
262
- setCurrent((cur) => {
263
- const last = cur[cur.length - 1];
264
- if (merge && last && last.kind === kind)
265
- return [...cur.slice(0, -1), { ...last, text: last.text + text }];
266
- return [...cur, { id: nid(), kind, text }];
267
- });
302
+ // Reconcile the synchronously-mutated live buffer into React state, at most once per ~33ms. First
303
+ // append any finalized blocks to <Static> (once, in order), then publish the remaining live tail.
304
+ // The array identities are fresh each flush so React/ink re-render exactly the minimal live region.
305
+ const flushLive = useCallback(() => {
306
+ flushTimerRef.current = null;
307
+ if (toStaticRef.current.length) {
308
+ const graduated = toStaticRef.current;
309
+ toStaticRef.current = [];
310
+ setHistory((h) => [...h, ...graduated]);
311
+ }
312
+ setCurrent(liveRef.current.slice());
268
313
  }, []);
314
+ // Schedule a throttled flush. Coalesces a burst of deltas into one re-render per LIVE_FRAME_MS —
315
+ // the leading edge is immediate (snappy first paint) and subsequent deltas ride the timer.
316
+ const scheduleFlush = useCallback(() => {
317
+ if (flushTimerRef.current)
318
+ return;
319
+ flushTimerRef.current = setTimeout(flushLive, LIVE_FRAME_MS);
320
+ }, [flushLive]);
321
+ const pushCurrent = useCallback((kind, text, merge = false) => {
322
+ const live = liveRef.current;
323
+ const last = live[live.length - 1];
324
+ if (merge && last && last.kind === kind) {
325
+ // Same streaming block continues — grow it in place in the live buffer (no new React item).
326
+ live[live.length - 1] = { ...last, text: last.text + text };
327
+ }
328
+ else {
329
+ // A new block begins. Everything before it in the live buffer is now finalized: graduate it
330
+ // to <Static> (folding reasoning) so it's written to scrollback ONCE and never re-rendered.
331
+ if (live.length) {
332
+ for (const it of live)
333
+ toStaticRef.current.push(foldForHistory(it));
334
+ liveRef.current = [{ id: nid(), kind, text }];
335
+ }
336
+ else {
337
+ live.push({ id: nid(), kind, text });
338
+ }
339
+ }
340
+ scheduleFlush();
341
+ }, [scheduleFlush]);
269
342
  // Lazy vision notice: 顾雅 spec — the header no longer carries an always-on "👁 …" line.
270
343
  // Instead, the first time an image attachment shows up in this session, we print the
271
344
  // routing notice once (inline). `visionShownRef` is the session-scoped flag.
@@ -357,17 +430,20 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
357
430
  catch (e) {
358
431
  pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
359
432
  }
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
- });
433
+ // Commit this turn's items to scrollback. The authoritative source is the synchronous live
434
+ // buffer (liveRef + any blocks already graduated to toStaticRef), NOT the throttled `current`
435
+ // React state so a fast slash-only turn (/design, /help, /skills…) that pushes a notice and
436
+ // returns before a flush fires still commits it, and a pending throttle timer can't double-emit.
437
+ if (flushTimerRef.current) {
438
+ clearTimeout(flushTimerRef.current);
439
+ flushTimerRef.current = null;
440
+ }
441
+ const committed = [...toStaticRef.current, ...liveRef.current.map(foldForHistory)];
442
+ toStaticRef.current = [];
443
+ liveRef.current = [];
444
+ if (committed.length)
445
+ setHistory((h) => [...h, ...committed]);
446
+ setCurrent([]);
371
447
  setWorking(false);
372
448
  ctrlRef.current = null;
373
449
  // Schedule a panel collapse: if there was a checklist this turn, fold it to a one-line summary
@@ -3,26 +3,39 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
3
  // prompt line in the middle, and a bottom border carrying the approval modes + token usage +
4
4
  // concurrency. Composed from <Text> rows (no ink border fork needed) so the title sits exactly
5
5
  // where we want it. Pure-ish: pass `width` to make rendering deterministic in tests.
6
+ //
7
+ // Render-stability principles (codex-style, for slow/remote terminals): ink erases and rewrites the
8
+ // ENTIRE dynamic region on every frame, so the box's cost scales with (a) how many lines it occupies
9
+ // and (b) how often any of them change. Two levers here:
10
+ // 1. The static chrome (borders, mode bar, mention popup) is memoized so a keystroke that only
11
+ // changes the prompt text doesn't force React to reconcile the unchanged rows.
12
+ // 2. Line-wrapping + cursor are computed deterministically from (value, cursor, width) in one memo,
13
+ // so long input wraps under a stable continuation indent and the cursor never drifts — instead
14
+ // of leaning on ink's soft-wrap of a single <Text>, which mis-aligns wrapped rows against the
15
+ // "› " prompt gutter and reflows unpredictably as you type.
6
16
  import { Box, Text, useInput, useStdout } from "ink";
7
- import { useMemo, useState } from "react";
17
+ import { memo, useMemo, useState } from "react";
8
18
  import { fileCandidates } from "../context/mentions.js";
9
19
  import { imagePathFromPaste } from "../images.js";
10
20
  import { vimNormal } from "./vim.js";
11
21
  export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
12
22
  export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
13
23
  const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
14
- function TopBorder({ name, width }) {
24
+ // The prompt gutter ("› " / "◆ ") is 2 cells wide; wrapped continuation lines indent by the same
25
+ // amount so the text column is stable across visual rows.
26
+ const GUTTER = 2;
27
+ const TopBorder = memo(function TopBorder({ name, width }) {
15
28
  const labelLen = name.length + 2; // "⏺ " + name
16
29
  const left = Math.max(2, width - labelLen - 3);
17
30
  return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["─".repeat(left), " "] }), _jsx(Text, { color: "cyan", children: "\u23FA" }), _jsxs(Text, { bold: true, children: [" ", name] }), _jsx(Text, { dimColor: true, children: " \u2500" })] }));
18
- }
31
+ });
19
32
  // Bottom border carries token usage + concurrency at the right corner (modes moved to ModeBar below).
20
- function BottomBorder({ s, width }) {
33
+ const BottomBorder = memo(function BottomBorder({ s, width }) {
21
34
  const usage = `↑${tok(s.input)} ↓${tok(s.output)}${s.ctxPct > 0 ? ` · ctx ${s.ctxPct}%` : ""}`;
22
35
  const label = s.agents > 0 ? `${usage} · ⛁${s.agents}` : `${usage} · ⛁ idle`;
23
36
  const left = Math.max(2, width - label.length - 3);
24
37
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${"─".repeat(left)} ${label} ─` }) }));
25
- }
38
+ });
26
39
  const MODE_DESC = {
27
40
  suggest: "confirms edits & commands",
28
41
  "auto-edit": "auto-applies edits · asks before commands",
@@ -31,23 +44,22 @@ const MODE_DESC = {
31
44
  };
32
45
  // Prominent approval-mode selector below the box: all three listed, the active one highlighted (red
33
46
  // for the dangerous full-auto) with a one-line description and the shift+tab hint.
34
- function ModeBar({ approval }) {
47
+ const ModeBar = memo(function ModeBar({ approval }) {
35
48
  const warn = approval === "full-auto";
36
49
  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 ⇄` })] }));
37
- }
50
+ });
38
51
  /** The active `@mention` token immediately left of the cursor (for the file popup), or null. */
39
52
  function activeMention(value, cursor) {
40
53
  const m = /(?:^|\s)@([^\s@]*)$/.exec(value.slice(0, cursor));
41
54
  return m ? { query: m[1], start: cursor - m[1].length } : null;
42
55
  }
43
56
  // Dropdown of fuzzy @path matches, shown above the input as you type `@…` (codex / Claude-Code style).
44
- function MentionPopup({ items, selected, query }) {
57
+ const MentionPopup = memo(function MentionPopup({ items, selected, query }) {
45
58
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: ` @${query} · ${items.length} match${items.length === 1 ? "" : "es"} — ↑↓ select · Tab/Enter insert · Esc dismiss` }), items.map((it, i) => (_jsxs(Text, { children: [i === selected ? _jsx(Text, { color: "cyan", children: " ▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: it.endsWith("/") ? "blue" : undefined, dimColor: i !== selected, bold: i === selected, children: it })] }, it)))] }));
46
- }
59
+ });
47
60
  const TOKEN_RE = /\[Image #\d+\]/g;
48
- /** Render the prompt line: plain text + the cursor, with any `[Image #N]` attachment tokens highlighted
49
- * (codex / Claude-Code style — the image lives inline in the text, visibly distinct from what you typed). */
50
- function InputLine({ value, cursor }) {
61
+ /** Split the value into text/image-token segments (image tokens render highlighted, codex-style). */
62
+ function segmentize(value) {
51
63
  const parts = [];
52
64
  let last = 0;
53
65
  let m;
@@ -60,30 +72,131 @@ function InputLine({ value, cursor }) {
60
72
  }
61
73
  if (last < value.length)
62
74
  parts.push({ text: value.slice(last), token: false });
75
+ return parts;
76
+ }
77
+ /** Wrap `value` into rows that each fit within `cols` cells, breaking on spaces where possible but
78
+ * never inside an `[Image #N]` token. Deterministic (no reliance on ink's soft-wrap) so wrapped rows
79
+ * align under a stable gutter and the cursor position is exact. Always returns at least one row. */
80
+ export function wrapRows(value, cols) {
81
+ const width = Math.max(1, cols);
82
+ const rows = [];
83
+ const parts = segmentize(value);
84
+ // Flatten to atomic units: a run of text is breakable per-char/word; an image token is atomic.
85
+ const atoms = [];
86
+ let pos = 0;
87
+ for (const p of parts) {
88
+ if (p.token) {
89
+ atoms.push({ text: p.text, start: pos, atomic: true });
90
+ pos += p.text.length;
91
+ }
92
+ else {
93
+ // Break the text run into word chunks (keep trailing space with the word) so wraps land on spaces.
94
+ const re = /\S+\s*|\s+/g;
95
+ let m;
96
+ let base = pos;
97
+ while ((m = re.exec(p.text))) {
98
+ atoms.push({ text: m[0], start: base + m.index, atomic: false });
99
+ }
100
+ pos += p.text.length;
101
+ }
102
+ }
103
+ if (atoms.length === 0)
104
+ return [{ start: 0, end: value.length }];
105
+ let rowStart = 0;
106
+ let col = 0;
107
+ const flush = (end) => {
108
+ rows.push({ start: rowStart, end });
109
+ rowStart = end;
110
+ col = 0;
111
+ };
112
+ for (const a of atoms) {
113
+ const aEnd = a.start + a.text.length;
114
+ if (a.atomic || a.text.length <= width) {
115
+ // A whole unit (image token OR a word chunk that fits within a full row): if it doesn't fit in
116
+ // the remaining room and the row already has content, wrap to a fresh row FIRST — never split it.
117
+ if (a.text.length > width - col && col > 0)
118
+ flush(a.start);
119
+ col += a.text.length; // an oversized atomic token may exceed width — acceptable (rare, kept whole)
120
+ if (col >= width)
121
+ flush(aEnd);
122
+ }
123
+ else {
124
+ // A single word longer than the whole width: hard-break it across rows.
125
+ let s = a.start;
126
+ if (col > 0)
127
+ flush(s); // start the long word on a fresh row
128
+ while (s < aEnd) {
129
+ const room = width - col;
130
+ const take = Math.min(room, aEnd - s);
131
+ col += take;
132
+ s += take;
133
+ if (col >= width)
134
+ flush(s);
135
+ }
136
+ }
137
+ }
138
+ // Flush the tail. If content exactly filled the last flushed row (rowStart === value.length), we add
139
+ // an EMPTY trailing row so an end-of-line cursor doesn't overflow the full row (would wrap oddly).
140
+ if (rowStart < value.length) {
141
+ rows.push({ start: rowStart, end: value.length });
142
+ }
143
+ else if (rows.length === 0) {
144
+ rows.push({ start: 0, end: value.length }); // whole value fit on one (un-flushed) row
145
+ }
146
+ else if (rowStart === value.length && col === 0) {
147
+ rows.push({ start: value.length, end: value.length }); // exactly-full last row → empty tail for the cursor
148
+ }
149
+ return rows;
150
+ }
151
+ /** Render a single visual row of the prompt: text + highlighted image tokens, drawing the block
152
+ * cursor (inverse cell) when it falls on this row. Splitting happens on precomputed rows so the
153
+ * whole prompt never reflows as a single <Text> — stable under wrapping and quick to diff. */
154
+ function renderRow(value, row, cursor, showCursor, isLastRow, keyPrefix) {
63
155
  const seg = (token, text, k) => token ? (_jsx(Text, { backgroundColor: "magenta", color: "white", children: text }, k)) : (_jsx(Text, { children: text }, k));
156
+ // Segments intersected with this row's [start,end) range.
157
+ const parts = segmentize(value);
64
158
  const nodes = [];
65
- let pos = 0;
66
159
  let ki = 0;
160
+ let pos = 0;
161
+ const cursorOnRow = showCursor && cursor >= row.start && cursor < row.end;
67
162
  for (const p of parts) {
68
- const start = pos;
69
- const end = pos + p.text.length;
70
- if (cursor >= start && cursor < end) {
71
- const rel = cursor - start;
163
+ const pStart = pos;
164
+ const pEnd = pos + p.text.length;
165
+ pos = pEnd;
166
+ const from = Math.max(pStart, row.start);
167
+ const to = Math.min(pEnd, row.end);
168
+ if (from >= to)
169
+ continue; // segment not on this row
170
+ const slice = p.text.slice(from - pStart, to - pStart);
171
+ if (cursorOnRow && cursor >= from && cursor < to) {
172
+ const rel = cursor - from;
72
173
  if (rel > 0)
73
- nodes.push(seg(p.token, p.text.slice(0, rel), `s${ki++}`));
74
- nodes.push(_jsx(Text, { inverse: true, children: p.text[rel] }, `c${ki++}`));
75
- if (rel + 1 < p.text.length)
76
- nodes.push(seg(p.token, p.text.slice(rel + 1), `e${ki++}`));
174
+ nodes.push(seg(p.token, slice.slice(0, rel), `${keyPrefix}s${ki++}`));
175
+ nodes.push(_jsx(Text, { inverse: true, children: slice[rel] }, `${keyPrefix}c${ki++}`));
176
+ if (rel + 1 < slice.length)
177
+ nodes.push(seg(p.token, slice.slice(rel + 1), `${keyPrefix}e${ki++}`));
77
178
  }
78
179
  else {
79
- nodes.push(seg(p.token, p.text, `p${ki++}`));
180
+ nodes.push(seg(p.token, slice, `${keyPrefix}p${ki++}`));
80
181
  }
81
- pos = end;
82
182
  }
83
- if (cursor >= value.length)
84
- nodes.push(_jsx(Text, { inverse: true, children: " " }, "end"));
85
- return _jsx(Text, { children: nodes });
183
+ // End-of-value cursor: a trailing inverse space, only ever on the LAST row (so it's drawn once).
184
+ if (showCursor && isLastRow && cursor >= value.length && cursor >= row.start) {
185
+ nodes.push(_jsx(Text, { inverse: true, children: " " }, `${keyPrefix}end`));
186
+ }
187
+ return nodes;
86
188
  }
189
+ /** The prompt: gutter + wrapped input rows (or a placeholder when empty). Each wrapped continuation
190
+ * row is indented under the gutter so the text column is stable. Memoized so it only re-renders when
191
+ * the value/cursor/width/gutter actually change (not when unrelated status ticks over). */
192
+ const InputLine = memo(function InputLine({ value, cursor, width, gutter, gutterColor, placeholder, }) {
193
+ const cols = Math.max(1, width - GUTTER); // text column width (after the 2-cell gutter)
194
+ const rows = useMemo(() => (value.length ? wrapRows(value, cols) : [{ start: 0, end: 0 }]), [value, cols]);
195
+ if (value.length === 0) {
196
+ return (_jsxs(Box, { children: [_jsx(Text, { color: gutterColor, children: gutter }), _jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })] }));
197
+ }
198
+ return (_jsx(Box, { flexDirection: "column", children: rows.map((row, i) => (_jsxs(Box, { children: [_jsx(Text, { color: gutterColor, children: i === 0 ? gutter : " " }), _jsx(Text, { children: renderRow(value, row, cursor, true, i === rows.length - 1, `r${i}_`) })] }, i))) }));
199
+ });
87
200
  /** Top border (session) + prompt line + bottom border (usage) + ModeBar, with an @path popup. */
88
201
  export function InputBox({ status, cwd, 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", }) {
89
202
  const { stdout } = useStdout();
@@ -124,7 +237,10 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
124
237
  setPending("");
125
238
  };
126
239
  const mention = activeMention(value, cursor);
127
- const candidates = useMemo(() => (isActive && mention && !dismissed ? fileCandidates(cwd, mention.query, 8) : []), [cwd, isActive, dismissed, mention?.query, mention?.start]);
240
+ // FS scan only when the mention QUERY changes not on every cursor move / unrelated keystroke.
241
+ // (`mention.start` moves whenever the cursor does; keying off it re-scanned the disk needlessly.)
242
+ const mentionQuery = isActive && mention && !dismissed ? mention.query : null;
243
+ const candidates = useMemo(() => (mentionQuery !== null ? fileCandidates(cwd, mentionQuery, 8) : []), [cwd, mentionQuery]);
128
244
  const popupOpen = candidates.length > 0;
129
245
  const selIdx = popupOpen ? Math.min(sel, candidates.length - 1) : 0;
130
246
  const complete = (cand) => {
@@ -238,5 +354,7 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
238
354
  set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
239
355
  }
240
356
  }, { isActive });
241
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsxs(Box, { children: [_jsx(Text, { color: vim ? (mode === "normal" ? "yellow" : "green") : "cyan", children: vim && mode === "normal" ? "◆ " : "› " }), value.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsx(InputLine, { value: value, cursor: cursor }))] }), 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(BottomBorder, { s: status, width: w }), 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 })] }));
357
+ const gutter = vim && mode === "normal" ? "◆ " : "› ";
358
+ const gutterColor = vim ? (mode === "normal" ? "yellow" : "green") : "cyan";
359
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsx(InputLine, { value: value, cursor: cursor, width: w, 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(BottomBorder, { s: status, width: w }), 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 })] }));
242
360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.98.0",
3
+ "version": "0.98.2",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"