mixdog 0.9.22 → 0.9.24

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 (132) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/boot-smoke.mjs +1 -1
  4. package/scripts/channel-daemon-smoke.mjs +483 -0
  5. package/scripts/channel-daemon-stub.mjs +80 -0
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  11. package/scripts/tool-smoke.mjs +68 -47
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  29. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  31. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  32. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
  33. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  34. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  35. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  36. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  38. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  39. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  40. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  42. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
  45. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  46. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  47. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  48. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  49. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
  51. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  52. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  53. package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
  54. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
  55. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  56. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  57. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  58. package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
  59. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  60. package/src/runtime/channels/lib/worker-main.mjs +42 -30
  61. package/src/runtime/memory/index.mjs +54 -7
  62. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  63. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  64. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  65. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  66. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  67. package/src/runtime/memory/tool-defs.mjs +1 -1
  68. package/src/runtime/shared/atomic-file.mjs +150 -6
  69. package/src/runtime/shared/background-tasks.mjs +1 -1
  70. package/src/runtime/shared/config.mjs +53 -1
  71. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  72. package/src/runtime/shared/tool-primitives.mjs +31 -1
  73. package/src/runtime/shared/tool-surface.mjs +42 -13
  74. package/src/runtime/shared/update-checker.mjs +3 -0
  75. package/src/runtime/shared/user-data-guard.mjs +66 -0
  76. package/src/session-runtime/config-lifecycle.mjs +221 -20
  77. package/src/session-runtime/lifecycle-api.mjs +9 -0
  78. package/src/session-runtime/mcp-glue.mjs +93 -1
  79. package/src/session-runtime/resource-api.mjs +62 -8
  80. package/src/session-runtime/runtime-core.mjs +118 -4
  81. package/src/session-runtime/session-text.mjs +41 -0
  82. package/src/session-runtime/session-turn-api.mjs +50 -0
  83. package/src/session-runtime/settings-api.mjs +8 -1
  84. package/src/session-runtime/tool-catalog.mjs +350 -38
  85. package/src/session-runtime/tool-defs.mjs +7 -7
  86. package/src/session-runtime/workflow.mjs +2 -1
  87. package/src/standalone/channel-admin.mjs +32 -3
  88. package/src/standalone/channel-daemon-client.mjs +226 -0
  89. package/src/standalone/channel-daemon-transport.mjs +545 -0
  90. package/src/standalone/channel-daemon.mjs +176 -0
  91. package/src/standalone/channel-worker.mjs +224 -4
  92. package/src/standalone/explore-tool.mjs +87 -15
  93. package/src/standalone/hook-bus.mjs +71 -3
  94. package/src/tui/App.jsx +107 -19
  95. package/src/tui/app/clipboard.mjs +39 -19
  96. package/src/tui/app/doctor.mjs +57 -0
  97. package/src/tui/app/extension-pickers.mjs +53 -9
  98. package/src/tui/app/maintenance-pickers.mjs +0 -5
  99. package/src/tui/app/slash-dispatch.mjs +4 -4
  100. package/src/tui/app/text-layout.mjs +11 -0
  101. package/src/tui/app/use-mouse-input.mjs +235 -51
  102. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  103. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  104. package/src/tui/app/use-transcript-window.mjs +55 -1
  105. package/src/tui/components/Message.jsx +1 -1
  106. package/src/tui/components/PromptInput.jsx +3 -1
  107. package/src/tui/components/QueuedCommands.jsx +21 -10
  108. package/src/tui/components/StatusLine.jsx +3 -3
  109. package/src/tui/components/ToolExecution.jsx +16 -4
  110. package/src/tui/components/TranscriptItem.jsx +1 -1
  111. package/src/tui/dist/index.mjs +820 -326
  112. package/src/tui/engine/agent-job-feed.mjs +5 -0
  113. package/src/tui/engine/notification-plan.mjs +5 -0
  114. package/src/tui/engine/session-api.mjs +23 -8
  115. package/src/tui/engine/session-flow.mjs +6 -0
  116. package/src/tui/engine/tool-card-results.mjs +14 -5
  117. package/src/tui/engine/turn.mjs +9 -2
  118. package/src/tui/engine.mjs +32 -5
  119. package/src/tui/index.jsx +62 -18
  120. package/src/tui/paste-attachments.mjs +26 -0
  121. package/src/ui/statusline-agents.mjs +36 -0
  122. package/src/ui/statusline.mjs +60 -10
  123. package/src/ui/tool-card.mjs +8 -1
  124. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  125. package/src/workflows/bench/WORKFLOW.md +46 -0
  126. package/src/workflows/default/WORKFLOW.md +6 -0
  127. package/src/workflows/solo/WORKFLOW.md +5 -0
  128. package/vendor/ink/build/ink.js +23 -1
  129. package/vendor/ink/build/output.js +154 -71
  130. package/vendor/ink/build/render-node-to-output.js +44 -2
  131. package/vendor/ink/build/render.js +4 -0
  132. package/vendor/ink/build/renderer.js +4 -1
