mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-06 08:56:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
353fb69c9b |
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user