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:
@@ -116,6 +116,7 @@ interface TerminalEmulatorProps {
|
|||||||
streamKey: string;
|
streamKey: string;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
xtermTheme?: ITheme;
|
xtermTheme?: ITheme;
|
||||||
|
scrollbackLines: number;
|
||||||
swipeGesturesEnabled?: boolean;
|
swipeGesturesEnabled?: boolean;
|
||||||
onSwipeLeft?: () => void;
|
onSwipeLeft?: () => void;
|
||||||
onSwipeRight?: () => void;
|
onSwipeRight?: () => void;
|
||||||
@@ -188,6 +189,7 @@ export default function TerminalEmulator({
|
|||||||
foreground: "#e6e6e6",
|
foreground: "#e6e6e6",
|
||||||
cursor: "#e6e6e6",
|
cursor: "#e6e6e6",
|
||||||
},
|
},
|
||||||
|
scrollbackLines,
|
||||||
swipeGesturesEnabled = false,
|
swipeGesturesEnabled = false,
|
||||||
onSwipeLeft,
|
onSwipeLeft,
|
||||||
onSwipeRight,
|
onSwipeRight,
|
||||||
@@ -206,6 +208,8 @@ export default function TerminalEmulator({
|
|||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||||
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
||||||
|
const scrollbackLinesRef = useRef(scrollbackLines);
|
||||||
|
scrollbackLinesRef.current = scrollbackLines;
|
||||||
const viewportRef = useRef<HTMLElement | null>(null);
|
const viewportRef = useRef<HTMLElement | null>(null);
|
||||||
const dragStartOffsetRef = useRef(0);
|
const dragStartOffsetRef = useRef(0);
|
||||||
const dragStartClientYRef = useRef(0);
|
const dragStartClientYRef = useRef(0);
|
||||||
@@ -289,6 +293,10 @@ export default function TerminalEmulator({
|
|||||||
runtimeRef.current?.setTheme({ theme: nextTheme });
|
runtimeRef.current?.setTheme({ theme: nextTheme });
|
||||||
}, [themeKey]);
|
}, [themeKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
|
||||||
|
}, [scrollbackLines]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ensureTerminalScrollbarStyle();
|
ensureTerminalScrollbarStyle();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -429,6 +437,7 @@ export default function TerminalEmulator({
|
|||||||
root,
|
root,
|
||||||
host,
|
host,
|
||||||
initialSnapshot: initialSnapshotRef.current,
|
initialSnapshot: initialSnapshotRef.current,
|
||||||
|
scrollback: scrollbackLinesRef.current,
|
||||||
theme: mountedThemeRef.current,
|
theme: mountedThemeRef.current,
|
||||||
});
|
});
|
||||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
|
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
shouldShowTerminalLoadingOverlay,
|
shouldShowTerminalLoadingOverlay,
|
||||||
type TerminalRendererReadyChange,
|
type TerminalRendererReadyChange,
|
||||||
} from "@/utils/terminal-renderer-readiness";
|
} from "@/utils/terminal-renderer-readiness";
|
||||||
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -157,6 +158,7 @@ export function TerminalPane({
|
|||||||
}: TerminalPaneProps) {
|
}: TerminalPaneProps) {
|
||||||
const isAppVisible = useAppVisible();
|
const isAppVisible = useAppVisible();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const { settings } = useAppSettings();
|
||||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
@@ -681,6 +683,7 @@ export function TerminalPane({
|
|||||||
streamKey={terminalStreamKey}
|
streamKey={terminalStreamKey}
|
||||||
testId="terminal-surface"
|
testId="terminal-surface"
|
||||||
xtermTheme={xtermTheme}
|
xtermTheme={xtermTheme}
|
||||||
|
scrollbackLines={settings.terminalScrollbackLines}
|
||||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||||
initialSnapshot={initialSnapshot}
|
initialSnapshot={initialSnapshot}
|
||||||
onRendererReadyChange={handleRendererReadyChange}
|
onRendererReadyChange={handleRendererReadyChange}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
const asyncStorageMock = vi.hoisted(() => ({
|
const asyncStorageMock = vi.hoisted(() => ({
|
||||||
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||||
@@ -87,6 +88,7 @@ describe("use-settings", () => {
|
|||||||
manageBuiltInDaemon: true,
|
manageBuiltInDaemon: true,
|
||||||
sendBehavior: "interrupt",
|
sendBehavior: "interrupt",
|
||||||
serviceUrlBehavior: "ask",
|
serviceUrlBehavior: "ask",
|
||||||
|
terminalScrollbackLines: 10_000,
|
||||||
releaseChannel: "stable",
|
releaseChannel: "stable",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -130,6 +132,7 @@ describe("use-settings", () => {
|
|||||||
theme: "dark",
|
theme: "dark",
|
||||||
sendBehavior: "interrupt",
|
sendBehavior: "interrupt",
|
||||||
serviceUrlBehavior: "ask",
|
serviceUrlBehavior: "ask",
|
||||||
|
terminalScrollbackLines: 10_000,
|
||||||
});
|
});
|
||||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
mod.APP_SETTINGS_KEY,
|
mod.APP_SETTINGS_KEY,
|
||||||
@@ -169,6 +172,7 @@ describe("use-settings", () => {
|
|||||||
theme: "light",
|
theme: "light",
|
||||||
sendBehavior: "interrupt",
|
sendBehavior: "interrupt",
|
||||||
serviceUrlBehavior: "ask",
|
serviceUrlBehavior: "ask",
|
||||||
|
terminalScrollbackLines: 10_000,
|
||||||
manageBuiltInDaemon: false,
|
manageBuiltInDaemon: false,
|
||||||
releaseChannel: "beta",
|
releaseChannel: "beta",
|
||||||
});
|
});
|
||||||
@@ -188,8 +192,58 @@ describe("use-settings", () => {
|
|||||||
theme: "light",
|
theme: "light",
|
||||||
sendBehavior: "interrupt",
|
sendBehavior: "interrupt",
|
||||||
serviceUrlBehavior: "ask",
|
serviceUrlBehavior: "ask",
|
||||||
|
terminalScrollbackLines: 10_000,
|
||||||
manageBuiltInDaemon: true,
|
manageBuiltInDaemon: true,
|
||||||
releaseChannel: "stable",
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
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 { queryClient as appQueryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
DEFAULT_DESKTOP_SETTINGS,
|
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_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
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 {
|
export interface AppSettings {
|
||||||
theme: ThemeName | "auto";
|
theme: ThemeName | "auto";
|
||||||
sendBehavior: SendBehavior;
|
sendBehavior: SendBehavior;
|
||||||
serviceUrlBehavior: ServiceUrlBehavior;
|
serviceUrlBehavior: ServiceUrlBehavior;
|
||||||
|
terminalScrollbackLines: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings extends AppSettings {
|
export interface Settings extends AppSettings {
|
||||||
@@ -37,6 +41,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
|||||||
theme: "auto",
|
theme: "auto",
|
||||||
sendBehavior: "interrupt",
|
sendBehavior: "interrupt",
|
||||||
serviceUrlBehavior: "ask",
|
serviceUrlBehavior: "ask",
|
||||||
|
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||||
@@ -73,11 +78,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
|||||||
const updateSettings = useCallback(
|
const updateSettings = useCallback(
|
||||||
async (updates: Partial<AppSettings>) => {
|
async (updates: Partial<AppSettings>) => {
|
||||||
try {
|
try {
|
||||||
const prev =
|
await saveAppSettings({ queryClient, updates });
|
||||||
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));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[AppSettings] Failed to save settings:", err);
|
console.error("[AppSettings] Failed to save settings:", err);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -122,7 +123,9 @@ export function useSettings(): UseSettingsReturn {
|
|||||||
if (updates.serviceUrlBehavior !== undefined) {
|
if (updates.serviceUrlBehavior !== undefined) {
|
||||||
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
|
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
|
||||||
}
|
}
|
||||||
|
if (updates.terminalScrollbackLines !== undefined) {
|
||||||
|
appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines;
|
||||||
|
}
|
||||||
const promises: Promise<void>[] = [];
|
const promises: Promise<void>[] = [];
|
||||||
if (Object.keys(appUpdates).length > 0) {
|
if (Object.keys(appUpdates).length > 0) {
|
||||||
promises.push(appSettings.updateSettings(appUpdates));
|
promises.push(appSettings.updateSettings(appUpdates));
|
||||||
@@ -171,11 +174,18 @@ export function useSettings(): UseSettingsReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function persistAppSettings(updates: Partial<AppSettings>): Promise<void> {
|
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 =
|
const current =
|
||||||
appQueryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
|
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
|
||||||
(await loadAppSettingsFromStorage());
|
(await loadAppSettingsFromStorage());
|
||||||
const next = { ...current, ...updates };
|
const next = { ...current, ...input.updates };
|
||||||
appQueryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(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;
|
result.serviceUrlBehavior = stored.serviceUrlBehavior;
|
||||||
}
|
}
|
||||||
|
const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines);
|
||||||
|
if (terminalScrollbackLines !== null) {
|
||||||
|
result.terminalScrollbackLines = terminalScrollbackLines;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +271,22 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
|
|||||||
return result;
|
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<{
|
async function loadLegacyDesktopSettingsFromStorage(): Promise<{
|
||||||
manageBuiltInDaemon?: boolean;
|
manageBuiltInDaemon?: boolean;
|
||||||
releaseChannel?: ReleaseChannel;
|
releaseChannel?: ReleaseChannel;
|
||||||
|
|||||||
@@ -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 type { ComponentType, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
View,
|
View,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -37,6 +38,7 @@ import { SettingsSection } from "@/screens/settings/settings-section";
|
|||||||
import {
|
import {
|
||||||
useAppSettings,
|
useAppSettings,
|
||||||
useSettings,
|
useSettings,
|
||||||
|
parseTerminalScrollbackLines,
|
||||||
type AppSettings,
|
type AppSettings,
|
||||||
type SendBehavior,
|
type SendBehavior,
|
||||||
type ServiceUrlBehavior,
|
type ServiceUrlBehavior,
|
||||||
@@ -209,6 +211,7 @@ interface GeneralSectionProps {
|
|||||||
handleThemeChange: (theme: AppSettings["theme"]) => void;
|
handleThemeChange: (theme: AppSettings["theme"]) => void;
|
||||||
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
||||||
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
||||||
|
handleTerminalScrollbackLinesChange: (lines: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThemeMenuItemProps {
|
interface ThemeMenuItemProps {
|
||||||
@@ -267,10 +270,35 @@ function GeneralSection({
|
|||||||
handleThemeChange,
|
handleThemeChange,
|
||||||
handleSendBehaviorChange,
|
handleSendBehaviorChange,
|
||||||
handleServiceUrlBehaviorChange,
|
handleServiceUrlBehaviorChange,
|
||||||
|
handleTerminalScrollbackLinesChange,
|
||||||
}: GeneralSectionProps) {
|
}: GeneralSectionProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const iconSize = theme.iconSize.md;
|
const iconSize = theme.iconSize.md;
|
||||||
const iconColor = theme.colors.foregroundMuted;
|
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 (
|
return (
|
||||||
<SettingsSection title="General">
|
<SettingsSection title="General">
|
||||||
@@ -350,6 +378,23 @@ function GeneralSection({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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>
|
</View>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
@@ -850,6 +895,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
[updateSettings],
|
[updateSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleTerminalScrollbackLinesChange = useCallback(
|
||||||
|
(terminalScrollbackLines: number) => {
|
||||||
|
void updateSettings({ terminalScrollbackLines });
|
||||||
|
},
|
||||||
|
[updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePlaybackTest = useCallback(async () => {
|
const handlePlaybackTest = useCallback(async () => {
|
||||||
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
||||||
return;
|
return;
|
||||||
@@ -1031,6 +1083,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
handleThemeChange={handleThemeChange}
|
handleThemeChange={handleThemeChange}
|
||||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||||
|
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "shortcuts":
|
case "shortcuts":
|
||||||
@@ -1233,6 +1286,19 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
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: {
|
placeholder: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
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");
|
const root = document.createElement("div");
|
||||||
root.style.width = `${input.width}px`;
|
root.style.width = `${input.width}px`;
|
||||||
root.style.height = `${input.height}px`;
|
root.style.height = `${input.height}px`;
|
||||||
@@ -101,6 +105,7 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
|
|||||||
root,
|
root,
|
||||||
host,
|
host,
|
||||||
initialSnapshot: null,
|
initialSnapshot: null,
|
||||||
|
scrollback: input.scrollback ?? 10_000,
|
||||||
theme: {
|
theme: {
|
||||||
background: "#0b0b0b",
|
background: "#0b0b0b",
|
||||||
foreground: "#e6e6e6",
|
foreground: "#e6e6e6",
|
||||||
@@ -163,6 +168,32 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("terminal emulator runtime in a real browser", () => {
|
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 () => {
|
it("reports a larger PTY size when the terminal container grows", async () => {
|
||||||
await page.viewport(900, 600);
|
await page.viewport(900, 600);
|
||||||
const mounted = createTerminalHost({ width: 360, height: 180 });
|
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", () => ({
|
vi.mock("@xterm/xterm", () => ({
|
||||||
Terminal: class Terminal {
|
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 {}
|
dispose(): void {}
|
||||||
|
refresh(): void {}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -63,7 +83,7 @@ interface StubTerminal {
|
|||||||
resize?: (cols: number, rows: number) => void;
|
resize?: (cols: number, rows: number) => void;
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
refresh?: (start: number, end: number) => void;
|
refresh?: (start: number, end: number) => void;
|
||||||
options?: { theme?: unknown };
|
options?: { theme?: unknown; scrollback?: number };
|
||||||
rows?: number;
|
rows?: number;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
}
|
}
|
||||||
@@ -118,6 +138,7 @@ describe("terminal-emulator-runtime", () => {
|
|||||||
(globalThis as { window?: { __paseoTerminal?: unknown } }).window = {
|
(globalThis as { window?: { __paseoTerminal?: unknown } }).window = {
|
||||||
__paseoTerminal: undefined,
|
__paseoTerminal: undefined,
|
||||||
};
|
};
|
||||||
|
terminalConstructorOptions.values = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -317,6 +338,26 @@ describe("terminal-emulator-runtime", () => {
|
|||||||
expect(refresh).toHaveBeenCalledWith(0, 11);
|
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", () => {
|
it("forces a refit when the page becomes visible again", () => {
|
||||||
const runtime = new TerminalEmulatorRuntime();
|
const runtime = new TerminalEmulatorRuntime();
|
||||||
const fitAndEmitResize = vi.fn();
|
const fitAndEmitResize = vi.fn();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface TerminalEmulatorRuntimeMountInput {
|
|||||||
root: HTMLDivElement;
|
root: HTMLDivElement;
|
||||||
host: HTMLDivElement;
|
host: HTMLDivElement;
|
||||||
initialSnapshot: TerminalState | null;
|
initialSnapshot: TerminalState | null;
|
||||||
|
scrollback: number;
|
||||||
theme: ITheme;
|
theme: ITheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +179,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
overviewRuler: {
|
overviewRuler: {
|
||||||
width: 8,
|
width: 8,
|
||||||
},
|
},
|
||||||
scrollback: 10_000,
|
scrollback: input.scrollback,
|
||||||
theme: withOverviewRulerBorderHidden(input.theme),
|
theme: withOverviewRulerBorderHidden(input.theme),
|
||||||
});
|
});
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
@@ -578,6 +579,22 @@ export class TerminalEmulatorRuntime {
|
|||||||
this.refreshVisibleRows();
|
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 {
|
focus(): void {
|
||||||
this.terminal?.focus();
|
this.terminal?.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user