Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/e2e/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]'
Expand Down
71 changes: 71 additions & 0 deletions packages/app/e2e/settings/settings.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test, expect, settingsKey } from "../fixtures"
import { closeDialog, openSettings } from "../actions"
import {
promptSelector,
settingsColorSchemeSelector,
settingsFontSelector,
settingsLanguageSelectSelector,
Expand All @@ -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()
Expand Down Expand Up @@ -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)
})
25 changes: 25 additions & 0 deletions packages/app/src/components/settings-general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -250,6 +251,30 @@ export const SettingsGeneral: Component = () => {
)}
</Select>
</SettingsRow>

<SettingsRow
title={language.t("settings.general.row.terminalFps.title")}
description={language.t("settings.general.row.terminalFps.description")}
>
<InlineInput
data-action="settings-terminal-fps"
type="number"
min={0}
max={240}
value={settings.appearance.terminalFps()}
onBlur={(e) => {
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"
/>
</SettingsRow>
</div>
</div>
)
Expand Down
52 changes: 51 additions & 1 deletion packages/app/src/components/terminal.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 })

Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/context/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface Settings {
appearance: {
fontSize: number
font: string
terminalFps: number
}
keybinds: Record<string, string>
permissions: {
Expand All @@ -49,6 +50,7 @@ const defaultSettings: Settings = {
appearance: {
fontSize: 14,
font: "ibm-plex-mono",
terminalFps: 15,
},
keybinds: {},
permissions: {
Expand Down Expand Up @@ -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],
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading