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
package/src/tui/App.jsx CHANGED
@@ -62,15 +62,6 @@ import {
62
62
  shouldSuppressFullyFailedToolItem,
63
63
  toolItemResultText,
64
64
  } from './transcript-tool-failures.mjs';
65
- import {
66
- toggleVoice,
67
- isVoiceEnabled,
68
- getRecorderState,
69
- startRecording,
70
- stopRecording,
71
- cancelRecording,
72
- disposeRecorder,
73
- } from './lib/voice-recorder.mjs';
74
65
 
75
66
  import { displayModelName } from '../ui/model-display.mjs';
76
67
  import { supportsExtendedKeys, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS } from './keyboard-protocol.mjs';
@@ -200,6 +191,9 @@ const PANEL_LAYOUT_SIG = {
200
191
  PICKER: 1,
201
192
  SLASH: 4,
202
193
  TEXT: 5,
194
+ // Prompt-wrap/meta row counts (trailing churn tokens, see token order note
195
+ // below). PROMPT_META is the 2-row live-spinner band slot.
196
+ PROMPT_META: 9,
203
197
  };
204
198
  const PROJECT_TEXT_ENTRY_KINDS = new Set(['project-new', 'project-create-confirm', 'project-rename']);
205
199
  const CORE_MULTILINE_TEXT_ENTRY_KINDS = new Set(['core-add', 'core-edit']);
@@ -697,9 +691,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
697
691
  closeUsagePanel,
698
692
  openModelPicker: (...a) => openModelPicker(...a),
699
693
  modelSwitchNotice,
700
- toggleVoice,
701
- setVoiceEnabled: (...a) => setVoiceEnabled(...a),
702
- isVoiceEnabled,
703
694
  openSearchPicker: (...a) => openSearchPicker(...a),
704
695
  openAgentsPicker: (...a) => openAgentsPicker(...a),
705
696
  openWorkflowPicker: (...a) => openWorkflowPicker(...a),
@@ -753,19 +744,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
753
744
  // region. STATUSLINE_ROWS mirrors the layout reserve below.
754
745
  const frameRowsRef = useRef(24);
755
746
  const STATUSLINE_BAND_ROWS = 3;
