feat(mobile): add terminal text size (zoom) setting (#5388)

* release: v1.4.48-rc.0 [rc-slot:2026-06-05-15]

* release: v0.0.1-rc.0 [rc-slot:2026-06-06-03]

* feat(mobile): add terminal text size (zoom) setting

The mobile terminal fits the desktop's full column count to the phone
width with a CSS scale, which cancels out xterm's raw fontSize — so there
was no way to make text bigger or smaller. Add a persisted baseline zoom
multiplier the WebView applies on top of the fit.

- Settings → Terminal → "Text size": 50%–200% presets.
- Pinch-to-zoom in the terminal snaps to the same presets and persists.
- Per-device display preference; does not change the desktop terminal.
- Extracts the xterm WebView HTML into terminal-webview-html.ts to keep
  TerminalWebView.tsx within its max-lines budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(mobile): make terminal text size reflow columns instead of CSS scaling

Text size now drives the real xterm fontSize rather than a CSS transform
baseline. A larger cell means fewer columns fit the phone width, so the
existing measure -> terminal.updateViewport pipeline resizes the PTY and the
shell rewraps to the new width (and smaller sizes show more columns). Pinch
still snaps to a preset, but now changes the font size and reflows instead of
scaling pixels; the refit hook re-fits the PTY whenever the scale changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mobile): restore terminal touch scrollback (undefined contentWiderThanViewport)

The single-finger touchmove handler gated horizontal panning on
contentWiderThanViewport(), which is never defined — so every one-finger
touchmove threw a ReferenceError before reaching the vertical-scroll code
below it, killing all touch scrolling including scrollback.

