diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 854fa4edc..be6357e9e 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -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(null); const runtimeRef = useRef(null); const mountedThemeRef = useRef(xtermTheme); + const scrollbackLinesRef = useRef(scrollbackLines); + scrollbackLinesRef.current = scrollbackLines; const viewportRef = useRef(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 }); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 1492f7b82..2e729ae73 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -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} diff --git a/packages/app/src/hooks/use-settings.test.ts b/packages/app/src/hooks/use-settings.test.ts index ec1b14bd9..cef8146dc 100644 --- a/packages/app/src/hooks/use-settings.test.ts +++ b/packages/app/src/hooks/use-settings.test.ts @@ -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>(), @@ -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(); + }); }); diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index a0492bff9..ecc3a8d78 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -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([...Object.keys(THEME_TO_UNISTYLES), "auto"]); const VALID_SERVICE_URL_BEHAVIORS = new Set(["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) => { try { - const prev = - queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_CLIENT_SETTINGS; - const next = { ...prev, ...updates }; - queryClient.setQueryData(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[] = []; 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): Promise { + await saveAppSettings({ queryClient: appQueryClient, updates }); +} + +export async function saveAppSettings(input: { + queryClient: QueryClient; + updates: Partial; +}): Promise { const current = - appQueryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? + input.queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? (await loadAppSettingsFromStorage()); - const next = { ...current, ...updates }; - appQueryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); + const next = { ...current, ...input.updates }; + input.queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); } @@ -246,6 +256,10 @@ function pickAppSettings(stored: Partial): Partial { ) { 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): Partial 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; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index e57a15fbf..090e7cd98 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -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 ( @@ -350,6 +378,23 @@ function GeneralSection({ ) : null} + + + Terminal scrollback + Lines kept in the built-in terminal buffer + + + ); @@ -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", diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index da95f6d5a..4a9b7f028 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -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 }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index b16aa4c4e..a58d63a61 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -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(); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index aeb18d7b0..1feff96e3 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -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(); }