756
- // Voice enabled is cached in state instead of calling isVoiceEnabled() on
757
- // every render — readSection() is a sync config-file read + JSON.parse per
758
- // call, which would otherwise run on every keystroke re-render. Updated by
759
- // the /voice command handler when toggleVoice resolves.
760
- const [voiceEnabled, setVoiceEnabled] = useState(() => isVoiceEnabled());
761
- // When the prompt-box voice indicator is visible, PromptInput reserves up
762
- // to 3 columns (2-cell 'recording' glyph pair + 1 gap) out of its content
763
- // width. The row-reservation math here must shrink by the SAME worst-case
764
- // amount, or wrapped input near the right edge under-reserves rows and the
765
- // transcript overprints the prompt box. Over-reserving by a column in the
766
- // 1-cell indicator states only costs an occasional early wrap — safe side.
767
- const voiceIndicatorVisible = Boolean(state.progressHint?.text) || voiceEnabled || getRecorderState() !== 'idle';
768
- const promptContentColumns = Math.max(1, frameColumns - 4 - (voiceIndicatorVisible ? 3 : 0));
747
+ const promptContentColumns = Math.max(1, frameColumns - 4);
769
748
  const syncPromptLayoutRows = useCallback((value) => {
770
749
  const text = String(value ?? '');
771
750
  promptLayoutValueRef.current = text;
@@ -871,7 +850,14 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
871
850
  // final setSelection() triggered, so the selected text is ready to read.
872
851
  const copySelection = useCallback((attempt = 0) => {
873
852
  const renderText = store.getRenderSelectionText?.();
874
- const text = renderText == null ? selectionTextRef.current : renderText;
853
+ const remembered = selectionTextRef.current || '';
854
+ // A selection that has partially scrolled out of the viewport renders —
855
+ // and therefore harvests — only its visible rows. The remembered text
856
+ // (captured while the selection was last fully painted) is the fuller
857
+ // copy; prefer whichever is longer so scrolling never shrinks a copy.
858
+ const text = renderText == null
859
+ ? remembered
860
+ : (remembered.length > renderText.length ? remembered : renderText);
875
861
  if ((!text || !text.trim()) && attempt < 4) {
876
862
  setTimeout(() => copySelection(attempt + 1), attempt === 0 ? 0 : 24);
877
863
  return;
@@ -899,6 +885,12 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
899
885
  useEffect(() => {
900
886
  if (!stdout) return undefined;
901
887
  let trailing = null;
888
+ // Leading-edge fire on the first event of a burst, then coalesce the rest:
889
+ // events arriving within DEBOUNCE_MS skip the immediate full-layout update
890
+ // and only refresh the (reset) trailing timer, so a resize storm commits at
891
+ // most once per window plus one settle update instead of once per event.
892
+ const DEBOUNCE_MS = 80;
893
+ let lastRun = 0;
902
894
  const update = () => {
903
895
  setResizeState((prev) => {
904
896
  const next = terminalSize(stdout);
@@ -913,12 +905,17 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
913
905
  });
914
906
  };
915
907
  const onResize = () => {
916
- update();
908
+ const now = Date.now();
909
+ if (now - lastRun >= DEBOUNCE_MS) {
910
+ lastRun = now;
911
+ update();
912
+ }
917
913
  if (trailing) clearTimeout(trailing);
918
914
  trailing = setTimeout(() => {
919
915
  trailing = null;
916
+ lastRun = Date.now();
920
917
  update();
921
- }, 60);
918
+ }, DEBOUNCE_MS);
922
919
  };
923
920
  stdout.on('resize', onResize);
924
921
  update();
@@ -952,36 +949,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
952
949
  }, 2200);
953
950
  }, []);
954
951
 
955
- // Voice recorder status hint — reuses the SAME promptHint state as
956
- // showPromptHint/clearPromptHint, but bypasses their 2200ms auto-clear
957
- // timer: '● REC' / '… transcribing' must stay visible for the ENTIRE
958
- // recording/transcribing duration (which can run well past 2.2s), not
959
- // vanish on a fixed timer. Also cancels any pending showPromptHint timer
960
- // so a stale auto-clear can never stomp the live recording status.
961
- const setVoiceStatusHint = useCallback((text, tone = 'info') => {
962
- if (promptHintTimerRef.current) {
963
- clearTimeout(promptHintTimerRef.current);
964
- promptHintTimerRef.current = null;
965
- }
966
- promptHintActiveRef.current = !!text;
967
- setPromptHint(String(text || ''));
968
- setPromptHintTone(tone);
969
- }, []);
970
-
971
- // Dedicated recorder-phase state for the prompt-box voice indicator.
972
- // Deliberately NOT derived from the promptHint text ('● REC' equality):
973
- // any draft keystroke clears promptHint via clearPromptHint(), which would
974
- // silently demote a live recording's indicator back to 'idle' while the
975
- // mic is still hot. This state only changes at real recorder transitions.
976
- const [voiceRecPhase, setVoiceRecPhase] = useState('idle');
977
-
978
- // Best-effort recorder teardown on unmount (component-level cleanup;
979
- // index.jsx's runTui() also disposes via store.dispose on exit/signal, but
980
- // that path doesn't know about the TUI-local recorder singleton).
981
- useEffect(() => () => {
982
- disposeRecorder();
983
- }, []);
984
-
985
952
  const installPastedImages = useCallback((images, { merge = true } = {}) => {
986
953
  if (!images || typeof images !== 'object' || Object.keys(images).length === 0) return;
987
954
  const next = merge ? { ...pastedImagesRef.current, ...images } : { ...images };
@@ -1248,7 +1215,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1248
1215
 
1249
1216
  // PROMPT HANDLER cluster extracted to app/use-prompt-handlers.mjs.
1250
1217
  const {
1251
- handleVoiceToggle,
1252
1218
  handlePromptPaste,
1253
1219
  handlePromptHistoryNavigate,
1254
1220
  handlePromptEscape,
@@ -1266,9 +1232,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1266
1232
  setPastedImages,
1267
1233
  setPastedTexts,
1268
1234
  setPromptDraftOverride,
1269
- setVoiceRecPhase,
1270
1235
  setContextPanel,
1271
- setVoiceStatusHint,
1272
1236
  syncPromptLayoutRows,
1273
1237
  showPromptHint,
1274
1238
  clearPromptHint,
@@ -2484,18 +2448,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2484
2448
  const inputHintTone = promptHint
2485
2449
  ? promptHintTone
2486
2450
  : (latestToast?.tone || progressHint?.tone || 'info');
2487
- // Voice indicator mode for the prompt-box glyph (PromptInput owns the
2488
- // actual animation timer). Recording/transcribing come from the dedicated
2489
- // voiceRecPhase state (NOT the promptHint text — typing clears promptHint,
2490
- // which must not demote a hot mic to 'idle'); install comes from the
2491
- // persistent progressHint; enabled/off from the cached voiceEnabled state.
2492
- const voiceIndicatorMode = voiceRecPhase !== 'idle'
2493
- ? voiceRecPhase
2494
- : progressHint?.text
2495
- ? 'installing'
2496
- : voiceEnabled
2497
- ? 'idle'
2498
- : 'off';
2499
2451
  const latestTranscriptItem = state.items[state.items.length - 1] || null;
2500
2452
  const latestDoneAtTail = latestTranscriptItem?.kind === 'turndone' || latestTranscriptItem?.kind === 'statusdone';
2501
2453
  // Bottom meta band ownership is LIVE-SPINNER ONLY. A finished turn's done row
@@ -2631,6 +2583,28 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2631
2583
  // already needs no clearance (panelShrinkRows is 0 in that case).
2632
2584
  const promptRowsOnlyChange = panelShrinkRows > 0
2633
2585
  && panelKindSignature(panelTransition.signature) === panelKindSignature(panelLayoutSignature);
2586
+ // Turn-end spinner meta collapse: the 2-row live-spinner band disappears in
2587
+ // the SAME commit the engine appends the turndone/statusdone tail (see
2588
+ // engine.mjs runTurn — turndone + spinner:null land in one set()). That new
2589
+ // done row already replaces the vacated height, so masking those rows blank
2590
+ // for one commit only to clear them on the next commit IS the visible
2591
+ // transcript bounce. Exempt exactly the meta-collapse rows from the ink mask
2592
+ // when the done row is the transcript tail. Every other prompt-row-only
2593
+ // shrink (typing newline removal, queued-row churn) AND the reclaimed/no-op
2594
+ // path (engine.mjs skips turndone, so latestDoneAtTail stays false and no
2595
+ // row replaces the height) keep the mask so they still reclaim smoothly.
2596
+ const prevMetaRows = Number(String(panelTransition.signature).split('|')[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
2597
+ const nextMetaRows = Number(String(panelLayoutSignature).split('|')[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
2598
+ // Require the done row to have been appended in THIS commit (tail id changed
2599
+ // since the last commit). A command spinner can leave a STALE done row at the
2600
+ // tail and then clear without appending statusdone (e.g. /recall — see
2601
+ // engine.mjs), collapsing the meta band with NO same-commit backfill; masking
2602
+ // must stay on for that path or the vacated rows overpaint the stale row.
2603
+ const doneTailAppendedThisCommit = latestDoneAtTail
2604
+ && (latestTranscriptItem?.id ?? null) !== panelTransition.tailId;
2605
+ const spinnerMetaCollapseRows = doneTailAppendedThisCommit
2606
+ ? Math.max(0, prevMetaRows - nextMetaRows)
2607
+ : 0;
2634
2608
  const instantPanelClose = panelShrinkRows > 0
2635
2609
  && (promptRowsOnlyChange
2636
2610
  || isInstantPanelCloseTransition(panelTransition.signature, panelLayoutSignature, initialProjectEntryClose));
@@ -2649,8 +2623,10 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2649
2623
  // Slash palette and initial project-entry closes land on the final bottom
2650
2624
  // reserve in one commit. Paint reclaimed rows as a blank mask band below
2651
2625
  // the transcript clip instead of inflating bottomReserve + reclaiming on
2652
- // the next tick.
2653
- panelCloseInkMaskRowsRef.current = panelShrinkRows;
2626
+ // the next tick. Subtract any turn-end spinner-meta rows that the same-
2627
+ // commit done tail already backfills (spinnerMetaCollapseRows) so that
2628
+ // transition masks nothing and does not bounce.
2629
+ panelCloseInkMaskRowsRef.current = Math.max(0, panelShrinkRows - spinnerMetaCollapseRows);
2654
2630
  panelTransition.clearRows = 0;
2655
2631
  panelTransition.guardRows = 0;
2656
2632
  panelTransition.epoch = panelTransitionEpoch;
@@ -2810,6 +2786,12 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2810
2786
  const timer = setTimeout(() => setPanelTransitionEpoch((epoch) => epoch + 1), 0);
2811
2787
  return () => clearTimeout(timer);
2812
2788
  }, [panelLayoutSignature, bottomClusterRows, panelTransitionClearRows, panelTransitionGuardRows]);
2789
+ // Record the transcript tail id AFTER every commit so the next render's
2790
+ // spinner-meta-collapse gate (doneTailAppendedThisCommit) can tell a freshly
2791
+ // appended done row from a stale one that was already at the tail.
2792
+ useEffect(() => {
2793
+ panelTransitionRef.current.tailId = latestTranscriptItem?.id ?? null;
2794
+ });
2813
2795
  // Row-index/window memo chain + measured-height harvest + anchor lock:
2814
2796
  // extracted to app/use-transcript-window.mjs.
2815
2797
  const {
@@ -2917,8 +2899,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2917
2899
  onTab={cycleWorkflowFromPrompt}
2918
2900
  onPasteText={handlePromptPaste}
2919
2901
  onHistoryNavigate={handlePromptHistoryNavigate}
2920
- onVoiceToggle={handleVoiceToggle}
2921
- voiceIndicatorMode={voiceIndicatorMode}
2922
2902
  // Palette stays MOUNTED with 0 matches (stable height, no flicker), but
2923
2903
  // key capture (Enter/arrows/Esc routing) only engages when a command can
2924
2904
  // actually be accepted — otherwise Enter must submit the raw text as
@@ -9,6 +9,7 @@
9
9
  * live opener at call time.
10
10
  */
11
11
  import { theme } from '../theme.mjs';
12
+ import { isVoiceEnabled, toggleVoice, isVoiceInstallBusy } from '../lib/voice-setup.mjs';
12
13
 
13
14
  export function createChannelPickers({
14
15
  store,
@@ -20,7 +21,14 @@ export function createChannelPickers({
20
21
  setContextPanel,
21
22
  onboardingWarnReopen,
22
23
  }) {
24
+ // Bumped whenever any picker in this cluster is (re)rendered or the root is
25
+ // cancelled. applyVoice() captures it before an async toggleVoice() install
26
+ // and only reopens the root if it is UNCHANGED when the promise resolves —
27
+ // a long first-time install finishing later must not hijack whatever
28
+ // screen/picker the user has since navigated to.
29
+ let pickerGen = 0;
23
30
  const openChannelTypeActionsPicker = (backend, options = {}) => {
31
+ pickerGen += 1;
24
32
  const parentReturn = typeof options.returnTo === 'function'
25
33
  ? options.returnTo
26
34
  : () => openChannelSettingTypePicker();
@@ -118,6 +126,7 @@ export function createChannelPickers({
118
126
  };
119
127
 
120
128
  const openChannelSettingTypePicker = (options = {}) => {
129
+ pickerGen += 1;
121
130
  const returnTo = typeof options.returnTo === 'function' ? options.returnTo : () => {};
122
131
  setProviderPrompt(null);
123
132
  setChannelPrompt(null);
@@ -186,6 +195,7 @@ export function createChannelPickers({
186
195
  };
187
196
 
188
197
  const openChannelSetupPicker = async (focus = 'all', options = {}) => {
198
+ pickerGen += 1;
189
199
  setProviderPrompt(null);
190
200
  setChannelPrompt(null);
191
201
  setHookPrompt(null);
@@ -403,6 +413,25 @@ export function createChannelPickers({
403
413
  }
404
414
  reopenRoot({ highlightValue, backendOverride: chosen });
405
415
  };
416
+ // Voice toggle: enabling installs the managed whisper/ffmpeg runtime (first
417
+ // time only) then flips voice.enabled so the channels pipeline transcribes
418
+ // incoming voice messages. toggleVoice owns all notices/progress; we just
419
+ // reopen the root once it settles so the meta/description reflect the new
420
+ // state. Guard against a double-trigger while an install is in flight.
421
+ const applyVoice = (highlightValue = 'voice') => {
422
+ if (isVoiceInstallBusy()) {
423
+ store.pushNotice('Voice install is already running', 'warn');
424
+ return;
425
+ }
426
+ const gen = pickerGen;
427
+ void Promise.resolve(toggleVoice({ pushNotice: store.pushNotice, setProgressHint: store.setProgressHint }))
428
+ .then(() => {
429
+ // toggleVoice already pushed its own ON/OFF/failure notice. Only
430
+ // reopen the root when it is still the active context; if the user
431
+ // navigated away during a long install, leave their screen alone.
432
+ if (pickerGen === gen) reopenRoot({ highlightValue });
433
+ });
434
+ };
406
435
  const items = [
407
436
  {
408
437
  value: 'remote-runtime',
@@ -424,6 +453,15 @@ export function createChannelPickers({
424
453
  description: 'Token and main target',
425
454
  _action: 'channel-setting',
426
455
  },
456
+ {
457
+ value: 'voice',
458
+ label: 'Voice',
459
+ meta: boolLabel(isVoiceEnabled()),
460
+ description: isVoiceEnabled()
461
+ ? 'Channel voice messages will be transcribed'
462
+ : 'Transcribe channel voice messages',
463
+ _action: 'voice',
464
+ },
427
465
  {
428
466
  value: 'webhook-endpoint',
429
467
  label: 'Webhook endpoint',
@@ -461,10 +499,12 @@ export function createChannelPickers({
461
499
  onLeft: options.confirmBar ? undefined : (item) => {
462
500
  if (item?._action === 'remote-runtime') applyRemoteRuntime(item.value);
463
501
  else if (item?._action === 'channel-backend') cycleChannelBackend(-1, item.value);
502
+ else if (item?._action === 'voice') applyVoice(item.value);
464
503
  },
465
504
  onRight: options.confirmBar ? undefined : (item) => {
466
505
  if (item?._action === 'remote-runtime') applyRemoteRuntime(item.value);
467
506
  else if (item?._action === 'channel-backend') cycleChannelBackend(1, item.value);
507
+ else if (item?._action === 'voice') applyVoice(item.value);
468
508
  },
469
509
  onSelect: (_value, item) => {
470
510
  try {
@@ -476,6 +516,10 @@ export function createChannelPickers({
476
516
  cycleChannelBackend(1, item.value);
477
517
  return;
478
518
  }
519
+ if (item._action === 'voice') {
520
+ applyVoice(item.value);
521
+ return;
522
+ }
479
523
  if (item._action === 'channel-setting') {
480
524
  openChannelSettingTypePicker({
481
525
  returnTo: () => reopenRoot({ highlightValue: 'channel-setting' }),
@@ -494,6 +538,7 @@ export function createChannelPickers({
494
538
  },
495
539
  onCancel: () => {
496
540
  setPicker(null);
541
+ pickerGen += 1;
497
542
  // Onboarding Step 5 Esc mirrors the other steps' reopen-next-launch warning.
498
543
  if (options.onboarding) onboardingWarnReopen();
499
544
  },
@@ -32,7 +32,6 @@ export const SLASH_COMMANDS = [
32
32
  { name: 'settings', usage: '/setting', aliases: ['setting', 'config'], aliasUsage: ['settings', 'config'], showAliasUsage: false, description: 'Open runtime settings' },
33
33
  { name: 'profile', usage: '/profile', description: 'Set your title and response language' },
34
34
  { name: 'update', usage: '/update', description: 'Check version and update mixdog' },
35
- { name: 'voice', usage: '/voice', description: 'Toggle voice input (Ctrl+Space to record)' },
36
35
  { name: 'quit', usage: '/quit', aliases: ['exit', 'q'], aliasUsage: ['exit', 'q'], description: 'Quit the TUI' },
37
36
  ];
38
37
 
@@ -17,9 +17,6 @@ export function createSlashDispatch({
17
17
  closeUsagePanel,
18
18
  openModelPicker,
19
19
  modelSwitchNotice,
20
- toggleVoice,
21
- setVoiceEnabled,
22
- isVoiceEnabled,
23
20
  openSearchPicker,
24
21
  openAgentsPicker,
25
22
  openWorkflowPicker,
@@ -85,19 +82,6 @@ export function createSlashDispatch({
85
82
  store.pushNotice(enabled ? 'Remote mode ON — this session owns remote now.' : 'Remote mode unavailable.', 'info');
86
83
  return true;
87
84
  }
88
- case 'voice': {
89
- // Step1 only: toggleVoice() owns config persistence (voice.enabled) +
90
- // the missing-component install sequence + its own notices (OFF/ON/
91
- // progress/failure). We don't push a redundant notice here; a null
92
- // return means "already running" or "failed", both already noticed.
93
- void toggleVoice({ pushNotice: store.pushNotice, setProgressHint: store.setProgressHint })
94
- .then((result) => {
95
- // true/false = new enabled state; null = failed/busy (unchanged).
96
- if (result === true || result === false) setVoiceEnabled(result);
97
- else if (result && typeof result === 'object') setVoiceEnabled(isVoiceEnabled());
98
- });
99
- return true;
100
- }
101
85
  case 'search':
102
86
  if (state.busy) {
103
87
  store.pushNotice('wait for the current turn to finish before /search', 'warn');
@@ -577,13 +577,56 @@ function assistantTextForStreamingRowEstimate(text) {
577
577
  return streamingLayoutText(text);
578
578
  }
579
579
 
580
- function streamingEstimateRows(item, columns, toolOutputExpanded) {
580
+ export function streamingEstimateRows(item, columns, toolOutputExpanded) {
581
581
  const trimmedText = assistantTextForStreamingRowEstimate(item.text);
582
582
  const estimateItem = trimmedText === item.text ? item : { ...item, text: trimmedText };
583
583
  const raw = Math.max(1, Math.ceil(estimateTranscriptItemRows(estimateItem, columns, toolOutputExpanded)));
584
584
  return Math.ceil(raw / STREAMING_ROW_QUANTUM) * STREAMING_ROW_QUANTUM;
585
585
  }
586
586
 
587
+ // ── Same-frame streaming-tail growth compensation (scrolled-up only) ────────
588
+ // Away from bottom, estimateTranscriptItemRowsCached freezes the trailing
589
+ // streaming item at its last Yoga-confirmed height (defer-growth), so the row
590
+ // index / totalRows this render still counts the OLD height even though the
591
+ // mounted Box lays out at the live-grown height NOW. That one-frame mismatch is
592
+ // the newline judder: the physical content grew but the scroll offset did not,
593
+ // so the visible window shifts up until measuredRowsVersion bumps next frame.
594
+ // Report how many rows the mounted tail has grown beyond the height the row
595
+ // index used so the caller can fold it into the scroll offset for the SAME
596
+ // render. Returns { tailRows, delta }:
597
+ // tailRows — the height the row index used for the tail (for the in-slice gate)
598
+ // delta — live-estimate rows grown beyond that (0 when nothing to correct)
599
+ // Returns null when the tail is not a deferred streaming item (no confirmed
600
+ // measurement yet → the index already used the live estimate, so no mismatch).
601
+ export function streamingTailMountedGrowth(items, columns, toolOutputExpanded) {
602
+ const list = Array.isArray(items) ? items : [];
603
+ const tail = list.length > 0 ? list[list.length - 1] : null;
604
+ if (!tail || tail.kind !== 'assistant' || !tail.streaming) return null;
605
+ const toolExpanded = toolOutputExpanded ? 1 : 0;
606
+ const idEntry = streamingMeasuredRowsById.get(tail.id);
607
+ if (!idEntry || !(idEntry.rows > 0) || idEntry.columns !== columns || idEntry.toolExpanded !== toolExpanded) {
608
+ return null;
609
+ }
610
+ const live = streamingEstimateRows(tail, columns, toolOutputExpanded);
611
+ // Bottom-pinned already folds the live estimate into the row index, so there
612
+ // is nothing to compensate (delta 0); the caller also only invokes this while
613
+ // scrolled up, so this is a belt-and-suspenders guard.
614
+ if (streamingBottomPinned) return { tailRows: Math.max(idEntry.rows, live), delta: 0 };
615
+ // Away from bottom the row index used the real Yoga height (idEntry.rows). The
616
+ // physical mismatch to compensate is real_now − real_measured, but `live` (an
617
+ // estimate) vs idEntry.rows (measured) also carries the estimator's steady
618
+ // bias, which would keep delta positive AFTER measuredRowsVersion has already
619
+ // absorbed the real height — permanent over-compensation. Measure growth in
620
+ // the SAME (estimate) metric instead: delta = live_now − estimateAtMeasure,
621
+ // where estimateAtMeasure is the estimate for the exact text that was
622
+ // measured. The estimator bias cancels, so an unchanged tail gives delta 0
623
+ // (nothing left for the index to consume) and only genuine text growth since
624
+ // the last measurement compensates.
625
+ const baseline = idEntry.estimateRows > 0 ? idEntry.estimateRows : idEntry.rows;
626
+ const delta = Math.max(0, live - baseline);
627
+ return { tailRows: idEntry.rows, delta };
628
+ }
629
+
587
630
  export function estimateTranscriptItemRowsCached(item, columns, toolOutputExpanded) {
588
631
  if (!item) return Math.max(1, Math.ceil(estimateTranscriptItemRows(item, columns, toolOutputExpanded)));
589
632
  if (shouldSuppressFullyFailedToolItem(item)) return 0;
@@ -399,6 +399,7 @@ export function useMouseInput({
399
399
  // Drag motion (transcript or status): extend the selection to the
400
400
  // current cell, clamped to the owning region's band.
401
401
  const selectionY = region === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
402
+ const prevDragY = dragRef.current.last ? Number(dragRef.current.last.y) : y;
402
403
  dragRef.current.last = { x, y: selectionY };
403
404
  const span = dragRef.current.anchorSpan;
404
405
  if (span) {
@@ -417,9 +418,15 @@ export function useMouseInput({
417
418
  // not scroll).
418
419
  if (region === 'transcript') {
419
420
  const frameRows = Math.max(1, Number(rows) || 24);
420
- if (y <= 1) {
421
+ const { top, bottom } = transcriptViewport();
422
+ // Edge auto-scroll only when the pointer pushes TOWARD the edge:
423
+ // either this motion moved vertically toward it, or the pointer
424
+ // sits beyond the transcript viewport rows entirely. A horizontal
425
+ // drag along the top/bottom rows must NOT scroll — it used to
426
+ // scroll away the very rows being selected.
427
+ if (y <= 1 && (y < prevDragY || y < top)) {
421
428
  queueScrollCoalesced(3);
422
- } else if (y >= frameRows - 5) {
429
+ } else if (y >= frameRows - 5 && (y > prevDragY || y > bottom)) {
423
430
  queueScrollCoalesced(-3);
424
431
  }
425
432
  }
@@ -2,12 +2,12 @@
2
2
  * use-prompt-handlers.mjs — the PROMPT HANDLER cluster extracted from App.jsx
3
3
  * (pass-7 split).
4
4
  *
5
- * Exports usePromptHandlers(), which owns the five useCallback handlers wired to
6
- * PromptInput: handleVoiceToggle / handlePromptPaste / handlePromptHistoryNavigate
7
- * / handlePromptEscape / handlePromptInterrupt. Every ref, setter, store value and
8
- * derived value the handlers close over is threaded in explicitly so the deps
9
- * arrays stay byte-identical to the original inline hooks. Import-level helpers
10
- * (voice recorder + paste attachments) are imported directly here.
5
+ * Exports usePromptHandlers(), which owns the four useCallback handlers wired to
6
+ * PromptInput: handlePromptPaste / handlePromptHistoryNavigate / handlePromptEscape
7
+ * / handlePromptInterrupt. Every ref, setter, store value and derived value the
8
+ * handlers close over is threaded in explicitly so the deps arrays stay
9
+ * byte-identical to the original inline hooks. Import-level helpers (paste
10
+ * attachments) are imported directly here.
11
11
  */
12
12
  import { useCallback } from 'react';
13
13
  import {
@@ -18,13 +18,6 @@ import {
18
18
  import {
19
19
  shouldFoldPastedText,
20
20
  } from '../paste-attachments.mjs';
21
- import {
22
- isVoiceEnabled,
23
- getRecorderState,
24
- startRecording,
25
- stopRecording,
26
- cancelRecording,
27
- } from '../lib/voice-recorder.mjs';
28
21
  import { promptHistoryKey } from '../prompt-history-store.mjs';
29
22
 
30
23
  export function usePromptHandlers({
@@ -42,10 +35,8 @@ export function usePromptHandlers({
42
35
  setPastedImages,
43
36
  setPastedTexts,
44
37
  setPromptDraftOverride,
45
- setVoiceRecPhase,
46
38
  setContextPanel,
47
39
  // derived / helper values + callbacks
48
- setVoiceStatusHint,
49
40
  syncPromptLayoutRows,
50
41
  showPromptHint,
51
42
  clearPromptHint,
@@ -63,70 +54,6 @@ export function usePromptHandlers({
63
54
  clearPastedTextsSnapshot,
64
55
  registerPastedText,
65
56
  }) {
66
- // Ctrl+Space handler wired to PromptInput's onVoiceToggle. Dispatches on the
67
- // recorder's CURRENT state (idle/recording/transcribing) — see
68
- // src/tui/lib/voice-recorder.mjs for the state machine itself.
69
- const handleVoiceToggle = useCallback(async () => {
70
- if (!isVoiceEnabled()) {
71
- store.pushNotice('Voice is off — run /voice to turn it on', 'warn');
72
- return;
73
- }
74
- const recState = getRecorderState();
75
- if (recState === 'transcribing') {
76
- store.pushNotice('Voice: already transcribing…', 'info');
77
- return;
78
- }
79
- if (recState === 'recording') {
80
- setVoiceStatusHint('… transcribing', 'info');
81
- setVoiceRecPhase('transcribing');
82
- let result;
83
- try {
84
- result = await stopRecording();
85
- } catch (e) {
86
- result = { ok: false, reason: e?.message || String(e) };
87
- }
88
- setVoiceStatusHint('', 'info');
89
- setVoiceRecPhase('idle');
90
- if (!result) return; // race: recorder was no longer RECORDING
91
- if (!result.ok) {
92
- store.pushNotice(`Voice: ${result.reason || 'transcription failed'}`, 'error');
93
- return;
94
- }
95
- const text = String(result.text || '').trim();
96
- if (!text) {
97
- store.pushNotice('Voice: no speech detected', 'warn');
98
- return;
99
- }
100
- // End-of-draft insertion via promptDraftOverride (approved design —
101
- // no cursor-position insert; see gamerscroll skill's out-of-scope note
102
- // on PromptInput's imperative surface).
103
- // Med-4: promptValueRef.current is read HERE — after `await
104
- // stopRecording()` has already resolved — not captured earlier before
105
- // the await. PromptInput keeps valueRef.current live on every
106
- // keystroke (commitDraft), so this always reflects whatever the user
107
- // typed during the recording+transcribe window; nothing typed in that
108
- // gap is overwritten.
109
- const current = promptValueRef.current || '';
110
- const next = current ? `${current}${/\s$/.test(current) ? '' : ' '}${text}` : text;
111
- syncPromptLayoutRows(next);
112
- setPromptDraftOverride({ id: Date.now(), value: next });
113
- return;
114
- }
115
- // idle -> recording
116
- let result;
117
- try {
118
- result = await startRecording();
119
- } catch (e) {
120
- result = { ok: false, reason: e?.message || String(e) };
121
- }
122
- if (!result?.ok) {
123
- store.pushNotice(`Voice: ${result?.reason || 'failed to start recording'}`, 'error');
124
- return;
125
- }
126
- setVoiceStatusHint('● REC', 'error');
127
- setVoiceRecPhase('recording');
128
- }, [store, setVoiceStatusHint, syncPromptLayoutRows]);
129
-
130
57
  const handlePromptPaste = useCallback((text, meta = {}) => {
131
58
  const source = String(meta?.source || 'paste');
132
59
  const value = String(text ?? '');
@@ -256,19 +183,6 @@ export function usePromptHandlers({
256
183
  // the active turn on the same Esc press.
257
184
  // - empty prompt + active turn interrupts the active turn.
258
185
  const handlePromptEscape = useCallback((text = '', meta = {}) => {
259
- // Recording takes priority over every other Esc branch (usage/context
260
- // panels, slash-clear, queue-restore, turn-interrupt): a live mic capture
261
- // must never silently keep running because Esc was consumed by something
262
- // else first. Only consumes Esc while actually RECORDING — transcribing
263
- // (already stopped, awaiting the HTTP round-trip) and idle fall through
264
- // to the existing branches unchanged.
265
- if (getRecorderState() === 'recording') {
266
- cancelRecording();
267
- setVoiceStatusHint('', 'info');
268
- setVoiceRecPhase('idle');
269
- store.pushNotice('Voice: recording cancelled', 'plain');
270
- return true;
271
- }
272
186
  if (usagePanel) { closeUsagePanel(); return true; }
273
187
  if (contextPanel) { setContextPanel(null); return true; }
274
188
 
@@ -283,7 +197,7 @@ export function usePromptHandlers({
283
197
  // Idle + empty + nothing to restore: nothing (double-press from empty
284
198
  // opens message selector, but we don't have that feature yet).
285
199
  return false;
286
- }, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, clearPromptHint, clearPastedImagesSnapshot, setVoiceStatusHint, store]);
200
+ }, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, clearPromptHint, clearPastedImagesSnapshot, store]);
287
201
 
288
202
  const handlePromptInterrupt = useCallback((currentText = '') => {
289
203
  const result = store.abort?.();
@@ -301,7 +215,6 @@ export function usePromptHandlers({
301
215
  }, [store, clearPromptHint, installPastedImages, clearPastedImagesSnapshot]);
302
216
 
303
217
  return {
304
- handleVoiceToggle,
305
218
  handlePromptPaste,
306
219
  handlePromptHistoryNavigate,
307
220
  handlePromptEscape,
@@ -341,7 +341,11 @@ export function useTranscriptScroll({
341
341
  }
342
342
  const clippedRect = withSelectionClip(rect);
343
343
  dragRef.current = { ...dragRef.current, rect: clippedRect };
344
- paintSelectionRect(clippedRect, { rememberText: !dragRef.current.active });
344
+ // Never re-harvest selection text from a scroll-shifted rect: the shift
345
+ // clips the rect to the viewport, so a harvest here would OVERWRITE the
346
+ // full text remembered at drag-release with only the still-visible rows
347
+ // (Ctrl+C after scrolling then copied just that fragment).
348
+ paintSelectionRect(clippedRect, { rememberText: false });
345
349
  }
346
350
  if (options.smooth) {
347
351
  startSmoothScroll();