@@ -84,30 +84,86 @@ export function useTranscriptScroll({
84
84
  const scroll = stitchHarvestScrollRef.current;
85
85
  for (const row of rows) {
86
86
  if (!row || typeof row.y !== 'number') continue;
87
- stitchBufferRef.current.set(row.y - scroll, typeof row.text === 'string' ? row.text : '');
87
+ // Store text AND the soft-wrap continuation flag so the stitch join can
88
+ // rejoin word-wrapped rows into their logical line (mirrors output.js).
89
+ stitchBufferRef.current.set(row.y - scroll, {
90
+ text: typeof row.text === 'string' ? row.text : '',
91
+ sw: row.sw === true,
92
+ });
88
93
  }
89
94
  }, 0);
90
95
  }, [store]);
91
96
 
97
+ // Synchronous sibling of harvestStitchRowsSoon: snapshot the rows CURRENTLY
98
+ // under the selection into the stitch buffer immediately, keyed by the given
99
+ // (pre-scroll) offset. Called right before a scroll shifts those rows out of
100
+ // view — mirrors selection.ts captureScrolledRows, which grabs the outgoing
101
+ // rows BEFORE scrollBy overwrites them. The deferred harvest could never see
102
+ // rows that a fast drag/wheel scrolled past between paint and its setTimeout.
103
+ // selectionRows is harvested by the renderer UNCONDITIONALLY (even on the
104
+ // captureText:false motion paints, output.js), so this works mid-drag.
105
+ const harvestStitchRowsNow = useCallback((scroll) => {
106
+ if (dragRef.current.region !== 'transcript') return;
107
+ const rows = store.getRenderSelectionRows?.();
108
+ if (!Array.isArray(rows)) return;
109
+ const s = Number(scroll) || 0;
110
+ for (const row of rows) {
111
+ if (!row || typeof row.y !== 'number') continue;
112
+ stitchBufferRef.current.set(row.y - s, {
113
+ text: typeof row.text === 'string' ? row.text : '',
114
+ sw: row.sw === true,
115
+ });
116
+ }
117
+ }, [store]);
118
+
92
119
  // Map the CURRENT rect + current scrollTarget onto the content-key range and
93
- // join buffered rows sorted by key with '\n' (skipping missing keys). Returns
94
- // '' when unusable so callers can fall back to render/remembered text.
120
+ // join buffered rows sorted by key with '\n'. Returns { text, complete }:
121
+ // `complete` is true only when the harvested keys covering the selection form
122
+ // a CONTIGUOUS run (no interior gap). The old code silently skipped missing
123
+ // keys and returned only the string, so a gap (a scrolled-off row that was
124
+ // never harvested) produced a stitched copy with a line dropped in the middle
125
+ // — a mangled, shorter-than-real result the caller then preferred purely on
126
+ // length. Callers must gate on `complete` before preferring the stitch.
127
+ // Returns { text: '', complete: false } when unusable so callers fall back to
128
+ // render/remembered text.
95
129
  const getStitchedSelectionText = useCallback(() => {
130
+ const empty = { text: '', complete: false };
96
131
  const buf = stitchBufferRef.current;
97
- if (!buf.size) return '';
98
- if (dragRef.current.region !== 'transcript') return '';
132
+ if (!buf.size) return empty;
133
+ if (dragRef.current.region !== 'transcript') return empty;
99
134
  const rect = dragRef.current.rect;
100
- if (!rect) return '';
135
+ if (!rect) return empty;
101
136
  const y1 = Number(rect.y1);
102
137
  const y2 = Number(rect.y2);
103
- if (!Number.isFinite(y1) || !Number.isFinite(y2)) return '';
138
+ if (!Number.isFinite(y1) || !Number.isFinite(y2)) return empty;
104
139
  const scroll = Number(scrollTargetRef.current) || 0;
105
140
  const lo = Math.min(y1, y2) - scroll;
106
141
  const hi = Math.max(y1, y2) - scroll;
107
142
  const keys = [...buf.keys()].filter((k) => k >= lo && k <= hi).sort((a, b) => a - b);
108
- if (!keys.length) return '';
109
- const text = keys.map((k) => buf.get(k)).filter((t) => t != null).join('\n');
110
- return text.trim() ? text : '';
143
+ if (!keys.length) return empty;
144
+ // FULL coverage of the selection's [lo..hi] row range with no hole. Keys are
145
+ // already filtered to [lo..hi] and unique, so a count equal to the range
146
+ // size means every selected row is present (interior AND both endpoints).
147
+ // Internal contiguity alone was not enough: an endpoint-missing stitch (e.g.
148
+ // the top/bottom selected row never harvested) is internally contiguous yet
149
+ // drops a boundary line, so it must NOT be marked complete and win in copy.
150
+ const complete = keys.length === hi - lo + 1;
151
+ // SOFT-WRAP JOIN (same rule as output.js getSelectedText): a row whose sw
152
+ // flag is set is a word-wrap continuation — concatenate it onto the prior
153
+ // logical line WITHOUT a newline; only source/hard breaks emit '\n'. Blank
154
+ // inner rows ('' text) survive as empty logical lines (paragraph gaps).
155
+ // Trailing whitespace is trimmed once per logical-line end.
156
+ const logical = [];
157
+ for (const k of keys) {
158
+ const entry = buf.get(k);
159
+ if (entry == null) continue;
160
+ const t = typeof entry === 'string' ? entry : (entry.text ?? '');
161
+ const sw = typeof entry === 'string' ? false : entry.sw === true;
162
+ if (sw && logical.length > 0) logical[logical.length - 1] += t;
163
+ else logical.push(t);
164
+ }
165
+ const text = logical.map((l) => l.replace(/\s+$/u, '')).join('\n');
166
+ return text.trim() ? { text, complete } : empty;
111
167
  }, []);
112
168
 
113
169
  const stopSmoothScroll = useCallback(() => {
@@ -221,6 +277,36 @@ export function useTranscriptScroll({
221
277
  return true;
222
278
  }, [store, rememberSelectionTextSoon, harvestStitchRowsSoon]);
223
279
 
280
+ // Shared guard for EVERY direct (non-throttled) paint path: a pending
281
+ // throttled repaint (state.timer/state.pending, armed by
282
+ // applySelectionRectThrottled) would fire AFTER a direct paint and stamp a
283
+ // stale pre-scroll/pre-direction rect over the current one — surfacing as two
284
+ // coexisting highlights. Cancel it before any direct paint.
285
+ const cancelPendingSelectionPaint = useCallback(() => {
286
+ const state = selectionPaintRef.current;
287
+ if (state.timer) {
288
+ clearTimeout(state.timer);
289
+ state.timer = null;
290
+ }
291
+ state.pending = null;
292
+ }, []);
293
+
294
+ // Commit an armed-but-unpainted throttled rect NOW, so paths that read the
295
+ // rendered selection (the pre-scroll stitch harvest) see the newest fast-drag
296
+ // rect rather than the previous rendered one. Cancel-only would drop the
297
+ // pending rect and lose rows it covered that scroll off before the rebuild.
298
+ const flushPendingSelectionPaint = useCallback(() => {
299
+ const state = selectionPaintRef.current;
300
+ if (!state.timer && !state.pending) return;
301
+ const pending = state.pending;
302
+ if (state.timer) {
303
+ clearTimeout(state.timer);
304
+ state.timer = null;
305
+ }
306
+ state.pending = null;
307
+ if (pending) paintSelectionRect(pending, { rememberText: false, immediate: true });
308
+ }, [paintSelectionRect]);
309
+
224
310
  const applySelectionRect = useCallback((rect) => {
225
311
  const clippedRect = withSelectionClip(rect);
226
312
  dragRef.current.rect = clippedRect || null;
@@ -228,14 +314,9 @@ export function useTranscriptScroll({
228
314
  selectionTextRef.current = '';
229
315
  clearStitchBuffer();
230
316
  }
231
- const state = selectionPaintRef.current;
232
- if (state.timer) {
233
- clearTimeout(state.timer);
234
- state.timer = null;
235
- state.pending = null;
236
- }
317
+ cancelPendingSelectionPaint();
237
318
  paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
238
- }, [paintSelectionRect, withSelectionClip, clearStitchBuffer]);
319
+ }, [paintSelectionRect, withSelectionClip, clearStitchBuffer, cancelPendingSelectionPaint]);
239
320
 
240
321
  const applySelectionRectThrottled = useCallback((rect) => {
241
322
  const clippedRect = withSelectionClip(rect, { captureText: false });
@@ -246,11 +327,7 @@ export function useTranscriptScroll({
246
327
  const now = Date.now();
247
328
  const elapsed = now - state.t;
248
329
  if (elapsed >= SELECTION_PAINT_INTERVAL_MS) {
249
- if (state.timer) {
250
- clearTimeout(state.timer);
251
- state.timer = null;
252
- state.pending = null;
253
- }
330
+ cancelPendingSelectionPaint();
254
331
  paintSelectionRect(clippedRect, { rememberText: false });
255
332
  return;
256
333
  }
@@ -265,7 +342,7 @@ export function useTranscriptScroll({
265
342
  }, Math.max(1, SELECTION_PAINT_INTERVAL_MS - elapsed));
266
343
  state.timer.unref?.();
267
344
  }
268
- }, [paintSelectionRect, withSelectionClip]);
345
+ }, [paintSelectionRect, withSelectionClip, cancelPendingSelectionPaint]);
269
346
 
