From 13e818641020afa3394069897c8ea7746cab365e Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 30 Oct 2026 05:42:46 +0100 Subject: [PATCH] chore(tui): use OpenTUI 0.8.0 (#60385) --- bun.lock | 44 +++++---- package.json | 12 +-- packages/plugin/package.json | 4 +- .../tui/src/component/spinner-renderable.ts | 42 +-------- packages/tui/src/component/tab-pulse.tsx | 94 +++++-------------- packages/tui/src/routes/session/index.tsx | 32 ++----- .../component/spinner-renderable.test.tsx | 17 ++-- .../tui/test/component/tab-pulse.test.tsx | 29 +++--- packages/tui/test/plugin-select.test.tsx | 2 + packages/tui/test/session-home.test.tsx | 14 ++- 10 files changed, 106 insertions(+), 184 deletions(-) diff --git a/bun.lock b/bun.lock index 4d8ebf18084..e2163a6f23a 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 01143415ddc..231f530de28 100644 --- a/package.json +++ b/package.json @@ -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:", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 5b16ac8a38c..6e60cd9e022 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -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", diff --git a/packages/tui/src/component/spinner-renderable.ts b/packages/tui/src/component/spinner-renderable.ts index b944d3e0c13..7f2e0ce4e04 100644 --- a/packages/tui/src/component/spinner-renderable.ts +++ b/packages/tui/src/component/spinner-renderable.ts @@ -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 & { 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) { diff --git a/packages/tui/src/component/tab-pulse.tsx b/packages/tui/src/component/tab-pulse.tsx index 9678d4c4f59..a3c4155a62b 100644 --- a/packages/tui/src/component/tab-pulse.tsx +++ b/packages/tui/src/component/tab-pulse.tsx @@ -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 & { @@ -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 diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 318e9d5aecd..9bd37c47364 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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() @@ -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, diff --git a/packages/tui/test/component/spinner-renderable.test.tsx b/packages/tui/test/component/spinner-renderable.test.tsx index d75082d5eac..f6768c3c696 100644 --- a/packages/tui/test/component/spinner-renderable.test.tsx +++ b/packages/tui/test/component/spinner-renderable.test.tsx @@ -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(() => ) @@ -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") }) diff --git a/packages/tui/test/component/tab-pulse.test.tsx b/packages/tui/test/component/tab-pulse.test.tsx index 02ef54db20d..41a14298463 100644 --- a/packages/tui/test/component/tab-pulse.test.tsx +++ b/packages/tui/test/component/tab-pulse.test.tsx @@ -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()) diff --git a/packages/tui/test/plugin-select.test.tsx b/packages/tui/test/plugin-select.test.tsx index 3eb2beaf652..e248f5c77d0 100644 --- a/packages/tui/test/plugin-select.test.tsx +++ b/packages/tui/test/plugin-select.test.tsx @@ -50,6 +50,8 @@ async function mount(root: string) { async function opened(app: Awaited>["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[]) { diff --git a/packages/tui/test/session-home.test.tsx b/packages/tui/test/session-home.test.tsx index 09e5aea20a5..fa979bf7291 100644 --- a/packages/tui/test/session-home.test.tsx +++ b/packages/tui/test/session-home.test.tsx @@ -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")