Make terminal scrollback configurable (#1021)

* Make terminal scrollback configurable

* Fix terminal scrollback lint violation

* Fix terminal runtime test environment split

* Fix terminal scrollback updates

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
ezra
2026-05-17 19:58:35 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 00e7ac9ec8
commit edb0ba888a
8 changed files with 265 additions and 14 deletions
@@ -116,6 +116,7 @@ interface TerminalEmulatorProps {
streamKey: string;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
@@ -188,6 +189,7 @@ export default function TerminalEmulator({
foreground: "#e6e6e6",
cursor: "#e6e6e6",
},
scrollbackLines,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
@@ -206,6 +208,8 @@ export default function TerminalEmulator({
const hostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const mountedThemeRef = useRef<ITheme>(xtermTheme);
const scrollbackLinesRef = useRef(scrollbackLines);
scrollbackLinesRef.current = scrollbackLines;
const viewportRef = useRef<HTMLElement | null>(null);
const dragStartOffsetRef = useRef(0);
const dragStartClientYRef = useRef(0);
@@ -289,6 +293,10 @@ export default function TerminalEmulator({
runtimeRef.current?.setTheme({ theme: nextTheme });
}, [themeKey]);
useEffect(() => {
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
}, [scrollbackLines]);
useEffect(() => {
ensureTerminalScrollbarStyle();
}, []);
@@ -429,6 +437,7 @@ export default function TerminalEmulator({
root,
host,
initialSnapshot: initialSnapshotRef.current,
scrollback: scrollbackLinesRef.current,
theme: mountedThemeRef.current,
});
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
@@ -35,6 +35,7 @@ import {
shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness";
import { useAppSettings } from "@/hooks/use-settings";
interface TerminalPaneProps {
serverId: string;
@@ -157,6 +158,7 @@ export function TerminalPane({
}: TerminalPaneProps) {
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
const { settings } = useAppSettings();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
@@ -681,6 +683,7 @@ export function TerminalPane({
streamKey={terminalStreamKey}
testId="terminal-surface"
xtermTheme={xtermTheme}
scrollbackLines={settings.terminalScrollbackLines}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
@@ -87,6 +88,7 @@ describe("use-settings", () => {
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
releaseChannel: "stable",
});
});
@@ -130,6 +132,7 @@ describe("use-settings", () => {
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
});
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
mod.APP_SETTINGS_KEY,
@@ -169,6 +172,7 @@ describe("use-settings", () => {
theme: "light",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
manageBuiltInDaemon: false,
releaseChannel: "beta",
});
@@ -188,8 +192,58 @@ describe("use-settings", () => {
theme: "light",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
manageBuiltInDaemon: true,
releaseChannel: "stable",
});
});
it("loads configured terminal scrollback lines from app settings", async () => {
asyncStorageMock.getItem.mockResolvedValue(
JSON.stringify({
terminalScrollbackLines: 42_000,
}),
);
const mod = await import("./use-settings");
const result = await mod.loadAppSettingsFromStorage();
expect(result.terminalScrollbackLines).toBe(42_000);
});
it("saves terminal scrollback through app settings persistence", async () => {
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
asyncStorageMock.getItem.mockResolvedValue(JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS));
const queryClient = new QueryClient();
await mod.saveAppSettings({
queryClient,
updates: { terminalScrollbackLines: 42_000 },
});
expect(asyncStorageMock.setItem).toHaveBeenLastCalledWith(
mod.APP_SETTINGS_KEY,
JSON.stringify({
...mod.DEFAULT_CLIENT_SETTINGS,
terminalScrollbackLines: 42_000,
}),
);
});
it("normalizes terminal scrollback lines from storage", async () => {
asyncStorageMock.getItem.mockResolvedValue(
JSON.stringify({
terminalScrollbackLines: 1_000_000.9,
}),
);
const mod = await import("./use-settings");
const result = await mod.loadAppSettingsFromStorage();
expect(result.terminalScrollbackLines).toBe(1_000_000);
expect(mod.parseTerminalScrollbackLines("-10")).toBe(0);
expect(mod.parseTerminalScrollbackLines("abc")).toBeNull();
});
});
+40 -10
View File
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { queryClient as appQueryClient } from "@/query/query-client";
import {
DEFAULT_DESKTOP_SETTINGS,
@@ -21,11 +21,15 @@ export type ServiceUrlBehavior = "ask" | "in-app" | "external";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
export interface AppSettings {
theme: ThemeName | "auto";
sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior;
terminalScrollbackLines: number;
}
export interface Settings extends AppSettings {
@@ -37,6 +41,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: "auto",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -73,11 +78,7 @@ export function useAppSettings(): UseAppSettingsReturn {
const updateSettings = useCallback(
async (updates: Partial<AppSettings>) => {
try {
const prev =
queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_CLIENT_SETTINGS;
const next = { ...prev, ...updates };
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
await saveAppSettings({ queryClient, updates });
} catch (err) {
console.error("[AppSettings] Failed to save settings:", err);
throw err;
@@ -122,7 +123,9 @@ export function useSettings(): UseSettingsReturn {
if (updates.serviceUrlBehavior !== undefined) {
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
}
if (updates.terminalScrollbackLines !== undefined) {
appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -171,11 +174,18 @@ export function useSettings(): UseSettingsReturn {
}
export async function persistAppSettings(updates: Partial<AppSettings>): Promise<void> {
await saveAppSettings({ queryClient: appQueryClient, updates });
}
export async function saveAppSettings(input: {
queryClient: QueryClient;
updates: Partial<AppSettings>;
}): Promise<void> {
const current =
appQueryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
(await loadAppSettingsFromStorage());
const next = { ...current, ...updates };
appQueryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
const next = { ...current, ...input.updates };
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
}
@@ -246,6 +256,10 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
) {
result.serviceUrlBehavior = stored.serviceUrlBehavior;
}
const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines);
if (terminalScrollbackLines !== null) {
result.terminalScrollbackLines = terminalScrollbackLines;
}
return result;
}
@@ -257,6 +271,22 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
return result;
}
export function parseTerminalScrollbackLines(value: unknown): number | null {
let numericValue = NaN;
if (typeof value === "number") {
numericValue = value;
} else if (typeof value === "string" && value.trim().length > 0) {
numericValue = Number(value);
}
if (!Number.isFinite(numericValue)) {
return null;
}
return Math.min(
MAX_TERMINAL_SCROLLBACK_LINES,
Math.max(MIN_TERMINAL_SCROLLBACK_LINES, Math.floor(numericValue)),
);
}
async function loadLegacyDesktopSettingsFromStorage(): Promise<{
manageBuiltInDaemon?: boolean;
releaseChannel?: ReleaseChannel;
+67 -1
View File
@@ -1,10 +1,11 @@
import { Fragment, useCallback, useMemo, useState, useSyncExternalStore } from "react";
import { Fragment, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
} from "react-native";
@@ -37,6 +38,7 @@ import { SettingsSection } from "@/screens/settings/settings-section";
import {
useAppSettings,
useSettings,
parseTerminalScrollbackLines,
type AppSettings,
type SendBehavior,
type ServiceUrlBehavior,
@@ -209,6 +211,7 @@ interface GeneralSectionProps {
handleThemeChange: (theme: AppSettings["theme"]) => void;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
handleTerminalScrollbackLinesChange: (lines: number) => void;
}
interface ThemeMenuItemProps {
@@ -267,10 +270,35 @@ function GeneralSection({
handleThemeChange,
handleSendBehaviorChange,
handleServiceUrlBehaviorChange,
handleTerminalScrollbackLinesChange,
}: GeneralSectionProps) {
const { theme } = useUnistyles();
const iconSize = theme.iconSize.md;
const iconColor = theme.colors.foregroundMuted;
const [terminalScrollbackValue, setTerminalScrollbackValue] = useState(
String(settings.terminalScrollbackLines),
);
const handleTerminalScrollbackChangeText = useCallback((value: string) => {
setTerminalScrollbackValue(value.replace(/[^\d]/g, ""));
}, []);
const commitTerminalScrollback = useCallback(() => {
const parsed = parseTerminalScrollbackLines(terminalScrollbackValue);
const nextValue = parsed ?? settings.terminalScrollbackLines;
setTerminalScrollbackValue(String(nextValue));
if (nextValue !== settings.terminalScrollbackLines) {
handleTerminalScrollbackLinesChange(nextValue);
}
}, [
handleTerminalScrollbackLinesChange,
settings.terminalScrollbackLines,
terminalScrollbackValue,
]);
useEffect(() => {
setTerminalScrollbackValue(String(settings.terminalScrollbackLines));
}, [settings.terminalScrollbackLines]);
return (
<SettingsSection title="General">
@@ -350,6 +378,23 @@ function GeneralSection({
</DropdownMenu>
</View>
) : null}
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Terminal scrollback</Text>
<Text style={settingsStyles.rowHint}>Lines kept in the built-in terminal buffer</Text>
</View>
<TextInput
value={terminalScrollbackValue}
onChangeText={handleTerminalScrollbackChangeText}
onBlur={commitTerminalScrollback}
onSubmitEditing={commitTerminalScrollback}
keyboardType="number-pad"
inputMode="numeric"
selectTextOnFocus
style={styles.terminalScrollbackInput}
accessibilityLabel="Terminal scrollback lines"
/>
</View>
</View>
</SettingsSection>
);
@@ -850,6 +895,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[updateSettings],
);
const handleTerminalScrollbackLinesChange = useCallback(
(terminalScrollbackLines: number) => {
void updateSettings({ terminalScrollbackLines });
},
[updateSettings],
);
const handlePlaybackTest = useCallback(async () => {
if (!voiceAudioEngine || isPlaybackTestRunning) {
return;
@@ -1031,6 +1083,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
handleThemeChange={handleThemeChange}
handleSendBehaviorChange={handleSendBehaviorChange}
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
/>
);
case "shortcuts":
@@ -1233,6 +1286,19 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
terminalScrollbackInput: {
width: 112,
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
textAlign: "right",
},
placeholder: {
flex: 1,
alignItems: "center",
@@ -61,7 +61,11 @@ async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }):
}
}
function createTerminalHost(input: { width: number; height: number }): MountedTerminal {
function createTerminalHost(input: {
width: number;
height: number;
scrollback?: number;
}): MountedTerminal {
const root = document.createElement("div");
root.style.width = `${input.width}px`;
root.style.height = `${input.height}px`;
@@ -101,6 +105,7 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
root,
host,
initialSnapshot: null,
scrollback: input.scrollback ?? 10_000,
theme: {
background: "#0b0b0b",
foreground: "#e6e6e6",
@@ -163,6 +168,32 @@ afterEach(() => {
});
describe("terminal emulator runtime in a real browser", () => {
it("passes configured scrollback to xterm", async () => {
await page.viewport(900, 600);
createTerminalHost({ width: 720, height: 360, scrollback: 42_000 });
await waitFor({
predicate: () => window.__paseoTerminal !== undefined,
});
expect(window.__paseoTerminal?.options.scrollback).toBe(42_000);
});
it("updates scrollback on the mounted xterm", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360, scrollback: 10_000 });
await waitFor({
predicate: () => window.__paseoTerminal !== undefined,
});
const terminal = window.__paseoTerminal;
mounted.runtime.setScrollback({ lines: 42_000 });
expect(window.__paseoTerminal).toBe(terminal);
expect(window.__paseoTerminal?.options.scrollback).toBe(42_000);
});
it("reports a larger PTY size when the terminal container grows", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 360, height: 180 });
@@ -49,9 +49,29 @@ vi.mock("@xterm/addon-webgl", () => ({
},
}));
const terminalConstructorOptions = vi.hoisted(() => ({
values: [] as unknown[],
}));
vi.mock("@xterm/xterm", () => ({
Terminal: class Terminal {
rows = 24;
cols = 80;
unicode = { activeVersion: "" };
parser = {
registerCsiHandler: () => undefined,
};
constructor(options: unknown) {
terminalConstructorOptions.values.push(options);
}
loadAddon(): void {}
open(): void {}
onData(): { dispose: () => void } {
return { dispose: () => undefined };
}
attachCustomKeyEventHandler(): void {}
dispose(): void {}
refresh(): void {}
},
}));
@@ -63,7 +83,7 @@ interface StubTerminal {
resize?: (cols: number, rows: number) => void;
focus: () => void;
refresh?: (start: number, end: number) => void;
options?: { theme?: unknown };
options?: { theme?: unknown; scrollback?: number };
rows?: number;
cols?: number;
}
@@ -118,6 +138,7 @@ describe("terminal-emulator-runtime", () => {
(globalThis as { window?: { __paseoTerminal?: unknown } }).window = {
__paseoTerminal: undefined,
};
terminalConstructorOptions.values = [];
});
afterEach(() => {
@@ -317,6 +338,26 @@ describe("terminal-emulator-runtime", () => {
expect(refresh).toHaveBeenCalledWith(0, 11);
});
it("updates terminal scrollback without remounting", () => {
const runtime = new TerminalEmulatorRuntime();
const refresh = vi.fn();
const terminal: StubTerminal = {
write: () => {},
reset: () => {},
focus: () => {},
refresh,
options: { scrollback: 10_000 },
rows: 12,
cols: 40,
};
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
runtime.setScrollback({ lines: 42_000 });
expect(terminal.options?.scrollback).toBe(42_000);
expect(refresh).toHaveBeenCalledWith(0, 11);
});
it("forces a refit when the page becomes visible again", () => {
const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn();
@@ -27,6 +27,7 @@ export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement;
host: HTMLDivElement;
initialSnapshot: TerminalState | null;
scrollback: number;
theme: ITheme;
}
@@ -178,7 +179,7 @@ export class TerminalEmulatorRuntime {
overviewRuler: {
width: 8,
},
scrollback: 10_000,
scrollback: input.scrollback,
theme: withOverviewRulerBorderHidden(input.theme),
});
const fitAddon = new FitAddon();
@@ -578,6 +579,22 @@ export class TerminalEmulatorRuntime {
this.refreshVisibleRows();
}
setScrollback(input: { lines: number }): void {
const terminal = this.terminal;
if (!terminal) {
return;
}
try {
terminal.options.scrollback = input.lines;
} catch {
// ignore
return;
}
this.refreshVisibleRows();
}
focus(): void {
this.terminal?.focus();
}