mixdog 0.9.18 → 0.9.19

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 (122) hide show
  1. package/package.json +3 -2
  2. package/scripts/output-style-smoke.mjs +2 -2
  3. package/scripts/recall-bench-cases.json +11 -0
  4. package/scripts/recall-bench.mjs +91 -2
  5. package/scripts/tool-efficiency-diag.mjs +4 -1
  6. package/scripts/tool-smoke.mjs +101 -27
  7. package/src/agents/debugger/AGENT.md +3 -3
  8. package/src/agents/heavy-worker/AGENT.md +7 -10
  9. package/src/agents/maintainer/AGENT.md +1 -2
  10. package/src/agents/reviewer/AGENT.md +1 -2
  11. package/src/agents/worker/AGENT.md +5 -8
  12. package/src/defaults/agents.json +3 -0
  13. package/src/mixdog-session-runtime.mjs +23 -6
  14. package/src/rules/agent/00-core.md +4 -3
  15. package/src/rules/agent/30-explorer.md +53 -22
  16. package/src/rules/lead/lead-tool.md +3 -2
  17. package/src/rules/shared/01-tool.md +24 -29
  18. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  21. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  23. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  25. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  26. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  28. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  29. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  30. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  32. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  33. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  34. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  35. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  36. package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
  37. package/src/runtime/channels/backends/discord.mjs +43 -1
  38. package/src/runtime/channels/index.mjs +145 -58
  39. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  40. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  41. package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
  42. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  43. package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
  44. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  45. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  46. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  47. package/src/runtime/memory/lib/query-handlers.mjs +36 -4
  48. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  49. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  50. package/src/runtime/shared/atomic-file.mjs +10 -4
  51. package/src/runtime/shared/background-tasks.mjs +4 -2
  52. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  53. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  54. package/src/runtime/shared/tool-surface.mjs +30 -1
  55. package/src/session-runtime/config-lifecycle.mjs +1 -1
  56. package/src/session-runtime/cwd-plugins.mjs +46 -3
  57. package/src/session-runtime/mcp-glue.mjs +24 -3
  58. package/src/session-runtime/output-styles.mjs +44 -10
  59. package/src/session-runtime/workflow.mjs +16 -1
  60. package/src/standalone/channel-worker.mjs +74 -5
  61. package/src/standalone/explore-tool.mjs +1 -1
  62. package/src/tui/App.jsx +57 -77
  63. package/src/tui/app/channel-pickers.mjs +45 -0
  64. package/src/tui/app/slash-commands.mjs +0 -1
  65. package/src/tui/app/slash-dispatch.mjs +0 -16
  66. package/src/tui/app/transcript-window.mjs +44 -1
  67. package/src/tui/app/use-mouse-input.mjs +9 -2
  68. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  69. package/src/tui/app/use-transcript-scroll.mjs +5 -1
  70. package/src/tui/app/use-transcript-window.mjs +65 -5
  71. package/src/tui/components/PromptInput.jsx +33 -64
  72. package/src/tui/components/ToolExecution.jsx +2 -2
  73. package/src/tui/dist/index.mjs +5330 -5443
  74. package/src/tui/engine.mjs +49 -10
  75. package/src/tui/lib/voice-setup.mjs +166 -0
  76. package/src/tui/paste-attachments.mjs +12 -5
  77. package/src/tui/prompt-history-store.mjs +125 -12
  78. package/scripts/bench/cache-probe-tasks.json +0 -8
  79. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  80. package/scripts/bench/lead-review-tasks.json +0 -20
  81. package/scripts/bench/r4-mixed-tasks.json +0 -20
  82. package/scripts/bench/r5-orchestrated-task.json +0 -7
  83. package/scripts/bench/review-tasks.json +0 -20
  84. package/scripts/bench/round-codex.json +0 -114
  85. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  86. package/scripts/bench/round-mixdog-lead.json +0 -269
  87. package/scripts/bench/round-mixdog.json +0 -126
  88. package/scripts/bench/round-r10-bigsample.json +0 -679
  89. package/scripts/bench/round-r11-codexalign.json +0 -257
  90. package/scripts/bench/round-r13-clientmeta.json +0 -464
  91. package/scripts/bench/round-r14-betafeatures.json +0 -466
  92. package/scripts/bench/round-r15-fulldefault.json +0 -462
  93. package/scripts/bench/round-r16-sessionid.json +0 -466
  94. package/scripts/bench/round-r17-wirebytes.json +0 -456
  95. package/scripts/bench/round-r18-prewarm.json +0 -468
  96. package/scripts/bench/round-r19-clean.json +0 -472
  97. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  98. package/scripts/bench/round-r21-delta-retry.json +0 -473
  99. package/scripts/bench/round-r22-full-probe.json +0 -693
  100. package/scripts/bench/round-r23-itemprobe.json +0 -701
  101. package/scripts/bench/round-r24-shapefix.json +0 -677
  102. package/scripts/bench/round-r25-serial.json +0 -464
  103. package/scripts/bench/round-r26-parallel3.json +0 -671
  104. package/scripts/bench/round-r27-parallel10.json +0 -894
  105. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  106. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  107. package/scripts/bench/round-r30-instid.json +0 -253
  108. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  109. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  110. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  111. package/scripts/bench/round-r34-orchestrated.json +0 -120
  112. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  113. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  114. package/scripts/bench/round-r4-codex.json +0 -114
  115. package/scripts/bench/round-r4-mixed.json +0 -225
  116. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  117. package/scripts/bench/round-r6-codex.json +0 -114
  118. package/scripts/bench/round-r6-solo.json +0 -257
  119. package/scripts/bench/round-r7-full.json +0 -254
  120. package/scripts/bench/round-r8-fulldefault.json +0 -255
  121. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  122. package/src/tui/lib/voice-recorder.mjs +0 -469