270
347
  const selectionPointAtCurrentScroll = useCallback((point, pointScroll = 0) => {
271
348
  if (!point) return null;
@@ -298,13 +375,13 @@ export function useTranscriptScroll({
298
375
  } else {
299
376
  const lr = store.getLineRectAt?.(y);
300
377
  if (lr) { mLo = { x: lr.x1, y: lr.y1 }; mHi = { x: lr.x2, y: lr.y2 }; }
301
- else { mLo = { x: 0, y }; mHi = { x, y }; }
378
+ else { mLo = { x: 0, y }; mHi = { x: Math.max(0, frameColumns - 1), y }; }
302
379
  }
303
380
  const rect = (a, b) => ({ mode: 'linear', x1: a.x, y1: a.y, x2: b.x, y2: b.y });
304
381
  if (comparePoints(mHi, spanLo) < 0) return rect(spanHi, mLo);
305
382
  if (comparePoints(mLo, spanHi) > 0) return rect(spanLo, mHi);
306
383
  return rect(spanLo, spanHi);
307
- }, [store, selectionPointAtCurrentScroll]);
384
+ }, [store, frameColumns, selectionPointAtCurrentScroll]);
308
385
 
309
386
  const transcriptViewportRows = useCallback(() => {
310
387
  const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
@@ -356,6 +433,17 @@ export function useTranscriptScroll({
356
433
  const maxTarget = Math.max(0, Number(maxScrollRowsRef.current) || 0);
357
434
  const target = Math.max(0, Math.min(maxTarget, scrollTargetRef.current + deltaRows));
358
435
  const appliedDelta = target - scrollTargetRef.current;
436
+ // Before the scroll moves selected rows out of view, snapshot the rows
437
+ // currently under the selection into the stitch buffer keyed by the
438
+ // PRE-scroll offset (ref selection.ts captureScrolledRows). Runs for BOTH
439
+ // an active drag and a wheel-shift of a released selection, so Ctrl+C
440
+ // reconstructs the full text no matter how far it scrolled off-screen.
441
+ if (appliedDelta !== 0 && dragRef.current.region === 'transcript' && dragRef.current.rect) {
442
+ // Commit any pending throttled rect first so the harvest reads the newest
443
+ // rendered selection (not the previous rect) before those rows scroll off.
444
+ flushPendingSelectionPaint();
445
+ harvestStitchRowsNow(Number(scrollTargetRef.current) || 0);
446
+ }
359
447
  // Any manual wheel/keyboard scroll takes precedence over an in-flight
360
448
  // transcript follow: drop the glide so the user's intent wins.
361
449
  if (appliedDelta !== 0) cancelTranscriptFollow();
@@ -411,8 +499,17 @@ export function useTranscriptScroll({
411
499
  } else {
412
500
  rect = shiftSelectionRectY(dragRef.current.rect, appliedDelta);
413
501
  }
414
- const clippedRect = withSelectionClip(rect);
502
+ // Active-drag rebuild paints directly, so route through the themed clip
503
+ // (captureText:false, matching rememberText:false below) — a bare rect
504
+ // without selectionBackground falls back to a near-white full-width block
505
+ // with vanishing text (vendor/ink output.js). Also cancel any armed
506
+ // throttled repaint first: it would fire the pre-scroll rect AFTER this
507
+ // one, leaving two coexisting highlights.
508
+ const clippedRect = dragRef.current.active
509
+ ? withSelectionClip(rect, { captureText: false })
510
+ : withSelectionClip(rect);
415
511
  dragRef.current = { ...dragRef.current, rect: clippedRect };
512
+ cancelPendingSelectionPaint();
416
513
  // Never re-harvest selection text from a scroll-shifted rect: the shift
417
514
  // clips the rect to the viewport, so a harvest here would OVERWRITE the
418
515
  // full text remembered at drag-release with only the still-visible rows
@@ -426,7 +523,7 @@ export function useTranscriptScroll({
426
523
  stopSmoothScroll();
427
524
  scrollPositionRef.current = target;
428
525
  setScrollOffset(Math.round(target));
429
- }, [startSmoothScroll, stopSmoothScroll, paintSelectionRect, selectionPointAtCurrentScroll, withSelectionClip, cancelTranscriptFollow, buildSpanRect]);
526
+ }, [startSmoothScroll, stopSmoothScroll, paintSelectionRect, selectionPointAtCurrentScroll, withSelectionClip, cancelTranscriptFollow, buildSpanRect, harvestStitchRowsNow, cancelPendingSelectionPaint, flushPendingSelectionPaint]);
430
527
 
431
528
  // Leading-edge coalescer for edge-drag auto-scroll + wheel deltas: the first
432
529
  // delta after an idle period flushes immediately (single wheel ticks/short
@@ -60,6 +60,18 @@ export function useTranscriptWindow({
60
60
  }) {
61
61
  const transcriptTotalRowsRef = useRef(0);
62
62
  const preservedScrollDeltaRef = useRef(0);
63
+ // Pessimistic "committed" max-scroll for the IMMEDIATE wheel/keyboard clamp.
64
+ // transcriptWindow.maxScrollRows is derived from the row index, which uses
65
+ // ESTIMATED heights for rows in the mounted slice. On the frame a scroll-up
66
+ // FIRST mounts a new row, its estimate may overshoot the real Yoga height, so
67
+ // that frame's maxScrollRows is inflated and a wheel offset clamped only
68
+ // against it scrolls past committed geometry — opening a one-frame blank band
69
+ // at the top until the post-commit harvest corrects it. We hold this cap only
70
+ // while a row IN the mounted slice is still unmeasured; the harvest measures
71
+ // it on the next commit and the estimate is adopted. Expansion can never be
72
+ // blocked longer than that frame (and is bypassed entirely when measured-rows
73
+ // mode is off — no harvest ever runs, so the estimate is the only geometry).
74
+ const committedMaxScrollRowsRef = useRef(0);
63
75
  // Per-hook-instance settled-prefix row-index cache for the incremental
64
76
  // builder. Was module-level (leaked across hook instances); now local so
65
77
  // each transcript window owns its own tail-flush cache.
@@ -389,7 +401,49 @@ export function useTranscriptWindow({
389
401
  rowIndex: transcriptRowIndex,
390
402
  // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: sig+scroll/viewport capture the relevant changes
391
403
  }), [transcriptStructureSig, renderScrollOffset, transcriptContentHeight, transcriptRowIndex]);
392
- maxScrollRowsRef.current = transcriptWindow.maxScrollRows;
404
+ // Publish the max for the immediate wheel/keyboard clamp (see the
405
+ // committedMaxScrollRowsRef note above). Adopt the estimate-based max unless a
406
+ // row in THIS frame's mounted slice is still unmeasured — that is the only
407
+ // case where an over-estimated freshly-mounted row can inflate maxScrollRows
408
+ // ahead of committed Yoga geometry. Off-slice appended rows (bottom spacer,
409
+ // never mounted) don't trigger the hold, so scroll can always reach the true
410
+ // oldest rows; and with measured-rows mode off, no harvest ever runs, so the
411
+ // raw estimate is published unconditionally.
412
+ const estimateMaxScrollRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
413
+ let holdCommittedMax = false;
414
+ if (TRANSCRIPT_MEASURED_ROWS && estimateMaxScrollRows > committedMaxScrollRowsRef.current) {
415
+ // Expansion requested: only trust it once every mounted-slice row has a
416
+ // committed measurement that is VALID FOR THIS geometry. A just-mounted row
417
+ // lacks a cache entry until the post-commit harvest records its real height;
418
+ // and a cache entry left over from a prior columns/tool-expanded/variant is
419
+ // stale (the harvest re-measures it against these same fields), so it must
420
+ // also count as unmeasured — otherwise the pre-harvest estimate for the new
421
+ // geometry could re-open the overshoot right after such a change.
422
+ const toolExpandedFlag = toolOutputExpanded ? 1 : 0;
423
+ const mountedSlice = transcriptWindow.items || [];
424
+ for (let i = 0; i < mountedSlice.length; i++) {
425
+ const it = mountedSlice[i];
426
+ if (!it || shouldSuppressFullyFailedToolItem(it)) continue;
427
+ if (it.kind === 'assistant' && it.streaming) {
428
+ const idPrev = streamingMeasuredRowsById.get(it.id);
429
+ if (!idPrev || idPrev.columns !== frameColumns || idPrev.toolExpanded !== toolExpandedFlag) {
430
+ holdCommittedMax = true;
431
+ break;
432
+ }
433
+ } else {
434
+ const prev = transcriptMeasuredRowsCache.get(it);
435
+ if (!prev
436
+ || prev.columns !== frameColumns
437
+ || prev.toolExpanded !== toolExpandedFlag
438
+ || prev.variantKey !== transcriptItemVariantKey(it)) {
439
+ holdCommittedMax = true;
440
+ break;
441
+ }
442
+ }
443
+ }
444
+ }
445
+ if (!holdCommittedMax) committedMaxScrollRowsRef.current = estimateMaxScrollRows;
446
+ maxScrollRowsRef.current = committedMaxScrollRowsRef.current;
393
447
  // Publish this frame's geometry so a manual scroll can capture the reading
394
448
  // anchor synchronously (see captureTranscriptAnchorAt).
395
449
  transcriptGeomRef.current = {
@@ -71,7 +71,7 @@ export const UserMessage = React.memo(function UserMessage({ text, attached = fa
71
71
  const bandColumns = Math.max(1, columns - 1);
72
72
  return (
73
73
  <Box flexDirection="column" width={bandColumns} marginTop={attached ? 0 : 1} backgroundColor={theme.userMessageBackground} paddingLeft={2} paddingRight={1}>
74
- <Text color={theme.text} wrap="wrap">{text}</Text>
74
+ <Text color={theme.mixdogIvory} wrap="wrap">{text}</Text>
75
75
  </Box>
76
76
  );
77
77
  });
@@ -660,7 +660,9 @@ export function PromptInput({
660
660
  }
661
661
 
662
662
  if (!commandPaletteActive && ((key.ctrl && inputKey === 'v') || (key.meta && inputKey === 'v'))) {
663
- handleExternalPaste('', { source: 'clipboard-image-shortcut' });
663
+ // Ctrl+V / Meta+V: read OS clipboard (text first, image fallback) — the
664
+ // empty text arg tags the shortcut path in handlePromptPaste.
665
+ handleExternalPaste('', { source: 'clipboard-shortcut' });
664
666
  return;
665
667
  }
666
668
 
@@ -7,13 +7,20 @@
7
7
  * line is promoted to a real transcript user row only when it starts executing
8
8
  * (see engine drain()).
9
9
  *
10
+ * Default (expanded) mode renders the FULL wrapped text at the same content
11
+ * width the promoted transcript user row uses, so promotion does not change
12
+ * the row height mid-flight (the old 1-line truncation made the frame visibly
13
+ * jump when a wrapped message expanded on promotion). App.jsx reserves the
14
+ * matching height via queuedBandRows() and flips `compact` on when the queue
15
+ * would not fit the frame — compact mode truncates each entry to one row.
16
+ *
10
17
  * Renders nothing when the queue is empty.
11
18
  */
12
19
  import React from 'react';
13
20
  import { Box, Text } from 'ink';
14
21
  import { theme } from '../theme.mjs';
15
22
 
16
- export function QueuedCommands({ queued, columns }) {
23
+ export function QueuedCommands({ queued, columns, compact = false }) {
17
24
  if (!queued || queued.length === 0) return null;
18
25
  // Each queued line reads as a full-width band like a user message (same
19
26
  // background, 2-col gutter), but stays readable while it waits to be sent.
@@ -21,19 +28,23 @@ export function QueuedCommands({ queued, columns }) {
21
28
  // One cell short of the edge: writing the last terminal column triggers
22
29
  // Windows auto-wrap/scroll that drifts the alt-screen frame (see UserMessage).
23
30
  const bandColumns = Math.max(1, columns - 1);
31
+ // Content width = bandColumns(columns-1) - paddingLeft(2) - paddingRight(1)
32
+ // = columns-4. Compact truncation appends '…' (1 cell), so the slice must
33
+ // leave room for that suffix and avoid a wrap to row 2.
34
+ const contentWidth = Math.max(1, columns - 4);
24
35
  return (
25
36
  <Box flexDirection="column">
26
37
  {queued.map((item) => {
27
- // Truncate to 1 line so the row reservation (queued.length in App.jsx)
28
- // stays accurate — wrapped text would push the input box off-screen.
29
- // Content width = bandColumns(columns-1) - paddingLeft(2) - paddingRight(1)
30
- // = columns-4. When truncating we append '…' (1 cell), so the slice
31
- // must leave room for that suffix and avoid a wrap to row 2.
32
- const contentWidth = Math.max(1, columns - 4);
33
38
  const sourceText = String(item.displayText || item.text || '');
34
- const displayText = sourceText.length > contentWidth
35
- ? (contentWidth <= 1 ? '…'.repeat(contentWidth) : sourceText.slice(0, Math.max(1, contentWidth - 1)) + '…')
36
- : sourceText;
39
+ let displayText = sourceText;
40
+ if (compact) {
41
+ // Compact fallback: exactly 1 row per entry (queued.length reserve).
42
+ // Collapse newlines first — a raw '\n' would still break the row.
43
+ const oneLine = sourceText.replace(/\r?\n/g, ' ');
44
+ displayText = oneLine.length > contentWidth
45
+ ? (contentWidth <= 1 ? '…'.repeat(contentWidth) : oneLine.slice(0, Math.max(1, contentWidth - 1)) + '…')
46
+ : oneLine;
47
+ }
37
48
  return (
38
49
  <Box key={item.id} width={bandColumns} backgroundColor={theme.userMessageBackground} paddingLeft={2} paddingRight={1}>
39
50
  <Text wrap="wrap">
@@ -295,8 +295,8 @@ function localOldestWorkerStartMs(agentWorkers = [], agentJobs = []) {
295
295
  // L2 assembly only — themed SGR (statusColors SUCCESS/STATUS/SUBTLE), already in
296
296
  // the active palette, so this must NOT be passed through normalizeStatusLine.
297
297
  // Returns the joined L2 string or '' when no active segment. Order:
298
- // Agents → Exploring → Searching → Shells. (Shell segment is disk-based and not
299
- // available to the instant-local path; intentionally omitted here.)
298
+ // Agents → Exploring → Web Searching → Shells. (Shell segment is disk-based and
299
+ // not available to the instant-local path; intentionally omitted here.)
300
300
  function localStatusLineL2({
301
301
  agentWorkers = [],
302
302
  agentJobs = [],
@@ -325,7 +325,7 @@ function localStatusLineL2({
325
325
  }
326
326
  if (searchInfo && localNum(searchInfo.count) > 0) {
327
327
  const elapsed = localNum(searchInfo.startedAt) > 0 ? localFormatElapsed(now - localNum(searchInfo.startedAt)) : '';
328
- l2Parts.push(`${spin} ${STATUS}Searching${RESET}${elapsedSuffix(elapsed)}`);
328
+ l2Parts.push(`${spin} ${STATUS}Web Searching${RESET}${elapsedSuffix(elapsed)}`);
329
329
  }
330
330
  return l2Parts.length ? l2Parts.join(segSep) : '';
331
331
  }
@@ -83,7 +83,7 @@ function statusCopy(name, label, count, doneCount, pending, isError, args = {})
83
83
  // dropping the pad just normalizes the spacing.
84
84
  return formatToolActionHeader(name, args, { pending, count });
85
85
  }
86
- export function ToolExecution({ name, args, result, rawResult, isError, errorCount, expanded, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, headerFinalized = true, deferredDisplayReady = false }) {
86
+ export function ToolExecution({ name, args, result, rawResult, isError, errorCount, expanded, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, doneCategories = null, headerFinalized = true, deferredDisplayReady = false }) {
87
87
  const rowWidth = Math.max(1, Number(columns || 80));
88
88
  const [blinkOn, setBlinkOn] = useState(true);
89
89
  const [blinkExpired, setBlinkExpired] = useState(false);
@@ -120,6 +120,18 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
120
120
  const failedCount = clampFailureCount(errorCount, groupCount, isError);
121
121
  const displayGroupCount = groupCount;
122
122
  const displayCategories = normalizeCountMap(categories || {});
123
+ // In the DONE state, count only successful calls: error-terminated calls are
124
+ // excluded from the category totals via the engine-supplied doneCategories
125
+ // map (failures still surface in the 'N Ok · N Failed' detail). The pending/
126
+ // in-flight header keeps using the raw call-time counts unchanged.
127
+ const normalizedDoneCategories = doneCategories ? normalizeCountMap(doneCategories) : displayCategories;
128
+ // All-failed aggregate collapses doneCategories to zero counts, which would
129
+ // render a blank header. Fall back to the raw call-time counts so the done
130
+ // header is never empty; the 'N Failed' detail still marks the failure.
131
+ const hasDoneCounts = Object.values(normalizedDoneCategories || {}).some(
132
+ (v) => (v && typeof v === 'object' ? Number(v.count || 0) : Number(v || 0)) > 0,
133
+ );
134
+ const displayDoneCategories = hasDoneCounts ? normalizedDoneCategories : displayCategories;
123
135
 
124
136
  useEffect(() => {
125
137
  if (!pending) {
@@ -206,7 +218,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
206
218
  // No stableVerbWidth: see statusCopy — the padding only left a mid-header
207
219
  // gap ("Searched 1 pattern, Read 1 file") since Ink trims trailing
208
220
  // spaces and never stabilized the flip.
209
- const headerText = safeInlineText(formatAggregateHeader(displayCategories || {}, { pending: headerPending, order: headerOrder }));
221
+ const headerText = safeInlineText(formatAggregateHeader((headerPending ? displayCategories : displayDoneCategories) || {}, { pending: headerPending, order: headerOrder }));
210
222
  let detailText;
211
223
  if (hasResult) {
212
224
  // The aggregate card reserves EXACTLY ONE detail row when it is not
@@ -456,7 +468,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
456
468
  labelText = safeInlineText(labelText);
457
469
  // Show the parenthesized arg summary for grouped cards too, matching single
458
470
  // calls so the header carries the same context.
459
- const toolSearchSummary = !pending && normalizedName === 'tool_search' && hasResult
471
+ const toolSearchSummary = !pending && normalizedName === 'load_tool' && hasResult
460
472
  ? toolSearchLoadedSummary(displayedResultText)
461
473
  : '';
462
474
  const rawSummaryText = safeInlineText(isAgentResponse || isBackgroundResponse
@@ -486,7 +498,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
486
498
  && hasDisplayBody
487
499
  && (totalLines > 1 || firstResultLineClipped || Boolean(shellCollapsedSummary && shellCollapsedSummary !== firstResultLine));
488
500
  const showHeaderExpandHint = (isShellSurface ? shellHasExpandableBody : (isAgentSurfaceCard ? agentHasExpandableBody : (hasHiddenDetail || backgroundMetadataExpandable)))
489
- && normalizedName !== 'tool_search';
501
+ && normalizedName !== 'load_tool';
490
502
  const expandHintColor = theme.subtle;
491
503
 
492
504
  // Build a single-line header that never wraps: reserve width for the fixed
@@ -77,7 +77,7 @@ export const Item = React.memo(function Item({ item, prevKind, columns, toolOutp
77
77
  node = <ToolHookDenialCard item={item} columns={columns} />;
78
78
  break;
79
79
  }
80
- node = <ToolExecution name={item.name} args={item.args} result={item.result} rawResult={item.rawResult} isError={item.isError} errorCount={item.errorCount} expanded={toolOutputExpanded || item.expanded} columns={columns} attached={false} count={item.count} completedCount={item.completedCount} startedAt={item.startedAt} completedAt={item.completedAt} aggregate={item.aggregate} categories={item.categories} headerFinalized={item.headerFinalized} deferredDisplayReady={item.deferredDisplayReady} themeEpoch={themeEpoch} />;
80
+ node = <ToolExecution name={item.name} args={item.args} result={item.result} rawResult={item.rawResult} isError={item.isError} errorCount={item.errorCount} expanded={toolOutputExpanded || item.expanded} columns={columns} attached={false} count={item.count} completedCount={item.completedCount} startedAt={item.startedAt} completedAt={item.completedAt} aggregate={item.aggregate} categories={item.categories} doneCategories={item.doneCategories} headerFinalized={item.headerFinalized} deferredDisplayReady={item.deferredDisplayReady} themeEpoch={themeEpoch} />;
81
81
  break;
82
82
  }
83
83
  case 'notice':