u-foo 2.5.7 → 2.5.9

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.
@@ -22,7 +22,7 @@ const { runInk } = require("../runInk");
22
22
  const fmt = require("../format");
23
23
  const { createMultilineInput } = require("./MultilineInput");
24
24
  const { createDashboardBar } = require("./DashboardBar");
25
- const { reducer, createInitialState } = require("./chatReducer");
25
+ const { reducer, createInitialState, activeStreamText } = require("./chatReducer");
26
26
  const {
27
27
  stripBlessedTags,
28
28
  compactDividerLabel,
@@ -396,13 +396,117 @@ function normalizeInkLogLines(text = "") {
396
396
  return clean.split(/\r?\n/);
397
397
  }
398
398
 
399
+ // Stream deltas are batched into one dispatch per window (see
400
+ // createInkStreamState) so a fast stream can't force 30+ full-tree
401
+ // re-renders per second.
402
+ const STREAM_FLUSH_INTERVAL_MS = 80;
403
+
404
+ // Burst-coalescing sender: the first call fires immediately, calls inside
405
+ // the window collapse into a single trailing send. Used for daemon STATUS
406
+ // requests, which arrive in bursts (bus traffic + router callbacks) and
407
+ // each trigger a dashboard re-render.
408
+ function createThrottledSender(send, windowMs = 500) {
409
+ let lastSentAt = 0;
410
+ let timer = null;
411
+ const fire = () => {
412
+ timer = null;
413
+ lastSentAt = Date.now();
414
+ send();
415
+ };
416
+ return () => {
417
+ const now = Date.now();
418
+ const elapsed = now - lastSentAt;
419
+ if (elapsed >= windowMs) {
420
+ if (timer) {
421
+ clearTimeout(timer);
422
+ timer = null;
423
+ }
424
+ lastSentAt = now;
425
+ send();
426
+ return;
427
+ }
428
+ if (!timer) {
429
+ timer = setTimeout(fire, windowMs - elapsed);
430
+ if (typeof timer.unref === "function") timer.unref();
431
+ }
432
+ };
433
+ }
434
+
435
+ // Kinds whose log entries render as a margin-bottom "transcript cell" in
436
+ // buildChatLogGroups. Kept in sync with canAppendToChatLogGroup in
437
+ // chatLogModel.js.
438
+ const STATIC_GROUPABLE_KINDS = new Set(["assistant", "agent", "success", "error", "meta", "plain"]);
439
+
440
+ // Shared row colors for both the dynamic (stream) and <Static> renderers.
441
+ const CHAT_LOG_ROW_PALETTE = {
442
+ assistant: { marker: "cyan", speaker: "white", body: undefined, bold: true },
443
+ agent: { marker: "cyan", speaker: "cyan", body: undefined, bold: false },
444
+ error: { marker: "red", speaker: "red", body: "red", bold: true },
445
+ success: { marker: "green", speaker: "green", body: "green", bold: false },
446
+ divider: { marker: "gray", speaker: "gray", body: "gray", bold: false },
447
+ banner: { marker: "cyan", speaker: "cyan", body: "cyan", bold: true },
448
+ meta: { marker: "gray", speaker: "gray", body: "gray", bold: false },
449
+ plain: { marker: "gray", speaker: "gray", body: undefined, bold: false },
450
+ };
451
+
452
+ // Decorate one finalized log entry with the grouping facts the <Static>
453
+ // renderer needs. Grouping is a deterministic left-to-right fold (same
454
+ // rules as buildChatLogGroups), so once an entry is decorated its flags
455
+ // never change — which is exactly what Static's append-only rendering
456
+ // requires. `marginBefore` reproduces the old group marginBottom as a
457
+ // margin-top on the next entry, because per-item rendering can't know a
458
+ // group's end until the following entry arrives.
459
+ function decorateStaticLogEntry(prev, entry) {
460
+ const row = buildChatLogLineModel(entry);
461
+ const continuation = Boolean(
462
+ prev
463
+ && (row.kind === "plain" || row.kind === "spacer")
464
+ && STATIC_GROUPABLE_KINDS.has(prev.groupKind)
465
+ );
466
+ const groupKind = continuation ? prev.groupKind : row.kind;
467
+ // A gap belongs between visual blocks: only on entries that START a new
468
+ // block, and only when the previous block was a transcript group (whose
469
+ // old dynamic renderer contributed a trailing marginBottom).
470
+ const marginBefore = Boolean(!continuation && prev && STATIC_GROUPABLE_KINDS.has(prev.groupKind));
471
+ return { entry, row, groupKind, continuation, marginBefore };
472
+ }
473
+
399
474
  function createInkStreamState({
400
475
  dispatch,
401
476
  appendHistory,
402
477
  displayNameForPublisher = (value) => value,
478
+ flushIntervalMs = STREAM_FLUSH_INTERVAL_MS,
403
479
  } = {}) {
404
480
  const streams = new Map();
405
481
  const pendingDeliveries = new Map();
482
+ // Delta batches awaiting dispatch, keyed like `streams`. Deltas arrive per
483
+ // daemon chunk (dozens per second); dispatching each one re-renders the
484
+ // whole Ink tree, so we accumulate for a short window and flush one
485
+ // stream/delta action per publisher per window.
486
+ const pendingDeltas = new Map();
487
+ let flushTimer = null;
488
+
489
+ function flushDeltas() {
490
+ if (flushTimer) {
491
+ clearTimeout(flushTimer);
492
+ flushTimer = null;
493
+ }
494
+ if (pendingDeltas.size === 0) return;
495
+ for (const batch of pendingDeltas.values()) {
496
+ dispatch({
497
+ type: "stream/delta",
498
+ publisher: batch.publisher,
499
+ delta: batch.parts.join(""),
500
+ });
501
+ }
502
+ pendingDeltas.clear();
503
+ }
504
+
505
+ function scheduleFlush() {
506
+ if (flushTimer) return;
507
+ flushTimer = setTimeout(flushDeltas, flushIntervalMs);
508
+ if (typeof flushTimer.unref === "function") flushTimer.unref();
509
+ }
406
510
 
407
511
  function deliveryKey(agentId, agentLabel) {
408
512
  return String(agentId || agentLabel || "").trim();
@@ -454,7 +558,7 @@ function createInkStreamState({
454
558
  displayName,
455
559
  prefix,
456
560
  continuationPrefix,
457
- full: "",
561
+ parts: [],
458
562
  meta: meta || {},
459
563
  };
460
564
  streams.set(key, state);
@@ -464,19 +568,30 @@ function createInkStreamState({
464
568
 
465
569
  function appendStreamDelta(state, delta) {
466
570
  if (!state || !delta) return;
467
- state.full += String(delta || "");
468
- dispatch({ type: "stream/delta", publisher: state.displayName || state.publisher, delta: String(delta || "") });
571
+ const text = String(delta || "");
572
+ state.parts.push(text);
573
+ let batch = pendingDeltas.get(state.publisher);
574
+ if (!batch) {
575
+ batch = { publisher: state.displayName || state.publisher, parts: [] };
576
+ pendingDeltas.set(state.publisher, batch);
577
+ }
578
+ batch.parts.push(text);
579
+ scheduleFlush();
469
580
  }
470
581
 
471
582
  function finalizeStream(publisher, meta, reason = "") {
472
583
  const key = String(publisher || "bus");
473
584
  const state = streams.get(key);
474
585
  if (!state) return;
586
+ // Flush first so the trailing deltas land on activeStream before the
587
+ // stream/end fold reads them.
588
+ flushDeltas();
475
589
  dispatch({ type: "stream/end" });
476
590
  if (typeof appendHistory === "function") {
591
+ const full = state.parts.join("");
477
592
  const text = state.displayName
478
- ? `${state.displayName}: ${state.full}`
479
- : state.full;
593
+ ? `${state.displayName}: ${full}`
594
+ : full;
480
595
  appendHistory("bus", text, { ...(meta || state.meta || {}), stream_done: true, stream_reason: reason });
481
596
  }
482
597
  streams.delete(key);
@@ -494,6 +609,7 @@ function createInkStreamState({
494
609
  appendStreamDelta,
495
610
  finalizeStream,
496
611
  hasStream,
612
+ flushDeltas,
497
613
  };
498
614
  }
499
615
 
@@ -1003,6 +1119,53 @@ function isAnimatedStatusType(type = "") {
1003
1119
  return value !== "done" && value !== "success" && value !== "error" && value !== "idle" && value !== "none";
1004
1120
  }
1005
1121
 
1122
+ // The status bar owns its spinner tick: the 100ms animation timer lives in
1123
+ // this leaf component instead of the ChatApp root, so animating the spinner
1124
+ // re-renders one line of text rather than the whole tree (previously every
1125
+ // tick re-rendered the full log area, which Ink erased and rewrote at
1126
+ // 10fps — the visible flicker).
1127
+ function createChatStatusLine({ React, ink }) {
1128
+ const { useEffect, useState } = React;
1129
+ const { Box, Text } = ink;
1130
+ const h = React.createElement;
1131
+ return function ChatStatusLine({ status, version }) {
1132
+ const message = String((status && status.message) || "");
1133
+ const animated = Boolean(message)
1134
+ && isAnimatedStatusType(inferStatusType(message, status && status.type));
1135
+ const [tick, setTick] = useState(0);
1136
+ useEffect(() => {
1137
+ if (!animated) return undefined;
1138
+ const timer = setInterval(() => setTick((t) => t + 1), 100);
1139
+ return () => clearInterval(timer);
1140
+ }, [animated]);
1141
+ return h(Box, { marginTop: 1, width: "100%" },
1142
+ h(Text, { color: "gray" }, computeStatusText(status, animated ? tick : 0)),
1143
+ h(Box, { flexGrow: 1 }),
1144
+ h(Text, { color: "gray" }, `v${version}`),
1145
+ );
1146
+ };
1147
+ }
1148
+
1149
+ // Same spinner isolation for the internal-agent view's status row.
1150
+ function createInternalStatusLine({ React, ink }) {
1151
+ const { useEffect, useState } = React;
1152
+ const { Text } = ink;
1153
+ const h = React.createElement;
1154
+ return function InternalStatusLine({ view, maxWidth }) {
1155
+ const status = internalStatusLabel(view && view.status);
1156
+ const active = status !== "ready";
1157
+ const [tick, setTick] = useState(0);
1158
+ useEffect(() => {
1159
+ if (!active) return undefined;
1160
+ const timer = setInterval(() => setTick((t) => t + 1), 100);
1161
+ return () => clearInterval(timer);
1162
+ }, [active]);
1163
+ const color = status === "blocked" ? "red" : (status === "ready" ? "gray" : "cyan");
1164
+ const text = computeInternalStatusText(view || {}, active ? tick : 0);
1165
+ return h(Text, { color, wrap: "truncate" }, fitPlainLine(text, maxWidth));
1166
+ };
1167
+ }
1168
+
1006
1169
  function inkKeyToRaw(input, key) {
1007
1170
  if (key.ctrl && input) {
1008
1171
  const code = input.charCodeAt(0) - 96;
@@ -1023,11 +1186,13 @@ function inkKeyToRaw(input, key) {
1023
1186
  }
1024
1187
 
1025
1188
  function createChatApp({ React, ink, props, interactive = true }) {
1026
- const { useReducer, useEffect, useState, useCallback, useRef } = React;
1189
+ const { useReducer, useEffect, useState, useCallback, useRef, useMemo } = React;
1027
1190
  const { Box, Text, Static, useInput, useApp, useStdout } = ink;
1028
1191
  const h = React.createElement;
1029
1192
  const MultilineInput = createMultilineInput({ React, ink });
1030
1193
  const DashboardBar = createDashboardBar({ React, ink });
1194
+ const ChatStatusLine = createChatStatusLine({ React, ink });
1195
+ const InternalStatusLine = createInternalStatusLine({ React, ink });
1031
1196
 
1032
1197
  // Build the initial log: chat history if there is any, otherwise an
1033
1198
  // ASCII banner with project / mode / version info. We resolve history
@@ -1052,7 +1217,6 @@ function createChatApp({ React, ink, props, interactive = true }) {
1052
1217
  })
1053
1218
  );
1054
1219
  const [size, setSize] = useState({ cols: 0, rows: 0 });
1055
- const [spinnerTick, setSpinnerTick] = useState(0);
1056
1220
  const [currentProjectRoot, setCurrentProjectRoot] = useState(props.activeProjectRoot || props.projectRoot || "");
1057
1221
  const [internalAgentView, setInternalAgentView] = useState(() => createInternalAgentViewState());
1058
1222
  const [multiWindowActive, setMultiWindowActive] = useState(false);
@@ -1556,13 +1720,24 @@ function createChatApp({ React, ink, props, interactive = true }) {
1556
1720
  return true;
1557
1721
  };
1558
1722
 
1723
+ // STATUS requests arrive in bursts (every bus message routes through
1724
+ // the router's requestStatus plus command callbacks); each response
1725
+ // dispatches dashboard updates. Coalesce bursts into one send per
1726
+ // window so a busy bus can't spin the render loop.
1727
+ const statusRequestThrottlerRef = useRef(null);
1728
+ if (!statusRequestThrottlerRef.current) {
1729
+ statusRequestThrottlerRef.current = createThrottledSender(() => {
1730
+ try {
1731
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
1732
+ const conn = props.daemonConnection;
1733
+ if (conn && typeof conn.send === "function") conn.send({ type: IPC_REQUEST_TYPES.STATUS });
1734
+ } catch { /* ignore */ }
1735
+ }, 500);
1736
+ }
1559
1737
  const requestDaemonStatus = useCallback(() => {
1560
- try {
1561
- const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
1562
- const conn = props.daemonConnection;
1563
- if (conn && typeof conn.send === "function") conn.send({ type: IPC_REQUEST_TYPES.STATUS });
1564
- } catch { /* ignore */ }
1565
- }, [props.daemonConnection]);
1738
+ const throttled = statusRequestThrottlerRef.current;
1739
+ if (throttled) throttled();
1740
+ }, []);
1566
1741
 
1567
1742
  const updateDashboardFromStatus = useCallback((data = {}) => {
1568
1743
  const activeIds = Array.isArray(data.active) ? data.active : [];
@@ -1661,17 +1836,30 @@ function createChatApp({ React, ink, props, interactive = true }) {
1661
1836
  hasStream: (...args) => streamState.hasStream(...args),
1662
1837
  setTransientAgentState: (agentId, value, options = {}) => {
1663
1838
  if (!agentId || !value) return;
1839
+ const detail = options.detail || "";
1840
+ // Activity updates fire per agent event; skip the dispatch (and
1841
+ // the re-render it implies) when nothing actually changed.
1842
+ const current = stateRef.current || {};
1843
+ const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : null;
1844
+ const existing = (metaMap && metaMap.get(agentId)) || {};
1845
+ if (existing.activity_state === value && String(existing.activity_detail || "") === detail) {
1846
+ return;
1847
+ }
1664
1848
  dispatch({
1665
1849
  type: "agents/patchMeta",
1666
1850
  agentId,
1667
1851
  patch: {
1668
1852
  activity_state: value,
1669
- activity_detail: options.detail || "",
1853
+ activity_detail: detail,
1670
1854
  },
1671
1855
  });
1672
1856
  },
1673
1857
  clearTransientAgentState: (agentId) => {
1674
1858
  if (!agentId) return;
1859
+ const current = stateRef.current || {};
1860
+ const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : null;
1861
+ const existing = (metaMap && metaMap.get(agentId)) || {};
1862
+ if (!existing.activity_state && !existing.activity_detail) return;
1675
1863
  dispatch({
1676
1864
  type: "agents/patchMeta",
1677
1865
  agentId,
@@ -1868,16 +2056,6 @@ function createChatApp({ React, ink, props, interactive = true }) {
1868
2056
  return () => clearInterval(timer);
1869
2057
  }, [interactive, props.globalMode, currentProjectRoot, refreshGlobalProjects]);
1870
2058
 
1871
- useEffect(() => {
1872
- const internalStatus = state.viewingAgentId ? internalStatusLabel(internalAgentView.status) : "ready";
1873
- const internalActive = internalStatus !== "ready";
1874
- const statusType = inferStatusType(state.status.message, state.status.type);
1875
- const statusAnimated = state.status.message && isAnimatedStatusType(statusType);
1876
- if ((!statusAnimated) && !internalActive) return undefined;
1877
- const timer = setInterval(() => setSpinnerTick((t) => t + 1), 100);
1878
- return () => clearInterval(timer);
1879
- }, [state.status.message, state.status.type, state.viewingAgentId, internalAgentView.status]);
1880
-
1881
2059
  const selectedProject = state.selectedProjectIndex >= 0 ? state.projects[state.selectedProjectIndex] : null;
1882
2060
  const selectedProjectRoot = state.selectedProjectRoot || resolveProjectRowRoot(selectedProject);
1883
2061
  const currentProject = state.projects.find((row) => resolveProjectRowRoot(row) === currentProjectRoot) || null;
@@ -3159,7 +3337,11 @@ function createChatApp({ React, ink, props, interactive = true }) {
3159
3337
  }
3160
3338
  }, { isActive: interactive });
3161
3339
 
3162
- const statusText = computeStatusText(state.status, spinnerTick);
3340
+ // Chrome text for multi-window mode only — the visible status bar is the
3341
+ // ChatStatusLine component below, which owns its spinner tick. The
3342
+ // multi-window controller re-renders on its own events, so a static
3343
+ // first-frame indicator here matches what was effectively shown before.
3344
+ const statusText = computeStatusText(state.status, 0);
3163
3345
  const inputWidth = Math.max(20, (size.cols || 80) - 4);
3164
3346
  const promptPrefix = (() => {
3165
3347
  const projectPrefix = inCommittedProjectScope && currentProjectLabel ? `${currentProjectLabel} ` : "";
@@ -3224,6 +3406,54 @@ function createChatApp({ React, ink, props, interactive = true }) {
3224
3406
  }
3225
3407
  }, [multiWindowActive, completionsOpen, completions.length, completionIndex, completionWindowStart]);
3226
3408
 
3409
+ // Append-only feed for the <Static> log area. Ink's Static renders each
3410
+ // item exactly once (permanently, above the live frame), which is what
3411
+ // stops the 10fps full-log erase/rewrite — but it also means items must
3412
+ // be immutable and the array must never shrink. The reducer's LOG_CAP
3413
+ // truncates from the head of state.logLines, so we can't pass it
3414
+ // directly; instead we copy only the newly appended tail (tracked via
3415
+ // the monotonic lineSeq) into our own list. log/clear resets lineSeq,
3416
+ // which bumps `generation` and remounts the Static (its internal cursor
3417
+ // would otherwise point past the shrunk array). The fresh array per
3418
+ // batch matters too: Static memoizes on the items reference.
3419
+ const staticLogRef = useRef({ items: [], lastSeq: 0, generation: 0 });
3420
+ const staticLog = useMemo(() => {
3421
+ const prev = staticLogRef.current || { items: [], lastSeq: 0, generation: 0 };
3422
+ let { items, lastSeq, generation } = prev;
3423
+ if (state.lineSeq < lastSeq) {
3424
+ items = [];
3425
+ lastSeq = 0;
3426
+ generation += 1;
3427
+ }
3428
+ const newCount = state.lineSeq - lastSeq;
3429
+ if (newCount > 0) {
3430
+ const fresh = state.logLines.slice(-newCount);
3431
+ items = items.slice();
3432
+ for (const entry of fresh) {
3433
+ items.push(decorateStaticLogEntry(items[items.length - 1], entry));
3434
+ }
3435
+ lastSeq = state.lineSeq;
3436
+ }
3437
+ const next = { items, lastSeq, generation };
3438
+ staticLogRef.current = next;
3439
+ return next;
3440
+ }, [state.logLines, state.lineSeq]);
3441
+
3442
+ // Grouped view of the in-flight stream, recomputed only when the stream
3443
+ // itself advances (the batched flush above keeps that cadence low)
3444
+ // instead of on every unrelated render.
3445
+ const activeStreamGroups = useMemo(() => {
3446
+ if (!state.activeStream) return null;
3447
+ const lines = activeStreamText(state.activeStream).split(/\r?\n/);
3448
+ const prefix = state.activeStream.publisher
3449
+ ? `${state.activeStream.publisher}: `
3450
+ : "";
3451
+ return buildChatLogGroups(lines.map((line, idx) => ({
3452
+ id: `s-${idx}`,
3453
+ text: idx === 0 ? `${prefix}${line}` : ` ${line}`,
3454
+ })));
3455
+ }, [state.activeStream]);
3456
+
3227
3457
  if (multiWindowActive) {
3228
3458
  return null;
3229
3459
  }
@@ -3234,17 +3464,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
3234
3464
  if (row.kind === "spacer") {
3235
3465
  return h(Text, { key, color: "gray" }, " ");
3236
3466
  }
3237
- const palette = {
3238
- assistant: { marker: "cyan", speaker: "white", body: undefined, bold: true },
3239
- agent: { marker: "cyan", speaker: "cyan", body: undefined, bold: false },
3240
- error: { marker: "red", speaker: "red", body: "red", bold: true },
3241
- success: { marker: "green", speaker: "green", body: "green", bold: false },
3242
- divider: { marker: "gray", speaker: "gray", body: "gray", bold: false },
3243
- banner: { marker: "cyan", speaker: "cyan", body: "cyan", bold: true },
3244
- meta: { marker: "gray", speaker: "gray", body: "gray", bold: false },
3245
- plain: { marker: "gray", speaker: "gray", body: undefined, bold: false },
3246
- };
3247
- const colors = palette[row.kind] || palette.plain;
3467
+ const colors = CHAT_LOG_ROW_PALETTE[row.kind] || CHAT_LOG_ROW_PALETTE.plain;
3248
3468
  if (row.kind === "divider") {
3249
3469
  return h(Box, { key, marginBottom: 1 },
3250
3470
  h(Text, { color: colors.body, wrap: "truncate" }, ` ${compactDividerLabel(row.body)}`),
@@ -3289,17 +3509,51 @@ function createChatApp({ React, ink, props, interactive = true }) {
3289
3509
  ...entries.map((entry) => renderChatLogEntry(entry, group)));
3290
3510
  };
3291
3511
 
3292
- const renderChatLogGroups = (items) => buildChatLogGroups(items)
3293
- .map(renderChatLogGroup)
3294
- .filter(Boolean);
3512
+ // Renderer for one finalized (append-only) <Static> log item. Mirrors
3513
+ // renderChatLogEntry visually; spacing differs because per-item
3514
+ // rendering can't wrap a group in one margin-bottom Box — instead the
3515
+ // decoration pass flags `marginBefore` on whatever entry follows a
3516
+ // transcript group.
3517
+ const renderStaticChatLogItem = (item) => {
3518
+ const { row, groupKind, continuation, marginBefore } = item;
3519
+ const key = item.entry && item.entry.id ? item.entry.id : `log-${row.body}`;
3520
+ const marginTop = marginBefore ? 1 : 0;
3521
+ if (row.kind === "spacer") {
3522
+ return h(Box, { key, marginTop },
3523
+ h(Text, { color: "gray" }, " "));
3524
+ }
3525
+ const colors = CHAT_LOG_ROW_PALETTE[row.kind] || CHAT_LOG_ROW_PALETTE.plain;
3526
+ if (row.kind === "divider") {
3527
+ return h(Box, { key, marginTop, marginBottom: 1 },
3528
+ h(Text, { color: colors.body, wrap: "truncate" }, ` ${compactDividerLabel(row.body)}`),
3529
+ );
3530
+ }
3531
+ if (row.kind === "banner") {
3532
+ return h(Box, { key, marginTop },
3533
+ h(Text, { color: colors.body, bold: true, wrap: "truncate" }, row.body),
3534
+ );
3535
+ }
3536
+ const markerText = continuation
3537
+ ? (groupKind === "assistant" || groupKind === "agent" ? " " : " ")
3538
+ : row.markerText;
3539
+ return h(Box, { key, width: "100%", marginTop },
3540
+ h(Text, { color: colors.marker, bold: row.kind === "error" }, markerText),
3541
+ h(Text, { color: colors.body, wrap: "wrap" },
3542
+ row.speaker && !continuation
3543
+ ? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
3544
+ : null,
3545
+ row.speaker && !continuation
3546
+ ? h(Text, { color: "gray" }, " · ")
3547
+ : null,
3548
+ row.bodyText,
3549
+ ),
3550
+ );
3551
+ };
3295
3552
 
3296
3553
  if (state.viewingAgentId) {
3297
3554
  const maxWidth = Math.max(20, size.cols || 80);
3298
3555
  const logRows = Math.max(1, (size.rows || 24) - 5);
3299
3556
  const visibleRows = buildInternalLogRows(internalAgentView.lines || [], maxWidth, logRows);
3300
- const status = internalStatusLabel(internalAgentView.status);
3301
- const internalStatusText = computeInternalStatusText(internalAgentView, spinnerTick);
3302
- const internalStatusColor = status === "blocked" ? "red" : (status === "ready" ? "gray" : "cyan");
3303
3557
  const inputText = String(internalAgentView.input || "");
3304
3558
  const cursor = Math.max(0, Math.min(inputText.length, Number(internalAgentView.cursor) || 0));
3305
3559
  const beforeCursor = inputText.slice(0, cursor);
@@ -3347,8 +3601,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
3347
3601
  }, (row && row.text) || " ");
3348
3602
  }),
3349
3603
  ),
3350
- h(Text, { color: internalStatusColor, wrap: "truncate" },
3351
- fitPlainLine(internalStatusText, maxWidth)),
3604
+ h(InternalStatusLine, { view: internalAgentView, maxWidth }),
3352
3605
  h(Text, { color: "gray", wrap: "truncate" }, "─".repeat(maxWidth)),
3353
3606
  h(Box, { width: "100%" },
3354
3607
  h(Text, { color: "magenta" }, "› "),
@@ -3367,31 +3620,30 @@ function createChatApp({ React, ink, props, interactive = true }) {
3367
3620
  );
3368
3621
  }
3369
3622
 
3623
+ const lastStaticItem = staticLog.items[staticLog.items.length - 1] || null;
3624
+
3370
3625
  return h(Box, { flexDirection: "column", width: "100%" },
3371
- h(Box, { flexDirection: "column", width: "100%" },
3372
- ...renderChatLogGroups(state.logLines),
3373
- ),
3626
+ // Finalized log entries live in <Static>: each item is written to the
3627
+ // terminal exactly once (above the live frame) and never re-rendered,
3628
+ // so spinner ticks and keystrokes no longer erase/rewrite the whole
3629
+ // scrollback. key=generation remounts the Static after log/clear.
3630
+ h(Static, {
3631
+ key: `chat-log-${staticLog.generation}`,
3632
+ items: staticLog.items,
3633
+ }, (item) => renderStaticChatLogItem(item)),
3634
+ // Reproduces the trailing marginBottom the last transcript group used
3635
+ // to contribute in the dynamic layout.
3636
+ lastStaticItem && STATIC_GROUPABLE_KINDS.has(lastStaticItem.groupKind)
3637
+ ? h(Text, { color: "gray" }, " ")
3638
+ : null,
3374
3639
  state.activeMerge ? h(Box, null,
3375
3640
  h(Text, { color: state.activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
3376
3641
  fmt.buildToolMergeRowText(state.activeMerge.entries)),
3377
3642
  ) : null,
3378
- state.activeStream ? h(Box, { flexDirection: "column" },
3379
- ...(() => {
3380
- const lines = String(state.activeStream.text || "").split(/\r?\n/);
3381
- const prefix = state.activeStream.publisher
3382
- ? `${state.activeStream.publisher}: `
3383
- : "";
3384
- return renderChatLogGroups(lines.map((line, idx) => ({
3385
- id: `s-${idx}`,
3386
- text: idx === 0 ? `${prefix}${line}` : ` ${line}`,
3387
- })));
3388
- })(),
3643
+ activeStreamGroups ? h(Box, { flexDirection: "column" },
3644
+ ...activeStreamGroups.map(renderChatLogGroup).filter(Boolean),
3389
3645
  ) : null,
3390
- h(Box, { marginTop: 1, width: "100%" },
3391
- h(Text, { color: "gray" }, statusText),
3392
- h(Box, { flexGrow: 1 }),
3393
- h(Text, { color: "gray" }, `v${fmt.UCODE_VERSION}`),
3394
- ),
3646
+ h(ChatStatusLine, { status: state.status, version: fmt.UCODE_VERSION }),
3395
3647
  completionsOpen ? (() => {
3396
3648
  const start = Math.min(completionWindowStart, Math.max(0, completions.length - POPUP_PAGE_SIZE));
3397
3649
  const end = Math.min(completions.length, start + POPUP_PAGE_SIZE);
@@ -3664,6 +3916,11 @@ async function runChatInk(projectRoot, options = {}) {
3664
3916
  module.exports = {
3665
3917
  runChatInk,
3666
3918
  createChatApp,
3919
+ createChatStatusLine,
3920
+ createInternalStatusLine,
3921
+ createInkStreamState,
3922
+ createThrottledSender,
3923
+ decorateStaticLogEntry,
3667
3924
  bootstrapEnvironment,
3668
3925
  buildDirectBusSendRequest,
3669
3926
  buildPromptIpcRequest,
@@ -44,6 +44,7 @@ const DEFAULT_PROVIDER_OPTIONS = [
44
44
  { label: "codex", value: "codex-cli" },
45
45
  { label: "claude", value: "claude-cli" },
46
46
  { label: "agy", value: "agy-cli" },
47
+ { label: "kimi", value: "kimi-cli" },
47
48
  ];
48
49
  function projectRootOf(row = {}) {
49
50
  return String((row && (row.root || row.project_root || row.projectRoot)) || "");
@@ -133,7 +134,9 @@ function createInitialState({ banner = [], globalMode = false, globalScope = "co
133
134
  // activeStream is the in-flight chunk-by-chunk publisher message (set
134
135
  // while the daemon is streaming). Rendered live below <Static>;
135
136
  // promoted to <Static> when the stream finishes the same way the
136
- // tool-merge group is.
137
+ // tool-merge group is. Text accumulates in `chunks` (joined on read via
138
+ // activeStreamText) so each delta dispatch is O(1) instead of O(n)
139
+ // string concatenation.
137
140
  activeStream: null,
138
141
  inputHistory: [],
139
142
  historyIndex: 0,
@@ -166,6 +169,15 @@ function freezeMergeIntoLog(state) {
166
169
  return appendLog({ ...state, activeMerge: null }, summary);
167
170
  }
168
171
 
172
+ // Joined text of the in-flight stream. Deltas accumulate in `chunks` so the
173
+ // reducer never does per-delta string concatenation (O(n²) over a stream);
174
+ // readers pay a single O(n) join when they actually need the text.
175
+ function activeStreamText(stream) {
176
+ if (!stream || typeof stream !== "object") return "";
177
+ if (Array.isArray(stream.chunks)) return stream.chunks.join("");
178
+ return String(stream.text || "");
179
+ }
180
+
169
181
  function reducer(state, action) {
170
182
  if (!action || !action.type) return state;
171
183
  switch (action.type) {
@@ -244,9 +256,17 @@ function reducer(state, action) {
244
256
  case "agents/patchMeta": {
245
257
  const agentId = String(action.agentId || "").trim();
246
258
  if (!agentId) return state;
259
+ const patch = action.patch || {};
260
+ const patchKeys = Object.keys(patch);
261
+ const current = (state.activeAgentMeta instanceof Map ? state.activeAgentMeta.get(agentId) : null) || {};
262
+ // Skip no-op patches: activity updates stream in at a high rate and an
263
+ // unchanged patch would still mint a new Map + state, forcing a full
264
+ // re-render of the Ink tree.
265
+ if (patchKeys.every((key) => stableJson(current[key]) === stableJson(patch[key]))) {
266
+ return state;
267
+ }
247
268
  const meta = new Map(state.activeAgentMeta instanceof Map ? state.activeAgentMeta : []);
248
- const current = meta.get(agentId) || {};
249
- meta.set(agentId, { ...current, ...(action.patch || {}) });
269
+ meta.set(agentId, { ...current, ...patch });
250
270
  return { ...state, activeAgentMeta: meta };
251
271
  }
252
272
  case "agents/select":
@@ -396,26 +416,30 @@ function reducer(state, action) {
396
416
  case "stream/begin":
397
417
  return {
398
418
  ...state,
399
- activeStream: { publisher: action.publisher || "", text: "" },
419
+ activeStream: { publisher: action.publisher || "", chunks: [] },
400
420
  };
401
421
  case "stream/delta": {
422
+ const delta = String(action.delta || "");
402
423
  if (!state.activeStream) {
403
424
  return {
404
425
  ...state,
405
- activeStream: { publisher: action.publisher || "", text: String(action.delta || "") },
426
+ activeStream: { publisher: action.publisher || "", chunks: [delta] },
406
427
  };
407
428
  }
429
+ const chunks = Array.isArray(state.activeStream.chunks)
430
+ ? state.activeStream.chunks
431
+ : [String(state.activeStream.text || "")];
408
432
  return {
409
433
  ...state,
410
434
  activeStream: {
411
435
  ...state.activeStream,
412
- text: state.activeStream.text + String(action.delta || ""),
436
+ chunks: chunks.concat([delta]),
413
437
  },
414
438
  };
415
439
  }
416
440
  case "stream/end": {
417
441
  if (!state.activeStream) return state;
418
- const lines = String(state.activeStream.text || "").split(/\r?\n/);
442
+ const lines = activeStreamText(state.activeStream).split(/\r?\n/);
419
443
  const prefix = state.activeStream.publisher
420
444
  ? `${state.activeStream.publisher}: `
421
445
  : "";
@@ -434,4 +458,4 @@ function reducer(state, action) {
434
458
  }
435
459
  }
436
460
 
437
- module.exports = { reducer, createInitialState, DASHBOARD_VIEWS };
461
+ module.exports = { reducer, createInitialState, DASHBOARD_VIEWS, activeStreamText };