diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index 261293e99013..06d0831337ca 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -19,8 +19,10 @@ import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSett import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state' import { loadTerminalAutocompleteEnabled, + loadTerminalDimDirListingSizes, loadTerminalTextScale, saveTerminalAutocompleteEnabled, + saveTerminalDimDirListingSizes, saveTerminalTextScale } from '../src/storage/preferences' @@ -176,6 +178,25 @@ export default function TerminalSettingsScreen() { void saveTerminalAutocompleteEnabled(next) }, []) + const [dimDirListingSizes, setDimDirListingSizes] = useState(false) + const userToggledDimSizesRef = useRef(false) + useEffect(() => { + let stale = false + void loadTerminalDimDirListingSizes().then((enabled) => { + if (!stale && !userToggledDimSizesRef.current) { + setDimDirListingSizes(enabled) + } + }) + return () => { + stale = true + } + }, []) + const toggleDimDirListingSizes = useCallback((next: boolean) => { + userToggledDimSizesRef.current = true + setDimDirListingSizes(next) + void saveTerminalDimDirListingSizes(next) + }, []) + useEffect(() => { let cancelled = false for (const host of hosts) { @@ -348,6 +369,27 @@ export default function TerminalSettingsScreen() { + DIRECTORY LISTINGS + + Dim trailing file-size tokens (1.4K, 738B, …) that coding agents print in directory + listings. Visual only — selection and copy still include the original text. Off by + default. + + + + + Dim file sizes + {dimDirListingSizes ? 'On' : 'Off'} + + + + + { }) }) +describe('terminal dim directory-listing sizes preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('defaults to disabled when unset', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + + await expect(loadTerminalDimDirListingSizes()).resolves.toBe(false) + expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:terminalDimDirListingSizes') + }) + + it('loads enabled only from the persisted true value', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue('true') + await expect(loadTerminalDimDirListingSizes()).resolves.toBe(true) + + vi.mocked(AsyncStorage.getItem).mockResolvedValue('false') + await expect(loadTerminalDimDirListingSizes()).resolves.toBe(false) + }) + + it('falls back to disabled when storage cannot be read', async () => { + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + await expect(loadTerminalDimDirListingSizes()).resolves.toBe(false) + }) + + it('persists the selected value and notifies subscribers', async () => { + const seen: boolean[] = [] + const unsubscribe = subscribeTerminalDimDirListingSizes((enabled) => { + seen.push(enabled) + }) + + await saveTerminalDimDirListingSizes(true) + expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalDimDirListingSizes', 'true') + expect(seen).toEqual([true]) + + await saveTerminalDimDirListingSizes(false) + expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalDimDirListingSizes', 'false') + expect(seen).toEqual([true, false]) + + unsubscribe() + await saveTerminalDimDirListingSizes(true) + expect(seen).toEqual([true, false]) + }) +}) + describe('terminal live input disabled handles preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5173ac5bc8ad..1eb29e3136bd 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -79,6 +79,37 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled)) } +const DIM_DIR_LISTING_SIZES_KEY = 'orca:terminalDimDirListingSizes' +type DimDirListingSizesListener = (enabled: boolean) => void +const dimDirListingSizesListeners = new Set() + +// Why: agent directory listings trail spaceless size tokens (1.4K, 738B) that +// crowd phone-width terminals; opt-in visual dim keeps the buffer intact. +export async function loadTerminalDimDirListingSizes(): Promise { + try { + const raw = await AsyncStorage.getItem(DIM_DIR_LISTING_SIZES_KEY) + return raw === 'true' + } catch { + return false + } +} + +export async function saveTerminalDimDirListingSizes(enabled: boolean): Promise { + await AsyncStorage.setItem(DIM_DIR_LISTING_SIZES_KEY, String(enabled)) + for (const listener of dimDirListingSizesListeners) { + listener(enabled) + } +} + +export function subscribeTerminalDimDirListingSizes( + listener: DimDirListingSizesListener +): () => void { + dimDirListingSizesListeners.add(listener) + return () => { + dimDirListingSizesListeners.delete(listener) + } +} + const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:' export type DisabledTerminalLiveInputHandlesPreference = { diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index f7ab3af1a3c1..49dc15d0475f 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -15,6 +15,10 @@ import { createTerminalWebViewPendingMessages } from './terminal-webview-pending import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch' import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing' import { createTerminalWriteCoalescer } from './terminal-write-coalescer' +import { + loadTerminalDimDirListingSizes, + subscribeTerminalDimDirListingSizes +} from '../storage/preferences' type Props = TerminalWebViewProps @@ -230,6 +234,26 @@ export const TerminalWebView = forwardRef(function postMessage({ type: 'set-font-scale', fontScale: textScale }) }, [postMessage, textScale]) + // Why: settings toggle must dim already-mounted terminals without a reload. + // Subscribe first so a live toggle cannot be overridden by a stale load result. + useEffect(() => { + let cancelled = false + let receivedSubscriptionUpdate = false + const unsubscribe = subscribeTerminalDimDirListingSizes((enabled) => { + receivedSubscriptionUpdate = true + postMessage({ type: 'set-dim-dir-listing-sizes', enabled }) + }) + void loadTerminalDimDirListingSizes().then((enabled) => { + if (!cancelled && !receivedSubscriptionUpdate) { + postMessage({ type: 'set-dim-dir-listing-sizes', enabled }) + } + }) + return () => { + cancelled = true + unsubscribe() + } + }, [postMessage]) + useImperativeHandle( ref, () => ({ diff --git a/mobile/src/terminal/terminal-dir-listing-size-dim-injected.ts b/mobile/src/terminal/terminal-dir-listing-size-dim-injected.ts new file mode 100644 index 000000000000..01eb6b92b72f --- /dev/null +++ b/mobile/src/terminal/terminal-dir-listing-size-dim-injected.ts @@ -0,0 +1,117 @@ +// Visual-only dim of trailing directory-listing size tokens inside the terminal +// WebView. Injected into XTERM_HTML; mirrors matchTrailingDirListingSize in +// terminal-dir-listing-size-dim.ts (unit-tested source of truth). +// +// Decorations overlay the size cells with the terminal background at partial +// opacity so the buffer, selection, and copy stay byte-identical to the PTY. + +export const TERMINAL_DIR_LISTING_SIZE_DIM_JS = String.raw` + var dimDirListingSizesEnabled = false; + var sizeDimDecorations = []; + var sizeDimRefreshScheduled = false; + var SIZE_TOKEN_RE = /(\d+(?:\.\d+)?)([KMGT]B?|B)\s*$/i; + + function matchTrailingDirListingSize(lineText) { + if (!lineText) return null; + var match = SIZE_TOKEN_RE.exec(lineText); + if (!match || match.index === undefined) return null; + var tokenStart = match.index; + if (tokenStart === 0) return null; + if (!/\s/.test(lineText.charAt(tokenStart - 1))) return null; + var prefixEnd = tokenStart; + while (prefixEnd > 0 && /\s/.test(lineText.charAt(prefixEnd - 1))) prefixEnd -= 1; + if (prefixEnd === 0) return null; + var token = match[1] + match[2]; + return { start: tokenStart, end: tokenStart + token.length }; + } + + function stringIndexToCellCol(line, stringIndex) { + if (!line || stringIndex <= 0) return 0; + var consumed = 0; + for (var col = 0; col < line.length; col++) { + if (consumed >= stringIndex) return col; + var cell = line.getCell(col); + if (!cell) return col; + var chars = cell.getChars(); + if (chars) consumed += chars.length; + } + return line.length; + } + + function clearSizeDimDecorations() { + for (var i = 0; i < sizeDimDecorations.length; i++) { + // Why: IDecoration.dispose leaves the linked IMarker alive in the buffer. + try { sizeDimDecorations[i].marker.dispose(); } catch (e) {} + try { sizeDimDecorations[i].dispose(); } catch (e) {} + } + sizeDimDecorations = []; + } + + function refreshSizeDimDecorations() { + sizeDimRefreshScheduled = false; + clearSizeDimDecorations(); + if (!dimDirListingSizesEnabled || !term || typeof term.registerMarker !== 'function') return; + var buf = term.buffer.active; + var baseY = buf.baseY; + var cursorY = buf.cursorY; + // Why: body.style.background is empty when the theme is only set via xterm options. + var bg = (typeof terminalTheme !== 'undefined' && terminalTheme && terminalTheme.background) || + (typeof defaultTheme !== 'undefined' && defaultTheme.background) || + '#1a1b26'; + for (var row = 0; row < term.rows; row++) { + var absRow = baseY + row; + var line = buf.getLine(absRow); + if (!line) continue; + var text = line.translateToString(false); + var range = matchTrailingDirListingSize(text); + if (!range) continue; + var startCol = stringIndexToCellCol(line, range.start); + var endCol = stringIndexToCellCol(line, range.end); + var width = Math.max(1, endCol - startCol); + var marker; + try { + marker = term.registerMarker(row - cursorY); + } catch (e) { + continue; + } + if (!marker) continue; + var decoration; + try { + decoration = term.registerDecoration({ + marker: marker, + x: startCol, + width: width, + layer: 'top' + }); + } catch (e) { + try { marker.dispose(); } catch (e2) {} + continue; + } + if (!decoration) { + try { marker.dispose(); } catch (e) {} + continue; + } + decoration.onRender(function (element) { + element.style.background = bg; + element.style.opacity = '0.62'; + element.style.pointerEvents = 'none'; + }); + sizeDimDecorations.push(decoration); + } + } + + function scheduleSizeDimRefresh() { + if (!dimDirListingSizesEnabled || sizeDimRefreshScheduled) return; + sizeDimRefreshScheduled = true; + requestAnimationFrame(refreshSizeDimDecorations); + } + + function setDimDirListingSizes(enabled) { + dimDirListingSizesEnabled = !!enabled; + if (!dimDirListingSizesEnabled) { + clearSizeDimDecorations(); + return; + } + scheduleSizeDimRefresh(); + } +` diff --git a/mobile/src/terminal/terminal-dir-listing-size-dim-wiring.test.ts b/mobile/src/terminal/terminal-dir-listing-size-dim-wiring.test.ts new file mode 100644 index 000000000000..157690695ac0 --- /dev/null +++ b/mobile/src/terminal/terminal-dir-listing-size-dim-wiring.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const injectedSource = readFileSync( + new URL('./terminal-dir-listing-size-dim-injected.ts', import.meta.url), + 'utf8' +) +const webViewSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') +const htmlSource = readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') + +describe('dir-listing size dim wiring', () => { + it('disposes the association marker when clearing decorations', () => { + expect(injectedSource).toMatch( + /try\s*\{\s*sizeDimDecorations\[i\]\.marker\.dispose\(\);\s*\}\s*catch/ + ) + expect(injectedSource).toMatch(/try\s*\{\s*sizeDimDecorations\[i\]\.dispose\(\);\s*\}\s*catch/) + }) + + it('tints size-dim overlays from terminalTheme.background', () => { + expect(injectedSource).toMatch(/terminalTheme\.background/) + expect(injectedSource).not.toMatch(/document\.body\.style\.background/) + }) + + it('refreshes size-dim decorations after set-theme', () => { + const setThemeIdx = htmlSource.indexOf("msg.type === 'set-theme'") + expect(setThemeIdx).toBeGreaterThanOrEqual(0) + const nextBranch = htmlSource.indexOf("} else if (msg.type ===", setThemeIdx + 1) + const setThemeBlock = htmlSource.slice(setThemeIdx, nextBranch > setThemeIdx ? nextBranch : undefined) + expect(setThemeBlock).toContain('scheduleSizeDimRefresh') + }) + + it('subscribes before load so a live toggle is not overridden by a stale initial read', () => { + const effectStart = webViewSource.indexOf( + 'settings toggle must dim already-mounted terminals without a reload' + ) + expect(effectStart).toBeGreaterThanOrEqual(0) + const effectEnd = webViewSource.indexOf('}, [postMessage])', effectStart) + expect(effectEnd).toBeGreaterThan(effectStart) + const effect = webViewSource.slice(effectStart, effectEnd) + const subscribeIdx = effect.indexOf('subscribeTerminalDimDirListingSizes') + const loadIdx = effect.indexOf('loadTerminalDimDirListingSizes') + expect(subscribeIdx).toBeGreaterThanOrEqual(0) + expect(loadIdx).toBeGreaterThan(subscribeIdx) + expect(effect).toContain('receivedSubscriptionUpdate') + expect(effect).toMatch(/!cancelled\s*&&\s*!receivedSubscriptionUpdate/) + }) +}) diff --git a/mobile/src/terminal/terminal-dir-listing-size-dim.test.ts b/mobile/src/terminal/terminal-dir-listing-size-dim.test.ts new file mode 100644 index 000000000000..374c0a7299ce --- /dev/null +++ b/mobile/src/terminal/terminal-dir-listing-size-dim.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { matchTrailingDirListingSize } from './terminal-dir-listing-size-dim' + +describe('matchTrailingDirListingSize', () => { + it('matches agent-style trailing size tokens', () => { + expect(matchTrailingDirListingSize('README.md 1.4K')).toEqual({ + start: 11, + end: 15, + token: '1.4K' + }) + expect(matchTrailingDirListingSize('src/index.ts 738B')).toEqual({ + start: 14, + end: 18, + token: '738B' + }) + expect(matchTrailingDirListingSize('bundle.js 45.9M')).toEqual({ + start: 11, + end: 16, + token: '45.9M' + }) + expect(matchTrailingDirListingSize('disk.img 2G')).toEqual({ + start: 10, + end: 12, + token: '2G' + }) + expect(matchTrailingDirListingSize('cache 1.2KB')).toEqual({ + start: 7, + end: 12, + token: '1.2KB' + }) + }) + + it('ignores prose and non-listing shapes', () => { + expect(matchTrailingDirListingSize('use a 4K display')).toBeNull() + expect(matchTrailingDirListingSize(' 1.4K')).toBeNull() + expect(matchTrailingDirListingSize('file1.4K')).toBeNull() + expect(matchTrailingDirListingSize('README.md')).toBeNull() + expect(matchTrailingDirListingSize('')).toBeNull() + }) + + it('trims trailing whitespace after the token', () => { + expect(matchTrailingDirListingSize('a.ts 12B ')).toEqual({ + start: 6, + end: 9, + token: '12B' + }) + }) +}) diff --git a/mobile/src/terminal/terminal-dir-listing-size-dim.ts b/mobile/src/terminal/terminal-dir-listing-size-dim.ts new file mode 100644 index 000000000000..6ff000d7b957 --- /dev/null +++ b/mobile/src/terminal/terminal-dir-listing-size-dim.ts @@ -0,0 +1,42 @@ +// Match trailing agent directory-listing size tokens (1.4K, 738B, 45.9M, …). +// Visual-only consumers use this to dim the token; the PTY buffer stays intact. + +const SIZE_TOKEN_RE = /(\d+(?:\.\d+)?)([KMGT]B?|B)\s*$/i + +export type DirListingSizeRange = { + readonly start: number + readonly end: number + readonly token: string +} + +/** + * Returns the string range of a trailing size token when the line looks like a + * directory listing entry (`name 1.4K`). Narrow by design: requires a non-space + * prefix (the filename) and end-of-line placement. + */ +export function matchTrailingDirListingSize(lineText: string): DirListingSizeRange | null { + if (!lineText) { + return null + } + const match = SIZE_TOKEN_RE.exec(lineText) + if (!match || match.index === undefined) { + return null + } + const tokenStart = match.index + if (tokenStart === 0) { + return null + } + // Why: size must be separated from the name; "file1.4K" is not a listing token. + if (!/\s/.test(lineText.charAt(tokenStart - 1))) { + return null + } + let prefixEnd = tokenStart + while (prefixEnd > 0 && /\s/.test(lineText.charAt(prefixEnd - 1))) { + prefixEnd -= 1 + } + if (prefixEnd === 0) { + return null + } + const token = match[1]! + match[2]! + return { start: tokenStart, end: tokenStart + token.length, token } +} diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 767b2cc6b6c4..b5825f29e9c7 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -11,6 +11,7 @@ import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' +import { TERMINAL_DIR_LISTING_SIZE_DIM_JS } from './terminal-dir-listing-size-dim-injected' const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { background: colors.terminalBg, @@ -620,6 +621,7 @@ ${TERMINAL_WEBVIEW_THEME_JS} for (var i = 0; i < disposables.length; i++) { try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} } + try { clearSizeDimDecorations(); } catch (e) {} } function extractMouseModeScanTail(input) { @@ -655,6 +657,7 @@ ${TERMINAL_WEBVIEW_THEME_JS} term.write(next, function() { if (gen !== terminalGeneration) return; writesDraining = false; + scheduleSizeDimRefresh(); pumpWrites(gen); }); } @@ -957,6 +960,10 @@ ${TERMINAL_WEBGL_RECOVERY_JS} applyFitScale('reset-zoom-msg'); } else if (msg.type === 'set-theme') { applyTerminalTheme(msg.terminalTheme); + // Why: size-dim overlays use the active theme background; re-tint after theme swaps. + try { scheduleSizeDimRefresh(); } catch (e) {} + } else if (msg.type === 'set-dim-dir-listing-sizes') { + setDimDirListingSizes(!!msg.enabled); } else if (msg.type === 'cancel-select') { if (selMode === 'select') cancelSelect(); } else if (msg.type === 'do-select-all') { @@ -1105,7 +1112,10 @@ ${TERMINAL_WEBGL_RECOVERY_JS} disposeTermObservers(); try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} try { - termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); + termObserverDisposables.push(term.onScroll(function() { + updateScrollIndicator(false); + scheduleSizeDimRefresh(); + })); } catch (e) {} // Why: emit modes on every parsed write so RN's mirror stays current // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. @@ -1444,6 +1454,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} // terminal-path-tap-injected.ts; mirrors the unit-tested terminal-path-tap.ts. ${TERMINAL_PATH_TAP_JS} ${URL_TAP_WEBVIEW_JS} + ${TERMINAL_DIR_LISTING_SIZE_DIM_JS} function seedWordSelection(col, absRow) { var line = getLineText(absRow); diff --git a/mobile/src/terminal/terminal-webview-messages.ts b/mobile/src/terminal/terminal-webview-messages.ts index c031363268c1..569a850316e8 100644 --- a/mobile/src/terminal/terminal-webview-messages.ts +++ b/mobile/src/terminal/terminal-webview-messages.ts @@ -26,3 +26,4 @@ export type TerminalWebViewCommand = | { type: 'cancel-select'; id?: number } | { type: 'do-select-all'; id?: number } | { type: 'set-theme'; id?: number; terminalTheme?: RuntimeMobileTerminalTheme } + | { type: 'set-dim-dir-listing-sizes'; id?: number; enabled: boolean }