@@ -22,6 +22,9 @@ import {
22
22
  setStreamingBottomPinned,
23
23
  transcriptRenderWindow,
24
24
  resolveAnchorScrollOffset,
25
+ streamingTailMountedGrowth,
26
+ streamingEstimateRows,
27
+ TRANSCRIPT_WINDOW_OVERSCAN_ROWS,
25
28
  upperBound,
26
29
  shiftSelectionRectY,
27
30
  } from './transcript-window.mjs';
@@ -347,6 +350,37 @@ export function useTranscriptWindow({
347
350
  contentHeight: lockViewRows,
348
351
  floatingPanelRows: Number(floatingPanelRows) || 0,
349
352
  };
353
+ // ── Same-frame streaming-tail growth compensation (scrolled-up only) ───────
354
+ // When the user reads older transcript, the row index freezes the trailing
355
+ // streaming item at its last Yoga-confirmed height (defer-growth), but the
356
+ // mounted Box lays out at the live-grown height THIS frame. renderScrollOffset
357
+ // was just resolved against that frozen row index, so the extra physical rows
358
+ // are unaccounted-for and the visible window jumps up one frame until the
359
+ // harvest bumps measuredRowsVersion. Fold the mounted growth delta into the
360
+ // offset NOW so marginBottom slides those extra rows off the bottom and the
361
+ // window holds still. Bottom-pinned/following is stable (flex-end owns it) and
362
+ // is excluded by the scrolledUp gate. No double-compensation next frame: once
363
+ // measuredRowsVersion bumps, the row index absorbs the growth (idEntry.rows ==
364
+ // the new measured height) and the live estimate matches it, so delta → 0.
365
+ if (scrolledUp && !followingRef.current) {
366
+ const growth = streamingTailMountedGrowth(items, frameColumns, toolOutputExpanded);
367
+ // Only compensate when the tail is actually mounted in the rendered slice
368
+ // (viewport + overscan). Off-slice it is represented by a row-index-sized
369
+ // bottom spacer that does NOT physically grow this frame, so shifting the
370
+ // offset there would itself introduce a jump. In-slice its Box grows now.
371
+ // Gate/clamp on the POST-compensation offset: transcriptRenderWindow drops
372
+ // the tail once effectiveScrollOffset >= tailRows + overscan, so adding the
373
+ // full delta could push the offset past that edge and unmount the very row
374
+ // we are compensating for (a reverse jump at the boundary). Cap the applied
375
+ // delta so the final offset keeps the tail mounted; any residual growth left
376
+ // uncompensated sits in the overscan below the viewport (not visible).
377
+ if (growth && growth.delta > 0) {
378
+ const maxOffsetKeepingTailMounted = growth.tailRows + TRANSCRIPT_WINDOW_OVERSCAN_ROWS - 1;
379
+ if (renderScrollOffset <= maxOffsetKeepingTailMounted) {
380
+ renderScrollOffset += Math.min(growth.delta, maxOffsetKeepingTailMounted - renderScrollOffset);
381
+ }
382
+ }
383
+ }
350
384
  const transcriptWindow = useMemo(() => transcriptRenderWindow(items, {
351
385
  scrollOffset: renderScrollOffset,
352
386
  viewportHeight: transcriptContentHeight,
@@ -367,8 +401,11 @@ export function useTranscriptWindow({
367
401
  // CAPTURE for a missing/dirty anchor (above) reads this from the PREVIOUS
368
402
  // frame to reconstruct the exact top-edge row that was on screen, so the
369
403
  // capture matches what the user saw rather than the stale scrollOffset
370
- // state. Bottom-relative, matching transcriptRenderWindow's window math.
371
- renderOffset: Math.max(0, Number(renderScrollOffset) || 0),
404
+ // state. Publish the CLAMPED effective offset (transcriptRenderWindow clamps
405
+ // renderScrollOffset to maxScrollRows internally) so the reconstruction uses
406
+ // the value the window math actually rendered with, not the raw compensated
407
+ // offset. Bottom-relative, matching transcriptRenderWindow's window math.
408
+ renderOffset: Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0),
372
409
  suppressMeasuredRowHeights,
373
410
  };
374
411
  // The window memo is keyed on a structure signature that intentionally
@@ -456,8 +493,24 @@ export function useTranscriptWindow({
456
493
  const variantKey = transcriptItemVariantKey(item);
457
494
  if (isStreamingAssistant) {
458
495
  const idPrev = streamingMeasuredRowsById.get(item.id);
496
+ // Baseline = the estimate for the text at THIS commit's measured state.
497
+ // Refresh it on EVERY harvest pass (not only on an id-store change): if
498
+ // the estimator's row count drifts while the Yoga height is unchanged
499
+ // (or a same-id text reset), a stale baseline would make
500
+ // streamingTailMountedGrowth compensate with no physical growth.
501
+ const estimateAtMeasure = streamingEstimateRows(item, frameColumns, toolOutputExpanded);
459
502
  if (!idPrev || idPrev.rows !== measured || idPrev.columns !== frameColumns || idPrev.toolExpanded !== toolExpandedFlag) {
460
- streamingMeasuredRowsById.set(item.id, { rows: measured, columns: frameColumns, toolExpanded: toolExpandedFlag });
503
+ // Record the estimate for the EXACT text just measured alongside the
504
+ // real Yoga height, so streamingTailMountedGrowth can measure later
505
+ // growth in the estimate metric (live_now − estimateAtMeasure) and the
506
+ // estimator's steady bias cancels instead of leaking into a permanent
507
+ // over-compensation once measuredRowsVersion has absorbed this height.
508
+ streamingMeasuredRowsById.set(item.id, {
509
+ rows: measured,
510
+ columns: frameColumns,
511
+ toolExpanded: toolExpandedFlag,
512
+ estimateRows: estimateAtMeasure,
513
+ });
461
514
  // ALWAYS bump on a real id-store change, bottom-pinned or not.
462
515
  // estimateTranscriptItemRowsCached (transcript-window.mjs) now
463
516
  // returns ONLY this id-store floor for a streaming item (defer-
@@ -470,6 +523,8 @@ export function useTranscriptWindow({
470
523
  // stream settles — invisible growth for anything reading rowIndex
471
524
  // (windowing, maxScrollRows, an anchor captured mid-stream).
472
525
  changed = true;
526
+ } else {
527
+ idPrev.estimateRows = estimateAtMeasure;
473
528
  }
474
529
  }
475
530
  const prev = transcriptMeasuredRowsCache.get(item);
@@ -662,13 +717,18 @@ export function useTranscriptWindow({
662
717
  if (deltaY === 0) return;
663
718
  const clippedRect = withSelectionClip(shiftSelectionRectY(dragRef.current.rect, deltaY));
664
719
  dragRef.current = { ...dragRef.current, rect: clippedRect };
665
- paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
720
+ // rememberText:false — the shifted rect is viewport-clipped, so harvesting
721
+ // here would replace the full selection text remembered at drag-release
722
+ // with only the still-visible fragment (partial Ctrl+C after scrolling).
723
+ paintSelectionRect(clippedRect, { rememberText: false, immediate: true });
666
724
  }, [transcriptContentHeight, transcriptWindow.totalRows, transcriptWindow.effectiveScrollOffset, withSelectionClip, paintSelectionRect]);
667
725
  useEffect(() => {
668
726
  if (!dragRef.current.rect) return;
669
727
  const clippedRect = withSelectionClip(dragRef.current.rect);
670
728
  dragRef.current = { ...dragRef.current, rect: clippedRect };
671
- paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
729
+ // Theme repaint: same cells, same text — no need to re-harvest (and a
730
+ // clipped rect would clobber the remembered full text with a fragment).
731
+ paintSelectionRect(clippedRect, { rememberText: false, immediate: true });
672
732
  }, [themeEpoch, withSelectionClip, paintSelectionRect]);
673
733
  useEffect(() => {
674
734
  const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
@@ -49,10 +49,6 @@ import {
49
49
  isModifiedEnterSequence,
50
50
  isAnyModifiedEnterSequence,
51
51
  } from './prompt-input/edit-helpers.mjs';
52
- import {
53
- VOICE_TICK_MS,
54
- voiceIndicatorTextFor,
55
- } from './prompt-input/voice-indicator.mjs';
56
52
 
57
53
  // Windows Terminal IME composition can clip a glyph that starts exactly at the
58
54
  // left edge of the editable text node. The rounded prompt box already adds a
@@ -100,8 +96,6 @@ export function PromptInput({
100
96
  onRestoreQueued,
101
97
  onHistoryNavigate,
102
98
  onPasteText,
103
- onVoiceToggle,
104
- voiceIndicatorMode = 'off',
105
99
  selectionRef,
106
100
  valueRef,
107
101
  boxRectRef,
@@ -129,27 +123,8 @@ export function PromptInput({
129
123
  const boxRef = useRef(null);
130
124
  const cursorEnabledRef = useRef(false); // latest enabled state, read by the anchor fn at render time
131
125
  const contentWidthRef = useRef(80);
132
- // Cells reserved on the right for the voice indicator glyph(s) (0 when
133
- // hidden, 1 idle/spinner-only, 2 recording's "◉"+braille). Read by the
134
- // cursor-anchor closure below (installed ONCE, so it must read live state
135
- // via a ref rather than close over a render-local variable) to keep the
136
- // caret/wrap math in sync with the actual reserved column, same pattern as
137
- // IME_LEFT_GUARD_COLUMNS on the left edge.
138
- const voiceIndicatorWidthRef = useRef(0);
139
126
  const preferredColumnRef = useRef(null);
140
127
  const mouseExtendCoalesceRef = useRef({ pendingNext: null, timer: null, t: 0 });
141
- // Voice indicator animation tick. Runs a single ~120ms interval ONLY while
142
- // an ANIMATED mode is active (installing/recording/transcribing) — 'idle'
143
- // renders a static glyph and 'off' renders nothing, so neither keeps a
144
- // timer alive (the old idle "breathing" pulse spun constantly whenever
145
- // voice was merely enabled, reading as phantom activity).
146
- const [voiceTick, setVoiceTick] = useState(0);
147
- useEffect(() => {
148
- if (voiceIndicatorMode === 'off' || voiceIndicatorMode === 'idle') return undefined;
149
- const timer = setInterval(() => setVoiceTick((t) => (t + 1) % 1000000), VOICE_TICK_MS);
150
- timer.unref?.();
151
- return () => clearInterval(timer);
152
- }, [voiceIndicatorMode]);
153
128
  // Undo/redo snapshot stack. Each entry is a { value, cursor, selectionAnchor }
154
129
  // draft snapshot. Continuous typing coalesces (see UNDO_COALESCE_MS) so a run
155
130
  // of characters collapses into one undo step; cursor-only moves are NOT
@@ -171,6 +146,7 @@ export function PromptInput({
171
146
  // the parent chain from our box and firing it flushes the new draft in the same
172
147
  // tick. Guarded so a structure change in the ink fork degrades to throttled
173
148
  // (slower) rendering, never a crash.
149
+ const flushThrottleRef = useRef({ lastAt: 0, timer: null });
174
150
  const flushImmediate = () => {
175
151
  let node = boxRef.current;
176
152
  for (let i = 0; node && i < 64; i++) {
@@ -182,6 +158,30 @@ export function PromptInput({
182
158
  }
183
159
  };
184
160
 
161
+ // commitDraft fires on every keystroke; a raw queueMicrotask(flushImmediate)
162
+ // per commit bypassed ink's maxFps and forced an unthrottled render for each
163
+ // char (a burst of paste/held-key edits = a render storm). Coalesce to a
164
+ // frame budget: flush the first keystroke immediately (no caret lag) and
165
+ // batch the rest to one flush per ~16ms, with a trailing flush so the final
166
+ // char of a burst always lands.
167
+ const FLUSH_INTERVAL_MS = 16;
168
+ const scheduleImmediateFlush = () => {
169
+ const t = flushThrottleRef.current;
170
+ const now = Date.now();
171
+ const elapsed = now - t.lastAt;
172
+ if (elapsed >= FLUSH_INTERVAL_MS) {
173
+ if (t.timer !== null) { clearTimeout(t.timer); t.timer = null; }
174
+ t.lastAt = now;
175
+ queueMicrotask(flushImmediate);
176
+ } else if (t.timer === null) {
177
+ t.timer = setTimeout(() => {
178
+ t.timer = null;
179
+ t.lastAt = Date.now();
180
+ flushImmediate();
181
+ }, FLUSH_INTERVAL_MS - elapsed);
182
+ }
183
+ };
184
+
185
185
  const commitDraft = (next, options = {}) => {
186
186
  const sameDraft = draftStateEqual(draftRef.current, next);
187
187
  if (!options.keepPreferredColumn) preferredColumnRef.current = null;
@@ -189,7 +189,7 @@ export function PromptInput({
189
189
  if (!options.skipHistory) recordUndoSnapshot(draftRef.current, next, options);
190
190
  draftRef.current = next;
191
191
  setDraft(next);
192
- queueMicrotask(flushImmediate);
192
+ scheduleImmediateFlush();
193
193
  if (next.value !== lastReportedValueRef.current) {
194
194
  lastReportedValueRef.current = next.value;
195
195
  onDraftChange?.(next.value);
@@ -231,12 +231,7 @@ export function PromptInput({
231
231
  const d = draftRef.current;
232
232
  const w = yogaNode?.getComputedWidth?.() ?? 0;
233
233
  const guardColumns = w > IME_LEFT_GUARD_COLUMNS ? IME_LEFT_GUARD_COLUMNS : 0;
234
- // Reserve the voice-indicator's own cells (+1 gap column when visible)
235
- // out of the SAME width the caret/wrap math uses, mirroring the left
236
- // guard above — otherwise a visible indicator would silently overlap
237
- // the caret/typed text at the right edge instead of pushing content in.
238
- const voiceReserve = voiceIndicatorWidthRef.current > 0 ? voiceIndicatorWidthRef.current + 1 : 0;
239
- const contentWidth = Math.max(1, (w ? w - guardColumns - voiceReserve : contentWidthRef.current) || 80);
234
+ const contentWidth = Math.max(1, (w ? w - guardColumns : contentWidthRef.current) || 80);
240
235
  contentWidthRef.current = contentWidth;
241
236
  const caret = w > 0
242
237
  // PromptInput renders a trailing space cell when the cursor is at
@@ -962,21 +957,15 @@ export function PromptInput({
962
957
  return;
963
958
  }
964
959
 
965
- // Ctrl+Space voice-record toggle. Two encodings observed across
966
- // terminals: a lone NUL byte (legacy Ctrl+Space, many terminals send
967
- // 0x00 directly) or the kitty CSI-u form `\x1b[32;5u` (codepoint 32
968
- // = space, modifier 5 = 1+4 = ctrl). Consumed here — never typed into
969
- // the prompt — and forwarded to the App-level recorder state machine
970
- // via onVoiceToggle; a no-op when the prop isn't wired (e.g. embedded
971
- // pickers) so this never throws.
972
- if (rawInput === '\x00' || /^(?:\x1b)?\[32;5u$/.test(rawInput)) {
973
- onVoiceToggle?.();
974
- return;
975
- }
976
-
977
960
  // Printable input (ignore other control keys). Strip any embedded SGR mouse
978
961
  // sequences as a belt-and-suspenders guard (the early return above catches
979
962
  // whole-sequence inputs; this removes partials that rode in with real text).
963
+ // Swallow Ctrl+Space raw encodings (lone NUL, or the kitty CSI-u form
964
+ // \x1b[32;5u) as a no-op so they never fall through into the prompt as
965
+ // garbage. No voice behavior — just discarded.
966
+ if (rawInput === '\x00' || /^(?:\x1b)?\[32;5u$/.test(rawInput)) {
967
+ return;
968
+ }
980
969
  const printable = rawInput
981
970
  .replace(/(?:\x1b)?\[<\d+;\d+;\d+[Mm]/g, '')
982
971
  .replace(/[\r\n]/g, '');
@@ -1019,15 +1008,6 @@ export function PromptInput({
1019
1008
  const displayValue = mask ? value.replace(/[^\n]/g, '*') : value;
1020
1009
  const renderedValue = renderSelectedText(displayValue, selectionRange(draft), cursor === value.length);
1021
1010
  const hintMeta = hintStyle(hintTone);
1022
- // Monotone voice indicator glyph text — zero-width (rendered as null) when
1023
- // mode is 'off'. Braille frame drives installing/recording/transcribing;
1024
- // 'idle' is a static ○ (no animation, no timer).
1025
- const voiceIndicatorText = voiceIndicatorTextFor(voiceIndicatorMode, voiceTick);
1026
- // stringWidth is unnecessary here — every glyph above is a single
1027
- // 1-column codepoint (○ ● and the braille frames), and 'recording'
1028
- // concatenates exactly two 1-cell glyphs, so plain .length gives the
1029
- // correct cell count without pulling in emoji-width special-casing.
1030
- voiceIndicatorWidthRef.current = voiceIndicatorText.length;
1031
1011
 
1032
1012
  return (
1033
1013
  <Box ref={boxRef} flexDirection="row" width="100%" flexGrow={1} flexShrink={1} backgroundColor={surfaceBackground()}>
@@ -1038,17 +1018,6 @@ export function PromptInput({
1038
1018
  <Text color={hintMeta.textColor}>{hint}</Text>
1039
1019
  </Box>
1040
1020
  ) : null}
1041
- {voiceIndicatorText ? (
1042
- <>
1043
- {/* flexGrow spacer pins the indicator to the RIGHT edge of the row
1044
- even when the typed text is short; without it the glyph would
1045
- ride directly after the caret instead of the box edge. */}
1046
- <Box flexGrow={1} backgroundColor={surfaceBackground()} />
1047
- <Box flexShrink={0} width={voiceIndicatorText.length + 1} justifyContent="flex-end" backgroundColor={surfaceBackground()}>
1048
- <Text color={theme.subtle}>{voiceIndicatorText}</Text>
1049
- </Box>
1050
- </>
1051
- ) : null}
1052
1021
  </Box>
1053
1022
  );
1054
1023
  }
@@ -254,7 +254,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
254
254
  // Keep the aggregate card at a fixed height (header + one detail row) for
255
255
  // its whole lifecycle. Pending cards have no result yet, so reserve the
256
256
  // detail row up front instead of growing from 1→2 rows when the summary
257
- // lands on completion — that late row push is the "줄 튐" jump. The empty
257
+ // lands on completion — that late row push is the "line-jump" jump. The empty
258
258
  // placeholder renders as a blank line under the ⎿ gutter; the final summary
259
259
  // simply fills it in place. This matches estimateTranscriptItemRows (always
260
260
  // 2 + resultRows), so windowing/scroll stay in lockstep too.
@@ -381,7 +381,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
381
381
  // (collapsedDetail = ''), so the card rendered as a single header row. But
382
382
  // estimateTranscriptItemRows() in App.jsx reserves 2 rows for a collapsed
383
383
  // non-aggregate tool (1 only for skill surfaces). That left a 1-row gap that
384
- // closed the instant the result landed — the surviving "". Reserve the same
384
+ // closed the instant the result landed — the surviving "line-jump". Reserve the same
385
385
  // dim placeholder detail row the aggregate card uses (`Running`) for the whole
386
386
  // pending lifecycle so the height stays fixed at header + one detail row and
387
387
  // the final summary just fills in place. Skill surfaces collapse to a single