@nanhara/hara 0.98.0 → 0.98.1

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
@@ -18,6 +18,22 @@ 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
+ /** 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;
21
37
  function Block({ item, open }) {
22
38
  switch (item.kind) {
23
39
  case "user":
@@ -233,15 +249,25 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
233
249
  const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
234
250
  const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
235
251
  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
252
  const statusRef = useRef(status);
239
253
  statusRef.current = status;
254
+ // Live-region write path (codex-style): deltas mutate `liveRef` synchronously (exact, never dropped),
255
+ // and a throttled flush (~30fps) reconciles it into the `current` React state that actually renders.
256
+ // As each block finalizes (a new block kind begins) its predecessors are appended to `history`
257
+ // (`<Static>`, rendered once) and dropped from the live buffer — so only the tail block stays dynamic.
258
+ const liveRef = useRef([]);
259
+ const flushTimerRef = useRef(null);
260
+ const toStaticRef = useRef([]); // finalized blocks awaiting append to <Static> on the next flush
240
261
  useEffect(() => {
241
262
  const fn = () => setStatus((s) => ({ ...s, agents: activity.running }));
242
263
  activity.onChange(fn);
243
264
  return () => activity.onChange(null);
244
265
  }, []);
266
+ // Cancel any pending live-region flush on unmount so a timer can't fire after teardown.
267
+ useEffect(() => () => {
268
+ if (flushTimerRef.current)
269
+ clearTimeout(flushTimerRef.current);
270
+ }, []);
245
271
  // Subscribe to todo_write updates so the panel re-renders when the agent edits the checklist.
246
272
  useEffect(() => {
247
273
  const unsub = onTodosChange((list) => {
@@ -258,14 +284,46 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
258
284
  clearTimeout(collapseTimerRef.current);
259
285
  };
260
286
  }, []);
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
- });
287
+ // Reconcile the synchronously-mutated live buffer into React state, at most once per ~33ms. First
288
+ // append any finalized blocks to <Static> (once, in order), then publish the remaining live tail.
289
+ // The array identities are fresh each flush so React/ink re-render exactly the minimal live region.
290
+ const flushLive = useCallback(() => {
291
+ flushTimerRef.current = null;
292
+ if (toStaticRef.current.length) {
293
+ const graduated = toStaticRef.current;
294
+ toStaticRef.current = [];
295
+ setHistory((h) => [...h, ...graduated]);
296
+ }
297
+ setCurrent(liveRef.current.slice());
268
298
  }, []);
299
+ // Schedule a throttled flush. Coalesces a burst of deltas into one re-render per LIVE_FRAME_MS —
300
+ // the leading edge is immediate (snappy first paint) and subsequent deltas ride the timer.
301
+ const scheduleFlush = useCallback(() => {
302
+ if (flushTimerRef.current)
303
+ return;
304
+ flushTimerRef.current = setTimeout(flushLive, LIVE_FRAME_MS);
305
+ }, [flushLive]);
306
+ const pushCurrent = useCallback((kind, text, merge = false) => {
307
+ const live = liveRef.current;
308
+ const last = live[live.length - 1];
309
+ if (merge && last && last.kind === kind) {
310
+ // Same streaming block continues — grow it in place in the live buffer (no new React item).
311
+ live[live.length - 1] = { ...last, text: last.text + text };
312
+ }
313
+ else {
314
+ // A new block begins. Everything before it in the live buffer is now finalized: graduate it
315
+ // to <Static> (folding reasoning) so it's written to scrollback ONCE and never re-rendered.
316
+ if (live.length) {
317
+ for (const it of live)
318
+ toStaticRef.current.push(foldForHistory(it));
319
+ liveRef.current = [{ id: nid(), kind, text }];
320
+ }
321
+ else {
322
+ live.push({ id: nid(), kind, text });
323
+ }
324
+ }
325
+ scheduleFlush();
326
+ }, [scheduleFlush]);
269
327
  // Lazy vision notice: 顾雅 spec — the header no longer carries an always-on "👁 …" line.
270
328
  // Instead, the first time an image attachment shows up in this session, we print the
271
329
  // routing notice once (inline). `visionShownRef` is the session-scoped flag.
@@ -357,17 +415,20 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
357
415
  catch (e) {
358
416
  pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
359
417
  }
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
- });
418
+ // Commit this turn's items to scrollback. The authoritative source is the synchronous live
419
+ // buffer (liveRef + any blocks already graduated to toStaticRef), NOT the throttled `current`
420
+ // React state so a fast slash-only turn (/design, /help, /skills…) that pushes a notice and
421
+ // returns before a flush fires still commits it, and a pending throttle timer can't double-emit.
422
+ if (flushTimerRef.current) {
423
+ clearTimeout(flushTimerRef.current);
424
+ flushTimerRef.current = null;
425
+ }
426
+ const committed = [...toStaticRef.current, ...liveRef.current.map(foldForHistory)];
427
+ toStaticRef.current = [];
428
+ liveRef.current = [];
429
+ if (committed.length)
430
+ setHistory((h) => [...h, ...committed]);
431
+ setCurrent([]);
371
432
  setWorking(false);
372
433
  ctrlRef.current = null;
373
434
  // Schedule a panel collapse: if there was a checklist this turn, fold it to a one-line summary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.98.0",
3
+ "version": "0.98.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"