Replace the call with the inline overflow check clampPan() already uses
(scrollWidth * getTotalScale() > innerWidth). Vertical scroll now always runs;
two-finger pinch-to-zoom is unaffected. Merged two adjacent var decls to keep
the file under its max-lines cap without a disable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mobile): guard terminal text scale viewport refit

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
gsxdsm
2026-06-15 13:14:58 -07:00
committed by GitHub
co-authored by Claude Opus 4.8 Orca github-actions[bot] Jinwoo-H
parent d17c22c826
commit cd31cd702d
10 changed files with 2111 additions and 1837 deletions
+7 -1
View File
@@ -28,7 +28,13 @@
{
"files": ["src/terminal/TerminalWebView.tsx"],
"rules": {
"max-lines": ["error", { "max": 2054, "skipBlankLines": true, "skipComments": true }]
"max-lines": ["error", { "max": 369, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/terminal/terminal-webview-html.ts"],
"rules": {
"max-lines": ["error", { "max": 1773, "skipBlankLines": true, "skipComments": true }]
}
},
{
+32 -1
View File
@@ -45,7 +45,11 @@ import {
} from 'lucide-react-native'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
import { loadTerminalAutocompleteEnabled } from '../../../../src/storage/preferences'
import {
loadTerminalAutocompleteEnabled,
loadTerminalTextScale,
saveTerminalTextScale
} from '../../../../src/storage/preferences'
import {
useHostClient,
useForceReconnect,
@@ -739,6 +743,9 @@ export default function SessionScreen() {
const sessionTabsRef = useRef<MobileSessionTab[]>([])
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
const [input, setInput] = useState('')
// Why: baseline terminal zoom, reloaded on focus so a Settings → Terminal change
// applies in place (the terminal panes stay mounted).
const [terminalTextScale, setTerminalTextScale] = useState(1)
// Why: local opt-in for keyboard autocomplete/autocorrect on the terminal
// command bar; reloaded on focus so a Settings → Terminal toggle takes effect on return.
const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
@@ -2082,6 +2089,7 @@ export default function SessionScreen() {
deviceTokenRef,
initializedHandlesRef,
tabStripVisible: terminals.length > 1,
textScale: terminalTextScale,
unsubscribeTerminal,
subscribeToTerminal
})
@@ -2306,6 +2314,22 @@ export default function SessionScreen() {
}, [connState, fetchSessionTabs, fetchTerminals])
)
// Why: pick up the Settings → Terminal text size when returning here — the
// terminal panes stay mounted, so they update in place.
useFocusEffect(
useCallback(() => {
let active = true
void loadTerminalTextScale().then((scale) => {
if (active) {
setTerminalTextScale(scale)
}
})
return () => {
active = false
}
}, [])
)
// Why: pick up the Settings → Terminal autocomplete toggle when returning here.
useFocusEffect(
useCallback(() => {
@@ -3997,6 +4021,13 @@ export default function SessionScreen() {
active={terminal.handle === activeHandle}
keyboardLift={terminal.handle === activeHandle ? activeTerminalKeyboardLift : 0}
terminalTheme={terminal.terminalTheme}
textScale={terminalTextScale}
onTextScaleChange={(scale) => {
// Why: pinch-to-zoom in the WebView reports a new preset; persist
// it so the size sticks across panes and app launches.
setTerminalTextScale(scale)
void saveTerminalTextScale(scale)
}}
onRef={setTerminalWebViewRef}
onWebReady={handleTerminalWebReady}
onSelectionMode={handleSelectionMode}
+69 -2
View File
@@ -8,7 +8,7 @@ import Animated, {
useSharedValue
} from 'react-native-reanimated'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native'
import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
@@ -19,11 +19,34 @@ import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSett
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
import {
loadTerminalAutocompleteEnabled,
saveTerminalAutocompleteEnabled
loadTerminalTextScale,
saveTerminalAutocompleteEnabled,
saveTerminalTextScale
} from '../src/storage/preferences'
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
type TextSizeValue = 'smallest' | 'smaller' | 'default' | 'large' | 'larger' | 'largest'
// scale = baseline zoom the terminal WebView applies on top of fit-to-width.
// Keep in sync with TERMINAL_TEXT_SCALES; pinch-to-zoom snaps to these values.
const TEXT_SIZE_OPTIONS: (PickerOption<TextSizeValue> & { scale: number })[] = [
{ value: 'smallest', label: 'Smallest (50%)', scale: 0.5 },
{ value: 'smaller', label: 'Smaller (75%)', scale: 0.75 },
{ value: 'default', label: 'Default (100%)', scale: 1 },
{ value: 'large', label: 'Large (125%)', scale: 1.25 },
{ value: 'larger', label: 'Larger (150%)', scale: 1.5 },
{ value: 'largest', label: 'Largest (200%)', scale: 2 }
]
function textSizeValueFromScale(scale: number): TextSizeValue {
return TEXT_SIZE_OPTIONS.find((o) => o.scale === scale)?.value ?? 'default'
}
function textSizeSummary(scale: number): string {
return (TEXT_SIZE_OPTIONS.find((o) => o.scale === scale) ?? TEXT_SIZE_OPTIONS[0]!).label
}
const AUTO_RESTORE_FIT_OPTIONS: (PickerOption<RestoreValue> & { ms: number | null })[] = [
{ value: 'indefinite', label: 'Keep at phone size (default)', ms: null },
{ value: '60s', label: 'After 1 minute', ms: 60_000 },
@@ -118,6 +141,20 @@ export default function TerminalSettingsScreen() {
const [hostMs, setHostMs] = useState<Record<string, number | null | undefined>>({})
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
const [textScale, setTextScale] = useState(1)
const [textSizePickerOpen, setTextSizePickerOpen] = useState(false)
useEffect(() => {
void loadTerminalTextScale().then(setTextScale)
}, [])
const selectTextSize = useCallback((value: TextSizeValue) => {
const opt = TEXT_SIZE_OPTIONS.find((o) => o.value === value)
if (!opt) {
return
}
setTextScale(opt.scale)
void saveTerminalTextScale(opt.scale)
}, [])
const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
// Why: a fast toggle before the initial load resolves must win — otherwise the
// delayed read would clobber the user's choice with the stored (stale) value.
@@ -268,6 +305,27 @@ export default function TerminalSettingsScreen() {
</View>
)}
<Text style={[styles.groupHeading, styles.inputGroupGap]}>TEXT SIZE</Text>
<Text style={styles.groupDescription}>
Scale the terminal text. Smaller sizes fit more columns with side margins; larger sizes
show fewer columns drag sideways to pan. You can also pinch to zoom in the terminal
itself, which updates this setting. Per-device display only; doesn&apos;t change the
desktop terminal.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setTextSizePickerOpen(true)}
>
<Type size={16} color={colors.textSecondary} />
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Text size</Text>
<Text style={styles.rowSublabel}>{textSizeSummary(textScale)}</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.inputGroupGap]}>KEYBOARD INPUT</Text>
<Text style={styles.groupDescription}>
Enable phone-style autocomplete, autocorrect, and spelling suggestions in the terminal
@@ -310,6 +368,15 @@ export default function TerminalSettingsScreen() {
}}
onClose={() => setPickerHostId(null)}
/>
<PickerModal<TextSizeValue>
visible={textSizePickerOpen}
title="Terminal text size"
options={TEXT_SIZE_OPTIONS}
selected={textSizeValueFromScale(textScale)}
onSelect={selectTextSize}
onClose={() => setTextSizePickerOpen(false)}
/>
</GestureHandlerRootView>
)
}
+7 -1
View File
@@ -13,6 +13,7 @@ type TerminalPaneViewProps = {
active: boolean
keyboardLift: number
terminalTheme?: MobileTerminalTheme
textScale: number
onRef: (handle: string, ref: TerminalWebViewHandle | null) => void
onWebReady: (handle: string) => void
onSelectionMode: (handle: string, active: boolean) => void
@@ -23,6 +24,7 @@ type TerminalPaneViewProps = {
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput: (handle: string, bytes: string) => void
onTerminalTap: (handle: string) => void
onTextScaleChange: (scale: number) => void
}
export function TerminalPaneView({
@@ -30,6 +32,7 @@ export function TerminalPaneView({
active,
keyboardLift,
terminalTheme,
textScale,
onRef,
onWebReady,
onSelectionMode,
@@ -39,7 +42,8 @@ export function TerminalPaneView({
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalTap
onTerminalTap,
onTextScaleChange
}: TerminalPaneViewProps) {
const setRef = useCallback(
(ref: TerminalWebViewHandle | null) => {
@@ -63,6 +67,7 @@ export function TerminalPaneView({
ref={setRef}
style={styles.terminalWebView}
terminalTheme={terminalTheme}
textScale={textScale}
onWebReady={() => onWebReady(handle)}
onSelectionMode={(a) => onSelectionMode(handle, a)}
onSelectionCopy={(t) => onSelectionCopy(handle, t)}
@@ -72,6 +77,7 @@ export function TerminalPaneView({
onHaptic={onHaptic}
onTerminalInput={(bytes) => onTerminalInput(handle, bytes)}
onTerminalTap={() => onTerminalTap(handle)}
onTextScaleChange={onTextScaleChange}
/>
</View>
)
+31
View File
@@ -25,6 +25,37 @@ export async function savePushNotificationsEnabled(enabled: boolean): Promise<vo
await AsyncStorage.setItem(NOTIF_KEY, String(enabled))
}
const TEXT_SCALE_KEY = 'orca:terminalTextScale'
// Why: the mobile terminal fits the desktop's full column count to the phone
// width with a CSS scale, so xterm's raw fontSize is cancelled out and can't
// drive apparent size. Instead we persist a baseline zoom multiplier ("text
// size") that the WebView applies on top of the fit. Discrete presets keep the
// settings picker simple and bound the value to ones the zoom logic handles;
// pinch-to-zoom in the terminal snaps to these same presets. Sub-1 steps shrink
// below fit-to-width (more columns visible with side margins).
export const TERMINAL_TEXT_SCALES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const
const DEFAULT_TEXT_SCALE = 1
export async function loadTerminalTextScale(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(TEXT_SCALE_KEY)
if (raw === null) {
return DEFAULT_TEXT_SCALE
}
const parsed = Number(raw)
return (TERMINAL_TEXT_SCALES as readonly number[]).includes(parsed)
? parsed
: DEFAULT_TEXT_SCALE
} catch {
return DEFAULT_TEXT_SCALE
}
}
export async function saveTerminalTextScale(scale: number): Promise<void> {
await AsyncStorage.setItem(TEXT_SCALE_KEY, String(scale))
}
const AUTOCOMPLETE_KEY = 'orca:terminalAutocompleteEnabled'
// Why: terminal command inputs default to autocorrect/suggestions OFF so the
File diff suppressed because it is too large Load Diff
@@ -35,9 +35,22 @@ describe('terminal viewport refit', () => {
expect(tabEffect).toContain('scheduleViewportRefit()')
})
it('refits the PTY when terminal text scale changes', () => {
// Why: mobile text size must change the real PTY grid, not just scale pixels
// in the WebView, or wrapped CLI output diverges from what the shell sees.
const start = hookSource.indexOf('const prevTextScaleRef = useRef(textScale)')
expect(start).toBeGreaterThanOrEqual(0)
const textScaleEffect = hookSource.slice(start, start + 600)
expect(textScaleEffect).toContain('prevTextScaleRef.current === textScale')
expect(textScaleEffect).toContain('viewportMeasuredRef.current = false')
expect(textScaleEffect).toContain('scheduleViewportRefit()')
expect(textScaleEffect).toContain('[textScale, viewportMeasuredRef, scheduleViewportRefit]')
})
it('is wired into the session screen', () => {
expect(sessionSource).toContain('useTerminalViewportRefit({')
expect(sessionSource).toContain('tabStripVisible: terminals.length > 1')
expect(sessionSource).toContain('textScale: terminalTextScale')
})
it('prefers the in-place updateViewport RPC over resubscribe', () => {
@@ -19,6 +19,9 @@ type TerminalViewportRefitOptions = {
deviceTokenRef: RefObject<string | null>
initializedHandlesRef: RefObject<Set<string>>
tabStripVisible: boolean
// Why: terminal text size (font scale) — changing it changes the cell size, so
// the PTY must be re-fitted to a new column count and reflowed.
textScale: number
unsubscribeTerminal: (handle: string) => void
subscribeToTerminal: (handle: string) => void
}
@@ -40,6 +43,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
deviceTokenRef,
initializedHandlesRef,
tabStripVisible,
textScale,
unsubscribeTerminal,
subscribeToTerminal
} = options
@@ -164,6 +168,20 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
scheduleViewportRefit()
}, [windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit])
// Why: the text size changed, so the WebView is re-rendering at a new font/cell
// size. Re-measure and resize the PTY so the server reflows to the new column
// count. The refit's own 150ms debounce gives the WebView a frame to apply the
// new fontSize before we measure the resulting cell metrics.
const prevTextScaleRef = useRef(textScale)
useEffect(() => {
if (prevTextScaleRef.current === textScale) {
return
}
prevTextScaleRef.current = textScale
viewportMeasuredRef.current = false
scheduleViewportRefit()
}, [textScale, viewportMeasuredRef, scheduleViewportRefit])
useEffect(() => {
disposedRef.current = false
return () => {
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,11 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8')
// The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in
// TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file.
const source =
readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8')
const sessionSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'