From acedd4c68fad7d9d87af261ecdd9ad6f9e4740a6 Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Mon, 16 Feb 2026 14:45:26 -0600 Subject: [PATCH 1/5] limited render fps of terminal to lower CPU usage --- packages/app/src/components/terminal.tsx | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 14413dfda677..735c2620e6ef 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -371,6 +371,49 @@ export const Terminal = (props: TerminalProps) => { fitAddon = fit serializeAddon = serializer + const termImpl = t as unknown as { + 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 } + animationFrameId: number | undefined + viewportY: number + scrollbarOpacity: number + } + + let lastFrameTime = 0 + const minFrameInterval = 1000 / 15 + + termImpl.startRenderLoop = function () { + const throttledLoop = () => { + if (termImpl.isDisposed || !termImpl.isOpen) return + + const now = performance.now() + if (now - lastFrameTime >= minFrameInterval) { + lastFrameTime = 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() + } + } + termImpl.animationFrameId = requestAnimationFrame(throttledLoop) + } + throttledLoop() + } + t.open(container) useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick }) From 2eae2b299590e48f6278561fb4fcfb59a4135962 Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Mon, 16 Feb 2026 15:12:28 -0600 Subject: [PATCH 2/5] added configuration ux --- .../app/src/components/settings-general.tsx | 25 +++++++++++++++++++ packages/app/src/components/terminal.tsx | 12 ++++++--- packages/app/src/context/settings.tsx | 6 +++++ packages/app/src/i18n/en.ts | 2 ++ 4 files changed, 41 insertions(+), 4 deletions(-) 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 735c2620e6ef..6e1b249553c3 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" @@ -393,15 +393,19 @@ export const Terminal = (props: TerminalProps) => { } let lastFrameTime = 0 - const minFrameInterval = 1000 / 15 + const minFrameInterval = 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() - if (now - lastFrameTime >= minFrameInterval) { - lastFrameTime = now + const interval = minFrameInterval() + if (interval === 0 || now - lastFrameTime >= interval) { + if (interval > 0) lastFrameTime = now termImpl.renderer.render(termImpl.wasmTerm, false, termImpl.viewportY, termImpl, termImpl.scrollbarOpacity) const cursor = termImpl.wasmTerm.getCursor() if (cursor.y !== termImpl.lastCursorY) { 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..e1a4425c11dc 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": "Terminal rendering speed. 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.", From 815ab71a93fe8640951e11b78b25f8d0006e7444 Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Mon, 16 Feb 2026 15:41:05 -0600 Subject: [PATCH 3/5] cleanup based on code review standards --- packages/app/src/components/terminal.tsx | 48 +++++++++++++----------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 6e1b249553c3..fa8ff679ac49 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -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,28 +385,13 @@ export const Terminal = (props: TerminalProps) => { fitAddon = fit serializeAddon = serializer - const termImpl = t as unknown as { - 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 } - animationFrameId: number | undefined - viewportY: number - scrollbarOpacity: number - } + // Monkey-patch the terminal's render loop to throttle FPS for CPU usage + // IMPORTANT: This must happen BEFORE t.open(container) so the patched function is used + // when the render loop starts + const termImpl = t as unknown as GhosttyTerminalImpl let lastFrameTime = 0 + let animationFrameId: number | undefined const minFrameInterval = createMemo(() => { const fps = settings.appearance.terminalFps() return fps === 0 ? 0 : 1000 / fps @@ -413,7 +412,7 @@ export const Terminal = (props: TerminalProps) => { termImpl.cursorMoveEmitter.fire() } } - termImpl.animationFrameId = requestAnimationFrame(throttledLoop) + animationFrameId = requestAnimationFrame(throttledLoop) } throttledLoop() } @@ -442,6 +441,13 @@ export const Terminal = (props: TerminalProps) => { }) cleanups.push(() => disposeIfDisposable(onKey)) + cleanups.push(() => { + if (animationFrameId !== undefined) { + cancelAnimationFrame(animationFrameId) + } + }) + + const startResize = () => { fit.observeResize() handleResize = scheduleFit From 47ac83a828f690c49272b7520412d526564ac6f2 Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Mon, 16 Feb 2026 16:09:57 -0600 Subject: [PATCH 4/5] added e2e tests --- packages/app/e2e/selectors.ts | 1 + packages/app/e2e/settings/settings.spec.ts | 72 ++++++++++++++++++++++ packages/app/src/components/terminal.tsx | 21 +++---- packages/app/src/i18n/en.ts | 2 +- 4 files changed, 83 insertions(+), 13 deletions(-) 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..2f6b0534417b 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,71 @@ 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/terminal.tsx b/packages/app/src/components/terminal.tsx index fa8ff679ac49..37cd3fb75170 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -385,14 +385,11 @@ export const Terminal = (props: TerminalProps) => { fitAddon = fit serializeAddon = serializer - // Monkey-patch the terminal's render loop to throttle FPS for CPU usage - // IMPORTANT: This must happen BEFORE t.open(container) so the patched function is used - // when the render loop starts const termImpl = t as unknown as GhosttyTerminalImpl - let lastFrameTime = 0 - let animationFrameId: number | undefined - const minFrameInterval = createMemo(() => { + let lastFrame = 0 + let frameId: number | undefined + const interval = createMemo(() => { const fps = settings.appearance.terminalFps() return fps === 0 ? 0 : 1000 / fps }) @@ -402,9 +399,9 @@ export const Terminal = (props: TerminalProps) => { if (termImpl.isDisposed || !termImpl.isOpen) return const now = performance.now() - const interval = minFrameInterval() - if (interval === 0 || now - lastFrameTime >= interval) { - if (interval > 0) lastFrameTime = 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) { @@ -412,7 +409,7 @@ export const Terminal = (props: TerminalProps) => { termImpl.cursorMoveEmitter.fire() } } - animationFrameId = requestAnimationFrame(throttledLoop) + frameId = requestAnimationFrame(throttledLoop) } throttledLoop() } @@ -442,8 +439,8 @@ export const Terminal = (props: TerminalProps) => { cleanups.push(() => disposeIfDisposable(onKey)) cleanups.push(() => { - if (animationFrameId !== undefined) { - cancelAnimationFrame(animationFrameId) + if (frameId !== undefined) { + cancelAnimationFrame(frameId) } }) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index e1a4425c11dc..9af96ff20956 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -604,7 +604,7 @@ export const dict = { "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": "Terminal rendering speed. 0 = unlimited (uses more CPU).", + "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.", From 6da0d86192cd77cbf4c1570800f5a52741b58294 Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Mon, 16 Feb 2026 17:12:35 -0600 Subject: [PATCH 5/5] touch a file to restart tests --- packages/app/e2e/settings/settings.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index 2f6b0534417b..fccbc8f05d8e 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -542,7 +542,6 @@ test("changing terminal FPS updates all open terminals", async ({ page, gotoSess await input.fill("1") await page.waitForTimeout(100) - await closeDialog(page, dialog) await expect(terminals).toHaveCount(2)