mixdog 0.9.53 → 0.9.55

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.
Files changed (77) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-model-liveness-test.mjs +68 -0
  3. package/scripts/anthropic-transport-policy-test.mjs +260 -0
  4. package/scripts/desktop-session-bridge-test.mjs +47 -0
  5. package/scripts/gemini-provider-test.mjs +396 -1
  6. package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
  7. package/scripts/interrupted-turn-history-test.mjs +28 -0
  8. package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
  9. package/scripts/pending-completion-drop-test.mjs +160 -4
  10. package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
  11. package/scripts/process-lifecycle-test.mjs +18 -4
  12. package/scripts/prompt-input-parity-test.mjs +145 -0
  13. package/scripts/provider-admission-scheduler-test.mjs +4 -5
  14. package/scripts/provider-contract-test.mjs +91 -33
  15. package/scripts/provider-toolcall-test.mjs +97 -17
  16. package/scripts/streaming-tail-window-test.mjs +146 -0
  17. package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
  18. package/scripts/tui-runtime-load-bench.mjs +45 -0
  19. package/scripts/tui-store-frame-batch-test.mjs +99 -0
  20. package/scripts/tui-transcript-perf-test.mjs +367 -2
  21. package/scripts/write-backpressure-test.mjs +147 -0
  22. package/src/cli.mjs +5 -5
  23. package/src/lib/keychain-cjs.cjs +114 -25
  24. package/src/repl.mjs +11 -0
  25. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
  26. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
  27. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
  28. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
  32. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
  39. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +145 -23
  41. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
  42. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
  43. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
  44. package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
  45. package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
  46. package/src/runtime/memory/lib/trace-store.mjs +66 -4
  47. package/src/runtime/shared/buffered-appender.mjs +83 -4
  48. package/src/runtime/shared/memory-snapshot.mjs +45 -36
  49. package/src/runtime/shared/process-lifecycle.mjs +166 -45
  50. package/src/runtime/shared/process-shutdown.mjs +2 -1
  51. package/src/session-runtime/model-route-api.mjs +4 -1
  52. package/src/session-runtime/native-search.mjs +4 -2
  53. package/src/session-runtime/provider-auth-api.mjs +8 -1
  54. package/src/session-runtime/provider-models.mjs +8 -3
  55. package/src/session-runtime/resource-api.mjs +2 -1
  56. package/src/session-runtime/runtime-core.mjs +32 -0
  57. package/src/session-runtime/session-turn-api.mjs +1 -0
  58. package/src/session-runtime/warmup-schedulers.mjs +5 -1
  59. package/src/standalone/agent-tool.mjs +19 -1
  60. package/src/tui/App.jsx +10 -4
  61. package/src/tui/app/text-layout.mjs +9 -1
  62. package/src/tui/app/transcript-window.mjs +146 -60
  63. package/src/tui/app/use-mouse-input.mjs +16 -8
  64. package/src/tui/app/use-transcript-scroll.mjs +71 -19
  65. package/src/tui/app/use-transcript-window.mjs +21 -10
  66. package/src/tui/components/PromptInput.jsx +13 -6
  67. package/src/tui/dist/index.mjs +1094 -232
  68. package/src/tui/engine/context-state.mjs +31 -31
  69. package/src/tui/engine/frame-batched-store.mjs +75 -0
  70. package/src/tui/engine/session-api-ext.mjs +6 -1
  71. package/src/tui/engine/session-api.mjs +9 -3
  72. package/src/tui/engine/session-flow.mjs +54 -27
  73. package/src/tui/engine/turn.mjs +44 -3
  74. package/src/tui/engine.mjs +515 -36
  75. package/src/tui/input-editing.mjs +33 -13
  76. package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
  77. package/vendor/ink/build/ink.js +8 -16
@@ -82,7 +82,16 @@ const STANDALONE_SOURCE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)),
82
82
  // cap and restore strictly-unbounded prep.
83
83
  const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIMEOUT_MS', 120_000);
84
84
 
