Compare commits

...
11 changed files with 238 additions and 63 deletions
+4 -1
View File
@@ -232,7 +232,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
Effect.gen(function* () {
const options = {
externalOutputMode: "passthrough",
targetFps: 60,
targetFps: config.targetFps ?? 60,
maxFps: config.maxFps ?? 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
@@ -598,6 +599,8 @@ function App(props: { pair?: DialogPairCredentials }) {
createEffect(() => {
renderer.useMouse = config.data.mouse
renderer.targetFps = config.data.targetFps ?? 60
renderer.maxFps = config.data.maxFps ?? 60
})
let active: { id: string; title?: string } | undefined
+6
View File
@@ -230,6 +230,12 @@ export const Info = Schema.Struct({
description: "Experimental features that may change or be removed at any time",
}),
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
targetFps: Schema.optional(Schema.Finite.check(Schema.isGreaterThan(0))).annotate({
description: "Target animation frame rate (default: 60 in the TUI, 30 in Mini)",
}),
maxFps: Schema.optional(Schema.Finite.check(Schema.isGreaterThan(0))).annotate({
description: "Maximum frame rate for requested redraws (default: 60)",
}),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
cursor: Schema.optional(Cursor),
})
+2 -2
View File
@@ -156,8 +156,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
const mono = miniSettings.mono
const renderer = await createCliRenderer({
stdin: input.host.terminal.stdin,
targetFps: 30,
maxFps: 60,
targetFps: tuiConfig.targetFps ?? 30,
maxFps: tuiConfig.maxFps ?? 60,
useMouse: false,
autoFocus: false,
openConsoleOnError: false,
+1 -1
View File
@@ -397,7 +397,7 @@ export type FormCancel = {
export type RunTuiConfig = Pick<
Config.Resolved,
"keybinds" | "leader" | "theme" | "mini" | "prompt" | "session" | "cursor" | "animations"
"keybinds" | "leader" | "theme" | "mini" | "prompt" | "session" | "cursor" | "animations" | "targetFps" | "maxFps"
>
export type MiniSettings = {
+24 -50
View File
@@ -1,3 +1,4 @@
import { useRenderer } from "@opentui/solid"
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
type AnimatableValue = number | readonly number[]
@@ -27,11 +28,6 @@ type ValueState = {
from: number[]
}
type AnimationTask = (now: number) => boolean
const tasks = new Set<AnimationTask>()
let timer: ReturnType<typeof setInterval> | undefined
const smoothstep = (progress: number) => progress * progress * (3 - 2 * progress)
export function spring(options: { visualDuration: number; restDelta?: number; restSpeed?: number }): Transition {
@@ -58,30 +54,25 @@ export function createAnimatable<T extends AnimatableTarget>(
enabled?: Accessor<boolean>
},
) {
const renderer = useRenderer()
const enabled = options.enabled ?? (() => true)
const state = new Map<string, ValueState>()
const [value, setValue] = createSignal(clone(initial), { equals: false })
let target = clone(initial)
let started = performance.now()
let previous = started
let elapsed = 0
let running = false
rebuild(initial)
const step: AnimationTask = (now) => {
if (!enabled()) {
jump(target)
return false
}
const step = async (deltaTime: number) => {
if (!running) return
if (!enabled()) return jump(target)
const delta = Math.min(0.05, (now - previous) / 1_000)
previous = now
const moving = options.transition.type === "spring" ? advanceSpring(delta) : advanceTween(now)
if (!moving) {
jump(target)
return false
}
elapsed += deltaTime
const moving =
options.transition.type === "spring" ? advanceSpring(Math.min(0.05, deltaTime / 1_000)) : advanceTween()
if (!moving) return jump(target)
setValue(() => read())
return true
}
function animate(next: T) {
@@ -89,25 +80,30 @@ export function createAnimatable<T extends AnimatableTarget>(
target = clone(next)
if (!enabled() || !sameShape(next)) return jump(next)
started = performance.now()
previous = started
elapsed = 0
for (const [key, current] of state) {
current.from = [...current.value]
current.target = values(next[key]!)
}
if (settled()) return jump(next)
schedule(step)
if (running) return
running = true
renderer.setFrameCallback(step)
renderer.requestLive()
}
function jump(next: T) {
target = clone(next)
unschedule(step)
stop()
rebuild(next)
setValue(() => clone(next))
}
function stop() {
unschedule(step)
if (!running) return
running = false
renderer.removeFrameCallback(step)
renderer.dropLive()
}
function rebuild(next: T) {
@@ -122,8 +118,7 @@ export function createAnimatable<T extends AnimatableTarget>(
from: [...value],
})
}
started = performance.now()
previous = started
elapsed = 0
}
function sameShape(next: T) {
@@ -174,10 +169,10 @@ export function createAnimatable<T extends AnimatableTarget>(
return moving
}
function advanceTween(now: number) {
function advanceTween() {
const transition = options.transition
if (transition.type !== "tween") return false
const progress = Math.min(1, (now - started) / 1_000 / Math.max(0.001, transition.duration))
const progress = Math.min(1, elapsed / 1_000 / Math.max(0.001, transition.duration))
const eased = transition.ease(progress)
for (const current of state.values())
current.value.forEach((_, index) => {
@@ -212,24 +207,3 @@ function clone<T extends AnimatableTarget>(target: T) {
Object.entries(target).map(([key, value]) => [key, typeof value === "number" ? value : [...value]]),
) as T
}
function schedule(task: AnimationTask) {
tasks.add(task)
if (timer) return
timer = setInterval(tick, 16)
}
function unschedule(task: AnimationTask) {
tasks.delete(task)
if (tasks.size > 0 || !timer) return
clearInterval(timer)
timer = undefined
}
function tick() {
const now = performance.now()
for (const task of tasks) if (!task(now)) tasks.delete(task)
if (tasks.size > 0 || !timer) return
clearInterval(timer)
timer = undefined
}
+42
View File
@@ -10,6 +10,48 @@ import { tmpdir } from "./fixture/fixture"
import { createAppFixture } from "./fixture/app"
import type { PluginInfo } from "@opencode-ai/client"
test("applies frame rates to the handed-off renderer and reloads edits and removal", async () => {
await using state = await tmpdir()
const file = Bun.file(path.join(state.path, "cli.json"))
await file.write(JSON.stringify({ targetFps: 24, maxFps: 90, animations: false }))
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, targetFps: 15, maxFps: 30 })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const calls = createFetch(undefined, createEventStream())
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { path: file.name, get: () => file.json(), update: () => file.json() },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await ready.promise
await setup.waitForFrame(() => setup.renderer.targetFps === 24 && setup.renderer.maxFps === 90)
expect(setup.renderer.targetFps).toBe(24)
expect(setup.renderer.maxFps).toBe(90)
await file.write(JSON.stringify({ targetFps: 120, maxFps: 45, animations: false }))
await setup.waitFor(() => setup.renderer.targetFps === 120 && setup.renderer.maxFps === 45)
expect(setup.renderer.targetFps).toBe(120)
expect(setup.renderer.maxFps).toBe(45)
await file.write(JSON.stringify({ animations: false }))
await setup.waitFor(() => setup.renderer.targetFps === 60 && setup.renderer.maxFps === 60)
expect(setup.renderer.targetFps).toBe(60)
expect(setup.renderer.maxFps).toBe(60)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task
await server.stop()
}
})
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
await using state = await tmpdir()
const requested = Promise.withResolvers<void>()
@@ -1,13 +1,26 @@
import { afterEach, describe, expect, jest, test } from "bun:test"
import { createRoot } from "solid-js"
import { ManualClock } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
import { createMarquee, createTabMarquee } from "../../src/component/session-tabs"
afterEach(() => jest.useRealTimers())
async function setup<T>(create: () => T) {
let marquee!: T
const app = await testRender(
() => {
marquee = create()
return null
},
{ clock: new ManualClock() },
)
return { marquee, dispose: () => app.renderer.destroy() }
}
describe("session tab marquee", () => {
test("starts for the hovered width and resets when the next tab fits", () => {
test("starts for the hovered width and resets when the next tab fits", async () => {
const scope = await setup(() => createMarquee(() => false))
jest.useFakeTimers()
const scope = createRoot((dispose) => ({ marquee: createMarquee(() => false), dispose }))
scope.marquee.enter("first", "opencode", 6)
expect(scope.marquee.active()).toBe("first")
@@ -23,9 +36,9 @@ describe("session tab marquee", () => {
scope.dispose()
})
test("stops after one cycle", () => {
test("stops after one cycle", async () => {
const scope = await setup(() => createMarquee(() => false))
jest.useFakeTimers()
const scope = createRoot((dispose) => ({ marquee: createMarquee(() => false), dispose }))
scope.marquee.enter("first", "opencode", 6)
jest.advanceTimersByTime(1_400)
@@ -36,9 +49,9 @@ describe("session tab marquee", () => {
scope.dispose()
})
test("resets immediately after leaving", () => {
test("resets immediately after leaving", async () => {
const scope = await setup(() => createMarquee(() => false))
jest.useFakeTimers()
const scope = createRoot((dispose) => ({ marquee: createMarquee(() => false), dispose }))
scope.marquee.enter("first", "opencode", 6)
jest.advanceTimersByTime(700)
@@ -50,9 +63,9 @@ describe("session tab marquee", () => {
scope.dispose()
})
test("resets when the pointer leaves the tab rail", () => {
test("resets when the pointer leaves the tab rail", async () => {
const scope = await setup(() => createTabMarquee(() => false))
jest.useFakeTimers()
const scope = createRoot((dispose) => ({ marquee: createTabMarquee(() => false), dispose }))
scope.marquee.enter("first", "opencode", 6)
jest.advanceTimersByTime(700)
+20
View File
@@ -9,6 +9,26 @@ import { CommandMap, Definitions } from "../src/config/v1/keybind"
const decodeInfo = Schema.decodeUnknownSync(Info)
test.each(["targetFps", "maxFps"])("validates optional positive finite %s", (key) => {
expect(decodeInfo({})).toEqual({})
for (const value of [0.5, 60, 1000]) {
expect(decodeInfo({ [key]: value })).toEqual({ [key]: value })
}
for (const value of [0, -1, NaN, Infinity, -Infinity, "60", null]) {
expect(() => decodeInfo({ [key]: value })).toThrow()
}
})
test("resolves independent frame rate settings without imposing defaults", () => {
expect(resolve(decodeInfo({ targetFps: 120, maxFps: 30 }), { terminalSuspend: true })).toMatchObject({
targetFps: 120,
maxFps: 30,
})
const defaults = resolve({}, { terminalSuspend: true })
expect(defaults.targetFps).toBeUndefined()
expect(defaults.maxFps).toBeUndefined()
})
test("validates the three explicit diff source defaults", () => {
for (const source of ["branch", "committed", "working"] as const) {
expect(decodeInfo({ diffs: { source } })).toEqual({ diffs: { source } })
@@ -31,12 +31,16 @@ describe("run runtime boot", () => {
theme: { mode: "light" },
leader: { timeout: 450 },
cursor: { style: "underline", blinking: false },
targetFps: 24,
maxFps: 120,
}),
)
expect(result.theme).toEqual({ mode: "light" })
expect(result.leader.timeout).toBe(450)
expect(result.cursor).toEqual({ style: "underline", blinking: false })
expect(result.targetFps).toBe(24)
expect(result.maxFps).toBe(120)
expect(resolveMiniSettings(result)).toEqual({
thinking: "hide",
shell_output: "hide",
+97
View File
@@ -0,0 +1,97 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { ManualClock } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
import { createSignal, Show } from "solid-js"
import { createAnimatable, spring, tween } from "../../src/ui/animation"
test("tweens advance together on renderer frames and release the renderer when settled", async () => {
const clock = new ManualClock()
const motions: ReturnType<typeof createAnimatable<{ value: number }>>[] = []
const app = await testRender(
() => {
const first = createAnimatable({ value: 0 }, { transition: tween({ duration: 0.1 }) })
const second = createAnimatable({ value: 0 }, { transition: tween({ duration: 0.1 }) })
motions.push(first, second)
return (
<text>
{first.value().value} {second.value().value}
</text>
)
},
{ clock, targetFps: 20, maxFps: 20, width: 20, height: 1 },
)
try {
await app.renderOnce()
motions.forEach((motion) => motion.animate({ value: 1 }))
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
expect(app.renderer.isRunning).toBe(true)
clock.advance(49)
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
expect(motions.map((motion) => motion.value().value)).toEqual([0, 0])
clock.advance(1)
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
expect(motions.map((motion) => motion.value().value)).toEqual([0.5, 0.5])
expect(app.captureCharFrame()).toContain("0.5 0.5")
clock.advance(50)
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
expect(motions.map((motion) => motion.value().value)).toEqual([1, 1])
expect(app.renderer.isRunning).toBe(false)
motions[0]!.animate({ value: 0 })
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
expect(app.renderer.isRunning).toBe(true)
motions[0]!.jump({ value: 0 })
expect(app.renderer.isRunning).toBe(false)
} finally {
app.renderer.destroy()
}
})
test("springs settle on renderer frames and disabled or disposed animations stop", async () => {
const clock = new ManualClock()
const [enabled, setEnabled] = createSignal(true)
const [mounted, setMounted] = createSignal(true)
let motion!: ReturnType<typeof createAnimatable<{ value: number }>>
const app = await testRender(
() => (
<Show when={mounted()}>
{(() => {
motion = createAnimatable({ value: 0 }, { enabled, transition: spring({ visualDuration: 0.2 }) })
return <text>{motion.value().value}</text>
})()}
</Show>
),
{ clock, targetFps: 50, maxFps: 50, width: 20, height: 1 },
)
try {
await app.renderOnce()
motion.animate({ value: 1 })
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
for (let frame = 0; frame < 60; frame++) {
clock.advance(20)
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
}
expect(motion.value().value).toBe(1)
expect(app.renderer.isRunning).toBe(false)
motion.animate({ value: 2 })
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
setEnabled(false)
expect(motion.value().value).toBe(2)
expect(app.renderer.isRunning).toBe(false)
setEnabled(true)
motion.animate({ value: 3 })
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
expect(app.renderer.isRunning).toBe(true)
setMounted(false)
expect(app.renderer.isRunning).toBe(false)
} finally {
app.renderer.destroy()
}
})
@@ -106,6 +106,22 @@ Set scroll speed and acceleration:
| `speed` | number, at least `0.001` | Sets the distance per input tick. |
| `acceleration` | boolean | Speeds up repeated scrolling. |
## Frame rate
Set the renderer frame rates for the TUI and Mini:
```json title="cli.json"
{
"targetFps": 30,
"maxFps": 60
}
```
- `targetFps` controls continuous rendering and animations. Defaults to `60` in the TUI and `30` in Mini.
- `maxFps` limits requested redraws, such as those triggered by input. Defaults to `60` in both.
Both accept positive finite numbers. The TUI applies edits to these settings live; removing a setting restores its default.
## Attention
Configure notifications and sounds: