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:
Jinjing 2026-06-28 01:20:00 -07:00 committed by GitHub
parent 112b6e51ca
commit c50391df84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 319 additions and 89 deletions

View File

@ -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}

View File

@ -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,

View File

@ -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>
</>
)
}

View File

@ -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
}
})

View File

@ -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')
})
})

View File

@ -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}`
}