Support dictation and image attachments in mobile live input mode (#6603)
* Support dictation and image attachments in live terminal input mode - Unify image attachment and voice dictation actions across both live and buffered terminal input views using a shared action bar. - Route completed dictations directly to the active PTY (matching live keystroke semantics) when live mode is active, or append them to the input field in buffered mode. - Add live terminal status headers to indicate mic activity and image upload progress. - Include unit tests for the dictation routing logic. * rm unused file
This commit is contained in:
parent
112b6e51ca
commit
c50391df84
|
|
@ -36,11 +36,9 @@ import {
|
|||
FileText,
|
||||
GitBranch,
|
||||
Globe,
|
||||
ImagePlus,
|
||||
Keyboard as KeyboardIcon,
|
||||
ListChecks,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Monitor,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
|
|
@ -110,6 +108,10 @@ import {
|
|||
getTerminalLiveInputKeyboardType
|
||||
} from '../../../../src/terminal/terminal-keyboard-type'
|
||||
import { normalizeTerminalTextInput } from '../../../../src/terminal/terminal-text-input-normalization'
|
||||
import {
|
||||
appendBufferedDictation,
|
||||
routeDictationTranscript
|
||||
} from '../../../../src/terminal/terminal-live-dictation-routing'
|
||||
import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input'
|
||||
import {
|
||||
recoverActiveTerminalAfterForeground,
|
||||
|
|
@ -163,6 +165,8 @@ import {
|
|||
type MobileClipboardImageResizer
|
||||
} from '../../../../src/session/mobile-clipboard-image'
|
||||
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
|
||||
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
|
||||
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
|
||||
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
|
||||
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
|
||||
import {
|
||||
|
|
@ -1044,6 +1048,10 @@ export default function SessionScreen() {
|
|||
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
|
||||
const liveInputRef = useRef<TextInput>(null)
|
||||
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const dictationRouteContextRef = useRef<{
|
||||
readonly handle: string | null
|
||||
readonly liveInputEnabled: boolean
|
||||
} | null>(null)
|
||||
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
|
||||
const subscribingHandlesRef = useRef<Set<string>>(new Set())
|
||||
const initializedHandlesRef = useRef<Set<string>>(new Set())
|
||||
|
|
@ -1232,15 +1240,29 @@ export default function SessionScreen() {
|
|||
client,
|
||||
enabled: canSend,
|
||||
onTranscript: (text) => {
|
||||
setInput((current) => {
|
||||
if (!current.trim()) {
|
||||
return text
|
||||
// Live mode inserts the transcript straight into its originating PTY as
|
||||
// text (no Return — the user sends it themselves), matching live keystroke
|
||||
// semantics; buffered mode keeps appending to the command field.
|
||||
const routeContext = dictationRouteContextRef.current
|
||||
dictationRouteContextRef.current = null
|
||||
const route = routeDictationTranscript(
|
||||
text,
|
||||
routeContext?.liveInputEnabled ?? liveInputEnabled
|
||||
)
|
||||
if (route.kind === 'live-insert') {
|
||||
const insertHandle = routeContext?.handle ?? activeHandleRef.current
|
||||
if (!insertHandle) {
|
||||
return
|
||||
}
|
||||
return `${current.trimEnd()} ${text}`
|
||||
})
|
||||
sendLiveTerminalInput(insertHandle, route.text)
|
||||
showToast('Dictation inserted')
|
||||
return
|
||||
}
|
||||
setInput((current) => appendBufferedDictation(current, route.text))
|
||||
showToast('Dictation inserted')
|
||||
},
|
||||
onError: (err) => {
|
||||
dictationRouteContextRef.current = null
|
||||
// Dictation isn't set up on the desktop yet → open the setup sheet so the
|
||||
// user can download a model + enable it from here, instead of a dead-end toast.
|
||||
if (isDictationSetupRequiredError(err.message)) {
|
||||
|
|
@ -1253,16 +1275,28 @@ export default function SessionScreen() {
|
|||
})
|
||||
|
||||
const startDictation = useCallback(() => {
|
||||
const routeContext = activeHandle
|
||||
? { handle: activeHandle, liveInputEnabled: liveInputTerminalHandles.has(activeHandle) }
|
||||
: null
|
||||
dictationRouteContextRef.current = routeContext
|
||||
void dictation.start().catch((err) => {
|
||||
if (dictationRouteContextRef.current === routeContext) {
|
||||
dictationRouteContextRef.current = null
|
||||
}
|
||||
triggerError()
|
||||
showToast(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
}, [dictation, triggerError, showToast])
|
||||
}, [activeHandle, dictation, liveInputTerminalHandles, triggerError, showToast])
|
||||
|
||||
const cancelDictation = useCallback(() => {
|
||||
dictationRouteContextRef.current = null
|
||||
void dictation.cancel()
|
||||
}, [dictation])
|
||||
|
||||
// Toggle mode: one tap starts, the next stops; long-press cancels mid-record.
|
||||
const handleDictationToggle = useCallback(() => {
|
||||
if (dictation.isProcessing) {
|
||||
void dictation.cancel()
|
||||
cancelDictation()
|
||||
} else if (dictation.isStarting) {
|
||||
return
|
||||
} else if (dictation.isRecording) {
|
||||
|
|
@ -1270,7 +1304,7 @@ export default function SessionScreen() {
|
|||
} else {
|
||||
startDictation()
|
||||
}
|
||||
}, [dictation, startDictation])
|
||||
}, [cancelDictation, dictation, startDictation])
|
||||
|
||||
// Hold mode: press starts, release stops — like a walkie-talkie.
|
||||
const handleDictationPressIn = useCallback(() => {
|
||||
|
|
@ -1284,9 +1318,9 @@ export default function SessionScreen() {
|
|||
void dictation.stop()
|
||||
} else if (dictation.isStarting) {
|
||||
// Released before recording began: cancel so we don't leave a live mic.
|
||||
void dictation.cancel()
|
||||
cancelDictation()
|
||||
}
|
||||
}, [dictation])
|
||||
}, [cancelDictation, dictation])
|
||||
|
||||
const refreshDictationMode = useCallback(async () => {
|
||||
if (!client) {
|
||||
|
|
@ -4166,10 +4200,11 @@ export default function SessionScreen() {
|
|||
})
|
||||
if (response.ok) {
|
||||
if (tab.type === 'terminal' && typeof tab.terminal === 'string') {
|
||||
unsubscribeTerminal(tab.terminal)
|
||||
terminalRefs.current.delete(tab.terminal)
|
||||
initializedHandlesRef.current.delete(tab.terminal)
|
||||
clearTerminalLiveInputDefault(tab.terminal)
|
||||
const terminalHandle = tab.terminal
|
||||
unsubscribeTerminal(terminalHandle)
|
||||
terminalRefs.current.delete(terminalHandle)
|
||||
initializedHandlesRef.current.delete(terminalHandle)
|
||||
clearTerminalLiveInputDefault(terminalHandle)
|
||||
}
|
||||
setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
|
||||
// Why: tombstone the closed tab and rely on the subscription/poll
|
||||
|
|
@ -4980,16 +5015,34 @@ export default function SessionScreen() {
|
|||
|
||||
{/* Input bar */}
|
||||
{liveInputEnabled ? (
|
||||
<Pressable
|
||||
style={[styles.inputBar, styles.liveInputBar]}
|
||||
disabled={!canSend}
|
||||
onPress={focusLiveInput}
|
||||
accessibilityLabel="Focus live terminal input"
|
||||
>
|
||||
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
|
||||
<Text style={styles.liveInputHint} numberOfLines={1}>
|
||||
Keyboard input directly goes to terminal
|
||||
</Text>
|
||||
<View style={[styles.inputBar, styles.liveInputBar]}>
|
||||
<Pressable
|
||||
style={styles.liveInputFocusTarget}
|
||||
disabled={!canSend}
|
||||
onPress={focusLiveInput}
|
||||
accessibilityLabel="Focus live terminal input"
|
||||
>
|
||||
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
|
||||
<MobileTerminalLiveInputStatus
|
||||
dictation={dictation}
|
||||
isAttaching={isAttaching}
|
||||
/>
|
||||
</Pressable>
|
||||
<MobileTerminalInputActions
|
||||
canSend={canSend}
|
||||
isAttaching={isAttaching}
|
||||
dictation={dictation}
|
||||
dictationMode={dictationMode}
|
||||
buttonStyle={styles.dictationButton}
|
||||
activeButtonStyle={styles.dictationButtonActive}
|
||||
disabledButtonStyle={styles.sendButtonDisabled}
|
||||
onAttachImage={() => void attachImage('library')}
|
||||
onAttachFile={() => void attachImage('files')}
|
||||
onDictationToggle={handleDictationToggle}
|
||||
onDictationPressIn={handleDictationPressIn}
|
||||
onDictationPressOut={handleDictationPressOut}
|
||||
onDictationCancel={cancelDictation}
|
||||
/>
|
||||
<TextInput
|
||||
ref={liveInputRef}
|
||||
style={styles.liveInputCapture}
|
||||
|
|
@ -5011,7 +5064,7 @@ export default function SessionScreen() {
|
|||
editable={canSend}
|
||||
importantForAutofill="no"
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.inputBar}>
|
||||
<TextInput
|
||||
|
|
@ -5046,64 +5099,21 @@ export default function SessionScreen() {
|
|||
editable={canSend}
|
||||
onSubmitEditing={() => void handleSend()}
|
||||
/>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.dictationButton,
|
||||
(!canSend || isAttaching) && styles.sendButtonDisabled
|
||||
]}
|
||||
disabled={!canSend || isAttaching}
|
||||
// Tap opens the photo library straight away (one-tap, like
|
||||
// Discord); long-press is the escape hatch for picking a file.
|
||||
onPress={() => void attachImage('library')}
|
||||
onLongPress={() => void attachImage('files')}
|
||||
delayLongPress={350}
|
||||
accessibilityLabel={isAttaching ? 'Sending image' : 'Attach a photo'}
|
||||
accessibilityHint="Long press to attach a file instead"
|
||||
>
|
||||
{isAttaching ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<ImagePlus size={17} color={colors.textSecondary} strokeWidth={2.4} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.dictationButton,
|
||||
(dictation.isStarting || dictation.isRecording) &&
|
||||
styles.dictationButtonActive,
|
||||
!canSend && styles.sendButtonDisabled
|
||||
]}
|
||||
disabled={!canSend}
|
||||
onPress={dictationMode === 'toggle' ? handleDictationToggle : undefined}
|
||||
onPressIn={dictationMode === 'hold' ? handleDictationPressIn : undefined}
|
||||
onPressOut={dictationMode === 'hold' ? handleDictationPressOut : undefined}
|
||||
onLongPress={
|
||||
dictationMode === 'toggle'
|
||||
? () => {
|
||||
if (dictation.isRecording || dictation.isProcessing) {
|
||||
void dictation.cancel()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
accessibilityLabel={
|
||||
dictation.isRecording
|
||||
? 'Stop voice dictation'
|
||||
: dictation.isProcessing
|
||||
? 'Cancel voice dictation'
|
||||
: dictation.isStarting
|
||||
? 'Starting voice dictation'
|
||||
: 'Start voice dictation'
|
||||
}
|
||||
>
|
||||
{dictation.isProcessing ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : dictation.isStarting || dictation.isRecording ? (
|
||||
<Mic size={17} color={colors.textPrimary} strokeWidth={2.4} />
|
||||
) : (
|
||||
<Mic size={17} color={colors.textSecondary} strokeWidth={2.4} />
|
||||
)}
|
||||
</Pressable>
|
||||
<MobileTerminalInputActions
|
||||
canSend={canSend}
|
||||
isAttaching={isAttaching}
|
||||
dictation={dictation}
|
||||
dictationMode={dictationMode}
|
||||
buttonStyle={styles.dictationButton}
|
||||
activeButtonStyle={styles.dictationButtonActive}
|
||||
disabledButtonStyle={styles.sendButtonDisabled}
|
||||
onAttachImage={() => void attachImage('library')}
|
||||
onAttachFile={() => void attachImage('files')}
|
||||
onDictationToggle={handleDictationToggle}
|
||||
onDictationPressIn={handleDictationPressIn}
|
||||
onDictationPressOut={handleDictationPressOut}
|
||||
onDictationCancel={cancelDictation}
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
|
||||
disabled={!canSend}
|
||||
|
|
|
|||
|
|
@ -136,12 +136,14 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
|
|||
gap: spacing.sm
|
||||
},
|
||||
|
||||
liveInputHint: {
|
||||
liveInputFocusTarget: {
|
||||
flex: 1,
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily
|
||||
minHeight: 34,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
|
||||
liveInputCapture: {
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
import { ActivityIndicator, Pressable, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import { ImagePlus, Mic } from 'lucide-react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
type DictationState = {
|
||||
readonly isStarting: boolean
|
||||
readonly isRecording: boolean
|
||||
readonly isProcessing: boolean
|
||||
}
|
||||
|
||||
type MobileTerminalInputActionsProps = {
|
||||
readonly canSend: boolean
|
||||
readonly isAttaching: boolean
|
||||
readonly dictation: DictationState
|
||||
readonly dictationMode: 'toggle' | 'hold'
|
||||
readonly buttonStyle: StyleProp<ViewStyle>
|
||||
readonly activeButtonStyle: StyleProp<ViewStyle>
|
||||
readonly disabledButtonStyle: StyleProp<ViewStyle>
|
||||
readonly onAttachImage: () => void
|
||||
readonly onAttachFile: () => void
|
||||
readonly onDictationToggle: () => void
|
||||
readonly onDictationPressIn: () => void
|
||||
readonly onDictationPressOut: () => void
|
||||
readonly onDictationCancel: () => void
|
||||
}
|
||||
|
||||
// Image + mic peer actions shared by the live and buffered input bars so both
|
||||
// surfaces offer identical multimodal entry points (and the JSX lives once).
|
||||
export function MobileTerminalInputActions({
|
||||
canSend,
|
||||
isAttaching,
|
||||
dictation,
|
||||
dictationMode,
|
||||
buttonStyle,
|
||||
activeButtonStyle,
|
||||
disabledButtonStyle,
|
||||
onAttachImage,
|
||||
onAttachFile,
|
||||
onDictationToggle,
|
||||
onDictationPressIn,
|
||||
onDictationPressOut,
|
||||
onDictationCancel
|
||||
}: MobileTerminalInputActionsProps) {
|
||||
const dictationActive = dictation.isStarting || dictation.isRecording
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
style={[buttonStyle, (!canSend || isAttaching) && disabledButtonStyle]}
|
||||
disabled={!canSend || isAttaching}
|
||||
// Tap opens the photo library; long-press picks a file. Uploads via host
|
||||
// RPC so SSH/remote sessions attach the same as local ones.
|
||||
onPress={onAttachImage}
|
||||
onLongPress={onAttachFile}
|
||||
delayLongPress={350}
|
||||
accessibilityLabel={isAttaching ? 'Sending image' : 'Attach a photo'}
|
||||
accessibilityHint="Long press to attach a file instead"
|
||||
>
|
||||
{isAttaching ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<ImagePlus size={17} color={colors.textSecondary} strokeWidth={2.4} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[buttonStyle, dictationActive && activeButtonStyle, !canSend && disabledButtonStyle]}
|
||||
disabled={!canSend}
|
||||
onPress={dictationMode === 'toggle' ? onDictationToggle : undefined}
|
||||
onPressIn={dictationMode === 'hold' ? onDictationPressIn : undefined}
|
||||
onPressOut={dictationMode === 'hold' ? onDictationPressOut : undefined}
|
||||
onLongPress={
|
||||
dictationMode === 'toggle'
|
||||
? () => {
|
||||
if (dictation.isRecording || dictation.isProcessing) {
|
||||
onDictationCancel()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
accessibilityLabel={
|
||||
dictation.isRecording
|
||||
? 'Stop voice dictation'
|
||||
: dictation.isProcessing
|
||||
? 'Cancel voice dictation'
|
||||
: dictation.isStarting
|
||||
? 'Starting voice dictation'
|
||||
: 'Start voice dictation'
|
||||
}
|
||||
>
|
||||
{dictation.isProcessing ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<Mic
|
||||
size={17}
|
||||
color={dictationActive ? colors.textPrimary : colors.textSecondary}
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, typography } from '../theme/mobile-theme'
|
||||
|
||||
type DictationStatus = {
|
||||
readonly isStarting: boolean
|
||||
readonly isRecording: boolean
|
||||
readonly isProcessing: boolean
|
||||
}
|
||||
|
||||
type MobileTerminalLiveInputStatusProps = {
|
||||
readonly dictation: DictationStatus
|
||||
readonly isAttaching: boolean
|
||||
}
|
||||
|
||||
export function MobileTerminalLiveInputStatus({
|
||||
dictation,
|
||||
isAttaching
|
||||
}: MobileTerminalLiveInputStatusProps) {
|
||||
const title = dictation.isRecording
|
||||
? 'Listening'
|
||||
: dictation.isProcessing
|
||||
? 'Processing'
|
||||
: dictation.isStarting
|
||||
? 'Starting mic'
|
||||
: 'Live input'
|
||||
const detail = dictation.isRecording
|
||||
? 'Tap mic to stop'
|
||||
: dictation.isProcessing
|
||||
? 'Transcribing on desktop'
|
||||
: isAttaching
|
||||
? 'Uploading image to host'
|
||||
: 'Keyboard goes directly to terminal'
|
||||
|
||||
return (
|
||||
<View style={styles.status}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.detail} numberOfLines={1}>
|
||||
{detail}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
status: {
|
||||
flex: 1,
|
||||
gap: 1
|
||||
},
|
||||
title: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
detail: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendBufferedDictation,
|
||||
routeDictationTranscript
|
||||
} from './terminal-live-dictation-routing'
|
||||
|
||||
describe('terminal live dictation routing', () => {
|
||||
it('routes to a direct live insert when live input is active', () => {
|
||||
expect(routeDictationTranscript('hello world', true)).toEqual({
|
||||
kind: 'live-insert',
|
||||
text: 'hello world'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes to buffered append when live input is inactive', () => {
|
||||
expect(routeDictationTranscript('hello world', false)).toEqual({
|
||||
kind: 'buffered-append',
|
||||
text: 'hello world'
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces an empty or whitespace-only buffered field', () => {
|
||||
expect(appendBufferedDictation('', 'spoken')).toBe('spoken')
|
||||
expect(appendBufferedDictation(' ', 'spoken')).toBe('spoken')
|
||||
})
|
||||
|
||||
it('appends after existing buffered text with one separating space', () => {
|
||||
expect(appendBufferedDictation('ls -la', 'in src')).toBe('ls -la in src')
|
||||
expect(appendBufferedDictation('ls -la ', 'in src')).toBe('ls -la in src')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// Routes a finished dictation transcript to the right surface: live mode inserts
|
||||
// it straight into the originating PTY (matching live keystroke semantics, no
|
||||
// auto-Return); buffered mode appends to the command field as before.
|
||||
|
||||
export type LiveDictationRoute =
|
||||
| { readonly kind: 'live-insert'; readonly text: string }
|
||||
| { readonly kind: 'buffered-append'; readonly text: string }
|
||||
|
||||
export function routeDictationTranscript(
|
||||
transcript: string,
|
||||
liveInputActive: boolean
|
||||
): LiveDictationRoute {
|
||||
return liveInputActive
|
||||
? { kind: 'live-insert', text: transcript }
|
||||
: { kind: 'buffered-append', text: transcript }
|
||||
}
|
||||
|
||||
// Mirrors the prior buffered onTranscript behavior: append after existing text
|
||||
// with a single separating space, or replace an empty/whitespace-only field.
|
||||
export function appendBufferedDictation(current: string, transcript: string): string {
|
||||
if (!current.trim()) {
|
||||
return transcript
|
||||
}
|
||||
return `${current.trimEnd()} ${transcript}`
|
||||
}
|
||||
Loading…
Reference in New Issue