mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 12:41:37 +03:00
chore(tui): use OpenTUI 0.8.0 (#60385)
This commit is contained in:
committed by
GitHub
Unverified
parent
a08b40eaea
commit
13e8186410
@@ -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
@@ -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:",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user