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 }