diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 1a0afbab1026..5d341ce5274a 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -14,6 +14,7 @@ export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]' export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]' export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]' +export const settingsTerminalFpsSelector = '[data-action="settings-terminal-fps"]' export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]' export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]' export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]' diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index 9fbcf79f5ee7..fccbc8f05d8e 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -1,6 +1,7 @@ import { test, expect, settingsKey } from "../fixtures" import { closeDialog, openSettings } from "../actions" import { + promptSelector, settingsColorSchemeSelector, settingsFontSelector, settingsLanguageSelectSelector, @@ -9,12 +10,15 @@ import { settingsNotificationsPermissionsSelector, settingsReleaseNotesSelector, settingsSoundsAgentSelector, + settingsTerminalFpsSelector, settingsSoundsAgentEnabledSelector, settingsSoundsErrorsSelector, settingsSoundsPermissionsSelector, settingsThemeSelector, + terminalSelector, settingsUpdatesStartupSelector, } from "../selectors" +import { terminalToggleKey } from "../utils" test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => { await gotoSession() @@ -477,3 +481,70 @@ test("toggling release notes switch updates localStorage", async ({ page, gotoSe expect(stored?.general?.releaseNotes).toBe(false) }) + +test("changing terminal FPS persists in localStorage", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + const input = dialog.locator(settingsTerminalFpsSelector) + await expect(input).toBeVisible() + + const initialFps = await input.inputValue() + expect(initialFps).toBe("15") + + await input.fill("30") + await page.waitForTimeout(100) + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, settingsKey) + + expect(stored?.appearance?.terminalFps).toBe(30) +}) + +test("terminal FPS value is retrieved after page reload", async ({ page, gotoSession }) => { + await page.addInitScript(() => { + const settings = JSON.parse(localStorage.getItem("settings.v3") || "{}") + settings.appearance = { ...(settings.appearance || {}), terminalFps: 60 } + localStorage.setItem("settings.v3", JSON.stringify(settings)) + }) + + await gotoSession() + + const dialog = await openSettings(page) + const input = dialog.locator(settingsTerminalFpsSelector) + await expect(input).toBeVisible() + + const fpsValue = await input.inputValue() + expect(fpsValue).toBe("60") +}) + +test("changing terminal FPS updates all open terminals", async ({ page, gotoSession }) => { + await gotoSession() + + const terminals = page.locator(terminalSelector) + const initiallyOpen = await terminals.first().isVisible() + if (!initiallyOpen) { + await page.keyboard.press(terminalToggleKey) + } + + await page.locator(promptSelector).click() + await page.keyboard.press("Control+Alt+T") + + await expect(terminals).toHaveCount(2) + await expect(terminals.first().locator("textarea")).toHaveCount(1) + await expect(terminals.nth(1).locator("textarea")).toHaveCount(1) + + const dialog = await openSettings(page) + const input = dialog.locator(settingsTerminalFpsSelector) + await expect(input).toBeVisible() + + await input.fill("1") + await page.waitForTimeout(100) + await closeDialog(page, dialog) + + await expect(terminals).toHaveCount(2) + await expect(terminals.first().locator("textarea")).toHaveCount(1) + await expect(terminals.nth(1).locator("textarea")).toHaveCount(1) +}) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index d5a0b813b6c2..00b81de9a456 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -2,6 +2,7 @@ import { Component, Show, createMemo, createResource, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" +import { InlineInput } from "@opencode-ai/ui/inline-input" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { Tooltip } from "@opencode-ai/ui/tooltip" @@ -250,6 +251,30 @@ export const SettingsGeneral: Component = () => { )} + + + { + const value = Math.min(240, Math.max(0, parseInt(e.currentTarget.value, 10) || 0)) + settings.appearance.setTerminalFps(value) + }} + onInput={(e) => { + const value = parseInt(e.currentTarget.value, 10) + if (!isNaN(value) && value >= 0 && value <= 240) { + settings.appearance.setTerminalFps(value) + } + }} + class="w-20 text-right" + /> + ) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 14413dfda677..37cd3fb75170 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,5 +1,5 @@ import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" -import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" +import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { monoFontFamily, useSettings } from "@/context/settings" @@ -42,6 +42,20 @@ type TerminalColors = { selectionBackground: string } +interface GhosttyTerminalImpl { + startRenderLoop: () => void + isDisposed: boolean + isOpen: boolean + renderer: { + render: (wasmTerm: unknown, force: boolean, viewportY: number, term: unknown, scrollbarOpacity: number) => void + } + wasmTerm: { getCursor: () => { x: number; y: number } } + lastCursorY: number + cursorMoveEmitter: { fire: () => void } + viewportY: number + scrollbarOpacity: number +} + const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { light: { background: "#fcfcfc", @@ -371,6 +385,35 @@ export const Terminal = (props: TerminalProps) => { fitAddon = fit serializeAddon = serializer + const termImpl = t as unknown as GhosttyTerminalImpl + + let lastFrame = 0 + let frameId: number | undefined + const interval = createMemo(() => { + const fps = settings.appearance.terminalFps() + return fps === 0 ? 0 : 1000 / fps + }) + + termImpl.startRenderLoop = function () { + const throttledLoop = () => { + if (termImpl.isDisposed || !termImpl.isOpen) return + + const now = performance.now() + const ms = interval() + if (ms === 0 || now - lastFrame >= ms) { + if (ms > 0) lastFrame = now + termImpl.renderer.render(termImpl.wasmTerm, false, termImpl.viewportY, termImpl, termImpl.scrollbarOpacity) + const cursor = termImpl.wasmTerm.getCursor() + if (cursor.y !== termImpl.lastCursorY) { + termImpl.lastCursorY = cursor.y + termImpl.cursorMoveEmitter.fire() + } + } + frameId = requestAnimationFrame(throttledLoop) + } + throttledLoop() + } + t.open(container) useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick }) @@ -395,6 +438,13 @@ export const Terminal = (props: TerminalProps) => { }) cleanups.push(() => disposeIfDisposable(onKey)) + cleanups.push(() => { + if (frameId !== undefined) { + cancelAnimationFrame(frameId) + } + }) + + const startResize = () => { fit.observeResize() handleResize = scheduleFit diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index fbcd0a851845..079f6e3fdd2f 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -29,6 +29,7 @@ export interface Settings { appearance: { fontSize: number font: string + terminalFps: number } keybinds: Record permissions: { @@ -49,6 +50,7 @@ const defaultSettings: Settings = { appearance: { fontSize: 14, font: "ibm-plex-mono", + terminalFps: 15, }, keybinds: {}, permissions: { @@ -136,6 +138,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setFont(value: string) { setStore("appearance", "font", value) }, + terminalFps: createMemo(() => store.appearance?.terminalFps ?? defaultSettings.appearance.terminalFps), + setTerminalFps(value: number) { + setStore("appearance", "terminalFps", value) + }, }, keybinds: { get: (action: string) => store.keybinds?.[action], diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index cb42b016f1fb..9af96ff20956 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -603,6 +603,8 @@ export const dict = { "settings.general.row.theme.description": "Customise how OpenCode is themed.", "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Customise the mono font used in code blocks", + "settings.general.row.terminalFps.title": "Terminal FPS", + "settings.general.row.terminalFps.description": "The maximum terminal framerate. 0 = unlimited (uses more CPU).", "settings.general.row.wayland.title": "Use native Wayland", "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",