chore(tui): use OpenTUI 0.8.0 (#60385)

This commit is contained in:
Simon Klee
2026-10-30 05:42:46 +01:00
committed by GitHub
Unverified
parent a08b40eaea
commit 13e8186410
10 changed files with 106 additions and 184 deletions
+24 -20
View File
@@ -517,8 +517,8 @@
},
"peerDependencies": {
"@opencode/theme": "workspace:*",
"@opentui/core": "0.7.0",
"@opentui/solid": "0.7.0",
"@opentui/core": ">=0.8.0",
"@opentui/solid": ">=0.8.0",
"electron": "*",
"solid-js": ">=1.9.0",
"vite": ">=6",
@@ -1022,9 +1022,9 @@
"overrides": {
"@astrojs/starlight": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@opentui/core": "0.7.0",
"@opentui/keymap": "0.7.0",
"@opentui/solid": "0.7.0",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"astro": "catalog:",
@@ -1045,9 +1045,9 @@
"@lydell/node-pty": "1.2.0-beta.12",
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@opentui/core": "0.7.0",
"@opentui/keymap": "0.7.0",
"@opentui/solid": "0.7.0",
"@opentui/core": "0.8.0",
"@opentui/keymap": "0.8.0",
"@opentui/solid": "0.8.0",
"@pierre/diffs": "1.6.1",
"@pierre/highlights": "0.2.0",
"@playwright/test": "1.60.0",
@@ -1988,27 +1988,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.7.0", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.7.0", "@opentui/core-darwin-x64": "0.7.0", "@opentui/core-linux-arm64": "0.7.0", "@opentui/core-linux-arm64-musl": "0.7.0", "@opentui/core-linux-x64": "0.7.0", "@opentui/core-linux-x64-musl": "0.7.0", "@opentui/core-win32-arm64": "0.7.0", "@opentui/core-win32-x64": "0.7.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-2iWBw9oo+ZJfPbOWeTLXfDV63yTDRfclxltI4IgTa6n7OB90x5xplLMH3y9DXhnWibzTui00GbK3ZCwKfUU1SQ=="],
"@opentui/core": ["@opentui/core@0.8.0", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.8.0", "@opentui/core-darwin-x64": "0.8.0", "@opentui/core-linux-arm64": "0.8.0", "@opentui/core-linux-arm64-musl": "0.8.0", "@opentui/core-linux-x64": "0.8.0", "@opentui/core-linux-x64-musl": "0.8.0", "@opentui/core-win32-arm64": "0.8.0", "@opentui/core-win32-x64": "0.8.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-LwnIT7k+rGelridv7390enJXZpNuQYKIS3NdttJyoLPjbRFPyHnAKkCHk6X8MRS64vcSjOeVq9sl7XLWII16jQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.7.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-meEyZVUeCIauqHLWo/IsSlKzZPTnGIEjRLigefSZbrSAcOiMryd3OcUWvSByIXpYb9UPOpW0YxKKfVwepb9u2A=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.8.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Cp+BL1DsRZhUG56AuyXlH8DA4dsKfZ3Qgh1XMYDGokkC4p9uv8kdNNhyzmURAFa2LK77XqVv1Zhwr/6DqqpMlw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.7.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZQ4OfAua5CtUv3hpZ81zhNTTLULbg0leQ/3eupjefityGP6163eS56IzymTWuiM/mVD/7Ad5bicH0I2JXhr1g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.8.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jp4pzdIuKsS3XYq9xuWuAO52tTj9gnWeeoI3bg456CF6XnY2yQMasWOd/WmjfvYfAaJzGDFa4DNXhByBMPVuTw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.7.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/u+Kalzkz8NntUW5zjypgt9fnzeKohLG9YMEas7n98KvyaYScrtO4Mdj5iv/STNB3IfocJOChzYAbO6C8TmlPQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.8.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-b6D/VP9y0Z18tO86GSTp9BBykgR6r5CQM1ol9UMJfUt+PjN5HGksbOCDTMIQufXvt4WIUFHrSBProysJRFzbYw=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.7.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-BkTn+WLf9OPR4KhBnuOki+R/cgk2qwbL49eQn5fEYPeiJD4HPalGbEZDea59UjwNEJ7rqKq2yuPAXQjkDZ6y3w=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.8.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-8ZPl/2DChcS7XIVRPIazigXaMNyxa2bwvuzPprNbzT0+cVJLIoGC19pjiV3bI3oVXBPVMNqgIcppOrEHBhlouw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.7.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OD2tGl1r27zcgCsuc+TYU5e+qhrbsRSI0YUBEb0ebQo8P6Ps2FL9LFenH9om4fddCh06G9Zd45ekJMhlDrGD3Q=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.8.0", "", { "os": "linux", "cpu": "x64" }, "sha512-aXBNIdE6iMf58/Tdnd6UzXeFjYfaHzQ6E0nukjLe2ir41/meJJYEAHUQ62EBr/gxvuCNZHTBQB4T9BzF7PdENQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.7.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-HrJbcAIuayzQmWVzv+cjez37fNHL9FDoHmjwxAb4iK5GTjl89FZCxeKopyua14RA6WpP8/zUJQi8U16mm6XptA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.8.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-xGEYw6wkX0sPwPx6RosQZanuWF0+5cIup2alhHHQQfKJkcLS8r8NBWlcy2LK9CoEWH6SBtNXC+9Y5IrryCOvJQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.7.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8yqBoRsDbvKl2kGX0O8t5kpnVEnbzs+2qUZlJ8sbeA3Z+73WFj36G+clUIMVxpspOu78iKXlD84NIwTxjZUPKw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.8.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-XtK0QMyIiqeDmrIyyAXqqp7TKaRGaWnBlbnEsNrkRLrgP+aLwRhRSHdafxi+RMqj3h9qOlb1MgnPal1kx7wP4g=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.7.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OfNAX7fNFHhOiiojUkIjhVOZf+IDjpbpT5G7HrxGqhlZ+n4jITrrJzACHctteWbMUYcibieJzIIoKuLdmD5I+w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.8.0", "", { "os": "win32", "cpu": "x64" }, "sha512-P5X2RcDDntIJ4q1WvQawGWCae5BzHrm50PMy7+PZ3Su8GGwnaCQ5xRHvDIQfhki8pZMo2SoTOkpStR0Wj1QoXQ=="],
"@opentui/keymap": ["@opentui/keymap@0.7.0", "", { "dependencies": { "@opentui/core": "0.7.0" }, "peerDependencies": { "@opentui/react": "0.7.0", "@opentui/solid": "0.7.0", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-2IqUnLP+SCZR7+LXRL/7/DGFyaAR6r9y16H5oQVdRTfbACxAjszOaq2iZDEzHp1HVtamQRqEfteuaLEDwmO3kg=="],
"@opentui/keymap": ["@opentui/keymap@0.8.0", "", { "dependencies": { "@opentui/core": "0.8.0" }, "peerDependencies": { "@opentui/react": "0.8.0", "@opentui/solid": "0.8.0", "react": ">=19.2.0", "solid-js": "^1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Kv00d1Bw/9U1ORM5fJyn9PQ9LbZi+Q7m9jO2EhyBeNAienSKb4PAjdcu9di6jxhkqt6jG+FXyndoemED8TzK4A=="],
"@opentui/solid": ["@opentui/solid@0.7.0", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.7.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-S6SNZGRykV02UjNbM+f/6lSeAPN3dfEbjcK1jAAdFCfa+EVhJqcJVyaJgtwHLU9fI6c/O1VKXPzbbFYj2RhNVQ=="],
"@opentui/solid": ["@opentui/solid@0.8.0", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.8.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.16", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "^1.9.12" } }, "sha512-fk035Q9iGXFgpkXSbpdyWYnT1jFuY0gOhzxaqBXDVtuWrQPB5MRxUxXHgESIMXJPsMTROIjOcKuW+PPhsakpnw=="],
"@opentunnel/client": ["@opentunnel/client@0.6.1", "", { "dependencies": { "@opentunnel/protocol": "0.6.1" }, "peerDependencies": { "effect": "^4.0.0", "undici": ">=7.0.0" }, "optionalPeers": ["undici"] }, "sha512-OuVNlWywQebIXCtkixjVHebOsR3KFRxJ0DSZry6Dzj4hHaHIf3eTVmQ7VwLCjuVmw+ctU2qHIP6RxuAPr07WDw=="],
@@ -4888,7 +4888,7 @@
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.16", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.11" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.16" }, "optionalPeers": ["solid-js"] }, "sha512-+0h40Qk2oBfkQ4OGDBHIDtvq/XQiOnCfzjNHmMxriAr8MmdsaIbyJgqYglDs4d70sF8O08sSpx61TKdpUt3oRw=="],
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
@@ -5340,6 +5340,8 @@
"@opentelemetry/instrumentation/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@opentui/solid/babel-preset-solid/babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.11", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-k7TkOv7uVYnrq/bYyVvG5cWOC9IObAGfmyW3aMkG8l2sq2qt/5THkUBja69PMLEGGi8G+fXB2Fh7eS4OwZRBoQ=="],
"@puppeteer/browsers/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"@puppeteer/browsers/yargs/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
@@ -5908,6 +5910,8 @@
"@opencode/web/@astrojs/check/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"@opentui/solid/babel-preset-solid/babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"@puppeteer/browsers/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"@puppeteer/browsers/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
+6 -6
View File
@@ -53,9 +53,9 @@
"@types/bun": "1.4.3",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@opentui/core": "0.7.0",
"@opentui/keymap": "0.7.0",
"@opentui/solid": "0.7.0",
"@opentui/core": "0.8.0",
"@opentui/keymap": "0.8.0",
"@opentui/solid": "0.8.0",
"@tanstack/solid-virtual": "3.13.37",
"@standard-schema/spec": "1.1.0",
"@kobalte/core": "0.13.13",
@@ -149,9 +149,9 @@
"electron"
],
"overrides": {
"@opentui/core": "0.7.0",
"@opentui/keymap": "0.7.0",
"@opentui/solid": "0.7.0",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
+2 -2
View File
@@ -51,8 +51,8 @@
},
"peerDependencies": {
"@opencode/theme": "workspace:*",
"@opentui/core": "0.7.0",
"@opentui/solid": "0.7.0",
"@opentui/core": ">=0.8.0",
"@opentui/solid": ">=0.8.0",
"electron": "*",
"solid-js": ">=1.9.0",
"vite": ">=6",
@@ -1,14 +1,11 @@
import {
CliRenderer,
parseColor,
Renderable,
RGBA,
type Clock,
type ColorInput,
type OptimizedBuffer,
type RenderableOptions,
type RenderContext,
type TimerHandle,
} from "@opentui/core"
import { extend } from "@opentui/solid"
import { stringWidth } from "../util/string-width"
@@ -32,9 +29,8 @@ type SpinnerOptions = RenderableOptions<SpinnerRenderable> & {
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
// Not live: a live node keeps the renderer running, so renderer.idle(), which mini and the CLI await, never resolves.
// A timer requests each frame instead. Core skips onUpdate for hidden and culled nodes, so the timer stops with them.
// The renderer's shared animation clock ticks it instead, on frames it shares with other animations, while it is drawn.
export class SpinnerRenderable extends Renderable {
private readonly clock: Clock
private frameTexts: string[] = []
private glyphs: { char: string; width: number }[][] = []
private frameWidth = 0
@@ -43,14 +39,9 @@ export class SpinnerRenderable extends Renderable {
private _color: RGBA | ColorGenerator
private _backgroundColor: RGBA
private index = 0
private elapsed = 0
private updatedFrame = -1
private timer: TimerHandle | undefined
constructor(ctx: RenderContext, options: SpinnerOptions = {}) {
if (!(ctx instanceof CliRenderer)) throw new Error("Spinner requires a renderer clock")
super(ctx, options)
this.clock = ctx.clock
this._interval = options.interval ?? 80
this._autoplay = options.autoplay ?? true
this._color = toColor(options.color ?? RGBA.defaultForeground())
@@ -98,31 +89,13 @@ export class SpinnerRenderable extends Renderable {
}
private restart() {
this.stop()
this.elapsed = 0
this.updatedFrame = -1
this.animationFps = this._autoplay && this.glyphs.length > 1 ? 1000 / this._interval : undefined
this.requestRender()
}
private stop() {
if (this.timer === undefined) return
this.clock.clearTimeout(this.timer)
this.timer = undefined
}
protected override onUpdate(deltaTime: number) {
if (!this._autoplay || this.glyphs.length < 2) return
// Count time only across consecutive frames, not the renderer's idle gap before this spinner appeared or resumed.
if (this.updatedFrame === this._ctx.frameId - 1) this.elapsed += deltaTime
this.updatedFrame = this._ctx.frameId
const steps = Math.floor(this.elapsed / this._interval)
this.index = (this.index + steps) % this.glyphs.length
this.elapsed -= steps * this._interval
if (this.timer !== undefined) return
this.timer = this.clock.setTimeout(() => {
this.timer = undefined
this.requestRender()
}, this._interval - this.elapsed)
protected override onAnimationTick(_deltaTime: number, ticks: number) {
this.index = (this.index + ticks) % this.glyphs.length
this.requestRender()
}
protected override renderSelf(buffer: OptimizedBuffer) {
@@ -138,11 +111,6 @@ export class SpinnerRenderable extends Renderable {
x += glyph.width
})
}
protected override destroySelf() {
this.stop()
super.destroySelf()
}
}
function toColor(value: ColorInput | ColorGenerator) {
+26 -68
View File
@@ -1,13 +1,4 @@
import {
CliRenderer,
OptimizedBuffer,
Renderable,
RGBA,
type Clock,
type RenderableOptions,
type RenderContext,
type TimerHandle,
} from "@opentui/core"
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
import { extend } from "@opentui/solid"
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
@@ -63,7 +54,7 @@ const GLOW_TAIL = 12
const GLOW_OPACITY = 0.16
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
// The sweep front crosses a 32-cell tab at up to about 24 cells per second, so 20 fps steps it about a cell at a time.
const FRAME_INTERVAL = 1000 / 20
const PULSE_FPS = 20
export const intensityAt = (index: number, front: number, head: number, tail: number) => {
const distance = front - index
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
@@ -429,14 +420,9 @@ class PulseState {
}
}
// Not live: a live node holds the renderer at its full frame rate. The pulse steps in whole FRAME_INTERVALs of
// elapsed time on whatever frames the renderer draws, and requests a frame only when no other frame comes in time.
// Not live: a live node holds the renderer at its full frame rate. The pulse ticks at PULSE_FPS on the renderer's shared
// animation clock while it animates and is drawn, on frames it shares with the other animations.
class TabPulseRenderable extends Renderable {
private readonly clock: Clock
private timer: TimerHandle | undefined
private updatedFrame = -1
// Elapsed time not yet stepped into the pulse; slightly negative after a step that rounded up.
private pending = 0
private _enabled: boolean
private inner: PulseState
private outer: PulseState
@@ -466,9 +452,7 @@ class TabPulseRenderable extends Renderable {
const edge = options.edge
const outerActive = options.outerActive ?? active
const outerGlow = options.outerGlow ?? glow
if (!(ctx instanceof CliRenderer)) throw new Error("TabPulse requires a renderer clock")
super(ctx, { ...options, height: 1 })
this.clock = ctx.clock
this._enabled = enabled
this.inner = new PulseState({
enabled,
@@ -499,6 +483,7 @@ class TabPulseRenderable extends Renderable {
this._outerCompletionColor = options.outerCompletionColor ?? options.completionColor ?? this._outerColor
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
this._onLevel = options.onLevel
this.changed()
}
set onLevel(value: ((level: number) => void) | undefined) {
@@ -518,7 +503,7 @@ class TabPulseRenderable extends Renderable {
this._enabled = value
this.inner.setEnabled(value)
this.outer.setEnabled(value && this._edge !== undefined)
this.requestRender()
this.changed()
}
set active(value: boolean) {
@@ -553,10 +538,23 @@ class TabPulseRenderable extends Renderable {
if (this.outer.setGlow(value)) this.changed()
}
// The level and the clock follow every state change, outside the paint, so onLevel's color update needs no frame.
private changed() {
const animating = this.inner.live || this.outer.live
this.animationFps = animating ? PULSE_FPS : undefined
this.emitLevel(this.level())
this.requestRender()
}
private level() {
const running = this.inner.running
if (running === 0 || this.width <= 0) return 0
const fronts = this.inner.fronts(this.width)
return (
Math.max(intensityAt(1, fronts[0], RUN_HEAD, RUN_TAIL), intensityAt(1, fronts[1], RUN_HEAD, RUN_TAIL)) * running
)
}
set color(value: RGBA) {
if (value.equals(this._color)) return
this._color = value
@@ -597,7 +595,7 @@ class TabPulseRenderable extends Renderable {
if (value === this._edge) return
this._edge = value
this.outer.setEnabled(this._enabled && value !== undefined)
this.requestRender()
this.changed()
}
set flashColor(value: RGBA) {
@@ -642,42 +640,11 @@ class TabPulseRenderable extends Renderable {
this.requestRender()
}
private stop() {
if (this.timer === undefined) return
this.clock.clearTimeout(this.timer)
this.timer = undefined
}
protected override onUpdate(deltaTime: number): void {
this.stop()
if (!this.inner.live && !this.outer.live) {
this.pending = 0
return
}
// Count time only across consecutive frames, not the renderer's idle gap before the pulse started or reappeared.
if (this.updatedFrame === this._ctx.frameId - 1) this.pending += deltaTime
this.updatedFrame = this._ctx.frameId
// Round up within 1 ms so timer jitter does not skip a step.
const step = Math.floor((this.pending + 1) / FRAME_INTERVAL) * FRAME_INTERVAL
if (step > 0) {
this.pending -= step
this.inner.advance(step)
this.outer.advance(step)
if (!this.inner.live && !this.outer.live) return
}
// Wait most of an interval after any frame, so frames drawn for other reasons carry the pulse without extra ones.
this.timer = this.clock.setTimeout(
() => {
this.timer = undefined
this.requestRender()
},
Math.max(FRAME_INTERVAL - this.pending, FRAME_INTERVAL * 0.9),
)
}
protected override destroySelf() {
this.stop()
super.destroySelf()
// deltaTime is the time since the previous tick, so a tick that runs early or late draws the sweep where it is.
protected override onAnimationTick(deltaTime: number): void {
this.inner.advance(deltaTime)
this.outer.advance(deltaTime)
this.changed()
}
protected override renderSelf(buffer: OptimizedBuffer): void {
@@ -703,19 +670,10 @@ class TabPulseRenderable extends Renderable {
outerRunning === 0 &&
outerCompletion === 0 &&
outerFlash === 0
) {
this.emitLevel(0)
)
return
}
const fronts = running === 0 ? undefined : this.inner.fronts(this.width)
const outerFronts = outerRunning === 0 ? undefined : this.outer.fronts(this.width)
if (this._onLevel)
this.emitLevel(
running === 0
? 0
: Math.max(intensityAt(1, fronts![0], RUN_HEAD, RUN_TAIL), intensityAt(1, fronts![1], RUN_HEAD, RUN_TAIL)) *
running,
)
const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2))
const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2))
const releaseSwell = this.inner.glowReleaseSwell
+6 -26
View File
@@ -30,7 +30,6 @@ import {
TextAttributes,
RGBA,
MouseEvent,
type Renderable,
} from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import {
@@ -386,7 +385,6 @@ export function Session(props: {
firstJump()?.()
if (!scroll || scroll.isDestroyed) return
scroll.verticalScrollBar.off("change", updateAwayFromBottom)
scroll.content.off("resize", holdAnchor)
saveScrollAnchor(true)
})
const [prompt, setPrompt] = createSignal<PromptRef>()
@@ -398,26 +396,14 @@ export function Session(props: {
r.set(route.prompt)
}
/** Runs after layout has settled (two frames), unless the transcript was torn down. */
/** Runs after the next frame's layout and before it paints, unless the transcript was torn down. */
const afterLayout = (continuation: () => void) => {
renderer.requestAnimationFrame(() => {
renderer.requestAnimationFrame(() => {
if (!scroll || scroll.isDestroyed) return
continuation()
})
if (!scroll || scroll.isDestroyed) return
scroll.afterLayout(() => {
if (!scroll.isDestroyed) continuation()
})
}
// Keeps a toggled disclosure's header on its viewport row. The content resize fires inside the
// layout pass, before rows take their screen positions, so the correction lands in the same frame.
// Renderable positions are still stale at that point; read the computed layout instead.
let held: { node: Renderable; top: number } | undefined
const layoutTop = (node: Renderable): number =>
node === scroll.content || !node.parent ? 0 : node.getComputedLayout().top + layoutTop(node.parent)
const holdAnchor = () => {
if (held && !held.node.isDestroyed) scroll.scrollTo(layoutTop(held.node) - held.top)
}
// Tail-first transcript mounting: only the newest rows mount when the session opens. Older rows
// mount on demand near the top, keeping inactive tabs cheap to tear down. While the reader stays
// at the bottom the hidden span follows appends; leaving the bottom pins it to preserve the viewport.
@@ -1483,13 +1469,8 @@ export function Session(props: {
// A group that ends the transcript would open off screen while the reader follows the bottom.
const last = rows.findLast((row) => row.type !== "assistant-footer" && row.type !== "turn-usage")
const ending = last?.type === "group" && groupID(last, 0) === id
if (anchor && !(ending && !isAwayFromBottom())) {
const hold = { node: anchor, top: layoutTop(anchor) - scroll.scrollTop }
held = hold
afterLayout(() => {
if (held === hold) held = undefined
})
}
// Keeps the toggled disclosure's header on its viewport row through the next layout.
if (anchor && !(ending && !isAwayFromBottom())) afterLayout(scroll.anchor(anchor))
sessionTabs.setGroupExpanded(sessionID, id, expanded)
afterLayout(saveScrollAnchor)
},
@@ -1532,7 +1513,6 @@ export function Session(props: {
scroll = r
props.scrollRef?.(r)
scroll.verticalScrollBar.on("change", updateAwayFromBottom)
scroll.content.on("resize", holdAnchor)
}}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
@@ -34,7 +34,8 @@ async function mount(spinner: () => JSX.Element) {
}
test.each([
{ name: "advances one frame per elapsed interval", autoplay: true, steps: [79, 1, 80, 160], frames: "abcb" },
// Ticks share a clock: due every 80 ms from time 0, and up to 40 ms early to share a frame.
{ name: "advances one frame per elapsed interval", autoplay: true, steps: [39, 1, 80, 160], frames: "abcb" },
{ name: "holds the first frame without autoplay", autoplay: false, steps: [80, 160], frames: "aa" },
])("$name", async (item) => {
await using spinner = await mount(() => <spinner frames={["a", "b", "c"]} interval={80} autoplay={item.autoplay} />)
@@ -42,7 +43,7 @@ test.each([
for (const [index, ms] of item.steps.entries()) expect(await spinner.step(ms)).toBe(item.frames[index]!)
})
test("frames, autoplay, and visibility changes restart the frame clock", async () => {
test("frames reset the glyph; autoplay and visibility stop and resume the ticks", async () => {
const [frames, setFrames] = createSignal(["a", "b", "c"])
const [autoplay, setAutoplay] = createSignal(true)
const [visible, setVisible] = createSignal(true)
@@ -60,17 +61,17 @@ test("frames, autoplay, and visibility changes restart the frame clock", async (
setAutoplay(false)
expect(await spinner.step(200)).toBe("y")
setAutoplay(true)
// Paused time does not count toward the next frame.
expect(await spinner.step(50)).toBe("y")
expect(await spinner.step(79)).toBe("y")
expect(await spinner.step(1)).toBe("x")
// Resumes on the shared clock at the first due time out of reach: 480 ms, so it ticks from 440 ms on.
expect(await spinner.step(40)).toBe("y")
expect(await spinner.step(40)).toBe("x")
// The first tick after a frame that skipped its onUpdate stops the clock.
setVisible(false)
await spinner.step(1000)
await spinner.step(80)
expect(spinner.clock.pendingTimerCount).toBe(0)
setVisible(true)
expect(await spinner.step(40)).toBe("x")
expect(await spinner.step(79)).toBe("x")
expect(await spinner.step(39)).toBe("x")
expect(await spinner.step(1)).toBe("y")
})
+14 -15
View File
@@ -31,22 +31,24 @@ async function mount(
onLevel={props().onLevel}
/>
),
{ width: 10, height: 1, clock },
// Uncapped, a requested frame never waits on a clock timer, so idle() in run() returns before the next step.
{ width: 10, height: 1, clock, maxFps: Number.POSITIVE_INFINITY },
)
let frames = 0
app.renderer.on("frame", () => frames++)
await app.renderOnce()
return Object.assign(app, {
clock,
/** Advances the clock in 5 ms steps, drawing another frame every `other` ms, and counts the frames. */
/** Advances the clock in 5 ms steps beside another animation that ticks every `other` ms, and counts frames.
* Each step waits until the requested frame has drawn, so frames land on the step that requests them. */
async run(ms: number, other = 0) {
frames = 0
const stop = other ? app.renderer.onAnimationTick(1000 / other, () => app.renderer.requestRender()) : undefined
for (let elapsed = 5; elapsed <= ms; elapsed += 5) {
clock.advance(5)
if (other && elapsed % other === 0) await app.renderOnce()
await Bun.sleep(0)
await Bun.sleep(0)
await app.renderer.idle()
}
stop?.()
return frames
},
colors() {
@@ -73,16 +75,12 @@ test("an attention glow becomes idle after ignition", async () => {
})
test.each([
// A requested frame draws asynchronously, on the same 5 ms clock step or a later one depending on the event loop,
// so frames alone come 50 to 55 ms apart.
{ name: "alone", other: 0, min: 18, max: 20 },
{ name: "beside a 25 fps animation", other: 40, min: 25, max: 25 },
{ name: "beside a 50 fps animation", other: 20, min: 50, max: 50 },
{ name: "alone", other: 0, frames: 20 },
{ name: "beside a 25 fps animation", other: 40, frames: 25 },
{ name: "beside a 50 fps animation", other: 20, frames: 50 },
])("a running pulse adds frames only up to 20 fps, $name", async (item) => {
await using pulse = await mount(() => ({ active: true }))
const frames = await pulse.run(1000, item.other)
expect(frames).toBeGreaterThanOrEqual(item.min)
expect(frames).toBeLessThanOrEqual(item.max)
expect(await pulse.run(1000, item.other)).toBe(item.frames)
expect(pulse.renderer.root.liveCount).toBe(0)
})
@@ -94,12 +92,13 @@ test("a running pulse changes its level at most 20 times a second while the rend
expect(levels.length).toBeLessThanOrEqual(40)
})
// Ticks carry the elapsed time, so the sweep is where the clock says when both tick at the same time.
test("the sweep shows the same colors at the same time at any frame rate", async () => {
await using slow = await mount(() => ({ active: true }))
await using fast = await mount(() => ({ active: true }))
for (const ms of [300, 700, 1400, 2800]) {
for (const ms of [400, 600, 1400, 2800]) {
await slow.run(ms)
await fast.run(ms, 20)
await fast.run(ms, 40)
await slow.renderOnce()
await fast.renderOnce()
expect(slow.colors()).toEqual(fast.colors())
+2
View File
@@ -50,6 +50,8 @@ async function mount(root: string) {
async function opened(app: Awaited<ReturnType<typeof mount>>["app"], title: string) {
await app.waitForFrame((frame) => frame.includes(title))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
// Focus re-renders the footer actions, so wait for a frame that presents their hits.
await app.renderOnce()
}
function rows(frame: string, titles: string[]) {
+12 -2
View File
@@ -149,17 +149,27 @@ test.each([
const capture = () => {
if (visible() !== frames.at(-1)) frames.push(visible())
if (mode !== "settling-reveal" || scroll.getRenderable("message-0")) mountCounts.push(mounted().length)
// Otherwise on the first frame that shows the beginning: the jump lands before that frame paints.
if (mode.startsWith("settling") && !navigated && setup.captureCharFrame().includes("History message 0000")) {
navigated = true
setup.mockInput.pressKey("F7")
}
}
setup.renderer.on("frame", capture)
// Navigate while the first jump is pending: the head is mounted and the jump waits for the next layout.
const afterLayout = scroll.afterLayout.bind(scroll)
scroll.afterLayout = (callback) => {
afterLayout(callback)
if (
mode.startsWith("settling") &&
!navigated &&
setup.captureCharFrame().includes("History message 0000") &&
scroll.getRenderable("message-0") &&
setup.captureCharFrame().includes("Loading session history")
) {
navigated = true
setup.mockInput.pressKey("F7")
}
}
setup.renderer.on("frame", capture)
if (mode.startsWith("prepend")) {
setup.mockInput.pressKey(mode === "prepend-navigation" ? "F9" : "F8")