mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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:
co-authored by
Claude Opus 4.8
Orca
github-actions[bot]
Jinwoo-H
parent
d17c22c826
commit
cd31cd702d
@@ -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 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user