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.
- package/package.json +3 -2
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/recall-bench-cases.json +11 -0
- package/scripts/recall-bench.mjs +91 -2
- package/scripts/tool-efficiency-diag.mjs +4 -1
- package/scripts/tool-smoke.mjs +101 -27
- package/src/agents/debugger/AGENT.md +3 -3
- package/src/agents/heavy-worker/AGENT.md +7 -10
- package/src/agents/maintainer/AGENT.md +1 -2
- package/src/agents/reviewer/AGENT.md +1 -2
- package/src/agents/worker/AGENT.md +5 -8
- package/src/defaults/agents.json +3 -0
- package/src/mixdog-session-runtime.mjs +23 -6
- package/src/rules/agent/00-core.md +4 -3
- package/src/rules/agent/30-explorer.md +53 -22
- package/src/rules/lead/lead-tool.md +3 -2
- package/src/rules/shared/01-tool.md +24 -29
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
- package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
- package/src/runtime/channels/backends/discord.mjs +43 -1
- package/src/runtime/channels/index.mjs +145 -58
- package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
- package/src/runtime/channels/lib/memory-client.mjs +32 -14
- package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
- package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
- package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
- package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
- package/src/runtime/memory/lib/query-handlers.mjs +36 -4
- package/src/runtime/memory/lib/recall-format.mjs +106 -6
- package/src/runtime/memory/lib/session-ingest.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +10 -4
- package/src/runtime/shared/background-tasks.mjs +4 -2
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-result-summary.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +30 -1
- package/src/session-runtime/config-lifecycle.mjs +1 -1
- package/src/session-runtime/cwd-plugins.mjs +46 -3
- package/src/session-runtime/mcp-glue.mjs +24 -3
- package/src/session-runtime/output-styles.mjs +44 -10
- package/src/session-runtime/workflow.mjs +16 -1
- package/src/standalone/channel-worker.mjs +74 -5
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/App.jsx +57 -77
- package/src/tui/app/channel-pickers.mjs +45 -0
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/transcript-window.mjs +44 -1
- package/src/tui/app/use-mouse-input.mjs +9 -2
- package/src/tui/app/use-prompt-handlers.mjs +7 -94
- package/src/tui/app/use-transcript-scroll.mjs +5 -1
- package/src/tui/app/use-transcript-window.mjs +65 -5
- package/src/tui/components/PromptInput.jsx +33 -64
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +5330 -5443
- package/src/tui/engine.mjs +49 -10
- package/src/tui/lib/voice-setup.mjs +166 -0
- package/src/tui/paste-attachments.mjs +12 -5
- package/src/tui/prompt-history-store.mjs +125 -12
- package/scripts/bench/cache-probe-tasks.json +0 -8
- package/scripts/bench/lead-review-tasks-r3.json +0 -20
- package/scripts/bench/lead-review-tasks.json +0 -20
- package/scripts/bench/r4-mixed-tasks.json +0 -20
- package/scripts/bench/r5-orchestrated-task.json +0 -7
- package/scripts/bench/review-tasks.json +0 -20
- package/scripts/bench/round-codex.json +0 -114
- package/scripts/bench/round-mixdog-lead-r3.json +0 -269
- package/scripts/bench/round-mixdog-lead.json +0 -269
- package/scripts/bench/round-mixdog.json +0 -126
- package/scripts/bench/round-r10-bigsample.json +0 -679
- package/scripts/bench/round-r11-codexalign.json +0 -257
- package/scripts/bench/round-r13-clientmeta.json +0 -464
- package/scripts/bench/round-r14-betafeatures.json +0 -466
- package/scripts/bench/round-r15-fulldefault.json +0 -462
- package/scripts/bench/round-r16-sessionid.json +0 -466
- package/scripts/bench/round-r17-wirebytes.json +0 -456
- package/scripts/bench/round-r18-prewarm.json +0 -468
- package/scripts/bench/round-r19-clean.json +0 -472
- package/scripts/bench/round-r20-prewarm-clean.json +0 -475
- package/scripts/bench/round-r21-delta-retry.json +0 -473
- package/scripts/bench/round-r22-full-probe.json +0 -693
- package/scripts/bench/round-r23-itemprobe.json +0 -701
- package/scripts/bench/round-r24-shapefix.json +0 -677
- package/scripts/bench/round-r25-serial.json +0 -464
- package/scripts/bench/round-r26-parallel3.json +0 -671
- package/scripts/bench/round-r27-parallel10.json +0 -894
- package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
- package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
- package/scripts/bench/round-r30-instid.json +0 -253
- package/scripts/bench/round-r31-upgradeprobe.json +0 -256
- package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
- package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
- package/scripts/bench/round-r34-orchestrated.json +0 -120
- package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
- package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
- package/scripts/bench/round-r4-codex.json +0 -114
- package/scripts/bench/round-r4-mixed.json +0 -225
- package/scripts/bench/round-r5-gpt-lead.json +0 -259
- package/scripts/bench/round-r6-codex.json +0 -114
- package/scripts/bench/round-r6-solo.json +0 -257
- package/scripts/bench/round-r7-full.json +0 -254
- package/scripts/bench/round-r8-fulldefault.json +0 -255
- package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
* PromptInput:
|
|
7
|
-
* /
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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,
|
|
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
|
-
|
|
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();
|