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.",