85
- export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultCwd, onSubagentEvent }) {
85
+ export function createStandaloneAgent({
86
+ cfgMod,
87
+ reg,
88
+ mgr,
89
+ dataDir,
90
+ cwd: defaultCwd,
91
+ onSubagentEvent,
92
+ awaitKeychainPrewarm = async () => {},
93
+ isKeychainPrewarmReady = () => true,
94
+ }) {
86
95
  // Optional bridge to the standard hook bus for SubagentStart / SubagentStop.
87
96
  // Best-effort: a hook error must never affect worker spawn/finish.
88
97
  function emitSubagentEvent(phase, agent, extra = {}) {
@@ -1588,6 +1597,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1588
1597
 
1589
1598
  async function execute(args = {}, context = {}) {
1590
1599
  try {
1600
+ await awaitKeychainPrewarm();
1591
1601
  const type = clean(args.type) || 'spawn';
1592
1602
  const callerCwd = clean(context.cwd || context.callerCwd);
1593
1603
  const scopedContext = agentScope(args, context);
@@ -1731,6 +1741,10 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1731
1741
  tools: [AGENT_TOOL],
1732
1742
  execute,
1733
1743
  getStatus: (context = {}) => {
1744
+ if (!isKeychainPrewarmReady()) {
1745
+ void awaitKeychainPrewarm();
1746
+ return { workers: [], jobs: [], scope: null };
1747
+ }
1734
1748
  const scopedContext = agentScope({}, context);
1735
1749
  const pid = terminalPidForContext(scopedContext);
1736
1750
  return {
@@ -1740,6 +1754,10 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1740
1754
  };
1741
1755
  },
1742
1756
  recoverWorkers: (context = {}) => {
1757
+ if (!isKeychainPrewarmReady()) {
1758
+ void awaitKeychainPrewarm();
1759
+ return [];
1760
+ }
1743
1761
  const scopedContext = agentScope({ recover: true }, context);
1744
1762
  refreshTagsFromSessions({ scanSessions: true, context: scopedContext });
1745
1763
  return list({ scanSessions: false, context: scopedContext });
package/src/tui/App.jsx CHANGED
@@ -2972,9 +2972,13 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2972
2972
  overlayHintFallbackRow,
2973
2973
  transcriptMeasureRef,
2974
2974
  } = useTranscriptWindow({
2975
- items: state.items,
2976
- structureRevision: state.structureRevision,
2977
- streamingTail: state.streamingTail,
2975
+ items: state.transcriptViewItems || state.items,
2976
+ structureRevision: state.transcriptViewItems
2977
+ ? state.transcriptViewRevision
2978
+ : state.structureRevision,
2979
+ // Historical pages are contiguous settled windows. Keep the independently
2980
+ // growing live tail hidden until paging forward reaches the live window.
2981
+ streamingTail: state.transcriptViewItems ? null : state.streamingTail,
2978
2982
  themeEpoch: state.themeEpoch,
2979
2983
  frameColumns,
2980
2984
  toolOutputExpanded,
@@ -3167,7 +3171,9 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
3167
3171
  const itemNode = (
3168
3172
  <Item
3169
3173
  item={item}
3170
- prevKind={i > 0 ? arr[i - 1].kind : state.items[transcriptWindow.startIndex - 1]?.kind ?? null}
3174
+ prevKind={i > 0
3175
+ ? arr[i - 1].kind
3176
+ : (state.transcriptViewItems || state.items)[transcriptWindow.startIndex - 1]?.kind ?? null}
3171
3177
  columns={frameColumns}
3172
3178
  toolOutputExpanded={toolOutputExpanded}
3173
3179
  rightMessage={attachOverlayHint ? inputHint : ''}
@@ -6,6 +6,7 @@ import { displayWidth } from '../display-width.mjs';
6
6
  import wrapAnsi from 'wrap-ansi';
7
7
 
8
8
  const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
9
+ let promptRowsCache = null;
9
10
 
10
11
  export function wrappedTextRows(value, width) {
11
12
  const w = Math.max(1, Math.floor(Number(width) || 1));
@@ -43,7 +44,14 @@ export function promptContentRows(value, contentColumns) {
43
44
  // offset, so reserve for the worst common case: caret at the end. This can
44
45
  // over-reserve by one row at exact wrap boundaries, but never under-reserves
45
46
  // and therefore prevents transcript rows from bleeding into the prompt box.
46
- return wrappedTextRows(`${String(value ?? '')} `, contentColumns);
47
+ const text = String(value ?? '');
48
+ const width = Math.max(1, Math.floor(Number(contentColumns) || 1));
49
+ if (promptRowsCache?.text === text && promptRowsCache.width === width) {
50
+ return promptRowsCache.rows;
51
+ }
52
+ const rows = wrappedTextRows(`${text} `, width);
53
+ promptRowsCache = { text, width, rows };
54
+ return rows;
47
55
  }
48
56
 
49
57
  // Rows an ink <Text wrap="wrap"> block occupies at `width`. Uses the SAME
@@ -435,7 +435,7 @@ export function lowerBound(values, target) {
435
435
  let hi = values.length;
436
436
  while (lo < hi) {
437
437
  const mid = Math.floor((lo + hi) / 2);
438
- if (values[mid] < target) lo = mid + 1;
438
+ if (transcriptRowAt(values, mid) < target) lo = mid + 1;
439
439
  else hi = mid;
440
440
  }
441
441
  return lo;
@@ -446,7 +446,7 @@ export function upperBound(values, target) {
446
446
  let hi = values.length;
447
447
  while (lo < hi) {
448
448
  const mid = Math.floor((lo + hi) / 2);
449
- if (values[mid] <= target) lo = mid + 1;
449
+ if (transcriptRowAt(values, mid) <= target) lo = mid + 1;
450
450
  else hi = mid;
451
451
  }
452
452
  return lo;
@@ -457,16 +457,16 @@ export function upperBound(values, target) {
457
457
  // render AND in the post-commit layout effect.
458
458
  export function resolveAnchorScrollOffset({ anchor, items, curPrefix, totalRows, viewRows, maxRows }) {
459
459
  if (!anchor || anchor.id == null) return null;
460
- if (!Array.isArray(curPrefix) || curPrefix.length <= 1) return null;
460
+ if (!curPrefix || curPrefix.length <= 1) return null;
461
461
  const list = Array.isArray(items) ? items : [];
462
462
  let idx = -1;
463
463
  for (let i = list.length - 1; i >= 0; i--) {
464
464
  if (list[i] && list[i].id === anchor.id) { idx = i; break; }
465
465
  }
466
466
  if (idx < 0 || idx > curPrefix.length - 2) return null;
467
- const itemHeight = Math.max(0, (curPrefix[idx + 1] || 0) - (curPrefix[idx] || 0));
467
+ const itemHeight = Math.max(0, transcriptRowAt(curPrefix, idx + 1) - transcriptRowAt(curPrefix, idx));
468
468
  const clampedOffset = Math.max(0, Math.min(Number(anchor.offset) || 0, itemHeight));
469
- const anchorRowCur = (curPrefix[idx] || 0) + clampedOffset;
469
+ const anchorRowCur = transcriptRowAt(curPrefix, idx) + clampedOffset;
470
470
  return Math.max(0, Math.min(maxRows, totalRows - viewRows - anchorRowCur));
471
471
  }
472
472
 
@@ -585,12 +585,44 @@ export const streamingMeasuredRowsById = new Map();
585
585
  // settle / invalidate delete sites in estimateTranscriptItemRowsCached.
586
586
  const streamingEstimateHighWaterById = new Map();
587
587
 
588
+ // The App asks for the live-tail estimate on every render, including renders
589
+ // caused only by prompt typing. Keep exactly the latest estimate per stream id
590
+ // so unchanged text never reaches markdown/plain row measurement. The key
591
+ // includes every App-level input that selects this rendering path or geometry.
592
+ const streamingTailEstimateById = new Map();
593
+ const STREAMING_TAIL_ESTIMATE_LRU_MAX = 8;
594
+
595
+ function cacheStreamingTailEstimate(id, entry) {
596
+ if (id == null) return;
597
+ if (streamingTailEstimateById.has(id)) streamingTailEstimateById.delete(id);
598
+ streamingTailEstimateById.set(id, entry);
599
+ while (streamingTailEstimateById.size > STREAMING_TAIL_ESTIMATE_LRU_MAX) {
600
+ const oldest = streamingTailEstimateById.keys().next().value;
601
+ if (oldest === undefined) break;
602
+ streamingTailEstimateById.delete(oldest);
603
+ // The high-water entry belongs to the same latest-estimate lifecycle.
604
+ // Leaving it behind both grows without bound and lets a later reuse of an
605
+ // evicted id inherit geometry from an unrelated completed/aborted stream.
606
+ streamingEstimateHighWaterById.delete(oldest);
607
+ }
608
+ }
609
+
610
+ /** Lifecycle visibility for focused cache-boundary tests. */
611
+ export function streamingRowEstimateStateForId(id) {
612
+ return {
613
+ tailEstimate: streamingTailEstimateById.has(id),
614
+ highWater: streamingEstimateHighWaterById.has(id),
615
+ };
616
+ }
617
+
588
618
  /** True when either streaming id-keyed store holds entries, so the mount-prune
589
619
  * call site fires even when only the estimate high-water map is populated (an
590
620
  * item that never got a Yoga measurement but reached an estimate high-water,
591
621
  * then was bulk-replaced, must still be pruned). */
592
622
  export function hasStreamingRowStateToPrune() {
593
- return streamingMeasuredRowsById.size > 0 || streamingEstimateHighWaterById.size > 0;
623
+ return streamingMeasuredRowsById.size > 0
624
+ || streamingEstimateHighWaterById.size > 0
625
+ || streamingTailEstimateById.size > 0;
594
626
  }
595
627
 
596
628
  /** Drop streamingMeasuredRowsById entries for ids no longer mounted, so the
@@ -608,6 +640,11 @@ export function pruneStreamingMeasuredRowsById(liveIds) {
608
640
  if (!liveIds.has(id)) streamingEstimateHighWaterById.delete(id);
609
641
  }
610
642
  }
643
+ if (streamingTailEstimateById.size > 0) {
644
+ for (const id of streamingTailEstimateById.keys()) {
645
+ if (!liveIds.has(id)) streamingTailEstimateById.delete(id);
646
+ }
647
+ }
611
648
  }
612
649
 
613
650
  /** Copy a Yoga-measured row entry onto a replacement item object (e.g. patchItem). */
@@ -656,6 +693,21 @@ function assistantTextForStreamingRowEstimate(text) {
656
693
  }
657
694
 
658
695
  export function streamingEstimateRows(item, columns, toolOutputExpanded) {
696
+ const id = item?.id;
697
+ const exactText = String(item?.text ?? '');
698
+ const toolExpanded = toolOutputExpanded ? 1 : 0;
699
+ const renderMode = item?.kind === 'assistant' && item?.streaming
700
+ ? 'assistant-streaming-markdown'
701
+ : 'other';
702
+ const cached = id == null ? null : streamingTailEstimateById.get(id);
703
+ if (cached
704
+ && cached.text === exactText
705
+ && cached.columns === columns
706
+ && cached.toolExpanded === toolExpanded
707
+ && cached.renderMode === renderMode) {
708
+ cacheStreamingTailEstimate(id, cached);
709
+ return cached.rows;
710
+ }
659
711
  const trimmedText = assistantTextForStreamingRowEstimate(item.text);
660
712
  const estimateItem = trimmedText === item.text ? item : { ...item, text: trimmedText };
661
713
  const raw = Math.max(1, Math.ceil(estimateTranscriptItemRows(estimateItem, columns, toolOutputExpanded)));
@@ -664,16 +716,32 @@ export function streamingEstimateRows(item, columns, toolOutputExpanded) {
664
716
  // this id already reached this run — absorbs the childCount 1↔2 gap flip that
665
717
  // otherwise dips the estimate ±1 frame-to-frame. A columns/expanded change
666
718
  // resets the water line (new width → legitimately new row count).
667
- const id = item?.id;
668
719
  if (id == null) return quantized;
669
- const toolExpanded = toolOutputExpanded ? 1 : 0;
670
720
  const prev = streamingEstimateHighWaterById.get(id);
671
- if (!prev || prev.columns !== columns || prev.toolExpanded !== toolExpanded) {
672
- streamingEstimateHighWaterById.set(id, { rows: quantized, columns, toolExpanded });
673
- return quantized;
721
+ let rows;
722
+ if (!prev
723
+ || prev.columns !== columns
724
+ || prev.toolExpanded !== toolExpanded
725
+ || prev.renderMode !== renderMode) {
726
+ streamingEstimateHighWaterById.set(id, {
727
+ rows: quantized,
728
+ columns,
729
+ toolExpanded,
730
+ renderMode,
731
+ });
732
+ rows = quantized;
733
+ } else {
734
+ if (quantized > prev.rows) prev.rows = quantized;
735
+ rows = prev.rows;
674
736
  }
675
- if (quantized > prev.rows) prev.rows = quantized;
676
- return prev.rows;
737
+ cacheStreamingTailEstimate(id, {
738
+ text: exactText,
739
+ columns,
740
+ toolExpanded,
741
+ renderMode,
742
+ rows,
743
+ });
744
+ return rows;
677
745
  }
678
746
 
679
747
  // ── Same-frame streaming-tail growth compensation (scrolled-up only) ────────
@@ -754,6 +822,7 @@ export function estimateTranscriptItemRowsCached(item, columns, toolOutputExpand
754
822
  if (idEntry) {
755
823
  streamingMeasuredRowsById.delete(item.id);
756
824
  streamingEstimateHighWaterById.delete(item.id);
825
+ streamingTailEstimateById.delete(item.id);
757
826
  }
758
827
  // No confirmed measurement yet for this id (item just started streaming):
759
828
  // nothing to defer against, so the first frame falls back to the estimate.
@@ -765,6 +834,7 @@ export function estimateTranscriptItemRowsCached(item, columns, toolOutputExpand
765
834
  // owns its height. Clear both so a later id reuse / this run cannot leak.
766
835
  if (streamingMeasuredRowsById.has(item.id)) streamingMeasuredRowsById.delete(item.id);
767
836
  if (streamingEstimateHighWaterById.has(item.id)) streamingEstimateHighWaterById.delete(item.id);
837
+ if (streamingTailEstimateById.has(item.id)) streamingTailEstimateById.delete(item.id);
768
838
  }
769
839
  const variantKey = transcriptItemVariantKey(item);
770
840
  const toolExpanded = toolOutputExpanded ? 1 : 0;
@@ -786,13 +856,16 @@ export function buildTranscriptRowIndex(items, {
786
856
  columns = 80,
787
857
  toolOutputExpanded = false,
788
858
  suppressMeasuredRowHeights = false,
859
+ streamingTailItem = null,
789
860
  } = {}) {
790
861
  const allItems = Array.isArray(items) ? items : [];
791
862
  const rows = new Array(allItems.length);
792
863
  const prefixRows = new Array(allItems.length + 1);
793
864
  prefixRows[0] = 0;
794
865
  for (let i = 0; i < allItems.length; i++) {
795
- const item = allItems[i];
866
+ const item = streamingTailItem && i === allItems.length - 1
867
+ ? streamingTailItem
868
+ : allItems[i];
796
869
  const measured = suppressMeasuredRowHeights
797
870
  ? null
798
871
  : measuredTranscriptRows(item, columns, toolOutputExpanded);
@@ -833,9 +906,10 @@ export function buildTranscriptRowIndexIncremental(items, {
833
906
  measuredRowsVersion = 0,
834
907
  cacheRef = null,
835
908
  prefixRevision = null,
909
+ streamingTailItem = null,
836
910
  } = {}) {
837
911
  const allItems = Array.isArray(items) ? items : [];
838
- const tail = trailingStreamingItem(allItems);
912
+ const tail = streamingTailItem || trailingStreamingItem(allItems);
839
913
  // Per-hook-instance cache holder (useRef object). Fall back to a throwaway
840
914
  // holder if none supplied so the builder still works in isolation.
841
915
  const holder = cacheRef || { current: null };
@@ -845,7 +919,7 @@ export function buildTranscriptRowIndexIncremental(items, {
845
919
  if (!tail) {
846
920
  holder.current = null;
847
921
  return buildTranscriptRowIndex(allItems, {
848
- columns, toolOutputExpanded, suppressMeasuredRowHeights,
922
+ columns, toolOutputExpanded, suppressMeasuredRowHeights, streamingTailItem,
849
923
  });
850
924
  }
851
925
  const prefixLen = allItems.length - 1;
@@ -873,11 +947,14 @@ export function buildTranscriptRowIndexIncremental(items, {
873
947
  const tailRows = tailMeasured != null
874
948
  ? tailMeasured
875
949
  : estimateTranscriptItemRowsCached(tail, columns, toolOutputExpanded);
876
- const rows = cache.prefixRowsArr.slice();
877
- rows.push(tailRows);
878
- const prefixRows = cache.prefixPrefixRows.slice();
879
- prefixRows.push(cache.prefixTotal + tailRows);
880
- return { rows, prefixRows, totalRows: prefixRows[allItems.length] || 0 };
950
+ // Immutable segmented views append one virtual tail slot to the cached
951
+ // settled prefix. No committed array is mutated and no prefix is copied.
952
+ const totalRows = cache.prefixTotal + tailRows;
953
+ return {
954
+ rows: appendTranscriptRow(cache.prefixRowsArr, tailRows),
955
+ prefixRows: appendTranscriptRow(cache.prefixPrefixRows, totalRows),
956
+ totalRows,
957
+ };
881
958
  }
882
959
  }
883
960
  // Cache miss: full build, then repopulate the settled-prefix cache so the NEXT
@@ -885,7 +962,7 @@ export function buildTranscriptRowIndexIncremental(items, {
885
962
  // full-build outputs truncated before the tail row — byte-identical to a
886
963
  // full rebuild's prefix by construction.
887
964
  const full = buildTranscriptRowIndex(allItems, {
888
- columns, toolOutputExpanded, suppressMeasuredRowHeights,
965
+ columns, toolOutputExpanded, suppressMeasuredRowHeights, streamingTailItem,
889
966
  });
890
967
  holder.current = {
891
968
  columns,
@@ -902,46 +979,55 @@ export function buildTranscriptRowIndexIncremental(items, {
902
979
  return full;
903
980
  }
904
981
 
905
- // Stable signature for the transcript row-index / window memos. Changes only
906
- // when transcript STRUCTURE changes or the streaming item's estimated height
907
- // changes not on every character. Per-item sigParts are identity-memoized.
908
- const transcriptSigPartCache = new WeakMap();
909
-
910
- export function transcriptStructureSignature(items, columns, toolOutputExpanded) {
982
+ // Stable O(1) signature for transcript row-index/window memos. The engine's
983
+ // monotonic prefixRevision proves settled-prefix identity; only the live tail
984
+ // needs resolving at render time.
985
+ export function transcriptStructureSignature(items, columns, toolOutputExpanded, prefixRevision = 0, streamingTailItem = null) {
911
986
  const list = Array.isArray(items) ? items : [];
912
- let hA = fnvStepA(0x811c9dc5, `${list.length}|${columns}|${toolOutputExpanded ? 1 : 0}`);
913
- let hB = fnvStepB(0xcbf29ce4, `${list.length}|${columns}|${toolOutputExpanded ? 1 : 0}`);
914
- for (let i = 0; i < list.length; i++) {
915
- const it = list[i];
916
- let sigPart;
917
- if (!it) {
918
- sigPart = '_';
919
- } else if (it.kind === 'assistant' && it.streaming) {
920
- // Resolve the SAME value buildTranscriptRowIndex/estimateTranscriptItemRowsCached
921
- // will use (measured re-slice height when available, else the estimate) so the
922
- // signature and the actual geometry can never diverge.
923
- const resolvedRows = estimateTranscriptItemRowsCached(it, columns, toolOutputExpanded);
924
- sigPart = `a${it.id}:${resolvedRows}`;
925
- } else {
926
- const variantKey = transcriptItemVariantKey(it);
927
- const cached = transcriptSigPartCache.get(it);
928
- if (cached
929
- && cached.variantKey === variantKey
930
- && cached.columns === columns
931
- && cached.id === it.id
932
- && cached.kind === it.kind) {
933
- sigPart = cached.sigPart;
934
- } else {
935
- sigPart = `${it.kind?.[0] || '?'}${it.id}:${variantKey}`;
936
- transcriptSigPartCache.set(it, { id: it.id, kind: it.kind, variantKey, columns, sigPart });
987
+ const tail = streamingTailItem || trailingStreamingItem(list);
988
+ const tailRows = tail
989
+ ? estimateTranscriptItemRowsCached(tail, columns, toolOutputExpanded)
990
+ : 0;
991
+ return `${Math.max(0, Number(prefixRevision) || 0)}|${list.length}|${columns}|${toolOutputExpanded ? 1 : 0}|${tail?.id ?? '_'}:${tailRows}`;
992
+ }
993
+
994
+ export function transcriptRowAt(values, index) {
995
+ if (!values || index < 0 || index >= values.length) return 0;
996
+ return typeof values.atIndex === 'function'
997
+ ? values.atIndex(index)
998
+ : (Number(values[index]) || 0);
999
+ }
1000
+
1001
+ function appendTranscriptRow(prefix, tailValue) {
1002
+ return Object.freeze({
1003
+ length: prefix.length + 1,
1004
+ atIndex: (index) => index === prefix.length
1005
+ ? tailValue
1006
+ : (Number(prefix[index]) || 0),
1007
+ slice: (start = 0, end = prefix.length + 1) => {
1008
+ const values = [];
1009
+ const lo = Math.max(0, start < 0 ? prefix.length + 1 + start : start);
1010
+ const hi = Math.min(prefix.length + 1, end < 0 ? prefix.length + 1 + end : end);
1011
+ for (let index = lo; index < hi; index++) {
1012
+ values.push(index === prefix.length ? tailValue : prefix[index]);
937
1013
  }
938
- }
939
- hA = fnvStepA(hA, `;${i};`);
940
- hA = fnvStepA(hA, sigPart);
941
- hB = fnvStepB(hB, `;${i};`);
942
- hB = fnvStepB(hB, sigPart);
1014
+ return values;
1015
+ },
1016
+ });
1017
+ }
1018
+
1019
+ export function transcriptItemsWithStableTail(settledItems, streamingTailItem, cacheRef) {
1020
+ const settled = Array.isArray(settledItems) ? settledItems : [];
1021
+ if (!streamingTailItem) return settled;
1022
+ const previous = cacheRef?.current;
1023
+ if (previous
1024
+ && previous.settled === settled
1025
+ && previous.tailId === streamingTailItem.id) {
1026
+ return previous.items;
943
1027
  }
944
- return `${hA.toString(36)}.${hB.toString(36)}`;
1028
+ const items = [...settled, streamingTailItem];
1029
+ if (cacheRef) cacheRef.current = { settled, tailId: streamingTailItem.id, items };
1030
+ return items;
945
1031
  }
946
1032
 
947
1033
  export function transcriptRenderWindow(items, { scrollOffset = 0, viewportHeight = 24, columns = 80, toolOutputExpanded = false, rowIndex = null } = {}) {
@@ -995,7 +1081,7 @@ export function transcriptRenderWindow(items, { scrollOffset = 0, viewportHeight
995
1081
  if (endIndex - startIndex > effectiveMaxItems) startIndex = Math.max(0, endIndex - effectiveMaxItems);
996
1082
  }
997
1083
 
998
- const bottomSpacerRows = Math.max(0, totalRows - (prefixRows[endIndex] || totalRows));
1084
+ const bottomSpacerRows = Math.max(0, totalRows - (transcriptRowAt(prefixRows, endIndex) || totalRows));
999
1085
  return {
1000
1086
  startIndex,
1001
1087
  endIndex,
@@ -199,6 +199,11 @@ export function useMouseInput({
199
199
  promptMouseSelectionRef.current?.clear?.();
200
200
  applySelectionRect(null);
201
201
  };
202
+ // Windows Terminal's native selection overlay survives incremental frames.
203
+ // Clear it only when an app-owned gesture completes, never on drag motion.
204
+ const finishWindowsMouseGesture = () => {
205
+ if (IS_WINDOWS_TERMINAL) store.forceRenderRepaint?.();
206
+ };
202
207
  // Edge auto-scroll TIMER (ref: ScrollKeybindingHandler useDragToScroll).
203
208
  // SGR mode 1002 reports drag-motion only when the pointer changes CELL, so
204
209
  // a pointer held stationary at the top/bottom edge stops emitting events
@@ -304,6 +309,7 @@ export function useMouseInput({
304
309
  if (dragRef.current.active) {
305
310
  const last = dragRef.current.last || {};
306
311
  finalizeActiveDrag(Number(last.x) || 0, Number(last.y) || 0);
312
+ finishWindowsMouseGesture();
307
313
  } else {
308
314
  stopEdgeAutoscroll();
309
315
  }
@@ -352,14 +358,8 @@ export function useMouseInput({
352
358
  // never forwards those events; keep this guard as a belt-and-braces so
353
359
  // a future WT that starts forwarding them can never double-paint.
354
360
  if (IS_WINDOWS_TERMINAL && shiftHeld) return;
355
- // WT's native (shift+drag) selection overlay is NOT cleared by our
356
- // incremental repaints it would sit on top of the app selection as a
357
- // second highlight. Any app-owned button press means the user is done
358
- // with that native selection: force one full clear+rewrite frame
359
- // (BSU/ESU-wrapped, no flash) which dismisses it before we paint ours.
360
- if (IS_WINDOWS_TERMINAL && press && !isMotion) {
361
- store.forceRenderRepaint?.();
362
- }
361
+ // Do not force a full clear/rewrite on app-owned presses. Selection
362
+ // changes below repaint through Ink's normal maxFps render throttle.
363
363
  // Ctrl+left-click is the app-side extend trigger (baseButton =
364
364
  // button & 3 already maps ctrl+left press to button 0); right-button
365
365
  // press is the second trigger (its own block below). This is
@@ -384,6 +384,7 @@ export function useMouseInput({
384
384
  dragRef.current = { anchor: { x, y }, anchorScroll: 0, last: { x, y }, active: false, rect: null, region: 'prompt', anchorSpan: null };
385
385
  if (offset != null) ctl.extendTo?.(offset, true);
386
386
  lastClickRef.current = { x: -1, y: -1, t: 0 };
387
+ finishWindowsMouseGesture();
387
388
  }
388
389
  return;
389
390
  }
@@ -401,6 +402,7 @@ export function useMouseInput({
401
402
  dragRef.current = { ...dragRef.current, last: { x, y: selectionY }, active: false, region: regionR };
402
403
  applySelectionRect(rect);
403
404
  lastClickRef.current = { x, y, t: nowR, count: 1 };
405
+ finishWindowsMouseGesture();
404
406
  return;
405
407
  }
406
408
  if (
@@ -420,6 +422,7 @@ export function useMouseInput({
420
422
  dragRef.current = { ...dragRef.current, last: { x, y: selectionY }, active: false, region: regionR };
421
423
  applySelectionRect(rect);
422
424
  lastClickRef.current = { x, y, t: nowR, count: 1 };
425
+ finishWindowsMouseGesture();
423
426
  return;
424
427
  }
425
428
  return;
@@ -486,6 +489,7 @@ export function useMouseInput({
486
489
  dragRef.current.region = null;
487
490
  dragRef.current.anchorSpan = null;
488
491
  clearAllSelections();
492
+ finishWindowsMouseGesture();
489
493
  return;
490
494
  }
491
495
  const region = inTranscript ? 'transcript' : 'status';
@@ -671,6 +675,10 @@ export function useMouseInput({
671
675
  // path the ctrl+wheel zoom passthrough uses. Guarded to baseButton 0
672
676
  // so a stray right-button release never finalizes.
673
677
  finalizeActiveDrag(x, y);
678
+ // WT keeps its native shift-selection overlay across incremental
679
+ // frames. Dismiss it once per completed gesture, after publishing the
680
+ // final app selection, rather than forcing a full rewrite on press.
681
+ finishWindowsMouseGesture();
674
682
  }
675
683
  }
676
684
  };