M2: terminal dictation: mic in tab-bar slot and key bar, recording pill and overlay, per-terminal recording lifetime incl. pending mic acquisition, Cmd+D on macOS by keyboard focus, i18n

This commit is contained in:
Sebastian Arena
2026-10-26 21:59:03 -04:00
parent 1eea1381eb
commit 113dd83d95
40 changed files with 2264 additions and 174 deletions
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/constants/layout";
import type { DictationStatus } from "@/hooks/use-dictation";
import { formatDictationDuration } from "@/utils/format-dictation-duration";
interface DictationControlsProps {
volume: number;
@@ -24,12 +25,6 @@ interface DictationControlsProps {
disabled?: boolean;
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
export function DictationControls({
volume,
duration,
@@ -88,7 +83,7 @@ export function DictationControls({
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={timerTextStyle}>
{status === "paused" ? t("message.dictation.paused") : formatDuration(duration)}
{status === "paused" ? t("message.dictation.paused") : formatDictationDuration(duration)}
</Text>
<View style={styles.actionGroup}>
<Pressable
@@ -152,7 +147,13 @@ export function DictationOverlay({
onAcceptAndSend,
onRetry,
onDiscard,
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
testID,
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript" | "onAcceptAndSend"> & {
errorText?: string;
/** Omit to show insert as the only confirm action (terminals never send). */
onAcceptAndSend?: () => void;
testID?: string;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isFailed = status === "failed";
@@ -190,7 +191,7 @@ export function DictationOverlay({
}
return (
<View style={containerStyle}>
<View style={containerStyle} testID={testID}>
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
@@ -210,7 +211,7 @@ export function DictationOverlay({
orientation="horizontal"
color={theme.colors.accentForeground}
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
<Text style={overlayTimerTextStyle}>{formatDictationDuration(duration)}</Text>
</View>
{status === "paused" ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
@@ -242,7 +243,7 @@ export function DictationOverlay({
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
) : null}
{!actionsDisabled && !isFailed ? (
{!actionsDisabled && !isFailed && onAcceptAndSend ? (
<>
<Pressable
onPress={onAccept}
@@ -266,6 +267,16 @@ export function DictationOverlay({
</Pressable>
</>
) : null}
{!actionsDisabled && !isFailed && !onAcceptAndSend ? (
<Pressable
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel={t("message.dictation.insert")}
style={overlayConfirmButtonStyle}
>
<Check size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
) : null}
</View>
</View>
);
+62 -13
View File
@@ -2,7 +2,7 @@ import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import * as Clipboard from "expo-clipboard";
import {
Pressable,
@@ -63,7 +63,20 @@ import {
type TerminalDropDelivery,
} from "@/terminal/drop/terminal-file-drop";
import { preparePathsForTerminalInput } from "@/terminal/drop/terminal-path";
import { useRegisterTerminalInputSink, type TerminalInputSink } from "@/terminal/input-sink";
import {
useRegisterTerminalInputSink,
type TerminalDictationActions,
type TerminalInputSink,
} from "@/terminal/input-sink";
import {
TerminalDictationOwner,
type TerminalDictationOwnerActions,
} from "@/terminal/dictation/terminal-dictation-owner";
import { isTerminalKeyboardOwner } from "@/terminal/dictation/terminal-dictation-lifecycle";
import {
TerminalKeyBarDictationButton,
TerminalKeyBarDictationGate,
} from "@/terminal/toolbar/terminal-key-bar-dictation";
import type { SelectedFile } from "@/attachments/selected-file";
import { TerminalUploadButton } from "@/terminal/toolbar/terminal-upload-button";
import { toErrorMessage } from "@/utils/error-messages";
@@ -91,7 +104,7 @@ import {
type TerminalResizeRequest,
} from "./terminal-resize-debouncer";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
@@ -950,13 +963,34 @@ export function TerminalPane({
);
});
// The owner mounts only while connected and remounts per terminal; the sink keeps
// one stable forwarding object and reaches whichever owner is mounted now.
const dictationOwnerId = useId();
const dictationActionsRef = useRef<TerminalDictationOwnerActions | null>(null);
const dictationActions = useMemo<TerminalDictationActions>(
() => ({
ownerId: dictationOwnerId,
toggle: () => dictationActionsRef.current?.toggle(),
cancel: () => dictationActionsRef.current?.cancel(),
confirm: () => dictationActionsRef.current?.confirm(),
retry: () => dictationActionsRef.current?.retry(),
discard: () => dictationActionsRef.current?.discard(),
}),
[dictationOwnerId],
);
const terminalSurfaceRef = useRef<View>(null);
const isDictationKeyboardOwner = useStableEvent(
() => isWeb && isTerminalKeyboardOwner(terminalSurfaceRef.current, document.activeElement),
);
const inputSink = useMemo<TerminalInputSink>(
() => ({
pasteText: pasteTerminalText,
deliverFiles: deliverPickedFiles,
focus: requestTerminalFocus,
dictation: dictationActions,
}),
[pasteTerminalText, deliverPickedFiles, requestTerminalFocus],
[pasteTerminalText, deliverPickedFiles, requestTerminalFocus, dictationActions],
);
useRegisterTerminalInputSink(serverId, terminalId, inputSink);
@@ -1203,6 +1237,8 @@ export function TerminalPane({
) : null;
case "upload":
return <TerminalUploadButton key={controlId} sink={inputSink} placement="keybar" />;
case "dictation":
return <TerminalKeyBarDictationButton key={controlId} sink={inputSink} />;
case "keyboardToggle":
return (
<KeyboardToggleButton
@@ -1232,9 +1268,20 @@ export function TerminalPane({
return (
<Animated.View style={containerStyle}>
<TerminalDictationOwner
key={`${serverId}:${terminalId}`}
ownerId={dictationOwnerId}
serverId={serverId}
terminalId={terminalId}
isPresented={isTerminalPresented}
pasteText={pasteTerminalText}
focus={requestTerminalFocus}
isKeyboardOwner={isDictationKeyboardOwner}
actionsRef={dictationActionsRef}
/>
<FileDropZone style={styles.outputContainer}>
<TerminalFileDropSink onFiles={handleDroppedFiles} />
<View style={styles.terminalGestureContainer}>
<View ref={terminalSurfaceRef} style={styles.terminalGestureContainer}>
<TerminalEmulator
ref={emulatorRef}
dom={TERMINAL_EMULATOR_DOM_PROPS}
@@ -1308,14 +1355,16 @@ export function TerminalPane({
onLayout={handleKeyBarLayout}
testID={isNative && !isMobile ? "terminal-virtual-keyboard-native-wide" : undefined}
>
{virtualKeyboardRows.map((row) => (
<View
key={row.map(getTerminalVirtualKeyboardControlId).join(":")}
style={styles.keyboardRow}
>
{row.map(renderVirtualKeyboardControl)}
</View>
))}
<TerminalKeyBarDictationGate sink={inputSink}>
{virtualKeyboardRows.map((row) => (
<View
key={row.map(getTerminalVirtualKeyboardControlId).join(":")}
style={styles.keyboardRow}
>
{row.map(renderVirtualKeyboardControl)}
</View>
))}
</TerminalKeyBarDictationGate>
</View>
</View>
) : null}