@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 +0 -0
- package/dist/tui/App.js +81 -20
- package/package.json +1 -1
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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.
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|