mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user