Compare commits

...
Author SHA1 Message Date
Simon Klee f5d3eb7237 feat(tui): add mini spinner choices and previews 2026-08-31 15:36:08 +02:00
Simon Klee 829de0d1ff feat(tui): make mini work spinner configurable 2026-08-31 15:10:46 +02:00
Simon Klee 807ad7d6af remove stupid tests 2026-08-31 14:15:41 +02:00
Simon Klee 7681326abb feat(tui): add one-cell mini activity indicators 2026-08-31 14:03:03 +02:00
Simon Klee c28d4e1fa5 fix(tui): prevent exit confirmation crash 2026-08-31 08:17:55 +02:00
Simon Klee 77fb7106b9 feat(tui): add image support to mini 2026-08-31 07:59:27 +02:00
Simon Klee fc8fae90b3 fix(tui): adapt mini to size and mode changes
Fixed-size layouts can hide essential controls in small terminals.
Keep prompts and actions visible across resizes, and make overflowing
content accessible by scrolling. Apply monochrome settings immediately
so users can switch modes without restarting or disrupting active
Markdown output.
2026-08-30 22:21:09 +02:00
Simon Klee b8793a1783 feat(tui): refactor mini layout 2026-08-30 20:10:35 +02:00
Aiden Cline b1e3a7b222 fix(ai): preserve forced reasoning signature (#46218) 2026-08-29 23:09:03 -05:00
opencode-agent[bot]andBrendonovich b12d43698e fix(app): recover sessions with unavailable locations (#46215)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-30 11:51:48 +08:00
opencode-agent[bot] 5cd2c22b27 chore: update nix node_modules hashes 2026-08-30 03:43:36 +00:00
Aiden Cline c2ef80a287 fix(ai): fail malformed converse output (#46193) 2026-08-29 22:29:53 -05:00
opencode-agent[bot]andBrendonovich 4df3029536 fix(desktop): scope library validation exception to CLI (#46212)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-30 03:29:31 +00:00
Aiden Cline e70d667a9f fix(ai): preserve Anthropic finish across usage deltas (#46171) 2026-08-29 15:45:27 -05:00
Kit Langton 8ba434b597 refactor(core): move projected Session reads into Store (#46075)
Move existing Session list and message queries into SessionStore. Preserve public response wrapping, Session existence checks, pagination, ordering, and typed message decoding errors.
2026-08-29 09:31:04 -04:00
Kit Langton 171947787c test(tui): wait for diff base search focus (#46083)
Wait for the diff-base search input to receive focus before typing. Preserve existing assertions and timeouts while removing the render-versus-focus test race.
2026-08-29 08:37:32 -04:00
Luke Parker 106629aa11 feat(infra): deploy beta web app with SST (#46086) 2026-08-29 14:51:22 +10:00
Luke Parker 3ee2e482ce fix(app): preserve Windows panel top outlines (#46090) 2026-08-29 04:40:55 +00:00
Kit Langton 849824efd2 refactor(core): merge defaults for selected MCP servers (#46072) 2026-08-28 23:48:29 -04:00
86 changed files with 8090 additions and 2124 deletions
+3 -1
View File
@@ -5,6 +5,7 @@ on:
branches:
- dev
- production
- beta
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -15,7 +16,7 @@ permissions:
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production' || github.ref_name == 'beta')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
@@ -28,6 +29,7 @@ jobs:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
if: github.ref_name != 'beta'
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
+56 -33
View File
@@ -430,6 +430,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
@@ -1678,7 +1679,7 @@
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
"@electron/get": ["@electron/get@5.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA=="],
"@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="],
@@ -2774,7 +2775,7 @@
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-Y1XfSefHIOub9762qm3ShafdlEE/Va8h3kLUeMq765fNeWeNLcOP2YUPr86H1SlyGwZTOqQ67RlBZPZ3k9Djgg=="],
@@ -3532,7 +3533,7 @@
"chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="],
@@ -3812,7 +3813,7 @@
"dot-prop": ["dot-prop@8.0.2", "", { "dependencies": { "type-fest": "^3.8.0" } }, "sha512-xaBe6ZT4DHPkg0k4Ytbvn5xoxgpG0jOS1dYxSOwAHPuNLjP3/OzN0gH55SrLqpx8cBfSaVt91lXYkApjb+nYdQ=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
@@ -3884,7 +3885,7 @@
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
@@ -4206,7 +4207,7 @@
"hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="],
"hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
@@ -5812,7 +5813,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -5960,6 +5961,8 @@
"@astrojs/starlight/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"@astrojs/telemetry/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"@astrojs/telemetry/is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="],
@@ -6114,7 +6117,9 @@
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
@@ -6150,6 +6155,12 @@
"@modelcontextprotocol/sdk/jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="],
"@npmcli/arborist/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
"@npmcli/config/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"@npmcli/package-json/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.14", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.12", "universal-user-agent": "^7.0.2" } }, "sha512-bgWgiSfFS689/AxQDarU+b3Qu1FYewfVr5vI/jhV84s7GQ3C2OsdRxukGGYKB37MCgwcS50UrfBLlHzhiG13sw=="],
@@ -6290,12 +6301,16 @@
"@pierre/trees/react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@sentry/bundler-plugins/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"@sentry/bundler-plugins/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
@@ -6368,14 +6383,6 @@
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"app-builder-lib/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -6386,6 +6393,8 @@
"astro/@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
"astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"astro/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
@@ -6430,6 +6439,8 @@
"conf/dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="],
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -6456,14 +6467,16 @@
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
"dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"electron/@electron/get": ["@electron/get@5.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA=="],
"electron-builder/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"electron-store/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
@@ -6510,7 +6523,7 @@
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"got/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
@@ -6556,10 +6569,10 @@
"nitro/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"npm-package-arg/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
@@ -6642,8 +6655,6 @@
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
@@ -6786,6 +6797,8 @@
"@astrojs/starlight/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"@astrojs/starlight/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"@astrojs/starlight/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"@astrojs/starlight/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -6908,6 +6921,8 @@
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
@@ -7130,6 +7145,8 @@
"@opencode-ai/web/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"@opencode-ai/web/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"@opencode-ai/web/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"@opencode-ai/web/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -7234,14 +7251,6 @@
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
@@ -7260,6 +7269,8 @@
"astro-expressive-code/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"astro-expressive-code/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"astro-expressive-code/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"astro-expressive-code/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -7394,6 +7405,10 @@
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"electron/@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"electron/@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
@@ -7402,6 +7417,8 @@
"gcp-metadata/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"js-beautify/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -7450,6 +7467,12 @@
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -7476,6 +7499,8 @@
"toolbeam-docs-theme/astro/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"toolbeam-docs-theme/astro/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"toolbeam-docs-theme/astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
"toolbeam-docs-theme/astro/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -8154,8 +8179,6 @@
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
+2 -9
View File
@@ -1,4 +1,5 @@
import { domain } from "./stage"
import { createWebApp } from "./webapp"
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
@@ -59,12 +60,4 @@ new sst.cloudflare.x.Astro("Web", {
},
})
new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
createWebApp("app." + domain)
+18
View File
@@ -0,0 +1,18 @@
export function createWebApp(domain: string) {
return new sst.cloudflare.StaticSite("WebApp", {
domain,
path: "packages/app",
environment:
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-No3mCuG2tGQauX1HUpO+rebiWh+rrpSHUCC7rtXFu1s=",
"aarch64-linux": "sha256-8joWv1iDkc6TejEukGBEX0wW8DPs55wJEPY7+9+HDdM=",
"aarch64-darwin": "sha256-4MWGFQUIP1Ae4dujztTb4G8/uDJx2wiOoypcPSUWDbw=",
"x86_64-darwin": "sha256-BtvnraCJmVagtA3Iv+EbWodjFG74sTd2Purqgo7Wkr4="
"x86_64-linux": "sha256-YAKhbMPKeXAZLYgJzLRQi/fwIMjPOkVIvOWkG3mCgTE=",
"aarch64-linux": "sha256-UnIhoAleUQXKP8E0pVG0OyEpugmKEhfh6fEvpkpN2QI=",
"aarch64-darwin": "sha256-m68nJKpcVrX9R6EZqVvPKgRmZEXilJGcISMaQpN/k+Q=",
"x86_64-darwin": "sha256-DEjeoEn10K5YdJJCWtLw7LtthXChUXc0Vniccl/6KIc="
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+29 -11
View File
@@ -1343,20 +1343,28 @@ const onMessageDelta = (
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
const pendingFinish = (() => {
const stopReason = event.delta?.stop_reason
if (stopReason === null || stopReason === undefined) return state.pendingFinish
const stopSequence = event.delta?.stop_sequence
const finishMetadata =
stopSequence === null || stopSequence === undefined
? state.pendingFinish?.providerMetadata
: providerMetadata(state.providerMetadataKey, { stopSequence })
return {
reason: {
normalized: mapFinishReason(stopReason),
raw: stopReason,
},
providerMetadata: finishMetadata,
}
})()
return [
{
...state,
usage,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
providerMetadata:
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
},
pendingFinish,
},
NO_EVENTS,
]
@@ -1367,7 +1375,17 @@ const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (st
const events: LLMEvent[] = []
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...result.events)
const finished = Lifecycle.finish(lifecycle, events, {
const closed = Object.entries(state.reasoningSignatures).reduce(
(current, [index, signature]) =>
Lifecycle.reasoningEnd(
current,
events,
`reasoning-${index}`,
providerMetadata(state.providerMetadataKey, { signature }),
),
lifecycle,
)
const finished = Lifecycle.finish(closed, events, {
reason: state.pendingFinish?.reason ?? {
normalized: "unknown",
raw: undefined,
@@ -469,7 +469,6 @@ const mapFinishReason = (reason: string): FinishReason => {
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
if (reason === "tool_use") return "tool-calls"
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
return "unknown"
}
@@ -624,6 +623,15 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.messageStop) {
if (
event.messageStop.stopReason === "malformed_model_output" ||
event.messageStop.stopReason === "malformed_tool_use"
)
return yield* ProviderShared.eventError(
ADAPTER,
`Bedrock Converse stopped with ${event.messageStop.stopReason}`,
ProviderShared.encodeJson(event),
)
return [
{
...state,
@@ -903,6 +903,54 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("preserves a reasoning signature when message_stop closes the block", () =>
Effect.gen(function* () {
const compatible = Route.make({
id: "custom-anthropic-messages",
provider: "custom-anthropic",
protocol: AnthropicMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL: "https://compatible.test/v1" }),
auth: Auth.header("x-api-key", "test"),
framing: AnthropicMessages.framing,
}).model({ id: "custom-model" })
const body = sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: "", signature: "" },
},
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Reasoning." } },
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
)
const response = yield* LLMClient.generate(LLM.request({ model: compatible, prompt: "Think." })).pipe(
Effect.provide(fixedResponse(body)),
)
const reasoningEnds = response.events.filter((event) => event.type === "reasoning-end")
expect(reasoningEnds).toHaveLength(1)
expect(reasoningEnds[0]).toMatchObject({
providerMetadata: { "custom-anthropic": { signature: "sig_1" } },
})
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Reasoning.",
providerMetadata: { "custom-anthropic": { signature: "sig_1" } },
},
])
const prepared = yield* compileRequest(
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "thinking", thinking: "Reasoning.", signature: "sig_1" }] },
])
}),
)
it.effect("parses text, reasoning, and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -936,6 +984,7 @@ describe("Anthropic Messages route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } },
})
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toBeUndefined()
expect(response.message.content).toEqual([
{ type: "text", text: "Hello!" },
@@ -949,6 +998,41 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("preserves terminal state across usage-only message deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: "X" },
usage: { output_tokens: 8 },
},
{ type: "message_delta", delta: {}, usage: { output_tokens: 10 } },
{ type: "message_stop" },
),
),
),
)
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 10, totalTokens: 15 })
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
expect(response.events.find((event) => event.type === "step-finish")).toMatchObject({
reason: { normalized: "stop", raw: "end_turn" },
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
providerMetadata: { anthropic: { stopSequence: "X" } },
})
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "stop", raw: "end_turn" },
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
providerMetadata: { anthropic: { stopSequence: "X" } },
})
}),
)
it.effect("requires message_stop before completing a streamed message", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -1,11 +1,12 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Ref, Stream } from "effect"
import {
CacheHint,
GenerationOptions,
LLM,
LLMEvent,
LLMRequest,
Message,
ToolCallPart,
@@ -385,19 +386,39 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("maps truncation and malformed output stop reasons", () =>
it.effect("maps model context window exhaustion to length", () =>
Effect.gen(function* () {
const reasons = [
["model_context_window_exceeded", "length"],
["malformed_model_output", "error"],
["malformed_tool_use", "error"],
] as const
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "model_context_window_exceeded" }]))),
)
for (const [raw, normalized] of reasons) {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
expect(response.finishReason).toEqual({
normalized: "length",
raw: "model_context_window_exceeded",
})
}),
)
it.effect("fails malformed output stop reasons", () =>
Effect.gen(function* () {
for (const reason of ["malformed_model_output", "malformed_tool_use"] as const) {
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
const error = yield* LLMClient.stream(baseRequest).pipe(
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
Stream.runDrain,
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: reason }]))),
Effect.flip,
)
expect(response.finishReason).toEqual({ normalized, raw })
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
message: `Bedrock Converse stopped with ${reason}`,
})
expect(JSON.parse(error.reason.body ?? "")).toMatchObject({
headers: { ":event-type": { value: "messageStop" } },
body: JSON.stringify({ stopReason: reason }),
})
expect((yield* Ref.get(events)).some((event) => event.type === "finish")).toBeFalse()
}
}),
)
+22 -1
View File
@@ -71,4 +71,25 @@ Environment options:
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
The `deploy` GitHub Actions workflow uses SST to deploy the web app from these branches in `anomalyco/opencode`:
| Branch | Site |
| ------------ | --------------------- |
| `dev` | `app.dev.opencode.ai` |
| `production` | `app.opencode.ai` |
| `beta` | `beta.opencode.ai` |
Changes merged into `v2` reach the beta site when they are promoted to `beta`. The beta SST stage deploys
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
override this default. Connecting still requires the service's credentials.
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
owns `opencode.ai`. The beta GitHub environment must allow deployments from the `beta` branch; it does not
need AWS credentials.
SST manages the beta site's custom domain. The first deployment creates its DNS record and TLS certificate.
Do not create a CNAME for `beta.opencode.ai` first, because it would conflict with the Workers custom domain.
@@ -0,0 +1,187 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { fixture } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("keeps history visible and recovers the composer by choosing another directory", async ({ page }) => {
const directory = "/projects/deleted"
const destination = "/projects/restored"
const sessionID = "ses_missing_location"
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" }
const events: OpenCodeEvent[] = []
const moves: unknown[] = []
await mockOpenCodeServer(page, {
directory: destination,
project: { ...fixture.project, worktree: destination },
provider: fixture.provider,
sessions: [session],
events: () => events.splice(0),
fileList: () => [],
pageMessages: () => ({
items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }],
}),
})
await page.route("**/api/**", (route) => {
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
await expect(page.getByRole("status")).toContainText("Session location unavailable")
await expect(page.getByRole("status")).toContainText(directory)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
const choose = page.getByRole("button", { name: "Choose directory", exact: true })
await expect(choose).toBeEnabled()
await choose.click()
const dialog = page.getByRole("dialog", { name: "Choose directory", exact: true })
await expect(dialog.getByRole("combobox")).toBeFocused()
await dialog.getByRole("combobox").press("Escape")
await expect(dialog).toHaveCount(0)
await expect(choose).toBeFocused()
expect(moves).toEqual([])
// The server can still reject a destination after it was selected in the picker.
await page.route(`**/api/session/${sessionID}/move`, (route) => {
if (route.request().method() !== "POST") return route.fallback()
moves.push(route.request().postDataJSON())
return route.fulfill({
status: 400,
json: { _tag: "InvalidRequestError", message: "Destination is unavailable" },
headers: { "access-control-allow-origin": "*" },
})
})
await choose.press("Enter")
await expect(dialog.getByRole("combobox")).toBeFocused()
await dialog.getByRole("combobox").fill(destination)
await dialog.getByRole("combobox").press("Enter")
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
await expect(page.getByText("Failed to move session", { exact: true })).toBeVisible()
await expect(choose).toBeEnabled()
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
await expect(choose).toBeFocused()
expect(moves).toEqual([{ directory: destination }])
await page.route(`**/api/session/${sessionID}/move`, (route) => {
if (route.request().method() !== "POST") return route.fallback()
moves.push(route.request().postDataJSON())
session.directory = destination
events.push({
id: "evt_location_recovered",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
await choose.click()
await dialog.getByRole("combobox").fill(destination)
await dialog.getByRole("combobox").press("Enter")
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toBeVisible()
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
expect(moves).toEqual([{ directory: destination }, { directory: destination }])
})
for (const create of [false, true]) {
test(`recovers into ${create ? "a new" : "an existing"} worktree without resolving the deleted location`, async ({
page,
}) => {
const canonical = "/projects/repository"
const directory = "/projects/deleted-worktree"
const destination = create ? "/projects/new-worktree" : "/projects/existing-worktree"
const sessionID = "ses_worktree_recovery"
const events: OpenCodeEvent[] = []
const requests: { operation: string; body: unknown }[] = []
let listing = Promise.withResolvers<void>()
let listingRequested = Promise.withResolvers<void>()
const moving = Promise.withResolvers<void>()
const moveRequested = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory: canonical,
project: { ...fixture.project, worktree: canonical },
provider: fixture.provider,
sessions: [{ id: sessionID, projectID: fixture.project.id, directory }],
pageMessages: () => ({
items: [{ id: "msg_worktree", type: "user", text: "Recover my worktree", time: { created: 1 } }],
}),
events: () => events.splice(0),
})
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
const headers = { "access-control-allow-origin": "*" }
if (url.searchParams.get("location[directory]") === directory)
return route.fulfill({ status: 500, body: "", headers })
if (url.pathname === `/api/worktree/${fixture.project.id}`) {
if (route.request().method() === "GET") {
listingRequested.resolve()
await listing.promise
return route.fulfill({
json: [
{ directory: canonical, strategy: null },
{ directory: "/projects/existing-worktree", strategy: "git" },
],
headers,
})
}
if (route.request().method() === "POST") {
requests.push({ operation: "create", body: route.request().postDataJSON() })
return route.fulfill({
json: { directory: destination, name: "new-worktree", branch: "new-worktree" },
headers,
})
}
}
if (url.pathname === `/api/session/${sessionID}/move` && route.request().method() === "POST") {
requests.push({ operation: "move", body: route.request().postDataJSON() })
moveRequested.resolve()
await moving.promise
events.push({
id: "evt_worktree_recovered",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
return route.fulfill({ status: 204, headers })
}
return route.fallback()
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
await listingRequested.promise
await expect(page.getByRole("menuitem", { name: "Loading", exact: true })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
await expect(page.getByText("Session location unavailable", { exact: true })).toBeVisible()
listing.resolve()
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menu")).toHaveCount(0)
listing = Promise.withResolvers<void>()
listingRequested = Promise.withResolvers<void>()
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
await listingRequested.promise
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
listing.resolve()
await page.getByRole("menuitem", { name: create ? "New workspace" : "existing-worktree", exact: true }).click()
await moveRequested.promise
await expect(page.getByRole("button", { name: "Moving session…", exact: true })).toBeDisabled()
await expect(page.getByRole("button", { name: "Choose worktree", exact: true })).toBeDisabled()
moving.resolve()
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
expect(requests).toEqual([
...(create ? [{ operation: "create", body: { strategy: "git", from: canonical, directory: "/projects/" } }] : []),
{ operation: "move", body: { directory: destination } },
])
})
}
+6
View File
@@ -2,6 +2,12 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
export const dict = {
...DESKTOP_NATIVE_ENGLISH,
"session.location.unavailable": "Session location unavailable",
"session.location.description": "Choose another directory to continue this session.",
"session.location.choose": "Choose directory",
"session.location.worktree": "Choose worktree",
"session.location.worktreesFailed": "Failed to load worktrees",
"session.location.moving": "Moving session…",
"command.category.suggested": "Suggested",
"command.category.view": "View",
"command.category.project": "Project",
+1 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
@@ -0,0 +1,136 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
import { getFilename } from "@opencode-ai/util/path"
import { createMutation } from "@tanstack/solid-query"
import { createEffect, createResource, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { showToast } from "@/shell/notifications/toast"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { createWorktree } from "@/workspaces/create"
export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) {
const language = useLanguage()
const sdk = useServerSDK()
const data = useData()
const dialog = useDialog()
const pickDirectory = useDirectoryPicker()
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
const project = () => data.project.get(props.projectID)
const [worktrees] = createResource(
() => (state.worktreesOpen ? props.projectID : undefined),
async (projectID) => {
try {
await sdk.api.worktree.refresh({ projectID })
return await sdk.api.worktree.list({ projectID })
} catch {
showToast({ variant: "error", title: language.t("session.location.worktreesFailed") })
return []
}
},
// Seed latest so even the first fetch does not enter Suspense.
{ initialValue: [] },
)
let button: HTMLButtonElement | undefined
const move = createMutation(() => ({
mutationFn: async (input: { sessionID: string; directory?: string }) => {
// A deleted worktree cannot resolve its own location. Create from the
// project's saved canonical checkout instead.
const current = project()
const destination =
input.directory ??
(current &&
(await createWorktree({
api: sdk.api,
directory: current.canonical,
project: { id: current.id, canonical: current.canonical, directory: current.canonical },
})))
if (!destination) return
await sdk.api.session.move({ sessionID: input.sessionID, directory: destination })
},
onError: (error) => {
setState("restoreFocus", true)
showToast({
variant: "error",
title: language.t("workspace.move.failed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
},
}))
createEffect(() => {
if (!state.restoreFocus || move.isPending || dialog.active) return
setState("restoreFocus", false)
button?.focus()
})
function choose() {
if (move.isPending) return
const sessionID = props.sessionID
pickDirectory({
server: sdk.server,
title: language.t("session.location.choose"),
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
if (directory) move.mutate({ sessionID, directory })
if (!directory) setState("restoreFocus", true)
},
})
}
return (
<div data-component="session-location-missing">
<DockShell class="flex flex-col gap-2 p-3">
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
<div class="min-w-0 flex flex-col gap-1">
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
<div class="text-text-base">{language.t("session.location.description")}</div>
</div>
</div>
</DockShell>
<DockTray class="flex flex-wrap justify-end gap-2 p-2">
<Show when={project()?.vcs === "git"}>
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
{language.t("session.location.worktree")}
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
<Menu.Item onSelect={() => move.mutate({ sessionID: props.sessionID })} disabled={move.isPending}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</Menu.Item>
<Show when={worktrees.loading}>
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
</Show>
<For each={worktrees.latest.filter((item) => item.strategy && item.directory !== props.directory)}>
{(worktree) => (
<Menu.Item
title={worktree.directory}
onSelect={() => move.mutate({ sessionID: props.sessionID, directory: worktree.directory })}
disabled={move.isPending}
>
<Icon name="workspace-isolated" />
<span class="truncate">{getFilename(worktree.directory)}</span>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
</Button>
</DockTray>
</div>
)
}
+30 -7
View File
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, on, onMount } from "solid-js"
import { createEffect, createMemo, on, onMount, Show } from "solid-js"
import { Composer } from "@/composer/composer"
import { createComposerModel, type ComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
@@ -32,6 +32,7 @@ import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import { useSettings } from "@/settings/model"
import { SessionLocationMissing } from "./location-missing"
export function createActiveSessionRegion(input: {
session: SessionModel
@@ -219,6 +220,14 @@ export function ActiveSessionComposerRegion(props: {
onResponseSubmit: () => void
}) {
const settings = useSettings()
const location = useWorkspaceLocation()
const missing = createMemo(() => {
const error = location().error
const current = props.session.data.info()?.location
if (error && current?.directory === error.location.directory && current.workspaceID === error.location.workspaceID)
return current.directory
return undefined
})
const region = createSessionComposerRegionController({
state: props.model.region.state,
parentID: props.session.data.parentID,
@@ -247,12 +256,26 @@ export function ActiveSessionComposerRegion(props: {
<SessionComposerRegion
controller={region}
composer={
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay />
</div>
</div>
<Show
when={missing()}
keyed
fallback={
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay />
</div>
</div>
}
>
{(directory) => (
<SessionLocationMissing
sessionID={requireSessionID(props.session)}
projectID={props.session.data.info()!.projectID}
directory={directory}
/>
)}
</Show>
}
/>
)
+2 -2
View File
@@ -43,12 +43,12 @@ export default function Layout(props: ParentProps) {
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
// The native Windows titlebar already includes the gap above the content panels.
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
"--shell-top-inset":
platform.platform === "desktop" &&
platform.os === "windows" &&
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
? "0px"
? "1px"
: "8px",
}}
>
+2
View File
@@ -4,6 +4,7 @@ import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
import { OPENCODE_VERSION } from "./version"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
@@ -137,6 +138,7 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
version: OPENCODE_VERSION,
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
+16 -2
View File
@@ -486,7 +486,14 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
draft.mini = {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
}
})
}),
)
@@ -494,7 +501,14 @@ test("updates a config draft while preserving JSONC comments", async () => {
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
mini: {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
},
})
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
})
+2
View File
@@ -9,6 +9,7 @@ import {
type InteractiveStdin,
usingInteractiveStdin,
} from "../src/mini-host"
import { OPENCODE_VERSION } from "../src/version"
import { tmpdir } from "./fixture/tmpdir"
const model = { providerID: "openai", modelID: "gpt-5" }
@@ -145,6 +146,7 @@ describe("Mini CLI host", () => {
const input = host({ stdin: stream(true), cleanup() {} }, directory.path)
expect(input.paths).toEqual({ home: directory.path })
expect(input.version).toBe(OPENCODE_VERSION)
expect(input.platform).toBe(process.platform)
expect(typeof input.files.readText).toBe("function")
const file = path.join(directory.path, "attachment.txt")
+2 -2
View File
@@ -46,12 +46,12 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
const servers = new Map<string, ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
servers.set(name, server)
}
}
for (const [name, server] of servers) {
if (draft.get(name)) continue
draft.set(name, server)
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
}
})
})
+11 -113
View File
@@ -3,9 +3,8 @@ export * from "./session/schema.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "./location.js"
import { SessionMessage } from "./session/message.js"
@@ -13,14 +12,13 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { SessionProjector } from "./session/projector.js"
import { SessionMessageTable, SessionTable } from "./session/sql.js"
import { SessionMessageTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
import { AbsolutePath, RelativePath } from "./schema.js"
import { Agent } from "@opencode-ai/schema/agent"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
import path from "path"
import { fromRow } from "./session/info.js"
import { SessionRunner } from "./session/runner/index.js"
import { SessionStore } from "./session/store.js"
import { SessionExecution } from "./session/execution.js"
@@ -58,7 +56,6 @@ import { Job } from "./job.js"
import { Command } from "./command.js"
import { Global } from "@opencode-ai/util/global"
import { SessionEnvironment } from "./session/environment.js"
import { SessionHistory } from "./session/history.js"
import { InstructionEntry } from "./session/instruction-entry.js"
// get project -> project.locations
@@ -72,30 +69,8 @@ import { InstructionEntry } from "./session/instruction-entry.js"
export { ListAnchor }
const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
parentID: Schema.NullOr(SessionSchema.ID).pipe(Schema.optional),
anchor: ListAnchor.pipe(Schema.optional),
}
const ListDirectoryInput = Schema.Struct({
...ListInputBase,
directory: AbsolutePath,
})
const ListProjectInput = Schema.Struct({
...ListInputBase,
project: Project.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const ListAllInput = Schema.Struct(ListInputBase)
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
export type ListInput = typeof ListInput.Type
export const ListInput = SessionStore.ListInput
export type ListInput = SessionStore.ListInput
type CreateBaseInput = {
id?: SessionSchema.ID
@@ -161,15 +136,9 @@ export interface Interface {
}) => Effect.Effect<SessionEnvironment.Variables | undefined, NotFoundError>
readonly view: (input: { sessionID: SessionSchema.ID; idle: number }) => Effect.Effect<void, NotFoundError>
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly messages: (input: {
sessionID: SessionSchema.ID
limit?: number
order?: "asc" | "desc"
cursor?: {
id: SessionMessage.ID
direction: "previous" | "next"
}
}) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
readonly messages: (
input: SessionStore.MessagesInput,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
readonly message: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
@@ -409,83 +378,12 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.Deleted, { sessionID })
yield* bus.remove(sessionID)
}),
list: Effect.fn("Session.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.parentID !== undefined)
conditions.push(
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
)
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return { data: (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) }
list: Effect.fn("Session.list")(function* (input) {
return { data: yield* store.list(input) }
}),
messages: Effect.fn("Session.messages")(function* (input) {
yield* result.get(input.sessionID)
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(
direction === "previous" ? rows.toReversed() : rows,
SessionHistory.decodeMessageRow,
)
return yield* store.messages(input)
}),
message: (input) => sessions.forSession(input.sessionID).message(input.messageID),
updateMessage: (input) => sessions.forSession(input.sessionID).updateMessage(input),
+119 -2
View File
@@ -1,7 +1,10 @@
export * as SessionStore from "./store.js"
import { and, eq, isNotNull, isNull, notInArray, sql } from "drizzle-orm"
import { Context, Effect, Layer } from "effect"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, notInArray, or, sql, type SQL } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "@opencode-ai/schema/project"
import { Workspace } from "@opencode-ai/schema/workspace"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionHistory } from "./history.js"
@@ -11,8 +14,45 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionMessageTable, SessionTable } from "./sql.js"
import { fromRow } from "./info.js"
const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
parentID: Schema.NullOr(Session.ID).pipe(Schema.optional),
anchor: Session.ListAnchor.pipe(Schema.optional),
}
const ListDirectoryInput = Schema.Struct({
...ListInputBase,
directory: AbsolutePath,
})
const ListProjectInput = Schema.Struct({
...ListInputBase,
project: Project.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const ListAllInput = Schema.Struct(ListInputBase)
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
export type ListInput = typeof ListInput.Type
export type MessagesInput = {
sessionID: Session.ID
limit?: number
order?: "asc" | "desc"
cursor?: {
id: SessionMessage.ID
direction: "previous" | "next"
}
}
export interface Interface {
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
readonly list: (input?: ListInput) => Effect.Effect<Session.Info[]>
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
@@ -55,6 +95,83 @@ const layer = Layer.effect(
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined
}),
list: Effect.fn("SessionStore.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.parentID !== undefined)
conditions.push(
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
)
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}),
messages: Effect.fn("SessionStore.messages")(function* (input) {
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(
direction === "previous" ? rows.toReversed() : rows,
SessionHistory.decodeMessageRow,
)
}),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
+62
View File
@@ -1207,6 +1207,68 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
)
})
testEffect(Layer.empty).live(
"merges MCP defaults into the winning configured server without changing runtime overrides",
() =>
Effect.gen(function* () {
const entries = [
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { startup: 10, catalog: 20, execution: 30 },
servers: {
resources: { type: "local", command: ["earlier"], disabled: true, timeout: { execution: 90 } },
},
}),
}),
}),
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { catalog: 40 },
servers: {
resources: { type: "local", command: ["later"], disabled: true, timeout: { startup: 50 } },
},
}),
}),
}),
]
const original = JSON.stringify(entries)
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
const check = yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual({
type: "local",
command: ["later"],
disabled: true,
timeout: { startup: 50, catalog: 40, execution: 30 },
})
})
yield* check.dispose
const runtime = {
type: "local",
command: ["runtime"],
disabled: true,
timeout: { catalog: 60 },
} satisfies ConfigMCP.Local
yield* service.add("resources", runtime)
yield* service.reload()
yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual(runtime)
})
}).pipe(
Effect.provide(
resourceMcpLayer("https://unused.example", undefined, undefined, {
entries: () => Effect.succeed(entries),
}),
),
)
expect(JSON.stringify(entries)).toBe(original)
}),
)
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
"manages live MCP servers entirely through scoped transforms",
() =>
+22 -3
View File
@@ -21,6 +21,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { fromRow } from "@opencode-ai/core/session/info"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Shell } from "@opencode-ai/schema/shell"
import {
InstructionStateTable,
@@ -32,9 +33,10 @@ import { testEffect } from "./lib/effect"
import { Snapshot } from "@opencode-ai/core/snapshot"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
[[Bus.node, Bus.configured({ persist: true })]],
),
)
const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]])
const sessionID = Session.ID.make("ses_projector_test")
@@ -278,7 +280,9 @@ describe("SessionProjector", () => {
yield* db.run(sql`update session_message set data = '{"time":{"created":0}}' where id = ${messageID}`)
const sessions = yield* Session.Service
const store = yield* SessionStore.Service
const expected = { _tag: "Session.MessageDecodeError", sessionID, messageID }
expect(yield* store.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
expect(yield* sessions.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
expect(yield* sessions.context(sessionID).pipe(Effect.flip)).toMatchObject(expected)
expect(yield* sessions.message({ sessionID, messageID }).pipe(Effect.catchDefect(Effect.succeed))).toMatchObject(
@@ -287,6 +291,21 @@ describe("SessionProjector", () => {
}).pipe(Effect.provide(sessionsLayer)),
)
it.effect("checks session existence before resolving a missing message cursor", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const missing = Session.ID.make("ses_missing")
expect(
yield* sessions
.messages({
sessionID: missing,
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
})
.pipe(Effect.flip),
).toEqual(new Session.NotFoundError({ sessionID: missing }))
}).pipe(Effect.provide(sessionsLayer)),
)
it.effect("consumes the pending row and projects the message at promotion", () =>
Effect.gen(function* () {
const db = yield* seedSession()
+190
View File
@@ -0,0 +1,190 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Event } from "@opencode-ai/schema/event"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
const seedSessions = (rows: { id: string; updated: number }[]) =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const directory = AbsolutePath.make("/project")
yield* database.db.insert(ProjectTable).values({ id: Project.ID.global, worktree: directory, sandboxes: [] }).run()
yield* Effect.forEach(rows, (row) =>
Effect.gen(function* () {
const sessionID = Session.ID.make(row.id)
yield* bus.publish(SessionEvent.Created, {
sessionID,
projectID: Project.ID.global,
location: { directory },
slug: "store-test",
version: "test",
})
yield* bus.replay({
id: Event.ID.create(),
created: row.updated,
aggregateID: sessionID,
seq: 1,
type: Bus.versionedType(SessionEvent.Renamed.type, 1),
data: { sessionID, title: row.id },
})
}),
)
return bus
})
describe("SessionStore", () => {
it.effect("lists by updated time and ID with exclusive two-item pages in either direction", () =>
Effect.gen(function* () {
yield* seedSessions([
{ id: "ses_d", updated: 20 },
{ id: "ses_z", updated: 10 },
{ id: "ses_a", updated: 30 },
{ id: "ses_c", updated: 20 },
{ id: "ses_y", updated: 10 },
{ id: "ses_e", updated: 30 },
{ id: "ses_b", updated: 20 },
])
const store = yield* SessionStore.Service
expect((yield* store.list()).map((session) => String(session.id))).toEqual([
"ses_e",
"ses_a",
"ses_d",
"ses_c",
"ses_b",
"ses_z",
"ses_y",
])
expect((yield* store.list({ order: "asc" })).map((session) => String(session.id))).toEqual([
"ses_y",
"ses_z",
"ses_b",
"ses_c",
"ses_d",
"ses_a",
"ses_e",
])
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
{ order: "asc", direction: "next", ids: ["ses_d", "ses_a"] },
{ order: "asc", direction: "previous", ids: ["ses_z", "ses_b"] },
{ order: "desc", direction: "next", ids: ["ses_b", "ses_z"] },
{ order: "desc", direction: "previous", ids: ["ses_a", "ses_d"] },
]
yield* Effect.forEach(pages, (page) =>
Effect.gen(function* () {
const sessions = yield* store.list({
order: page.order,
limit: 2,
anchor: { id: Session.ID.make("ses_c"), time: 20, direction: page.direction },
})
expect(sessions.map((session) => String(session.id))).toEqual(page.ids)
}),
)
}),
)
it.effect("pages messages by durable sequence, not timestamp or ID, and scopes cursor lookup", () =>
Effect.gen(function* () {
const sessionID = Session.ID.make("ses_messages")
const foreignID = Session.ID.make("ses_foreign")
const bus = yield* seedSessions([
{ id: sessionID, updated: 0 },
{ id: foreignID, updated: 0 },
])
const store = yield* SessionStore.Service
yield* Effect.forEach(
[
{ id: "evt_z", created: 300 },
{ id: "evt_b", created: 700 },
{ id: "evt_x", created: 100 },
{ id: "evt_c", created: 400 },
{ id: "evt_w", created: 200 },
{ id: "evt_a", created: 600 },
{ id: "evt_y", created: 500 },
],
(event, index) =>
bus.replay({
id: Event.ID.make(event.id),
created: event.created,
aggregateID: sessionID,
seq: index + 2,
type: Bus.versionedType(SessionEvent.Synthetic.type, 1),
data: { sessionID, text: event.id },
}),
)
yield* bus.publish(
SessionEvent.Synthetic,
{ sessionID: foreignID, text: "foreign" },
{
id: Event.ID.make("evt_foreign"),
},
)
expect((yield* store.messages({ sessionID })).map((message) => String(message.id))).toEqual([
"msg_y",
"msg_a",
"msg_w",
"msg_c",
"msg_x",
"msg_b",
"msg_z",
])
expect((yield* store.messages({ sessionID, order: "asc" })).map((message) => String(message.id))).toEqual([
"msg_z",
"msg_b",
"msg_x",
"msg_c",
"msg_w",
"msg_a",
"msg_y",
])
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
{ order: "asc", direction: "next", ids: ["msg_w", "msg_a"] },
{ order: "asc", direction: "previous", ids: ["msg_b", "msg_x"] },
{ order: "desc", direction: "next", ids: ["msg_x", "msg_b"] },
{ order: "desc", direction: "previous", ids: ["msg_a", "msg_w"] },
]
yield* Effect.forEach(pages, (page) =>
Effect.gen(function* () {
const messages = yield* store.messages({
sessionID,
order: page.order,
limit: 2,
cursor: { id: SessionMessage.ID.make("msg_c"), direction: page.direction },
})
expect(messages.map((message) => String(message.id))).toEqual(page.ids)
}),
)
expect(yield* store.messages({ sessionID: Session.ID.make("ses_missing") })).toEqual([])
expect(
yield* store.messages({
sessionID,
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
}),
).toEqual([])
expect(
yield* store.messages({
sessionID,
order: "asc",
cursor: { id: SessionMessage.ID.make("msg_foreign"), direction: "next" },
}),
).toEqual([])
}),
)
})
+2
View File
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Session } from "@opencode-ai/core/session"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
@@ -126,6 +127,7 @@ test("Core reuses the canonical shared schemas", async () => {
[Session.ID, schemaSession.Session.ID],
[Session.Info, schemaSession.Session.Info],
[Session.ListAnchor, schemaSession.Session.ListAnchor],
[Session.ListInput, SessionStore.ListInput],
[coreSessionInbox.Delivery, SessionInbox.Delivery],
[coreSessionInbox.Item, SessionInbox.Item],
[coreSessionInbox.User, SessionInbox.User],
@@ -3,6 +3,7 @@ import path from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
import type { CustomMacSignOptions } from "app-builder-lib"
import type { Configuration } from "electron-builder"
const execFileAsync = promisify(execFile)
@@ -29,6 +30,18 @@ async function signWindows(configuration: { path: string }) {
)
}
export function macSignOptions(options: CustomMacSignOptions): CustomMacSignOptions {
return {
...options,
optionsForFile: (file) => {
const defaults = options.optionsForFile?.(file)
if (file !== path.join(options.app, "Contents/Resources/opencode-cli")) return defaults ?? {}
// The Bun CLI loads bun-pty's native library; Electron and its helpers do not need this exception.
return { ...defaults, entitlements: path.join(packageDir, "resources/entitlements.cli.plist") }
},
}
}
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
@@ -88,6 +101,10 @@ const getBase = (appId: string): Configuration => ({
gatekeeperAssess: false,
entitlements: "resources/entitlements.plist",
entitlementsInherit: "resources/entitlements.plist",
sign: async (options) => {
const { sign } = await import("app-builder-lib/out/codeSign/macCodeSign")
await sign(macSignOptions(options))
},
notarize: true,
target: ["dmg", "zip"],
},
+1
View File
@@ -44,6 +44,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"app-builder-lib": "26.15.7",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,81 @@
import { parseColor, RGBA, type ColorInput } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { oneCellFrame, type OneCellMotion } from "../ui/one-cell-motion"
import { registerOpencodeSpinner } from "./register-spinner"
registerOpencodeSpinner()
export function OneCellSpinner(props: {
animation: OneCellMotion
color: ColorInput
animations?: boolean
speed?: number
paused?: boolean
glow?: boolean
age?: number
still?: string
}) {
const renderer = useRenderer()
const [elapsed, setElapsed] = createSignal(0)
const sequenced = () => !!props.animation.intro || !!props.animation.once || !!props.animation.pace
const frame = createMemo(() => oneCellFrame(props.animation, elapsed()))
const complete = createMemo(() => frame().complete)
const base = createMemo(() => parseColor(props.color))
const colors = createMemo(() =>
props.animation.levels?.map((level) => {
const color = RGBA.clone(base())
color.a *= level
return color
}),
)
const color = createMemo(() => {
if (props.glow === false) return base()
if (sequenced()) {
const color = RGBA.clone(base())
color.a *= frame().level
return color
}
const palette = colors()
return palette ? (frame: number) => palette[frame]! : base()
})
createEffect(() => {
props.animation
props.animations
setElapsed(props.age ?? 0)
})
createEffect(() => {
if (!sequenced() || props.animations === false || props.paused || complete()) return
let previous = performance.now()
// Leave idle gaps: mini awaits renderer.idle() to flush and admit prompts.
const timer = setInterval(
() => {
const now = performance.now()
setElapsed((value) => value + (now - previous) * (props.speed ?? 1))
previous = now
},
Math.max(
1000 / 60,
Math.min(40, props.animation.interval / (props.speed ?? 1) / (props.animation.pace?.initial ?? 1)),
),
)
onCleanup(() => {
clearInterval(timer)
renderer.requestRender()
})
})
return (
<box width={1} height={1} flexShrink={0}>
<Show when={props.animations !== false} fallback={<text fg={base()}>{props.still ?? "\u25aa"}</text>}>
<spinner
frames={sequenced() ? [frame().glyph] : props.animation.frames}
interval={props.animation.interval / (props.speed ?? 1)}
autoplay={!props.paused && !sequenced()}
color={color()}
/>
</Show>
</box>
)
}
+10 -45
View File
@@ -51,13 +51,7 @@ import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import {
normalizePastedFilepath,
parsePastedFilepaths,
readLocalAttachment,
MAX_LOCAL_ATTACHMENT_BYTES,
type LocalAttachment,
} from "./local-attachment"
import { resolvePastedAttachments } from "./local-attachment"
import { locationKey, useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
@@ -1454,36 +1448,19 @@ export function Prompt(props: PromptProps) {
async function pasteInputText(text: string, changed: () => boolean) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
const attachment = await readLocalAttachment(filepath)
if (attachment) {
if (changed()) return
pasteLocalAttachment(filepath, attachment)
return
}
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
if (filepaths.length > 1) {
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
for (const candidate of filepaths) {
const next = await readLocalAttachment(candidate, remaining)
if (!next) break
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
attachments.push({ filepath: candidate, attachment: next })
}
if (attachments.length === filepaths.length) {
if (changed()) return
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
const attachments = await resolvePastedAttachments(pastedContent, terminalEnvironment.platform)
if (changed()) return
if (attachments) {
attachments.forEach((attachment) => {
if (attachment.type === "text") {
pasteText(attachment.content, `[SVG: ${attachment.filename || "image"}]`)
return
}
}
pasteAttachment(attachment)
})
return
}
if (changed()) return
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
@@ -1508,18 +1485,6 @@ export function Prompt(props: PromptProps) {
}, 0)
}
function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) {
const filename = path.basename(filepath)
if (attachment.type === "text") {
pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
return
}
pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
}
function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
@@ -26,6 +26,38 @@ export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMEN
)
}
export async function resolvePastedAttachments(text: string, platform: string) {
const pastedContent = text.trim()
const filepath = normalizePastedFilepath(pastedContent, platform)
if (/^(https?):\/\//.test(filepath)) return undefined
const attachment = await readLocalAttachment(filepath)
const attachments = attachment ? [{ filepath, attachment }] : []
if (!attachment) {
const filepaths = parsePastedFilepaths(pastedContent, platform)
if (filepaths.length <= 1) return undefined
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
for (const candidate of filepaths) {
const next = await readLocalAttachment(candidate, remaining)
if (!next) return undefined
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
attachments.push({ filepath: candidate, attachment: next })
}
}
return attachments.map((item) => {
const filename = path.basename(item.filepath)
if (item.attachment.type === "text") {
return { type: "text" as const, content: item.attachment.content, filename }
}
return {
type: "file" as const,
uri: `data:${item.attachment.mime};base64,${Buffer.from(item.attachment.content).toString("base64")}`,
filename,
}
})
}
const mimeTypes: Record<string, string> = {
".avif": "image/avif",
".gif": "image/gif",
+21
View File
@@ -26,6 +26,24 @@ export const AttentionSoundName = Schema.Literals([
export type AttentionSoundName = Schema.Schema.Type<typeof AttentionSoundName>
export type AttentionSoundPaths = Partial<Record<AttentionSoundName, string>>
export const MiniWorkSpinner = Schema.Literals([
"block-soft-slide",
"block-soft-sweep",
"block-low-comet",
"block-low-duet",
"block-shuttle",
"block-bridge",
"block-squeeze",
"small-toggle",
"square-toggle",
"grow-shrink",
"quadrant-orbit",
"crosshatch",
"density-wave",
"seed",
])
export type MiniWorkSpinner = Schema.Schema.Type<typeof MiniWorkSpinner>
export const Plugin = Schema.Union([
Schema.String,
Schema.Struct({
@@ -185,6 +203,9 @@ export const Info = Schema.Struct({
splash: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide the entry and exit splash banners",
}),
work_spinner: Schema.optional(MiniWorkSpinner).annotate({
description: "Work spinner animation in the Mini footer (default: block-soft-slide)",
}),
mono: Schema.optional(Schema.Boolean).annotate({
description: "Use monochrome ASCII output",
}),
@@ -5,6 +5,7 @@ import { StoryFooter } from "./footer"
import { mermanLayoutsStory } from "./merman-layouts"
import { sessionTabsStory } from "./session-tabs"
import { sessionLocationMissingStory } from "./session-location-missing"
import { oneCellSpinnerStory } from "./one-cell-spinner"
/**
* A story is a full-screen, fixture-driven simulation of a real production component. Stories own
@@ -16,7 +17,7 @@ export type Story = {
render: (context: Plugin.Context) => JSX.Element
}
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory]
const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory, oneCellSpinnerStory]
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
@@ -0,0 +1,144 @@
import { SEED_LAUNCH, SEED_WORK, WORK_SPINNERS, type OneCellMotion } from "../../../ui/one-cell-motion"
import { SUBCELL_SPINNERS } from "./subcell-spinner.fixtures"
export type SpinnerFixture = OneCellMotion & {
name: string
description: string
launch?: OneCellMotion
}
function heldFrames(glyphs: string, holds: number[] = []) {
return Array.from(glyphs).flatMap((glyph, index) => Array.from({ length: holds[index] ?? 1 }, () => glyph))
}
function sequence(
name: string,
description: string,
glyphs: string,
interval: number,
holds?: number[],
): SpinnerFixture {
return { name, description, frames: heldFrames(glyphs, holds), interval }
}
function pulse(
name: string,
description: string,
glyphs: string,
duration: number,
level: (phase: number) => number,
holds?: number[],
): SpinnerFixture {
const shapes = heldFrames(glyphs, holds)
const phases = Array.from({ length: duration / 40 }, (_, index) => index / (duration / 40))
return {
name,
description,
frames: phases.map((phase) => shapes[Math.floor(phase * shapes.length)]!),
interval: 40,
// Keep a visible floor: a working indicator must never blink out entirely.
levels: phases.map((phase) => 0.3 + 0.7 * level(phase)),
}
}
const breathe = (phase: number) => (1 - Math.cos(phase * 2 * Math.PI)) / 2
const ember = (phase: number) => (phase < 0.1 ? phase / 0.1 : ((1 - phase) / 0.9) ** 3)
const seedBreathe = pulse("Seed breathe", "A still seed carries a slow breath of light.", "\u25aa", 1600, breathe)
const seedToggle = pulse(
"Seed toggle",
"An outline fills with light, then opens again.",
"\u25ab\u25aa\u25aa\u25ab",
1600,
breathe,
)
export const ONE_CELL_SPINNERS: SpinnerFixture[] = [
{
...WORK_SPINNERS["small-toggle"],
name: "Small toggle",
description: "A small square opens and closes in an even rhythm.",
},
{
...WORK_SPINNERS["square-toggle"],
name: "Square toggle",
description: "A larger square opens and closes at a slower pace.",
},
{
...WORK_SPINNERS["grow-shrink"],
name: "Grow / shrink",
description: "An outline fills, grows, and returns to a seed.",
},
sequence(
"Inset bloom",
"A square within a square opens into a full bloom.",
"\u25ab\u25aa\u25a3\u25a0\u25a3\u25aa",
120,
[3],
),
sequence("Hollow bloom", "The outline grows before its center fills.", "\u25ab\u25a1\u25a3\u25a0\u25a3\u25a1", 160, [
3,
]),
sequence(
"Corner orbit",
"A small square traces four corners without leaving its cell.",
"\u25f0\u25f3\u25f2\u25f1",
160,
),
{
...WORK_SPINNERS["quadrant-orbit"],
name: "Quadrant orbit",
description: "Four corners take their turn, clockwise.",
},
sequence("Half rotation", "Light and shade circle a still square.", "\u25e7\u2b12\u25e8\u2b13", 200),
{ ...WORK_SPINNERS.crosshatch, name: "Crosshatch", description: "Diagonal threads cross, then change direction." },
{
...WORK_SPINNERS["density-wave"],
name: "Density wave",
description: "Grain gathers into a block, then thins. The color stays still.",
},
sequence(
"Fill / drain",
"A narrow tide rises, then returns.",
"\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588\u2587\u2586\u2585\u2584\u2583\u2582",
80,
[2, 2, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 2],
),
sequence(
"Double beat",
"Two quick swells, then a quiet seed.",
"\u25aa\u25a0\u25aa\u25a0\u25aa",
100,
[1, 1, 2, 1, 11],
),
sequence("Beacon", "One large flash settles into a seed that stays in sight.", "\u25a0\u25a3\u25aa", 120, [1, 1, 12]),
sequence(
"Held bloom",
"A seed swells, holds its bloom, then rests.",
"\u25aa\u25a3\u25a0\u25a3\u25aa",
160,
[4, 1, 2, 1, 2],
),
seedBreathe,
pulse("Square breathe", "A larger square takes a longer breath.", "\u25a0", 2400, breathe),
pulse("Inset breathe", "A square within a square holds a soft breath of light.", "\u25a3", 2000, breathe),
pulse(
"Bloom + glow",
"A small bloom grows with the light, then recedes.",
"\u25aa\u25a3\u25a0\u25a3\u25aa",
1600,
breathe,
[6, 3, 2, 3, 6],
),
pulse("Soft heartbeat", "Two soft beats of light, then a quiet glow.", "\u25aa", 1600, (phase) =>
Math.max(Math.exp(-(((phase - 0.2) / 0.08) ** 2)), 0.75 * Math.exp(-(((phase - 0.4) / 0.08) ** 2))),
),
pulse("Ember", "A quick spark leaves a long glow in a still square.", "\u25a3", 1600, ember),
{ ...seedToggle, launch: SEED_LAUNCH },
pulse("Seed ember", "A small, still seed catches light and lets it linger.", "\u25aa", 1600, ember),
{
...SEED_WORK,
name: "Seed handoff",
description: "A spark lingers, then the breath grows quiet.",
launch: SEED_LAUNCH,
},
...SUBCELL_SPINNERS,
]
@@ -0,0 +1,293 @@
import type { Plugin } from "@opencode-ai/plugin/tui"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { OneCellSpinner } from "../../../component/one-cell-spinner"
import { useConfig } from "../../../config"
import { entrySplashLayout } from "../../../mini/splash"
import { stringWidth } from "../../../util/string-width"
import { StoryFooter } from "./footer"
import type { Story } from "./index"
import { ONE_CELL_SPINNERS } from "./one-cell-spinner.fixtures"
function OneCellSpinnerStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const config = useConfig()
const [selected, setSelected] = createSignal(39)
const [speed, setSpeed] = createSignal(1)
const [animations, setAnimations] = createSignal(config.data.animations ?? true)
const [paused, setPaused] = createSignal(false)
const [glow, setGlow] = createSignal(true)
const [solo, setSolo] = createSignal(false)
const [epoch, setEpoch] = createSignal(0)
const [age, setAge] = createSignal(0)
const animation = () => ONE_CELL_SPINNERS[selected()]!
const timing = createMemo(() => {
const item = animation()
const cycle = (item.frames.length * item.interval) / speed()
if (!item.pace) return `${Math.round((item.interval / speed()) * 10) / 10}ms tick / ${cycle}ms cycle`
return `${(cycle / item.pace.initial / 1000).toFixed(2)}s to ${(cycle / item.pace.final / 1000).toFixed(2)}s cycle`
})
const previewWidth = () => (solo() ? Math.min(dimensions().width, 64) : dimensions().width)
const speeds = createMemo(() => (dimensions().width >= 60 ? [0.5, 1, 2] : [speed()]))
const splash = createMemo(() =>
entrySplashLayout({ width: Math.max(1, previewWidth() - 8), version: "1.18.4", detail: "~/src/opencode" }),
)
let scroll: ScrollBoxRenderable | undefined
const revealSelected = () => {
if (!scroll || scroll.isDestroyed) return
const rows = scroll.viewport.height
scroll.scrollTo(Math.max(0, Math.min(selected() - Math.floor(rows / 2), ONE_CELL_SPINNERS.length - rows)))
}
createEffect(revealSelected)
props.context.keymap.layer(() => ({
commands: [
{
bind: "escape",
title: solo() ? "Leave focus" : "Back to storybook",
group: "Storybook",
run: () => {
if (solo()) return setSolo(false)
props.context.ui.router.navigate({ type: "plugin", name: "storybook" })
},
},
{
bind: "up,k",
title: "Previous animation",
group: "Storybook",
run: () => setSelected((value) => (value + ONE_CELL_SPINNERS.length - 1) % ONE_CELL_SPINNERS.length),
},
{
bind: "down,j",
title: "Next animation",
group: "Storybook",
run: () => setSelected((value) => (value + 1) % ONE_CELL_SPINNERS.length),
},
{
bind: "s",
title: "Cycle preview speed",
group: "Storybook",
run: () => setSpeed((value) => (value === 0.5 ? 1 : value === 1 ? 2 : 0.5)),
},
{ bind: "space", title: "Pause / resume", group: "Storybook", run: () => setPaused((value) => !value) },
{ bind: "a", title: "Toggle animations", group: "Storybook", run: () => setAnimations((value) => !value) },
{ bind: "g", title: "Toggle intensity pulse", group: "Storybook", run: () => setGlow((value) => !value) },
{
bind: "f",
title: "Focus selected animation",
group: "Storybook",
run: () =>
batch(() => {
setSolo((value) => !value)
if (solo()) setEpoch((value) => value + 1)
}),
},
{
bind: "p",
title: "Replay animation",
group: "Storybook",
run: () =>
batch(() => {
setAge(0)
setEpoch((value) => value + 1)
}),
},
...(animation().pace
? [
{
bind: "t",
title: "Cycle work age",
group: "Storybook",
run: () => setAge((value) => (value === 0 ? 30_000 : value === 30_000 ? 60_000 : 0)),
},
]
: []),
{
bind: "r",
title: "Reset comparison",
group: "Storybook",
run: () =>
batch(() => {
setSelected(39)
setSpeed(1)
setAnimations(config.data.animations ?? true)
setPaused(false)
setGlow(true)
setSolo(false)
setAge(0)
setEpoch((value) => value + 1)
}),
},
],
}))
return (
<box
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background.default}
justifyContent={solo() ? "center" : undefined}
alignItems={solo() ? "center" : undefined}
>
<Show when={!solo()}>
<text fg={theme.text.default} flexShrink={0}>
one-cell motion lab.
</text>
</Show>
<For each={[epoch()]}>
{() => (
<>
<Show when={!solo()}>
<box flexDirection="row" height={1} flexShrink={0} paddingLeft={1}>
<text width={22} fg={theme.text.subdued}>
pattern
</text>
<For each={speeds()}>
{(value) => (
<text width={7} fg={theme.text.subdued}>
{value}x
</text>
)}
</For>
<text fg={theme.text.subdued}>cycle @1x</text>
</box>
<scrollbox
ref={scroll}
flexGrow={1}
minHeight={1}
viewportOptions={{ paddingLeft: 1 }}
onSizeChange={() => queueMicrotask(revealSelected)}
>
<For each={ONE_CELL_SPINNERS}>
{(item, index) => (
<box height={1} flexShrink={0} flexDirection="row">
<text
width={22}
wrapMode="none"
fg={index() === selected() ? theme.text.formfield.selected : theme.text.formfield.default}
>
{index() === selected() ? ">" : " "}
{String(index() + 1).padStart(2)} {item.name.toLowerCase()}.
</text>
<For each={speeds()}>
{(value) => (
<box width={7} flexShrink={0}>
<OneCellSpinner
animation={item}
age={age()}
speed={value}
animations={animations()}
paused={paused()}
glow={glow()}
color={theme.text.status.running}
/>
</box>
)}
</For>
<text width={10} fg={theme.text.subdued}>
{item.pace ? "adaptive" : `${item.frames.length * item.interval}ms`}
</text>
<Show when={dimensions().width >= 80}>
<text fg={theme.text.subdued}>
{[...new Set(item.frames)].join(" ")}
{item.levels ? " + intensity" : ""}
</text>
</Show>
</box>
)}
</For>
</scrollbox>
</Show>
<box
width={previewWidth()}
flexShrink={0}
paddingLeft={1}
paddingRight={1}
alignItems={solo() ? "center" : undefined}
>
<text fg={theme.text.default} maxWidth="100%" attributes={solo() ? TextAttributes.BOLD : 0}>
<Show when={!solo()}>{String(selected() + 1).padStart(2, "0")} / </Show>
{animation().name.toLowerCase()}.
</text>
<Show when={!solo()}>
<text fg={theme.text.subdued} maxWidth="100%">
{animation().description}
</text>
<text fg={theme.text.subdued}>
{speed()}x: {timing()}
</text>
</Show>
<box
width={
solo() ? Math.min(previewWidth() - 2, stringWidth(splash().label + splash().metadata) + 7) : "100%"
}
marginTop={solo() ? 1 : 0}
>
<box height={1} flexDirection="row">
<text width={7} fg={theme.text.subdued}>
work
</text>
<OneCellSpinner
animation={animation()}
age={age()}
speed={speed()}
animations={animations()}
paused={paused()}
glow={glow()}
color={theme.text.status.running}
/>
<text fg={theme.text.default}> esc stop</text>
</box>
<box height={1} flexDirection="row">
<text width={7} fg={theme.text.subdued}>
launch
</text>
<OneCellSpinner
animation={animation().launch ?? animation()}
speed={speed()}
animations={animations()}
paused={paused()}
glow={glow()}
color={theme.text.default}
/>
<text fg={theme.text.default} wrapMode="none">
{splash().label.slice(1)}
<span style={{ fg: theme.text.subdued }}>{splash().metadata}</span>
</text>
</box>
</box>
</box>
</>
)}
</For>
<Show when={!solo()}>
<StoryFooter
context={props.context}
title="motion lab."
details={[animations() ? (paused() ? "paused" : "playing") : "motion off", glow() ? "glow on" : "shape only"]}
status={animation().pace ? `start at ${age() / 1000}s | slows after 30s` : undefined}
controls={[
{ shortcut: "j/k", label: "select" },
{ shortcut: "s", label: "speed" },
{ shortcut: "space", label: "pause" },
{ shortcut: "a", label: "motion" },
{ shortcut: "g", label: "glow" },
{ shortcut: "f", label: "focus" },
{ shortcut: "p", label: "replay" },
...(animation().pace ? [{ shortcut: "t", label: "age 0/30/60s" }] : []),
{ shortcut: "r", label: "reset" },
{ shortcut: "esc", label: "back" },
]}
/>
</Show>
</box>
)
}
export const oneCellSpinnerStory: Story = {
id: "one-cell-spinners",
title: "one-cell spinners.",
render: (context) => <OneCellSpinnerStory context={context} />,
}
@@ -0,0 +1,115 @@
import {
BLOCK_LOW_COMET,
BLOCK_SOFT_SLIDE,
BLOCK_SOFT_SWEEP,
SEED_LAUNCH,
WORK_SPINNERS,
} from "../../../ui/one-cell-motion"
import { octantGlyph } from "../../../ui/subcell"
import type { SpinnerFixture } from "./one-cell-spinner.fixtures"
const perimeter = [0, 1, 3, 5, 7, 6, 4, 2]
const patterns = [
{
name: "comet",
description: "Three pixels chase the edge.",
interval: 100,
masks: perimeter.map(
(point, index) => (1 << point) | (1 << perimeter[(index + 7) % 8]!) | (1 << perimeter[(index + 6) % 8]!),
),
},
{
name: "orbit",
description: "Two pixels keep a steady orbit.",
interval: 140,
masks: perimeter.map((point, index) => (1 << point) | (1 << perimeter[(index + 7) % 8]!)),
},
{
name: "duet",
description: "Two pairs circle opposite edges.",
interval: 180,
masks: perimeter
.slice(0, 4)
.map((_, index) => [0, 3, 4, 7].reduce((mask, offset) => mask | (1 << perimeter[(index + offset) % 8]!), 0)),
},
{
name: "scan",
description: "A row sweeps down, pauses, and returns.",
interval: 130,
masks: [0, 0, 1, 2, 3, 3, 2, 1].map((row) => 3 << (row * 2)),
},
{
name: "weave",
description: "Four pixels pass from side to side.",
interval: 200,
masks: [0x55, 0x69, 0xaa, 0x96],
},
{
name: "tide",
description: "Rows rise from the floor, then recede.",
interval: 160,
masks: [0xc0, 0xc0, 0xf0, 0xfc, 0xff, 0xfc, 0xf0],
},
{
name: "low comet",
description: "The tail recedes before the head moves. The top row stays empty.",
motion: BLOCK_LOW_COMET,
blockOnly: true,
},
{
name: "low duet",
description: "Two tails recede, then the heads move. The top row stays empty.",
motion: WORK_SPINNERS["block-low-duet"],
blockOnly: true,
},
// The middle four octants are bits 2-5: left column 0x14, right column 0x28.
{
name: "shuttle",
description: "A narrow bar moves left and right through the middle four.",
motion: WORK_SPINNERS["block-shuttle"],
blockOnly: true,
},
{
name: "bridge",
description: "The bar stretches across the middle four, then settles opposite.",
motion: WORK_SPINNERS["block-bridge"],
blockOnly: true,
},
{
name: "soft sweep",
description: "A staggered wipe crosses the middle four and retraces its path.",
motion: BLOCK_SOFT_SWEEP,
blockOnly: true,
},
{
name: "squeeze",
description: "The middle bar folds, hops sideways, and opens.",
motion: WORK_SPINNERS["block-squeeze"],
blockOnly: true,
},
{
name: "soft slide",
description: "The middle bar softens before each sideways step.",
blockOnly: true,
motion: BLOCK_SOFT_SLIDE,
},
]
export const SUBCELL_SPINNERS: SpinnerFixture[] = patterns.flatMap((pattern) =>
(pattern.blockOnly ? ["block"] : ["block", "dot"]).map((style) => ({
name: `${style} ${pattern.name}`,
description: `${pattern.description} ${style === "block" ? "2x4 octants need a recent font." : "2x4 braille dots."}`,
...(pattern.motion ?? {
interval: pattern.interval!,
frames: pattern.masks!.map((mask) => {
if (style === "dot") {
// Braille numbers its dots by column rather than by raster row.
const dots = [0, 3, 1, 4, 2, 5, 6, 7].reduce((value, bit, index) => value | (((mask >> index) & 1) << bit), 0)
return String.fromCodePoint(0x2800 + dots)
}
return octantGlyph(mask)
}),
}),
launch: SEED_LAUNCH,
})),
)
+19
View File
@@ -32,6 +32,7 @@ import type {
} from "./types"
const KINDS = [
"motion",
"markdown",
"table",
"text",
@@ -152,6 +153,7 @@ type State = {
perms: Map<string, Perm>
forms: Map<string, FormRequest>
started: Set<string>
motion?: AbortController
}
type Input = {
@@ -814,6 +816,17 @@ function emitForm(state: State, kind: FormKind = "question"): void {
}
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
if (kind === "motion") {
note(state.footer, "Working indicator demo: 70 seconds, no model calls. Interrupt to stop early.")
const controller = new AbortController()
state.motion = controller
try {
await wait(70_000, signal ? AbortSignal.any([controller.signal, signal]) : controller.signal)
} finally {
state.motion = undefined
}
return true
}
if (kind === "text") {
await emitText(state, body || SAMPLE_MARKDOWN, signal)
return true
@@ -900,6 +913,7 @@ function intro(state: State): void {
"- /form question",
"- /form external",
"- /fmt markdown",
"- /fmt motion",
"- /fmt table",
"- /fmt text your custom text",
].join("\n"),
@@ -1037,6 +1051,11 @@ export function createRunDemo(input: Input) {
return {
start,
prompt,
interrupt() {
if (!state.motion) return false
state.motion.abort()
return true
},
permission,
formReply,
formCancel,
+5
View File
@@ -194,6 +194,11 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru
const raw = cleanRunText(commit.text)
const mono = options?.mono === true
if (commit.image) {
const caption = raw.trim() || "Image"
return commit.kind === "user" ? userBody(caption, mono) : textBody(mono ? monoToolText(caption, true) : caption)
}
if (commit.kind === "user") {
return userBody(raw, mono)
}
+161 -51
View File
@@ -1,10 +1,20 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/core"
import { useKeyboard, type JSX } from "@opentui/solid"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { Keymap } from "../context/keymap"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { Config } from "../config"
import { OneCellSpinner } from "../component/one-cell-spinner"
import { SEED_MONO, WORK_SPINNERS } from "../ui/one-cell-motion"
import {
FOOTER_COMPACT_WIDTH,
RunFooterMenu,
createFooterMenuState,
footerMenuText,
type RunFooterMenuItem,
} from "./footer.menu"
import { stringWidth } from "../util/string-width"
import { monoShortcut } from "./mono"
import type { RunFooterTheme } from "./theme"
import type {
@@ -78,7 +88,12 @@ const PANEL_FRAME_ROWS = 6
export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + PANEL_FRAME_ROWS
const SUBAGENT_LIST_ROWS = 12
export const RUN_SUBAGENT_PANEL_ROWS = SUBAGENT_LIST_ROWS + PANEL_FRAME_ROWS
const PANEL_PAGE = PANEL_LIST_ROWS - 1
export function footerPanelLayout(height: number, limit = PANEL_LIST_ROWS) {
const available = Math.max(3, height - 1)
const compact = available < limit + PANEL_FRAME_ROWS
const frame = compact ? 2 : PANEL_FRAME_ROWS
return { compact, frame, limit: Math.max(1, Math.min(limit, available - frame)) }
}
const HALF_BLOCK_BORDER = {
topLeft: "",
bottomLeft: "",
@@ -138,10 +153,17 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
onKey?: (event: KeyEvent, item: T | undefined) => boolean
onRows?: (rows: number) => void
}) {
const renderer = useRenderer()
const term = useTerminalDimensions()
const layout = createMemo(() => {
term()
// The panel mounts before the footer expands, so its initial render height is stale.
return footerPanelLayout(renderer.terminalHeight, input.limit)
})
let field: InputRenderable | undefined
const [query, setQuery] = createSignal("")
const items = createMemo<T[]>(() => match(query(), input.entries()))
const menu = createFooterMenuState({ count: () => items().length, limit: input.limit })
const menu = createFooterMenuState({ count: () => items().length, limit: () => layout().limit })
const selected = () => items()[menu.selected()]
createEffect(() => {
@@ -161,7 +183,9 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
})
createEffect(() => {
input.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
term()
const desired = footerPanelLayout(renderer.terminalHeight, input.limit)
input.onRows?.(Math.max(1, Math.min(items().length, desired.limit)) + desired.frame)
})
useKeyboard((event) => {
@@ -201,13 +225,13 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
if (name === "pageup") {
event.preventDefault()
menu.reveal(menu.selected() - PANEL_PAGE)
menu.reveal(menu.selected() - Math.max(1, menu.limit() - 1))
return
}
if (name === "pagedown") {
event.preventDefault()
menu.reveal(menu.selected() + PANEL_PAGE)
menu.reveal(menu.selected() + Math.max(1, menu.limit() - 1))
return
}
@@ -244,6 +268,7 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
setQuery,
items,
menu,
layout,
inputRef(input: InputRenderable) {
field = input
},
@@ -263,50 +288,66 @@ function PanelShell(props: {
children: JSX.Element
hint?: string
mono?: boolean
background?: boolean
layout: ReturnType<typeof footerPanelLayout>
}) {
const background = () => props.theme().shade
const term = useTerminalDimensions()
const pad = () => (term().width < FOOTER_COMPACT_WIDTH ? 1 : panelPad(props.mono))
const header = createMemo(() => {
const width = Math.max(0, term().width - pad() * 2 - 4)
const title = footerMenuText(props.title, width, props.mono)
const count = countLabel(props.count, props.total, props.query)
const showCount = props.countVisible !== false && stringWidth(props.title) + stringWidth(count) + 1 <= width
const hint =
props.hint &&
stringWidth(props.title) + (showCount ? stringWidth(count) + 1 : 0) + stringWidth(props.hint) + 3 <= width
return { title, count: showCount ? count : undefined, hint: hint ? props.hint : undefined }
})
const background = () => (props.background === false ? "transparent" : props.theme().shade)
const content = (
<>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
<box
width="100%"
height={1}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
paddingLeft={pad()}
paddingRight={pad()}
flexDirection="row"
gap={1}
gap={0}
flexShrink={0}
backgroundColor={background()}
>
<text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
{props.title}
{header().title}
</text>
{props.countVisible !== false ? (
{header().count ? (
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
{countLabel(props.count, props.total, props.query)}
{" " + header().count}
</text>
) : null}
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
{props.hint ? `${props.hint} ${props.mono ? "-" : "·"} ` : ""}esc
<box minWidth={1} flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
{header().hint ? `${header().hint} ${props.mono ? "-" : "·"} ` : ""}esc
</text>
</box>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
<box
width="100%"
height={1}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
paddingLeft={pad()}
paddingRight={pad()}
flexShrink={0}
backgroundColor={background()}
>
<input
width="100%"
focusedBackgroundColor={background()}
focusedTextColor={props.theme().text}
focusedBackgroundColor={props.background === false ? "transparent" : props.theme().formfieldFocusedBg}
focusedTextColor={
props.background === false ? props.theme().formfieldText : props.theme().formfieldFocusedText
}
placeholder={props.placeholder}
placeholderColor={props.theme().muted}
cursorColor={props.theme().highlight}
cursorColor={props.background === false ? props.theme().formfieldText : props.theme().formfieldFocusedText}
onInput={props.onQuery}
ref={(input) => {
props.inputRef(input)
@@ -319,7 +360,7 @@ function PanelShell(props: {
}}
/>
</box>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box height={props.layout.compact ? 0 : 1} flexShrink={0} backgroundColor={background()} />
<box width="100%" flexDirection="column" flexShrink={0} backgroundColor={background()}>
{props.children}
</box>
@@ -330,8 +371,14 @@ function PanelShell(props: {
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{content}
</box>
<box width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
{props.mono ? null : (
<box
width="100%"
height={props.layout.compact ? 0 : 1}
border={false}
backgroundColor="transparent"
flexShrink={0}
>
{props.layout.compact || props.mono || props.background === false ? null : (
<box
width="100%"
height={1}
@@ -562,6 +609,7 @@ export function RunCommandMenuBody(props: {
return (
<PanelShell
title="Commands"
layout={controller.layout()}
countVisible={false}
query={controller.query()}
count={controller.items().length}
@@ -577,8 +625,9 @@ export function RunCommandMenuBody(props: {
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
empty="No results found"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -609,6 +658,7 @@ export function RunAgentSelectBody(props: {
display: agent.id,
description: agent.description,
footer: props.current() === agent.id ? "current" : undefined,
footerTone: "selection" as const,
keywords: `${agent.id} ${agent.name} ${agent.description ?? ""}`,
id: agent.id,
current: props.current() === agent.id,
@@ -625,6 +675,7 @@ export function RunAgentSelectBody(props: {
return (
<PanelShell
title="Select agent"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -639,8 +690,9 @@ export function RunAgentSelectBody(props: {
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
empty="No agents found"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -659,6 +711,7 @@ export function RunSettingsBody(props: {
onClose: () => void
onChange: (change: MiniSettingChange) => void | Promise<void>
mono?: boolean
animations?: boolean
}) {
const [saving, setSaving] = createSignal<keyof MiniSettings>()
const entries = createMemo<SettingEntry[]>(() => [
@@ -666,6 +719,7 @@ export function RunSettingsBody(props: {
category: "Transcript",
display: "Thinking",
footer: saving() === "thinking" ? "saving" : props.settings().thinking,
footerTone: saving() === "thinking" ? "running" : "selection",
keywords: `thinking reasoning ${props.settings().thinking}`,
key: "thinking",
},
@@ -673,6 +727,7 @@ export function RunSettingsBody(props: {
category: "Transcript",
display: "Shell",
footer: saving() === "shell_output" ? "saving" : props.settings().shell_output,
footerTone: saving() === "shell_output" ? "running" : "selection",
keywords: `shell tool command output ${props.settings().shell_output}`,
key: "shell_output",
},
@@ -680,6 +735,7 @@ export function RunSettingsBody(props: {
category: "Transcript",
display: "Turn summary",
footer: saving() === "turn_summary" ? "saving" : props.settings().turn_summary,
footerTone: saving() === "turn_summary" ? "running" : "selection",
keywords: `turn summary agent model duration ${props.settings().turn_summary}`,
key: "turn_summary",
},
@@ -687,6 +743,7 @@ export function RunSettingsBody(props: {
category: "Terminal",
display: "Footer details",
footer: saving() === "footer" ? "saving" : props.settings().footer,
footerTone: saving() === "footer" ? "running" : "selection",
keywords: `footer status activity model context usage ${props.settings().footer}`,
key: "footer",
},
@@ -694,6 +751,7 @@ export function RunSettingsBody(props: {
category: "Terminal",
display: "Splash",
footer: saving() === "splash" ? "saving" : props.settings().splash,
footerTone: saving() === "splash" ? "running" : "selection",
keywords: `splash entry exit banner ${props.settings().splash}`,
key: "splash",
},
@@ -701,16 +759,46 @@ export function RunSettingsBody(props: {
category: "Terminal",
display: "Monochrome UI",
footer: saving() === "mono" ? "saving" : props.settings().mono ? "on" : "off",
footerTone: saving() === "mono" ? "running" : "selection",
keywords: `mono monochrome ascii legacy compat terminal ${props.settings().mono ? "on" : "off"}`,
key: "mono",
},
{
category: "Terminal",
display: "Work spinner",
icon: (color) => (
<OneCellSpinner
animation={props.mono ? SEED_MONO : WORK_SPINNERS[props.settings().work_spinner]}
color={color}
animations={props.animations}
glow={!props.mono}
still={props.mono ? "*" : undefined}
/>
),
footer:
saving() === "work_spinner"
? "saving"
: props.settings().work_spinner.replace("block-", "").replaceAll("-", " "),
footerTone: saving() === "work_spinner" ? "running" : "selection",
keywords: `work spinner animation ${props.settings().work_spinner}`,
key: "work_spinner",
},
])
const change = (item: SettingEntry) => {
const change = (item: SettingEntry, direction = 1) => {
if (saving()) return
const spinners = Config.MiniWorkSpinner.literals
const next: MiniSettingChange =
item.key === "mono"
? { key: "mono", value: !props.settings().mono }
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
item.key === "work_spinner"
? {
key: "work_spinner",
value:
spinners[
(spinners.indexOf(props.settings().work_spinner) + direction + spinners.length) % spinners.length
]!,
}
: item.key === "mono"
? { key: "mono", value: !props.settings().mono }
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
setSaving(item.key)
void Promise.resolve(props.onChange(next))
.catch(() => {})
@@ -725,7 +813,7 @@ export function RunSettingsBody(props: {
const name = event.name.toLowerCase()
if (name !== "left" && name !== "right") return false
event.preventDefault()
if (item) change(item)
if (item) change(item, name === "left" ? -1 : 1)
return true
},
})
@@ -733,6 +821,7 @@ export function RunSettingsBody(props: {
return (
<PanelShell
title="Settings"
layout={controller.layout()}
countVisible={false}
query={controller.query()}
count={controller.items().length}
@@ -749,8 +838,9 @@ export function RunSettingsBody(props: {
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
empty="No settings found"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -785,6 +875,12 @@ export function RunSubagentSelectBody(props: {
display: title,
description: title === item.label ? undefined : item.label,
footer: subagentStatusLabel(item.status),
footerTone:
item.status === "running" || item.status === "error"
? item.status
: item.status === "completed"
? ("success" as const)
: undefined,
keywords: `${item.label} ${item.description} ${item.title ?? ""} ${item.status}`,
sessionID: item.sessionID,
current: props.current() === item.sessionID,
@@ -810,6 +906,7 @@ export function RunSubagentSelectBody(props: {
return (
<PanelShell
title="Select subagent"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -826,7 +923,8 @@ export function RunSubagentSelectBody(props: {
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={controller.menu.rows}
limit={SUBAGENT_LIST_ROWS}
limit={controller.menu.limit()}
compact={controller.layout().compact}
empty="No subagents found"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -885,6 +983,7 @@ export function RunQueuedPromptSelectBody(props: {
return (
<PanelShell
title="Queued prompts"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -901,7 +1000,8 @@ export function RunQueuedPromptSelectBody(props: {
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={controller.menu.rows}
limit={SUBAGENT_LIST_ROWS}
limit={controller.menu.limit()}
compact={controller.layout().compact}
empty="No queued prompts"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -943,6 +1043,7 @@ export function RunSkillSelectBody(props: {
return (
<PanelShell
title="Skills"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -957,8 +1058,9 @@ export function RunSkillSelectBody(props: {
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
empty={props.commands() ? "No skills found" : "Skills loading"}
border={false}
paddingLeft={panelPad(props.mono)}
@@ -983,7 +1085,8 @@ export function RunVariantSelectBody(props: {
{
category: "",
display: "Default",
description: props.current() === undefined ? "current" : undefined,
footer: props.current() === undefined ? "current" : undefined,
footerTone: "selection",
keywords: "default",
variant: undefined,
current: props.current() === undefined,
@@ -991,7 +1094,8 @@ export function RunVariantSelectBody(props: {
...props.variants().map((variant) => ({
category: "",
display: variant,
description: props.current() === variant ? "current" : undefined,
footer: props.current() === variant ? "current" : undefined,
footerTone: "selection" as const,
keywords: variant,
variant,
current: props.current() === variant,
@@ -1008,6 +1112,7 @@ export function RunVariantSelectBody(props: {
return (
<PanelShell
title="Select variant"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -1022,8 +1127,9 @@ export function RunVariantSelectBody(props: {
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
empty="No results found"
border={false}
paddingLeft={panelPad(props.mono)}
@@ -1066,6 +1172,7 @@ export function RunModelSelectBody(props: {
category: provider.name,
display: title,
footer,
footerTone: current ? ("selection" as const) : undefined,
keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`,
current,
}
@@ -1096,6 +1203,7 @@ export function RunModelSelectBody(props: {
return (
<PanelShell
title="Select model"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -1104,24 +1212,26 @@ export function RunModelSelectBody(props: {
inputRef={controller.inputRef}
onQuery={controller.setQuery}
mono={props.mono}
background={false}
>
<RunFooterMenu
theme={props.theme}
items={() =>
controller.query().trim()
? controller.items().map((item) => ({ ...item, footer: item.providerName }))
controller.query().trim() ||
(controller.layout().compact && new Set(controller.items().map((item) => item.providerID)).size > 1)
? controller.items().map((item) => ({ ...item, footer: item.providerName, footerTone: undefined }))
: controller.items()
}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={() => PANEL_LIST_ROWS}
limit={PANEL_LIST_ROWS}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
empty={props.providers() ? "No results found" : "Models loading"}
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
grouped={!controller.query().trim()}
background
headerColor={props.theme().muted}
mono={props.mono}
/>
+383 -170
View File
@@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */
import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import type { BoxRenderable, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import {
createFormBodyState,
@@ -34,6 +34,7 @@ import {
import type { FormBodyState } from "./form.shared"
import type { RunFooterTheme } from "./theme"
import type { FormCancel, FormReply, MiniFormRequest } from "./types"
import { stringWidth } from "../util/string-width"
export function RunFormBody(props: {
request: MiniFormRequest
@@ -45,6 +46,11 @@ export function RunFormBody(props: {
onState?: (state: FormBodyState) => void
mono?: boolean
}) {
const dims = useTerminalDimensions()
const [size, setSize] = createSignal(dims())
const [contentHeight, setContentHeight] = createSignal(1)
const [viewportHeight, setViewportHeight] = createSignal(0)
const compact = () => size().width < 56 || size().height < 12
const [state, setLocalState] = createSignal(props.state ?? createFormBodyState(props.request))
const setState = (next: FormBodyState | ((previous: FormBodyState) => FormBodyState)) => {
const value = typeof next === "function" ? next(state()) : next
@@ -66,11 +72,39 @@ export function RunFormBody(props: {
const custom = createMemo(() => formCustom(current()))
const textual = createMemo(() => formTextual(current()))
const multiple = createMemo(() => current()?.type === "multiselect")
const editing = () => !unsupported() && !confirm() && (textual() || state().editing)
const message = createMemo(() => {
const value = props.request.metadata?.message
return typeof value === "string" ? value : undefined
})
let area: TextareaRenderable | undefined
let scroll: ScrollBoxRenderable | undefined
const choices = new Map<number, BoxRenderable>()
const revealChoice = () => {
const row = choices.get(state().selected)
if (!scroll || scroll.isDestroyed || !row || row.isDestroyed || state().editing || confirm()) return
if (row.y < scroll.viewport.y) scroll.scrollBy(row.y - scroll.viewport.y)
const height = Math.min(row.height, scroll.viewport.height)
if (row.y + height > scroll.viewport.y + scroll.viewport.height)
scroll.scrollBy(row.y + height - scroll.viewport.y - scroll.viewport.height)
}
createEffect(() => {
state().field
state().selected
size()
revealChoice()
})
const action = createMemo(() => {
if (confirm()) return "submit"
if (textual() || state().editing) return "save"
const field = externalField()
if (!field) return "choose"
if (state().answers[field.key] === true) return formSingle(props.request) ? "submit" : "next"
return state().externalReady[field.key] ? (size().width < 24 ? "done" : "acknowledge") : "open URL"
})
createEffect(() => {
setState((previous) => formSync(previous, props.request))
@@ -143,6 +177,12 @@ export function RunFormBody(props: {
const choose = (selected = state().selected) => {
const base = formSetSelected(state(), selected)
const row = choices.get(selected)
if (scroll && row && (row.y < scroll.viewport.y || row.y >= scroll.viewport.y + scroll.viewport.height)) {
setState(base)
revealChoice()
return
}
const next = formPick(base, props.request)
setState(next)
if (next.editing || multiple()) return
@@ -208,6 +248,11 @@ export function RunFormBody(props: {
event.preventDefault()
return
}
if (event.name === "pageup" || event.name === "pagedown") {
scroll?.scrollBy(event.name === "pageup" ? -1 : 1, "viewport")
event.preventDefault()
return
}
if (unsupported()) return
if (state().editing) return
if (
@@ -256,178 +301,346 @@ export function RunFormBody(props: {
})
return (
<box width="100%" height="100%" flexDirection="column" backgroundColor={props.theme.surface}>
<box flexDirection="column" gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} flexGrow={1} flexShrink={1}>
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={unsupported() ? props.theme.warning : props.theme.highlight}>{props.mono ? "*" : "◆"}</text>
<text fg={props.theme.text}>{props.request.title}</text>
<Show when={!unsupported() && !formSingle(props.request)}>
<text fg={props.theme.muted}>
{confirm()
? "Review"
: `${Math.min(state().field + 1, props.request.fields.length)}/${props.request.fields.length}`}
</text>
</Show>
</box>
<Show when={message()}>{(value) => <text fg={props.theme.muted}>{value()}</text>}</Show>
<Show when={unsupported()}>
{(value) => (
<box flexDirection="column" gap={1}>
<text fg={props.theme.warning} wrapMode="word">
{value()}
</text>
<text fg={props.theme.muted}>This request remains pending until you dismiss it.</text>
</box>
)}
</Show>
<Show when={!unsupported() && externalField()}>
{(field) => (
<box flexDirection="column" gap={1}>
<text fg={props.theme.text}>{field().description ?? formLabel(field())}</text>
<text fg={props.theme.highlight} wrapMode="word">
{field().url}
</text>
<text fg={props.theme.muted}>
{state().answers[field().key] === true
? "Acknowledged"
: state().externalReady[field().key]
? "Press enter to acknowledge completion"
: "Press enter to open the URL"}
</text>
</box>
)}
</Show>
<Show when={!unsupported() && answerField() && !confirm()}>
<box flexDirection="column" gap={1}>
<text fg={props.theme.text} wrapMode="word">
{answerField()!.description ?? formLabel(answerField()!)}
{answerField()!.required ? " (required)" : ""}
{multiple() ? " (select all that apply)" : ""}
</text>
<Show when={textual() || state().editing}>
<textarea
ref={(item: TextareaRenderable) => {
area = item
}}
width="100%"
minHeight={1}
maxHeight={3}
initialValue={formInput(state(), current())}
placeholder={formPlaceholder(answerField())}
placeholderColor={props.theme.muted}
textColor={props.theme.text}
focusedTextColor={props.theme.text}
backgroundColor={props.theme.surface}
focusedBackgroundColor={props.theme.surface}
cursorColor={props.theme.text}
focused
onSubmit={commitInput}
onContentChange={() => {
const currentArea = area
if (!currentArea || currentArea.isDestroyed) return
setState((previous) => formSetDraft(previous, current(), currentArea.plainText))
}}
onKeyDown={(event) => {
if (event.name === "escape") {
event.preventDefault()
void cancel()
}
}}
/>
</Show>
<Show when={!textual() && !state().editing}>
<box flexDirection="column">
<For each={rows()}>
{(row, index) => {
const active = () => state().selected === index()
const picked = () => {
const field = current()
if (!field) return false
const value = state().answers[field.key]
return Array.isArray(value) ? value.includes(String(row.value)) : value === row.value
}
return (
<box
flexDirection="row"
gap={1}
onMouseOver={() => setState((previous) => formSetSelected(previous, index()))}
onMouseUp={() => choose(index())}
>
<text fg={active() ? props.theme.highlight : props.theme.muted}>
{props.mono ? `${active() ? ">" : " "}${index() + 1}.` : `${index() + 1}.`}
</text>
<text fg={active() ? props.theme.text : props.theme.muted}>
{multiple() ? `[${picked() ? "x" : " "}] ` : ""}
{row.label}
{!multiple() && picked() ? " *" : ""}
</text>
<Show when={row.description}>
<text fg={props.theme.muted}>{row.description}</text>
</Show>
</box>
)
}}
</For>
<Show when={custom()}>
<box flexDirection="row" gap={1} onMouseUp={() => choose(rows().length)}>
<text fg={state().selected === rows().length ? props.theme.highlight : props.theme.muted}>
{props.mono
? `${state().selected === rows().length ? ">" : " "}${rows().length + 1}.`
: `${rows().length + 1}.`}
</text>
<text fg={state().selected === rows().length ? props.theme.text : props.theme.muted}>
Type your own answer
</text>
</box>
</Show>
</box>
</Show>
</box>
</Show>
<Show when={!unsupported() && confirm()}>
<box flexDirection="column">
<For each={props.request.fields}>
{(field) => (
<text fg={props.theme.muted} wrapMode="none" truncate>
{formLabel(field)}:{" "}
{field.type === "external"
? state().answers[field.key] === true
? "acknowledged"
: "required"
: formDisplay(field, state().answers[field.key]) || "(not answered)"}
</text>
)}
</For>
</box>
</Show>
</box>
<box
flexDirection="row"
justifyContent="space-between"
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
flexShrink={0}
>
<text fg={props.theme.muted}>
{state().submitting
? "submitting..."
: unsupported()
? "esc dismiss"
: confirm()
? "enter submit esc dismiss"
: textual() || state().editing
? "enter save esc dismiss"
: props.mono
? "up/down select enter choose tab next esc dismiss"
: "↑↓ select enter choose tab next esc dismiss"}
<box
width="100%"
height="100%"
minHeight={0}
flexDirection="column"
backgroundColor={props.theme.surface}
paddingLeft={compact() ? 0 : 2}
paddingRight={compact() ? 0 : 3}
paddingTop={compact() ? 0 : 1}
paddingBottom={compact() ? 0 : 1}
onSizeChange={function () {
setSize({ width: this.width, height: this.height })
}}
>
<box height={1} flexDirection="row" gap={1} flexShrink={0} marginBottom={compact() ? 0 : 1}>
<text fg={unsupported() ? props.theme.warning : props.theme.question} wrapMode="none" flexShrink={0}>
{props.mono ? "*" : "◆"}
</text>
<Show when={state().error}>
<text fg={props.theme.error} wrapMode="none" truncate>
{state().error}
<text fg={props.theme.text} wrapMode="none" truncate minWidth={0}>
{props.request.title}
</text>
<Show when={!unsupported() && !formSingle(props.request)}>
<text fg={props.theme.muted} wrapMode="none" flexShrink={0}>
{confirm()
? "Review"
: `${Math.min(state().field + 1, props.request.fields.length)}/${props.request.fields.length}`}
</text>
</Show>
</box>
<scrollbox
width="100%"
height={!compact() && editing() ? Math.min(contentHeight(), Math.max(1, size().height - 9)) : undefined}
flexGrow={!compact() && editing() ? 0 : 1}
minHeight={0}
viewportOptions={{
paddingRight: props.mono ? 0 : 1,
onSizeChange() {
setViewportHeight(this.height)
},
}}
verticalScrollbarOptions={{
visible: !props.mono && contentHeight() > viewportHeight(),
trackOptions: { backgroundColor: props.theme.surface, foregroundColor: props.theme.line },
}}
onSizeChange={revealChoice}
ref={(item) => {
scroll = item
}}
>
<box
width="100%"
flexDirection="column"
flexShrink={0}
gap={compact() ? 0 : 1}
onSizeChange={function () {
setContentHeight(this.height)
}}
>
<Show when={message()}>
{(value) => (
<text width="100%" fg={props.theme.muted} flexShrink={0}>
{value()}
</text>
)}
</Show>
<Show when={unsupported()}>
{(value) => (
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
<text width="100%" fg={props.theme.warning} wrapMode="word" flexShrink={0}>
{value()}
</text>
<text width="100%" fg={props.theme.muted} flexShrink={0}>
This request remains pending until you dismiss it.
</text>
</box>
)}
</Show>
<Show when={!unsupported() && externalField()}>
{(field) => (
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
{field().description ?? formLabel(field())}
</text>
<text width="100%" fg={props.theme.link} wrapMode="word" flexShrink={0}>
{field().url}
</text>
<text
width="100%"
fg={state().answers[field().key] === true ? props.theme.selection : props.theme.muted}
flexShrink={0}
>
{state().answers[field().key] === true
? "Acknowledged"
: state().externalReady[field().key]
? "Press enter to acknowledge completion"
: "Press enter to open the URL"}
</text>
</box>
)}
</Show>
<Show when={!unsupported() && answerField() && !confirm()}>
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
{answerField()!.description ?? formLabel(answerField()!)}
{answerField()!.required ? " (required)" : ""}
{multiple() ? " (select all that apply)" : ""}
</text>
<Show when={!textual() && !state().editing}>
<box width="100%" flexDirection="column" flexShrink={0}>
<For each={rows()}>
{(row, index) => {
const active = () => state().selected === index()
const picked = () => {
const field = current()
if (!field) return false
const value = state().answers[field.key]
return Array.isArray(value) ? value.includes(String(row.value)) : value === row.value
}
const ordinal = () => (props.mono ? `${active() ? ">" : " "}${index() + 1}.` : `${index() + 1}.`)
const inline = () =>
!!row.description &&
stringWidth(ordinal()) +
2 +
stringWidth(row.label) +
(multiple() ? 4 : picked() ? 2 : 0) +
stringWidth(row.description) <=
size().width - (compact() ? 0 : 5) - (props.mono ? 0 : 1)
return (
<box
ref={(item) => {
choices.set(index(), item)
}}
onSizeChange={revealChoice}
flexShrink={0}
flexDirection="row"
gap={1}
alignItems="flex-start"
onMouseOver={() => setState((previous) => formSetSelected(previous, index()))}
backgroundColor={active() ? props.theme.formfieldFocusedBg : "transparent"}
onMouseUp={() => choose(index())}
>
<text
fg={active() ? props.theme.formfieldFocusedText : props.theme.formfieldText}
wrapMode="none"
flexShrink={0}
>
{ordinal()}
</text>
<box
flexDirection={inline() ? "row" : "column"}
gap={inline() ? 1 : 0}
flexGrow={1}
minWidth={0}
>
<text
fg={active() ? props.theme.formfieldFocusedText : props.theme.formfieldText}
wrapMode="word"
flexShrink={0}
>
<span style={{ fg: picked() ? props.theme.selection : undefined }}>
{multiple() ? `[${picked() ? "x" : " "}] ` : ""}
</span>
{row.label}
<span style={{ fg: props.theme.selection }}>{!multiple() && picked() ? " *" : ""}</span>
</text>
<Show when={row.description}>
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
{row.description}
</text>
</Show>
</box>
</box>
)
}}
</For>
<Show when={custom()}>
<box
ref={(item) => {
choices.set(rows().length, item)
}}
onSizeChange={revealChoice}
flexShrink={0}
flexDirection="row"
gap={1}
backgroundColor={
state().selected === rows().length ? props.theme.formfieldFocusedBg : "transparent"
}
onMouseUp={() => choose(rows().length)}
>
<text
wrapMode="none"
flexShrink={0}
fg={
state().selected === rows().length
? props.theme.formfieldFocusedText
: props.theme.formfieldText
}
>
{props.mono
? `${state().selected === rows().length ? ">" : " "}${rows().length + 1}.`
: `${rows().length + 1}.`}
</text>
<text
wrapMode="word"
flexGrow={1}
minWidth={0}
fg={
state().selected === rows().length
? props.theme.formfieldFocusedText
: props.theme.formfieldText
}
>
Type your own answer
</text>
</box>
</Show>
</box>
</Show>
</box>
</Show>
<Show when={!unsupported() && confirm()}>
<box width="100%" flexDirection="column" flexShrink={0}>
<For each={props.request.fields}>
{(field) => (
<text width="100%" fg={props.theme.muted} wrapMode="word" flexShrink={0}>
{formLabel(field)}:{" "}
{field.type === "external"
? state().answers[field.key] === true
? "acknowledged"
: "required"
: formDisplay(field, state().answers[field.key]) || "(not answered)"}
</text>
)}
</For>
</box>
</Show>
<Show when={stringWidth(state().error) > size().width || state().error.includes("\n")}>
<text width="100%" fg={props.theme.error} wrapMode="word" flexShrink={0}>
{state().error}
</text>
</Show>
</box>
</scrollbox>
<Show when={!unsupported() && answerField() && editing()}>
<textarea
ref={(item: TextareaRenderable) => {
area = item
}}
width="100%"
minHeight={1}
maxHeight={Math.max(1, Math.min(3, size().height - 6))}
flexShrink={0}
marginTop={compact() ? 0 : 1}
initialValue={formInput(state(), current())}
placeholder={formPlaceholder(answerField())}
placeholderColor={props.theme.muted}
textColor={props.theme.formfieldText}
focusedTextColor={props.theme.formfieldFocusedText}
backgroundColor={props.theme.surface}
focusedBackgroundColor={props.theme.formfieldFocusedBg}
cursorColor={props.theme.formfieldFocusedText}
focused
onSubmit={commitInput}
onContentChange={() => {
const currentArea = area
if (!currentArea || currentArea.isDestroyed) return
setState((previous) => formSetDraft(previous, current(), currentArea.plainText))
}}
onKeyDown={(event) => {
if (event.name === "escape") {
event.preventDefault()
void cancel()
}
}}
/>
</Show>
<Show when={state().error && compact()}>
<text height={1} fg={props.theme.error} wrapMode="none" truncate flexShrink={0}>
{state().error}
</text>
</Show>
<Show when={!compact() && editing()}>
<box flexGrow={1} minHeight={0} />
</Show>
<Show
when={compact()}
fallback={
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
<text
fg={state().submitting ? props.theme.running : props.theme.muted}
wrapMode="word"
flexShrink={1}
minWidth={0}
>
{state().submitting
? "submitting..."
: unsupported()
? "esc dismiss"
: confirm()
? "enter submit esc dismiss"
: editing()
? "enter save esc dismiss"
: externalField()
? `enter ${action()} esc dismiss`
: props.mono
? "up/down select enter choose tab next esc dismiss"
: "↑↓ select enter choose tab next esc dismiss"}
</text>
<Show when={state().error}>
<text fg={props.theme.error} wrapMode="none" truncate flexShrink={1}>
{state().error}
</text>
</Show>
</box>
}
>
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexShrink={0}>
<Show when={!unsupported()}>
<text
height={1}
fg={state().submitting ? props.theme.running : props.theme.muted}
wrapMode="none"
flexShrink={0}
>
{state().submitting ? "submitting..." : `enter ${action()}`}
</text>
</Show>
<Show when={!state().submitting}>
<text height={1} fg={props.theme.muted} wrapMode="none" flexShrink={0}>
esc dismiss
</text>
</Show>
<Show when={!state().submitting && size().height >= 10}>
<text fg={props.theme.muted} wrapMode="word" maxWidth="100%" flexShrink={0}>
{size().width >= 56 && rows().length > 0 && !state().editing ? "up/down select tab next " : ""}pgup/pgdn
scroll
</text>
</Show>
</box>
</Show>
</box>
)
}
+72 -59
View File
@@ -1,20 +1,31 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes, type ColorInput } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { useTerminalDimensions, type JSX } from "@opentui/solid"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { transparent, type RunFooterTheme } from "./theme"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { moveSelection, moveSelectionOffset, reconcileSelection, revealSelectionOffset } from "../ui/select-controller"
import { monoTruncate } from "./mono"
export const FOOTER_MENU_ROWS = 8
export const FOOTER_COMPACT_WIDTH = 40
export function footerMenuText(text: string, width: number, mono = false) {
if (!mono) return Locale.truncateWidth(text, width)
if (stringWidth(text) <= width) return text
const suffix = ".".repeat(Math.min(3, Math.max(0, width)))
return Locale.takeWidth(text, width - suffix.length) + suffix
}
export type RunFooterMenuItem = {
display: string
icon?: (color: ColorInput) => JSX.Element
current?: boolean
description?: string
descriptionTone?: "selection"
category?: string
footer?: string
footerTone?: "selection" | "running" | "error" | "success"
}
type RunFooterMenuRow =
@@ -22,10 +33,10 @@ type RunFooterMenuRow =
| { type: "item"; item: RunFooterMenuItem; index: number }
| { type: "spacer" }
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number }) {
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number | Accessor<number> }) {
const [selected, setSelected] = createSignal(0)
const [offset, setOffset] = createSignal(0)
const limit = () => input.limit ?? FOOTER_MENU_ROWS
const limit = () => Math.max(1, typeof input.limit === "function" ? input.limit() : (input.limit ?? FOOTER_MENU_ROWS))
const rows = createMemo(() => Math.max(1, Math.min(limit(), input.count())))
const reveal = (index: number) => {
@@ -58,6 +69,7 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
selected,
offset,
rows,
limit,
reveal,
reset,
move,
@@ -76,13 +88,17 @@ export function RunFooterMenu(props: {
paddingLeft?: number
paddingRight?: number
grouped?: boolean
compact?: boolean
background?: boolean
headerColor?: ColorInput
mono?: boolean
}) {
const term = useTerminalDimensions()
const limit = () => props.limit ?? FOOTER_MENU_ROWS
const limit = () => Math.max(1, Math.min(props.rows(), props.limit ?? FOOTER_MENU_ROWS))
const border = () => props.border ?? true
const paddingLeft = () => Math.min(props.paddingLeft ?? 1, term().width < FOOTER_COMPACT_WIDTH ? 1 : Infinity)
const paddingRight = () => Math.min(props.paddingRight ?? 0, term().width < FOOTER_COMPACT_WIDTH ? 1 : Infinity)
const width = () => Math.max(0, term().width - (border() ? 1 : 0) - paddingLeft() - paddingRight())
const [groupOffset, setGroupOffset] = createSignal(0)
let previous = -1
const groupedRows = createMemo<RunFooterMenuRow[]>(() => {
@@ -90,12 +106,12 @@ export function RunFooterMenu(props: {
let category = ""
props.items().forEach((item, index) => {
if (item.category && item.category !== category) {
if (all.length > 0) {
if (all.length > 0 && !props.compact) {
all.push({ type: "spacer" })
}
category = item.category
all.push({ type: "header", label: item.category })
if (!props.compact) all.push({ type: "header", label: item.category })
}
all.push({ type: "item", item, index })
@@ -151,34 +167,11 @@ export function RunFooterMenu(props: {
)
return width === 0 ? 0 : width + 2
})
const descriptionPad = (item: RunFooterMenuItem) => {
if (!item.description) {
return ""
}
return " ".repeat(Math.max(1, descriptionColumn() - stringWidth(item.display)))
}
const descriptionText = (item: RunFooterMenuItem) => {
if (!item.description) {
return
}
const footerWidth = item.footer ? stringWidth(item.footer) + 1 : 0
const available =
term().width -
(border() ? 1 : 0) -
(props.paddingLeft ?? 1) -
(props.paddingRight ?? 0) -
descriptionColumn() -
footerWidth -
4
const width = Math.max(12, available)
return props.mono ? monoTruncate(item.description, width, true) : Locale.truncate(item.description, width)
}
return (
<box
width="100%"
height={props.rows()}
flexShrink={0}
backgroundColor={props.background ? props.theme().shade : transparent}
flexDirection="column"
>
@@ -196,8 +189,8 @@ export function RunFooterMenu(props: {
<box
flexGrow={1}
flexShrink={1}
paddingLeft={props.paddingLeft ?? 1}
paddingRight={props.paddingRight ?? 0}
paddingLeft={paddingLeft()}
paddingRight={paddingRight()}
backgroundColor={props.background ? props.theme().shade : transparent}
>
<text fg={props.theme().muted} wrapMode="none" truncate>
@@ -213,9 +206,9 @@ export function RunFooterMenu(props: {
if (row.type === "header") {
return (
<box paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1}>
<box height={1} flexShrink={0} paddingLeft={paddingLeft()} paddingRight={paddingRight()}>
<text
fg={props.headerColor ?? props.theme().highlight}
fg={props.headerColor ?? props.theme().muted}
attributes={TextAttributes.BOLD}
wrapMode="none"
truncate
@@ -227,71 +220,91 @@ export function RunFooterMenu(props: {
}
const active = () => row.index === props.selected()
const available = () => Math.max(0, width() - (row.item.icon ? 2 : 0))
const attributes = () =>
active() ? TextAttributes.BOLD | (props.mono ? TextAttributes.INVERSE : 0) : undefined
const background = () =>
active()
? props.background
? props.theme().selected
: props.theme().shade
: props.background
? props.theme().shade
: transparent
active() ? props.theme().actionFocusedBg : props.background ? props.theme().shade : transparent
const footer = () => {
if (!row.item.footer) return
const title = stringWidth(row.item.display)
const primary = row.item.footerTone && !(row.item.current && row.item.footerTone === "selection")
return (primary ? Math.min(row.item.icon ? 4 : 8, title) : title) + 1 + stringWidth(row.item.footer) <=
available()
? row.item.footer
: undefined
}
const description = () => {
if (!row.item.description) return
const remaining = available() - descriptionColumn() - (footer() ? stringWidth(footer()!) + 1 : 0)
if (remaining < Math.min(12, stringWidth(row.item.description))) return
return footerMenuText(row.item.description, remaining, props.mono)
}
return (
<box paddingRight={0} flexDirection="row" backgroundColor={background()}>
<box height={1} flexShrink={0} paddingRight={0} flexDirection="row" backgroundColor={background()}>
{border() ? (
<text fg={props.theme().highlight} bg={background()} wrapMode="none">
<text fg={props.theme().actionFocusedText} bg={background()} wrapMode="none">
{active() ? (props.mono ? ">" : "▌") : " "}
</text>
) : undefined}
<box
flexGrow={1}
flexShrink={1}
paddingLeft={props.paddingLeft ?? 1}
paddingRight={props.paddingRight ?? 0}
paddingLeft={paddingLeft()}
paddingRight={paddingRight()}
backgroundColor={background()}
>
<box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
<box flexDirection="row" gap={0} flexGrow={1} flexShrink={1}>
{row.item.icon ? (
<box width={2} flexShrink={0}>
{row.item.icon(active() ? props.theme().actionFocusedText : props.theme().formfieldText)}
</box>
) : undefined}
<text
fg={active() ? props.theme().selectedText : props.theme().text}
fg={active() ? props.theme().actionFocusedText : props.theme().formfieldText}
attributes={attributes()}
wrapMode="none"
truncate
flexShrink={0}
>
{row.item.display}
{footerMenuText(
row.item.display,
available() - (footer() ? stringWidth(footer()!) + 1 : 0),
props.mono,
)}
</text>
{row.item.description ? (
{description() ? (
<>
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
fg={active() ? props.theme().actionFocusedText : props.theme().muted}
wrapMode="none"
flexShrink={0}
>
{descriptionPad(row.item)}
{" ".repeat(Math.max(1, descriptionColumn() - stringWidth(row.item.display)))}
</text>
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
fg={
active()
? props.theme().actionFocusedText
: props.theme()[row.item.descriptionTone ?? "muted"]
}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={1}
>
{descriptionText(row.item)}
{description()}
</text>
</>
) : undefined}
</box>
{row.item.footer ? (
{footer() ? (
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
fg={active() ? props.theme().actionFocusedText : props.theme()[row.item.footerTone ?? "muted"]}
attributes={attributes()}
wrapMode="none"
truncate
flexShrink={0}
>
{row.item.footer}
{footer()}
</text>
) : undefined}
</box>
+196 -136
View File
@@ -11,7 +11,7 @@
// The diff view (when available) uses the same diff component as scrollback
// tool snapshots.
/** @jsxImportSource @opentui/solid */
import { TextAttributes, type TextareaRenderable } from "@opentui/core"
import { TextAttributes, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import {
@@ -28,7 +28,7 @@ import {
permissionShift,
type PermissionOption,
} from "./permission.shared"
import { footerWidthPolicy } from "./footer.width"
import { stringWidth } from "../util/string-width"
import { toolFiletype } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { MiniPermissionRequest, PermissionReply } from "./types"
@@ -44,13 +44,16 @@ function buttons(
mono: boolean,
) {
return (
<box flexDirection="row" gap={1} flexShrink={0}>
<box width="100%" flexDirection="row" flexWrap="wrap" columnGap={1} flexShrink={0}>
<For each={list}>
{(option) => (
<box
width={stringWidth(permissionLabel(option)) + 2}
height={1}
flexShrink={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={option === selected ? theme.highlight : transparent}
backgroundColor={option === selected ? theme.actionFocusedBg : transparent}
onMouseOver={() => {
if (!disabled) onHover(option)
}}
@@ -59,7 +62,8 @@ function buttons(
}}
>
<text
fg={option === selected ? theme.surface : theme.muted}
wrapMode="none"
fg={option === selected ? theme.actionFocusedText : theme.actionSecondaryText}
attributes={option === selected && mono ? TextAttributes.INVERSE : undefined}
>
{permissionLabel(option)}
@@ -108,11 +112,11 @@ export function RejectField(props: {
wrapMode="word"
placeholder="Tell OpenCode what to do differently"
placeholderColor={props.theme.muted}
textColor={props.theme.text}
focusedTextColor={props.theme.text}
textColor={props.theme.formfieldText}
focusedTextColor={props.theme.formfieldFocusedText}
backgroundColor={props.theme.surface}
focusedBackgroundColor={props.theme.surface}
cursorColor={props.theme.text}
focusedBackgroundColor={props.theme.formfieldFocusedBg}
cursorColor={props.theme.formfieldFocusedText}
focused={!props.disabled}
onSubmit={props.onConfirm}
onContentChange={() => {
@@ -144,10 +148,14 @@ export function RunPermissionBody(props: {
mono?: boolean
}) {
const dims = useTerminalDimensions()
const [size, setSize] = createSignal(dims())
const width = () => size().width
const compact = () => width() < 56 || size().height < 12
const [state, setState] = createSignal(createPermissionBodyState(props.request))
const stage = createMemo(() => state().stage)
const info = createMemo(() => permissionInfo(props.request, props.directory?.(), props.mono))
const ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
let scroll: ScrollBoxRenderable | undefined
const scrollbar = createMemo(() => ({
visible: !props.mono,
trackOptions: {
@@ -156,19 +164,25 @@ export function RunPermissionBody(props: {
},
}))
const opts = createMemo(() =>
permissionOptions(state().stage).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
permissionOptions(stage()).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
)
const busy = createMemo(() => state().submitting)
const controlsWidth = () => opts().reduce((total, option) => total + stringWidth(permissionLabel(option)) + 3, -1)
const hint = () =>
compact() && width() < 56
? "pgup/pgdn scroll"
: `${props.mono ? "left/right" : "⇆"} select enter confirm esc ${stage() === "always" ? "cancel" : "reject"}`
const inlineControls = () => controlsWidth() + stringWidth(hint()) + 1 <= width() - (compact() ? 0 : 5)
const title = createMemo(() => {
if (state().stage === "always") {
if (stage() === "always") {
return "Always allow"
}
if (state().stage === "reject") {
return "Reject permission"
if (stage() === "reject") {
return width() < 24 ? "Reject" : "Reject permission"
}
return "Permission required"
return width() < 24 ? "Permission" : "Permission required"
})
createEffect(() => {
@@ -184,6 +198,12 @@ export function RunPermissionBody(props: {
setState((prev) => permissionShift(prev, dir, opts()))
}
createEffect(() => {
stage()
props.request.id
if (scroll && !scroll.isDestroyed) scroll.scrollTo(0)
})
const submit = async (next: PermissionReply) => {
setState((prev) => ({
...prev,
@@ -233,6 +253,12 @@ export function RunPermissionBody(props: {
return
}
if (event.name === "pageup" || event.name === "pagedown") {
scroll?.scrollBy(event.name === "pageup" ? -1 : 1, "viewport")
event.preventDefault()
return
}
if (cur.submitting) {
if (["left", "right", "h", "l", "tab", "return", "escape"].includes(event.name)) {
event.preventDefault()
@@ -273,58 +299,50 @@ export function RunPermissionBody(props: {
})
return (
<box width="100%" height="100%" flexDirection="column" backgroundColor={props.theme.surface}>
<box
width="100%"
height="100%"
minHeight={0}
flexDirection="column"
backgroundColor={props.theme.surface}
onSizeChange={function () {
setSize({ width: this.width, height: this.height })
}}
>
<box
flexDirection="column"
gap={1}
paddingLeft={1}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
paddingLeft={compact() ? 0 : 2}
paddingRight={compact() ? 0 : 3}
paddingTop={compact() ? 0 : 1}
paddingBottom={compact() ? 0 : 1}
gap={compact() ? 0 : 1}
flexShrink={0}
>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}>
{props.mono ? "!" : "△"}
</text>
<text fg={props.theme.text}>{title()}</text>
</box>
<Switch>
<Match when={state().stage === "permission"}>
<box flexDirection="row" gap={1} paddingLeft={2}>
<text fg={props.theme.muted} flexShrink={0}>
{info().icon}
</text>
<text fg={props.theme.text} wrapMode="word">
{info().title}
</text>
</box>
</Match>
<Match when={state().stage === "reject"}>
<box paddingLeft={1}>
<text fg={props.theme.muted}>Tell OpenCode what to do differently</text>
</box>
</Match>
</Switch>
<text height={1} fg={props.theme.text} wrapMode="none" truncate>
<span style={{ fg: props.theme.permission }}>{props.mono ? "! " : "△ "}</span>
{title()}
</text>
<Show when={!compact() && stage() === "reject"}>
<text fg={props.theme.muted}>Tell OpenCode what to do differently</text>
</Show>
</box>
<Show
when={state().stage !== "reject"}
when={stage() !== "reject"}
fallback={
<box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end">
<box width="100%" flexGrow={1} minHeight={0} justifyContent="flex-end">
<box
flexDirection={narrow() ? "column" : "row"}
flexShrink={0}
backgroundColor={props.theme.line}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
gap={1}
flexDirection={width() >= 80 ? "row" : "column"}
alignItems={width() >= 80 ? "center" : "stretch"}
justifyContent="space-between"
paddingLeft={compact() ? 0 : 2}
paddingRight={compact() ? 0 : 3}
paddingTop={compact() ? 0 : 1}
paddingBottom={compact() ? 0 : 1}
gap={compact() ? 0 : 1}
flexShrink={0}
>
<box width={narrow() ? "100%" : undefined} flexGrow={1} flexShrink={1}>
<box width={width() >= 80 ? undefined : "100%"} flexGrow={1} flexShrink={1} minWidth={0}>
<RejectField
theme={props.theme}
text={state().message}
@@ -342,17 +360,17 @@ export function RunPermissionBody(props: {
<Show
when={!busy()}
fallback={
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
Waiting for permission event...
<text fg={props.theme.running} height={1} wrapMode="none" truncate flexShrink={0}>
{compact() ? "Waiting..." : "Waiting for permission event..."}
</text>
}
>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={props.theme.text}>
enter <span style={{ fg: props.theme.muted }}>confirm</span>
<box flexDirection="row" flexWrap="wrap" columnGap={compact() ? 1 : 2} flexShrink={0}>
<text fg={props.theme.text} height={1} wrapMode="none" flexShrink={0}>
enter <span style={{ fg: props.theme.muted }}>{compact() ? "reject" : "confirm"}</span>
</text>
<text fg={props.theme.text}>
esc <span style={{ fg: props.theme.muted }}>cancel</span>
<text fg={props.theme.text} height={1} wrapMode="none" flexShrink={0}>
esc <span style={{ fg: props.theme.muted }}>{compact() ? "back" : "cancel"}</span>
</text>
</box>
</Show>
@@ -360,21 +378,46 @@ export function RunPermissionBody(props: {
</box>
}
>
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} paddingRight={3} paddingBottom={1}>
<Switch>
<Match when={state().stage === "permission"}>
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
<box width="100%" flexDirection="column" gap={1}>
<box
width="100%"
flexGrow={1}
minHeight={0}
paddingLeft={compact() ? 0 : 1}
paddingRight={compact() ? 0 : 3}
paddingBottom={compact() ? 0 : 1}
>
<scrollbox
width="100%"
flexGrow={1}
minHeight={0}
viewportOptions={{
paddingLeft: compact() ? 0 : 1,
paddingRight: props.mono ? 0 : 1,
}}
verticalScrollbarOptions={scrollbar()}
ref={(item) => {
scroll = item
}}
>
<Switch>
<Match when={stage() === "permission"}>
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
<box width="100%" paddingLeft={compact() ? 0 : 1} flexShrink={0}>
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
<span style={{ fg: props.theme.muted }}>{info().icon} </span>
{info().title}
</text>
</box>
<Show
when={info().diff}
fallback={
<Show
when={info().patch}
fallback={
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
<For each={info().lines}>
{(line) => (
<text fg={props.theme.text} wrapMode="word">
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
{line}
</text>
)}
@@ -386,13 +429,16 @@ export function RunPermissionBody(props: {
<Show
when={props.block.syntax}
fallback={
<text fg={props.theme.muted} wrapMode="word">
<text width="100%" fg={props.theme.muted} wrapMode="word" flexShrink={0}>
{patch()}
</text>
}
>
{(syntax) => (
<code
width="100%"
flexShrink={0}
wrapMode="word"
filetype="diff"
drawUnstyledText={false}
streaming={true}
@@ -406,93 +452,107 @@ export function RunPermissionBody(props: {
</Show>
}
>
<PatchDiff
diff={info().diff!}
hunkFg={props.block.diffLineNumber}
view="unified"
filetype={ft()}
syntaxStyle={props.block.syntax}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={props.theme.text}
addedBg={props.block.diffAddedBg}
removedBg={props.block.diffRemovedBg}
contextBg={props.block.diffContextBg}
addedSignColor={props.block.diffHighlightAdded}
removedSignColor={props.block.diffHighlightRemoved}
lineNumberFg={props.block.diffLineNumber}
lineNumberBg={props.block.diffContextBg}
addedLineNumberBg={props.block.diffAddedLineNumberBg}
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
/>
<Show
when={width() >= 40}
fallback={
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
{info().diff}
</text>
}
>
<PatchDiff
diff={info().diff!}
hunkFg={props.block.diffLineNumber}
view="unified"
filetype={ft()}
syntaxStyle={props.block.syntax}
showLineNumbers={true}
width="100%"
flexShrink={0}
wrapMode="word"
fg={props.theme.text}
addedBg={props.block.diffAddedBg}
removedBg={props.block.diffRemovedBg}
contextBg={props.block.diffContextBg}
addedSignColor={props.block.diffHighlightAdded}
removedSignColor={props.block.diffHighlightRemoved}
lineNumberFg={props.block.diffLineNumber}
lineNumberBg={props.block.diffContextBg}
addedLineNumberBg={props.block.diffAddedLineNumberBg}
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
/>
</Show>
</Show>
<Show when={!info().diff && !info().patch && info().lines.length === 0}>
<box paddingLeft={1}>
<text fg={props.theme.muted}>No diff provided</text>
</box>
<text width="100%" fg={props.theme.muted} flexShrink={0}>
No diff provided
</text>
</Show>
</box>
</scrollbox>
</Match>
<Match when={true}>
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
</Match>
<Match when={true}>
<box width="100%" flexDirection="column" flexShrink={0} gap={compact() ? 0 : 1}>
<For each={permissionAlwaysLines(props.request)}>
{(line) => (
<text fg={props.theme.text} wrapMode="word">
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
{line}
</text>
)}
</For>
</box>
</scrollbox>
</Match>
</Switch>
</Match>
</Switch>
</scrollbox>
</box>
<box
flexDirection={narrow() ? "column" : "row"}
width="100%"
flexDirection={inlineControls() ? "row" : "column"}
justifyContent="space-between"
gap={compact() ? 0 : 1}
paddingLeft={compact() ? 0 : 2}
paddingRight={compact() ? 0 : 3}
paddingTop={compact() ? 0 : 1}
paddingBottom={compact() ? 0 : 1}
flexShrink={0}
backgroundColor={props.theme.pane}
gap={1}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
>
{buttons(
opts(),
state().selected,
props.theme,
busy(),
(option) => {
setState((prev) => permissionHover(prev, option))
},
run,
props.mono ?? false,
)}
<box width={inlineControls() ? controlsWidth() : "100%"} flexShrink={0}>
{buttons(
opts(),
state().selected,
props.theme,
busy(),
(option) => {
setState((prev) => permissionHover(prev, option))
},
run,
props.mono ?? false,
)}
</box>
<Show
when={!busy()}
fallback={
<text fg={props.theme.muted} wrapMode="word" flexShrink={0}>
Waiting for permission event...
<text fg={props.theme.running} height={1} wrapMode="none" truncate flexShrink={0}>
{compact() ? "Waiting..." : "Waiting for permission event..."}
</text>
}
>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={props.theme.text}>
{props.mono ? "left/right" : "⇆"} <span style={{ fg: props.theme.muted }}>select</span>
</text>
<text fg={props.theme.text}>
enter <span style={{ fg: props.theme.muted }}>confirm</span>
</text>
<text fg={props.theme.text}>
esc <span style={{ fg: props.theme.muted }}>{state().stage === "always" ? "cancel" : "reject"}</span>
</text>
</box>
<text fg={props.theme.text} height={1} wrapMode="none" flexShrink={0}>
<Show
when={compact() && width() < 56}
fallback={
<>
{props.mono ? "left/right" : "⇆"}
<span style={{ fg: props.theme.muted }}>{" select "}</span>
enter<span style={{ fg: props.theme.muted }}>{" confirm "}</span>
esc<span style={{ fg: props.theme.muted }}> {stage() === "always" ? "cancel" : "reject"}</span>
</>
}
>
pgup/pgdn<span style={{ fg: props.theme.muted }}> scroll</span>
</Show>
</text>
</Show>
</box>
</Show>
+295 -46
View File
@@ -5,15 +5,38 @@
// It produces a PromptState that RunPromptBody renders as a slim single-line
// composer while the footer view renders any active menus below it.
/** @jsxImportSource @opentui/solid */
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import {
StyledText,
decodePasteBytes,
fg,
stripAnsiSequences,
type ColorInput,
type KeyEvent,
type PasteEvent,
type TextareaRenderable,
} from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { normalizePromptContent } from "../prompt/content"
import { deduplicatePromptImages, promptAttachmentLabel } from "../prompt/attachment"
import { resolvePastedAttachments } from "../component/prompt/local-attachment"
import { createTuiClipboard, type OwnedClipboardService } from "../clipboard"
import type { ClipboardService } from "../context/clipboard"
import fuzzysort from "fuzzysort"
import path from "path"
import { pathToFileURL } from "node:url"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { Locale } from "../util/locale"
import {
For,
Show,
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
onMount,
type Accessor,
} from "solid-js"
import { stringWidth } from "../util/string-width"
import { errorMessage } from "../util/error"
import {
createPromptHistory,
displayCharAt,
@@ -30,8 +53,7 @@ import {
import { parseFileLineRange, parseSlashHead, stripFileLineRange } from "../prompt/parse"
import { Keymap } from "../context/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { monoTruncateMiddle } from "./mono"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { FOOTER_COMPACT_WIDTH, FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type {
FooterQueuedPrompt,
@@ -44,13 +66,31 @@ import type {
RunReference,
RunTuiConfig,
} from "./types"
import { EmptyBorder } from "../ui/border"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1
export const TEXTAREA_MIN_ROWS = 1
const TEXTAREA_MAX_ROWS = 6
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS
export function footerPromptLayout(
height: number,
lines = TEXTAREA_MIN_ROWS,
options = 0,
statusRows = 1,
images = false,
) {
const padding = height >= PROMPT_MAX_ROWS + 4 ? 1 : 0
// Reserve status and, where possible, one transcript row.
const available = Math.max(1, height - 1 - statusRows - padding * 2)
const preview = images && options === 0 ? Math.min(4, available - Math.min(TEXTAREA_MAX_ROWS, Math.max(1, lines))) : 0
const imageRows = preview >= 3 ? preview : 0
const textarea = Math.max(1, Math.min(TEXTAREA_MAX_ROWS, available - imageRows - (options > 0 ? 1 : 0)))
const rows = Math.min(textarea, Math.max(1, lines))
const menu = options > 0 ? Math.max(1, Math.min(AUTOCOMPLETE_ROWS, options, available - rows)) : 0
return { padding, textarea, menu, images: imageRows, rows: rows + menu + imageRows }
}
type Mention = Extract<RunPromptPart, { type: "file" | "agent" | "skill" }>
@@ -86,8 +126,11 @@ type PromptInput = {
view: Accessor<string>
prompt: Accessor<boolean>
width: Accessor<number>
statusRows: Accessor<number>
theme: Accessor<RunFooterTheme>
mono: Accessor<boolean>
imagePreview?: boolean
clipboard?: Pick<ClipboardService, "read">
history?: Accessor<RunPrompt[]>
queuedPrompts: Accessor<FooterQueuedPrompt[]>
onQueuedPromptSteer: (inboxID: string) => Promise<boolean>
@@ -112,20 +155,20 @@ export type PromptState = {
selected: Accessor<number>
offset: Accessor<number>
rows: Accessor<number>
images: Accessor<ReadonlyArray<{ uri: string }>>
layout: Accessor<ReturnType<typeof footerPromptLayout>>
requestExit: () => boolean
onSubmit: () => void
submitText: (text: string) => void
openEditor: (input?: { value?: string }) => Promise<void>
onKeyDown: (event: KeyEvent) => void
onPaste: (event: PasteEvent) => Promise<void>
onContentChange: () => void
onSizeChange: () => void
replacePrompt: (prompt: RunPrompt) => void
bind: (area?: TextareaRenderable) => void
}
function clamp(rows: number): number {
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
}
function emptyPrompt(shell: boolean): RunPrompt {
return shell ? { text: "", parts: [], mode: "shell" } : { text: "", parts: [] }
}
@@ -177,11 +220,17 @@ export function selectedCommand(text: string, command: RunPrompt["command"]) {
export function RunPromptBody(props: {
theme: () => RunFooterTheme
background: () => ColorInput
rail: () => ColorInput
mono: boolean
cursorStyle: RunTuiConfig["cursor"]
placeholder: () => StyledText | string
onSubmit: () => void
onKeyDown: (event: KeyEvent) => void
onPaste: (event: PasteEvent) => Promise<void>
images: Accessor<ReadonlyArray<{ uri: string }>>
layout: Accessor<ReturnType<typeof footerPromptLayout>>
onContentChange: () => void
onSizeChange: () => void
bind: (area?: TextareaRenderable) => void
}) {
const renderer = useRenderer()
@@ -227,12 +276,53 @@ export function RunPromptBody(props: {
})
return (
<box width="100%">
<box paddingTop={1} paddingBottom={1} paddingRight={2}>
<box width="100%" paddingTop={props.layout().padding} paddingBottom={props.layout().padding}>
<box
border={["left"]}
borderColor={props.rail()}
customBorderChars={{ ...EmptyBorder, vertical: props.mono ? "|" : "┃" }}
paddingLeft={1}
paddingRight={2}
onSizeChange={props.onSizeChange}
>
<Show when={props.layout().images > 0}>
<box width="100%" height={props.layout().images} flexDirection="row" gap={1}>
<For
each={props
.images()
.slice(0, 3)
.map((image) => image.uri)}
>
{(image, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box width={props.layout().images * 2} height="100%" flexShrink={1}>
<Show when={!failed()} fallback={<text fg={props.theme().muted}>No preview</text>}>
<image
id={`mini-prompt-image-${index()}`}
source={image}
fit="fit"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={props.images().length > 3}>
<text fg={props.theme().muted} wrapMode="none" truncate>
+{props.images().length - 3} more
</text>
</Show>
</box>
</Show>
<textarea
width="100%"
minHeight={TEXTAREA_MIN_ROWS}
maxHeight={TEXTAREA_MAX_ROWS}
maxHeight={props.layout().textarea}
wrapMode="word"
placeholder={props.placeholder()}
placeholderColor={props.theme().muted}
@@ -244,8 +334,8 @@ export function RunPromptBody(props: {
cursorStyle={props.cursorStyle}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onPaste={() => {
refreshPasteLayout()
onPaste={(event) => {
void props.onPaste(event).finally(refreshPasteLayout)
}}
onContentChange={props.onContentChange}
ref={(next) => {
@@ -258,6 +348,10 @@ export function RunPromptBody(props: {
}
export function createPromptState(input: PromptInput): PromptState {
const renderer = useRenderer()
const term = useTerminalDimensions()
const [lines, setLines] = createSignal(TEXTAREA_MIN_ROWS)
const [statusRows, setStatusRows] = createSignal(1)
const [shell, setShell] = createSignal(false)
const placeholder = createMemo(() => {
if (shell()) {
@@ -268,7 +362,9 @@ export function createPromptState(input: PromptInput): PromptState {
return ""
}
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
return new StyledText([
fg(input.theme().muted)(`Ask anything, / for commands, @ for context${input.mono() ? "..." : "…"}`),
])
})
let history = createPromptHistory(input.history?.())
@@ -283,6 +379,31 @@ export function createPromptState(input: PromptInput): PromptState {
let type = 0
let parts: Mention[] = []
let marks = new Map<number, number>()
const [draftParts, setDraftParts] = createSignal<RunPromptPart[]>([])
const attachments = createMemo(() =>
draftParts().flatMap((part) =>
part.type === "file"
? [
{
uri: part.url,
name: part.filename,
description: part.description,
mention: part.source?.text
? { start: part.source.text.start, end: part.source.text.end, text: part.source.text.value }
: undefined,
},
]
: [],
),
)
const images = createMemo(() =>
(deduplicatePromptImages(attachments()) ?? []).filter((file) => file.uri.startsWith("data:image/")),
)
let clipboard: OwnedClipboardService | undefined
let pasteQueue: Promise<void> | undefined
let applyingPaste = false
let disposed = false
let revision = 0
const [mode, setMode] = createSignal<MenuMode>(false)
const [at, setAt] = createSignal(0)
@@ -290,11 +411,12 @@ export function createPromptState(input: PromptInput): PromptState {
const visible = createMemo(() => mode() !== false)
const setShellMode = (value: boolean) => {
revision += 1
setShell(value)
draft = value ? { ...draft, mode: "shell" } : { text: draft.text, parts: structuredClone(draft.parts) }
}
const width = createMemo(() => Math.max(20, input.width() - 8))
const width = createMemo(() => Math.max(0, input.width() - (input.width() < FOOTER_COMPACT_WIDTH ? 2 : 4)))
const agents = createMemo<Auto[]>(() => {
return input
.agents()
@@ -317,9 +439,7 @@ export function createPromptState(input: PromptInput): PromptState {
const references = createMemo<Auto[]>(() => {
return input.references().map((item) => ({
kind: "mention",
display: input.mono()
? monoTruncateMiddle("@" + item.name, width(), true)
: Locale.truncateMiddle("@" + item.name, width()),
display: "@" + item.name,
value: item.name,
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
part: {
@@ -339,7 +459,7 @@ export function createPromptState(input: PromptInput): PromptState {
},
}))
})
const [files] = createResource(
const [fileResults] = createResource(
query,
async (value) => {
if (!visible() || mode() !== "mention") {
@@ -361,9 +481,7 @@ export function createPromptState(input: PromptInput): PromptState {
return {
kind: "mention",
display: input.mono()
? monoTruncateMiddle("@" + filename, width(), true)
: Locale.truncateMiddle("@" + filename, width()),
display: "@" + filename,
value: filename,
directory: item.endsWith("/"),
part: {
@@ -386,6 +504,15 @@ export function createPromptState(input: PromptInput): PromptState {
},
{ initialValue: [] as Auto[] },
)
const files = createMemo(() =>
fileResults().map((item) => {
const parts = item.value.split("/")
const paths = parts
.slice(0, item.directory ? -1 : undefined)
.map((_, index) => "@" + parts.slice(index).join("/"))
return { ...item, display: paths.find((value) => stringWidth(value) <= width()) ?? paths.at(-1) ?? item.display }
}),
)
const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()])
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
const skillOptions = createMemo<SkillOption[]>(() =>
@@ -482,10 +609,17 @@ export function createPromptState(input: PromptInput): PromptState {
})
.map((item) => item.obj)
})
const menu = createFooterMenuState({ count: () => options().length, limit: AUTOCOMPLETE_ROWS })
const popup = createMemo(() => {
return visible() ? menu.rows() - 1 + AUTOCOMPLETE_BOTTOM_ROWS : 0
const layout = createMemo(() => {
term()
return footerPromptLayout(
renderer.terminalHeight,
lines(),
visible() ? Math.max(1, options().length) : 0,
statusRows(),
input.imagePreview === true && !input.mono() && !shell() && images().length > 0,
)
})
const menu = createFooterMenuState({ count: () => options().length, limit: () => Math.max(1, layout().menu) })
const hide = () => {
setMode(false)
@@ -498,7 +632,9 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
input.onRows(clamp(Math.max(area.lineCount, area.virtualLineCount)) + popup())
setLines(Math.max(area.lineCount, area.virtualLineCount))
area.maxHeight = layout().textarea
input.onRows(layout().rows)
}
const scheduleRows = () => {
@@ -518,8 +654,10 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
const next: Mention[] = []
const map = new Map<number, number>()
const next = parts.map<Mention | undefined>((part) =>
part.type === "file" && !part.source?.text ? part : undefined,
)
let tracked = 0
for (const item of area.extmarks.getAllForTypeId(type)) {
const idx = marks.get(item.id)
if (idx === undefined) {
@@ -556,15 +694,15 @@ export function createPromptState(input: PromptInput): PromptState {
copy.source.text.value = text
}
map.set(item.id, next.length)
next.push(copy)
tracked += 1
next[idx] = copy
}
const stale = map.size !== marks.size
parts = next
marks = map
const retained = next.filter((part): part is Mention => part !== undefined)
const stale = tracked !== marks.size || retained.length !== parts.length
parts = retained
if (stale) {
restoreParts(next)
restoreParts(retained)
}
}
@@ -574,6 +712,7 @@ export function createPromptState(input: PromptInput): PromptState {
}
parts = []
marks = new Map()
setDraftParts([])
}
const restoreParts = (value: RunPromptPart[]) => {
@@ -581,6 +720,7 @@ export function createPromptState(input: PromptInput): PromptState {
parts = value
.filter((item): item is Mention => item.type === "file" || item.type === "agent" || item.type === "skill")
.map((item) => structuredClone(item))
setDraftParts(parts)
if (!area || area.isDestroyed || type === 0) {
return
}
@@ -604,6 +744,7 @@ export function createPromptState(input: PromptInput): PromptState {
}
const restore = (value: RunPrompt, cursor = stringWidth(value.text)) => {
revision += 1
draft = promptCopy(value)
setShell(value.mode === "shell")
if (!area || area.isDestroyed) {
@@ -619,6 +760,7 @@ export function createPromptState(input: PromptInput): PromptState {
}
const resetDraft = () => {
revision += 1
if (area && !area.isDestroyed) {
area.setText("")
}
@@ -719,6 +861,7 @@ export function createPromptState(input: PromptInput): PromptState {
}
syncParts()
setDraftParts(parts)
const command = shell() ? undefined : selectedCommand(area.plainText, draft.command)
draft = shell()
? {
@@ -733,6 +876,87 @@ export function createPromptState(input: PromptInput): PromptState {
}
}
const pasteAttachment = (file: { uri: string; filename?: string }) => {
if (!area || area.isDestroyed) return
syncDraft()
const value = promptAttachmentLabel(attachments(), { uri: file.uri, name: file.filename })
area.insertText(value + " ")
const end = area.cursorOffset - 1
const start = end - stringWidth(value)
const id = area.extmarks.create({ start, end, virtual: true, typeId: type })
marks.set(id, parts.length)
parts.push({
type: "file",
url: file.uri,
filename: file.filename,
mime: file.uri.slice(5, file.uri.indexOf(";")),
source: { type: "file", text: { start, end, value } },
})
syncDraft()
}
const paste = (text?: string) => {
const next = (pasteQueue ?? Promise.resolve())
.then(async () => {
const target = area
if (disposed || !target || target.isDestroyed || !input.prompt()) return
const before = revision
const changed = () =>
disposed || area !== target || target.isDestroyed || revision !== before || !input.prompt()
const content =
text === undefined
? await (input.clipboard ?? (clipboard ??= createTuiClipboard(renderer))).read()
: { mime: "text/plain", data: text }
if (!content || changed()) return
const image = content.mime.startsWith("image/")
if (image && shell()) {
input.onStatus("image attachments are unavailable in shell mode")
return
}
if (!image && content.mime !== "text/plain") return
const normalized = image ? content.data : stripAnsiSequences(content.data).replace(/\r\n?/g, "\n")
const files = image
? [{ type: "file" as const, uri: `data:${content.mime};base64,${content.data}`, filename: "clipboard" }]
: shell()
? undefined
: await resolvePastedAttachments(normalized, process.platform)
if (changed()) return
// A paste's own text edits must not cancel a submit waiting on that paste.
applyingPaste = true
try {
files?.forEach((file) => {
if (file.type === "file") {
pasteAttachment(file)
return
}
target.insertText(file.content)
})
if (!files) target.insertText(normalized)
} finally {
applyingPaste = false
}
hide()
syncDraft()
target.getLayoutNode().markDirty()
renderer.requestRender()
scheduleRows()
})
.catch((error) => {
revision += 1
if (!disposed) input.onStatus(errorMessage(error))
})
.finally(() => {
if (pasteQueue === next) pasteQueue = undefined
})
pasteQueue = next
return next
}
const onPaste = (event: PasteEvent) => {
event.preventDefault()
return paste(event.bytes.length ? decodePasteBytes(event.bytes) : undefined)
}
const push = (value: RunPrompt) => {
history = pushPromptHistory(history, value)
}
@@ -788,7 +1012,8 @@ export function createPromptState(input: PromptInput): PromptState {
const requestExit = () => {
const text = area && !area.isDestroyed ? area.plainText : draft.text
if (input.prompt() && text.length > 0) {
revision += 1
if (input.prompt() && (text.length > 0 || draft.parts.some((part) => part.type === "file"))) {
input.onInputClear()
resetDraft()
return true
@@ -1033,6 +1258,12 @@ export function createPromptState(input: PromptInput): PromptState {
Keymap.createLayer(() => ({
enabled: input.prompt(),
commands: [
{
id: "prompt.paste",
title: "Paste",
group: "Prompt",
run: () => paste(),
},
{
id: "session.interrupt",
title: "Interrupt session",
@@ -1055,8 +1286,7 @@ export function createPromptState(input: PromptInput): PromptState {
group: "Prompt",
palette: true,
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
onSubmit("queue")
},
},
{
@@ -1210,7 +1440,7 @@ export function createPromptState(input: PromptInput): PromptState {
if (submitting) return
if (!next.text.trim()) {
if (!next.text.trim() && !next.parts.some((part) => part.type === "file")) {
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
if (queued) {
submitting = true
@@ -1282,9 +1512,17 @@ export function createPromptState(input: PromptInput): PromptState {
})
}
const onSubmit = () => {
const onSubmit = (delivery: RunDelivery = "steer") => {
if (pasteQueue) {
const before = revision
void pasteQueue.then(() => {
if (revision === before) onSubmit(delivery)
})
return
}
if (disposed || !input.prompt()) return
syncDraft()
submitPrompt(promptCopy(draft))
submitPrompt(promptCopy(draft), delivery)
}
const submitText = (text: string) => {
@@ -1292,14 +1530,20 @@ export function createPromptState(input: PromptInput): PromptState {
}
onCleanup(() => {
disposed = true
void clipboard?.dispose().catch(() => {})
if (area && !area.isDestroyed) {
area.off("line-info-change", scheduleRows)
}
})
createEffect(() => {
setStatusRows(input.statusRows())
})
createEffect(() => {
input.width()
popup()
layout()
if (input.prompt()) {
scheduleRows()
}
@@ -1354,17 +1598,22 @@ export function createPromptState(input: PromptInput): PromptState {
selected: menu.selected,
offset: menu.offset,
rows: menu.rows,
images,
layout,
requestExit,
onSubmit,
onSubmit: () => onSubmit(),
submitText,
openEditor,
onKeyDown,
onPaste,
onContentChange: () => {
if (!applyingPaste && area && area.plainText !== draft.text) revision += 1
input.onInputClear()
syncDraft()
refresh()
scheduleRows()
},
onSizeChange: scheduleRows,
replacePrompt: restore,
bind,
}
+124 -53
View File
@@ -1,12 +1,14 @@
/** @jsxImportSource @opentui/solid */
import type { ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { registerOpencodeSpinner } from "../component/register-spinner"
import { Show, createMemo, indexArray } from "solid-js"
import { Show, createMemo, createSignal, indexArray } from "solid-js"
import { SPINNER_FRAMES } from "../component/spinner-frames"
import { RunEntryContent, separatorRows } from "./scrollback.writer"
import type { FooterSubagentDetail, FooterSubagentTab } from "./types"
import type { RunFooterTheme, RunTheme } from "./theme"
import { stringWidth } from "../util/string-width"
import { footerMenuText } from "./footer.menu"
registerOpencodeSpinner()
@@ -14,7 +16,7 @@ export const SUBAGENT_INSPECTOR_ROWS = 14
function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
if (status === "completed") {
return theme.highlight
return theme.success
}
if (status === "cancelled") {
@@ -25,7 +27,7 @@ function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"])
return theme.error
}
return theme.highlight
return theme.running
}
function statusIcon(status: FooterSubagentTab["status"], mono: boolean) {
@@ -59,6 +61,10 @@ export function RunFooterSubagentBody(props: {
shellOutput?: () => boolean
mono?: boolean
}) {
const dims = useTerminalDimensions()
const [size, setSize] = createSignal(dims())
const width = () => size().width
const compact = () => width() < 56 || size().height < 12
const theme = createMemo(() => props.theme())
const footer = createMemo(() => theme().footer)
const tab = createMemo(() => props.tab())
@@ -102,6 +108,11 @@ export function RunFooterSubagentBody(props: {
if (tab()?.status !== "running") return undefined
return props.interrupt?.()
})
const count = () => (props.total() > 1 && props.index() > 0 ? `${props.index()} of ${props.total()}` : "")
const headerControlsWidth = () =>
(interruptHint() ? stringWidth(`${interruptHint()} interrupt`) + 1 : 0) + (count() ? stringWidth(count()) + 1 : 0)
const headerControls = () => !compact() && stringWidth(title()) + 2 + headerControlsWidth() <= width() - 4
const titleWidth = () => Math.max(1, width() - (compact() ? 2 : 6) - (headerControls() ? headerControlsWidth() : 0))
useKeyboard((event) => {
if (!props.active()) {
@@ -129,70 +140,130 @@ export function RunFooterSubagentBody(props: {
if (event.name === "down" || event.name === "j") {
event.preventDefault()
scroll?.scrollBy(1)
return
}
if (event.name === "pageup" || event.name === "pagedown") {
event.preventDefault()
scroll?.scrollBy(event.name === "pageup" ? -1 : 1, "viewport")
}
})
return (
<box width="100%" height="100%" flexDirection="column" backgroundColor={footer().surface}>
<box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
<Show when={tab()}>
{(current) => (
<box width="100%" flexDirection="row" gap={1} paddingBottom={1} flexShrink={0}>
{current().status === "running" ? (
<box flexShrink={0}>
<spinner
frames={props.mono ? ["-", "\\", "|", "/"] : SPINNER_FRAMES}
interval={props.mono ? 160 : 80}
color={statusColor(footer(), current().status)}
/>
</box>
) : (
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
{statusIcon(current().status, props.mono ?? false)}
</text>
)}
<text fg={footer().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{title()}
<Show when={subtitle().length > 0}>
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
</Show>
<box
width="100%"
height="100%"
minHeight={0}
flexDirection="column"
backgroundColor={footer().surface}
paddingTop={compact() ? 0 : 1}
paddingLeft={compact() ? 0 : 1}
paddingRight={compact() ? 0 : 3}
paddingBottom={compact() ? 0 : 1}
onSizeChange={function () {
setSize({ width: this.width, height: this.height })
}}
>
<Show when={tab()}>
{(current) => (
<box
width="100%"
height={compact() ? 1 : 2}
paddingBottom={compact() ? 0 : 1}
flexDirection="row"
gap={1}
flexShrink={0}
>
{current().status === "running" ? (
<box flexShrink={0}>
<spinner
frames={props.mono ? ["-", "\\", "|", "/"] : SPINNER_FRAMES}
interval={props.mono ? 160 : 80}
color={statusColor(footer(), current().status)}
/>
</box>
) : (
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
{statusIcon(current().status, props.mono ?? false)}
</text>
)}
<text fg={footer().text} wrapMode="none" flexGrow={1} flexShrink={1}>
{footerMenuText(title(), titleWidth(), props.mono)}
<Show when={subtitle().length > 0 && titleWidth() >= stringWidth(title()) + stringWidth(subtitle()) + 2}>
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
</Show>
</text>
<Show when={headerControls()}>
<Show when={interruptHint()}>
{(hint) => (
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
<text fg={footer().muted} wrapMode="none" flexShrink={0}>
{hint()} interrupt
</text>
)}
</Show>
<Show when={props.total() > 1 && props.index() > 0}>
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
{props.index()} of {props.total()}
</text>
<Show when={count()}>
{(value) => (
<text fg={footer().muted} wrapMode="none" flexShrink={0}>
{value()}
</text>
)}
</Show>
</box>
</Show>
</box>
)}
</Show>
<scrollbox
width="100%"
flexGrow={1}
minHeight={0}
stickyScroll={true}
stickyStart="bottom"
verticalScrollbarOptions={scrollbar()}
viewportOptions={{ paddingRight: props.mono ? 0 : 1 }}
ref={(item) => {
scroll = item
}}
>
<box width="100%" flexDirection="column" gap={0} flexShrink={0}>
{commits().length > 0 ? (
rows()
) : (
<text width="100%" fg={footer().muted} wrapMode="word" flexShrink={0}>
No subagent activity yet
</text>
)}
</Show>
<scrollbox
width="100%"
height="100%"
stickyScroll={true}
stickyStart="bottom"
verticalScrollbarOptions={scrollbar()}
ref={(item) => {
scroll = item
}}
>
<box width="100%" flexDirection="column" gap={0}>
{commits().length > 0 ? (
rows()
) : (
<text fg={footer().muted} wrapMode="word">
No subagent activity yet
</box>
</scrollbox>
<Show when={!headerControls()}>
<box width="100%" flexDirection="row" flexWrap="wrap" columnGap={1} flexShrink={0}>
<text height={1} fg={footer().actionSecondaryText} wrapMode="none" flexShrink={0} onMouseUp={props.onClose}>
esc back
</text>
<Show when={interruptHint()}>
{(hint) => (
<text maxWidth="100%" fg={footer().actionSecondaryText} wrapMode="word" flexShrink={0}>
{hint()} {width() >= stringWidth(hint()) + 10 ? "interrupt" : "stop"}
</text>
)}
</box>
</scrollbox>
</box>
</Show>
<Show when={width() >= 56}>
<text height={1} fg={footer().muted} wrapMode="none" flexShrink={0}>
pgup/pgdn scroll
</text>
<Show when={props.total() > 1 && props.index() > 0}>
<text
height={1}
fg={footer().actionSecondaryText}
wrapMode="none"
flexShrink={0}
onMouseUp={() => props.onCycle(1)}
>
tab next {props.index()}/{props.total()}
</text>
</Show>
</Show>
</box>
</Show>
</box>
)
}
+136 -48
View File
@@ -24,19 +24,22 @@
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
// two-press pattern where the first press shows a hint and the second press
// within 5 seconds actually fires the action.
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
import { CliRenderEvents, type CliRenderer, type CliRendererExternalOutputEvent } from "@opentui/core"
import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { batch, createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { RUN_SUBAGENT_PANEL_ROWS, footerPanelLayout } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { TEXTAREA_MIN_ROWS, footerPromptLayout } from "./footer.prompt"
import { RunFooterView } from "./footer.view"
import { monoSnapshot } from "./mono"
import { RunScrollbackStream } from "./scrollback.surface"
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
import { resolveRunTheme, type RunTheme } from "./theme"
import { modelInfo } from "./variant.shared"
import { entrySplash } from "./splash"
import { SEED_LAUNCH } from "../ui/one-cell-motion"
import type {
FooterApi,
FooterEvent,
@@ -75,6 +78,7 @@ type RunFooterOptions = {
agents: RunAgent[]
references: RunReference[]
wrote?: boolean
startup?: { version: string; detail: string }
agent: string | undefined
modelLabel: string
model: RunInput["model"]
@@ -82,12 +86,12 @@ type RunFooterOptions = {
first: boolean
history?: RunPrompt[]
theme: RunTheme
mono: boolean
tuiConfig: RunTuiConfig
miniSettings: {
current: MiniSettings
update?: (change: MiniSettingChange) => Promise<MiniSettings>
}
onMonoChange?: (mono: boolean) => void
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void>
@@ -208,6 +212,9 @@ export class RunFooter implements FooterApi {
private noticeTimeout: NodeJS.Timeout | undefined
private turnAgent: string | undefined
private requestExitHandler: (() => boolean) | undefined
private startup: Accessor<RunFooterOptions["startup"]>
private setStartup: Setter<RunFooterOptions["startup"]>
private startupTimer: ReturnType<typeof setTimeout> | undefined
private scrollback: RunScrollbackStream
private themes: RunTheme[]
private paletteRefreshRunning = false
@@ -225,7 +232,8 @@ export class RunFooter implements FooterApi {
.finally(() => this.destroyTheme(theme))
},
shellOutput: () => this.miniSettings().shell_output === "show",
mono: this.options.mono,
mono: this.miniSettings().mono,
imagePreview: this.options.tuiConfig.session?.image_preview,
})
}
@@ -238,7 +246,7 @@ export class RunFooter implements FooterApi {
status: "",
notice: "",
model: options.modelLabel,
usage: "",
usage: undefined,
first: options.first,
interrupt: 0,
exit: 0,
@@ -300,16 +308,19 @@ export class RunFooter implements FooterApi {
const [miniSettings, setMiniSettings] = createSignal(options.miniSettings.current)
this.miniSettings = miniSettings
this.setMiniSettings = setMiniSettings
const [startup, setStartup] = createSignal(options.startup)
this.startup = startup
this.setStartup = setStartup
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
this.scrollback = this.createScrollback(options.wrote ?? false)
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
if (!options.mono) {
this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette)
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
this.renderer.prependInputHandler(this.handleThemeNotification)
this.unsubscribeThemeSignal = options.subscribeThemeSignal(this.handleThemeSignal)
}
this.renderer.on(CliRenderEvents.RESIZE, this.handleResize)
this.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, this.handleExternalOutput)
this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette)
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
this.renderer.prependInputHandler(this.handleThemeNotification)
this.unsubscribeThemeSignal = options.subscribeThemeSignal(this.handleThemeSignal)
const footer = this
void render(
@@ -320,6 +331,7 @@ export class RunFooter implements FooterApi {
return createComponent(RunFooterView, {
directory: options.directory,
state: footer.state,
startup: footer.startup,
view: footer.view,
subagent: footer.subagent,
queuedPrompts: footer.queuedPrompts,
@@ -330,13 +342,14 @@ export class RunFooter implements FooterApi {
providers: footer.providers,
currentAgent: footer.currentAgent,
currentAgentID: footer.currentAgentID,
currentAgentExplicit: () => selectedAgentID() !== undefined,
currentModel: footer.currentModel,
variants: footer.variants,
currentVariant: footer.currentVariant,
theme: footer.theme,
tuiConfig: options.tuiConfig,
mono: options.mono,
get mono() {
return footer.miniSettings().mono
},
miniSettings: footer.miniSettings,
history: footer.history,
onSubmit: footer.handlePrompt,
@@ -347,7 +360,10 @@ export class RunFooter implements FooterApi {
onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground,
onQueuedPromptAction: options.onQueuedPromptAction,
onEditorOpen: options.onEditorOpen,
onEditorOpen: (input) => {
footer.finishStartup()
return options.onEditorOpen(input)
},
onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit,
onRequestExit: footer.setRequestExitHandler,
@@ -370,6 +386,26 @@ export class RunFooter implements FooterApi {
this.close()
}
})
if (options.startup)
this.startupTimer = setTimeout(() => this.finishStartup(), (SEED_LAUNCH.frames.length - 1) * SEED_LAUNCH.interval)
}
public finishStartup(): void {
const startup = this.startup()
if (!startup) return
clearTimeout(this.startupTimer)
this.startupTimer = undefined
this.setStartup(undefined)
if (this.isGone) return
this.applyHeight()
this.renderer.writeToScrollback(
entrySplash({
...startup,
theme: this.theme().splash,
mono: this.miniSettings().mono,
}),
)
this.renderer.requestRender()
}
public get isClosed(): boolean {
@@ -415,6 +451,7 @@ export class RunFooter implements FooterApi {
}
if (next.type === "turn.duration") {
this.finishStartup()
const agent = this.turnAgent ?? this.currentAgent()
this.turnAgent = undefined
if (this.miniSettings().turn_summary === "hide") return
@@ -516,7 +553,7 @@ export class RunFooter implements FooterApi {
status: typeof next.status === "string" ? next.status : prev.status,
notice: typeof next.notice === "string" ? next.notice : prev.notice,
model: typeof next.model === "string" ? next.model : prev.model,
usage: typeof next.usage === "string" ? next.usage : prev.usage,
usage: "usage" in next ? next.usage : prev.usage,
first: typeof next.first === "boolean" ? next.first : prev.first,
interrupt:
typeof next.interrupt === "number" && Number.isFinite(next.interrupt)
@@ -551,6 +588,7 @@ export class RunFooter implements FooterApi {
return
}
if (view.type !== "prompt") this.finishStartup()
this.setView(view)
this.applyHeight()
}
@@ -564,6 +602,7 @@ export class RunFooter implements FooterApi {
return
}
this.finishStartup()
const last = this.queue.at(-1)
const merged = last ? coalesceProgressCommit(last, commit) : undefined
if (merged) this.queue[this.queue.length - 1] = merged
@@ -614,6 +653,7 @@ export class RunFooter implements FooterApi {
return
}
this.finishStartup()
this.scrollback.destroy()
this.scrollback = this.createScrollback(wrote)
}
@@ -641,6 +681,7 @@ export class RunFooter implements FooterApi {
return
}
this.finishStartup()
this.flush()
this.notifyClose()
}
@@ -693,23 +734,34 @@ export class RunFooter implements FooterApi {
this.patch({ interrupt: 0, exit: 0 })
}
// Resizes the footer to fit the current view. Permission and form views
// get fixed extra rows; the prompt view scales with textarea line count.
private handleResize = (): void => {
if (!this.isGone) this.applyHeight()
}
private applyHeight(): void {
const type = this.view().type
const route = this.promptRoute.type
const height =
const panel = footerPanelLayout(this.renderer.terminalHeight)
const prompt = footerPromptLayout(this.renderer.terminalHeight)
const desired =
type === "permission"
? this.base + PERMISSION_ROWS
: type === "form"
? this.base + FORM_ROWS
: ["command", "skill", "agent", "model", "variant", "settings"].includes(route)
? 1 + RUN_COMMAND_PANEL_ROWS
? 1 + panel.frame + panel.limit
: route === "queued-menu" || route === "subagent-menu"
? 1 + this.subagentMenuRows
: route === "subagent"
? this.base + SUBAGENT_INSPECTOR_ROWS
: this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
: prompt.padding * 2 + 1 + this.rows
const height = Math.max(
1,
Math.min(
desired + (this.startup() ? 2 : 0),
this.renderer.terminalHeight - (type === "prompt" && route === "composer" ? 1 : 0),
),
)
if (height !== this.renderer.footerHeight) {
this.renderer.footerHeight = height
@@ -721,7 +773,7 @@ export class RunFooter implements FooterApi {
return
}
const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, value))
const rows = Math.max(TEXTAREA_MIN_ROWS, value)
if (rows === this.rows) {
return
}
@@ -733,6 +785,7 @@ export class RunFooter implements FooterApi {
}
private syncLayout = (next: { route: FooterPromptRoute; subagentRows: number }): void => {
if (next.route.type !== "composer") this.finishStartup()
this.promptRoute = next.route
this.subagentMenuRows = next.subagentRows
if (this.view().type === "prompt") {
@@ -861,8 +914,29 @@ export class RunFooter implements FooterApi {
}
try {
this.setMiniSettings(await this.options.miniSettings.update(change))
this.setNotice(change.key === "mono" ? "Mono applies after restart" : "settings updated")
const settings = await this.options.miniSettings.update(change)
if (this.isClosed) return
if (settings.mono === this.miniSettings().mono) {
this.setMiniSettings(settings)
this.setNotice("settings updated")
return
}
const theme = await resolveRunTheme(this.renderer, this.options.tuiConfig.theme, settings.mono)
this.flush()
this.flushing = this.flushing.then(async () => {
if (this.isClosed) {
theme.block.syntax?.destroy()
return
}
await this.scrollback.setMono(settings.mono)
batch(() => {
this.setMiniSettings(settings)
this.applyTheme(theme)
this.options.onMonoChange?.(settings.mono)
})
})
await this.flushing
this.setNotice("settings updated")
} catch (error) {
this.setNotice("failed to save settings")
throw error
@@ -959,26 +1033,34 @@ export class RunFooter implements FooterApi {
return true
}
private handlePalette = (): void => {
void resolveRunTheme(this.renderer, this.options.tuiConfig.theme).then((theme) => {
if (this.isGone) {
theme.block.syntax?.destroy()
return
}
private applyTheme(theme: RunTheme): void {
if (theme === this.theme()) return
this.themes.push(theme)
this.setTheme(theme)
this.renderer.setBackgroundColor(theme.background)
this.scrollback.setTheme(theme)
}
// Keep the last known good theme when a runtime OSC probe times out.
if (theme === RUN_THEME_FALLBACK) {
return
}
private handleExternalOutput = (event: CliRendererExternalOutputEvent): void => {
if (this.miniSettings().mono) monoSnapshot(event)
}
this.themes.push(theme)
this.setTheme(theme)
this.renderer.setBackgroundColor(theme.background)
private handlePalette = (): Promise<void> | undefined => {
if (this.isGone || this.paletteRefreshRunning) return
const mono = this.miniSettings().mono
return resolveRunTheme(this.renderer, this.options.tuiConfig.theme, mono).then((theme) => {
this.flushing = this.flushing
.then(() => this.scrollback.setTheme(theme))
.then(() => {
if (this.isGone || mono !== this.miniSettings().mono) {
theme.block.syntax?.destroy()
return
}
this.applyTheme(theme)
})
.catch((error) => {
this.flushError = error
})
return this.flushing
})
}
@@ -993,10 +1075,11 @@ export class RunFooter implements FooterApi {
return false
}
private handleThemeRefresh = (): void => {
if (this.isGone || this.options.mono) {
private handleThemeRefresh = (): Promise<void> | undefined => {
if (this.isGone) {
return
}
if (this.miniSettings().mono) return this.handlePalette()
if (this.paletteRefreshRunning) {
this.paletteRefreshQueued = true
@@ -1006,22 +1089,23 @@ export class RunFooter implements FooterApi {
this.paletteRefreshRunning = true
const retry = this.renderer.paletteDetectionStatus === "detecting"
this.renderer.clearPaletteCache()
void this.renderer
return this.renderer
.getPalette({ size: 256 })
.catch(() => {})
.finally(() => {
.then(() => {
this.paletteRefreshRunning = false
if (!retry && !this.paletteRefreshQueued) {
return
// Theme files can change without a new terminal palette.
return this.handlePalette()
}
this.paletteRefreshQueued = false
this.handleThemeRefresh()
return this.handleThemeRefresh()
})
}
public refreshTheme(): void {
this.handleThemeRefresh()
public refreshTheme() {
return this.handleThemeRefresh()
}
private handleThemeSignal = (): void => {
@@ -1039,6 +1123,8 @@ export class RunFooter implements FooterApi {
return
}
clearTimeout(this.startupTimer)
this.startupTimer = undefined
this.flush()
this.destroyed = true
this.notifyClose()
@@ -1046,6 +1132,8 @@ export class RunFooter implements FooterApi {
this.clearExitTimer()
this.clearNoticeTimer()
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
this.renderer.off(CliRenderEvents.RESIZE, this.handleResize)
this.renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, this.handleExternalOutput)
this.renderer.off(CliRenderEvents.PALETTE, this.handlePalette)
this.renderer.off(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
this.renderer.removeInputHandler(this.handleThemeNotification)
+175 -229
View File
@@ -8,10 +8,12 @@
// All state comes from the parent RunFooter through SolidJS signals.
// The view itself is stateless except for derived memos.
/** @jsxImportSource @opentui/solid */
import { useTerminalDimensions } from "@opentui/solid"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { TextBuffer, TextBufferView } from "@opentui/core"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { registerOpencodeSpinner } from "../component/register-spinner"
import { createColors, createFrames } from "../ui/spinner"
import { OneCellSpinner } from "../component/one-cell-spinner"
import { WORK_SPINNERS, SEED_LAUNCH, SEED_MONO } from "../ui/one-cell-motion"
import { entrySplashLayout } from "./splash"
import {
RUN_SUBAGENT_PANEL_ROWS,
RunAgentSelectBody,
@@ -31,9 +33,11 @@ import { RunFormBody } from "./footer.form"
import { createFormBodyState, type FormBodyState } from "./form.shared"
import { footerStatuslinePolicy } from "./footer.width"
import { Keymap } from "../context/keymap"
import type { ClipboardService } from "../context/clipboard"
import { modelInfo } from "./variant.shared"
import { monoShortcut } from "./mono"
import { stringWidth } from "../util/string-width"
import { formatContextUsage } from "../util/session"
import { errorMessage } from "../util/error"
import { createSingleFlight } from "../util/single-flight"
@@ -59,7 +63,7 @@ import type {
} from "./types"
import type { RunTheme } from "./theme"
registerOpencodeSpinner()
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
const EMPTY_BORDER = {
topLeft: "",
@@ -84,11 +88,11 @@ type RunFooterViewProps = {
providers: () => RunProvider[] | undefined
currentAgent: () => string
currentAgentID: () => string | undefined
currentAgentExplicit: () => boolean
currentModel: () => RunInput["model"]
variants: () => string[]
currentVariant: () => string | undefined
state: () => FooterState
startup?: () => { version: string; detail: string } | undefined
view?: () => FooterView
subagent?: () => FooterSubagentState
queuedPrompts?: () => FooterQueuedPrompt[]
@@ -97,6 +101,7 @@ type RunFooterViewProps = {
mono: boolean
miniSettings: () => MiniSettings
history?: () => RunPrompt[]
clipboard?: Pick<ClipboardService, "read">
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void>
@@ -122,8 +127,13 @@ type RunFooterViewProps = {
}
export function RunFooterView(props: RunFooterViewProps) {
const renderer = useRenderer()
const term = useTerminalDimensions()
const width = createMemo(() => term().width)
const startup = createMemo(() => {
const value = props.startup?.()
return value ? entrySplashLayout({ ...value, width: width(), mono: props.mono }) : undefined
})
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
const subagent = createMemo<FooterSubagentState>(() => {
return (
@@ -181,7 +191,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background))
const model = createMemo(() => {
const current = props.currentModel()
return current ? modelInfo(props.providers(), current).model : undefined
return current ? modelInfo(props.providers(), current) : undefined
})
const detail = createMemo(() => {
const current = route()
@@ -198,10 +208,18 @@ export function RunFooterView(props: RunFooterViewProps) {
const variantCycle = () => monoShortcut(shortcuts.all("variant.cycle") ?? "", props.mono)
const clearShortcut = () => shortcut("prompt.clear")
const busy = createMemo(() => props.state().phase === "running")
const started = createMemo(() => (busy() ? performance.now() : undefined))
const statusWidth = createMemo(() => Math.max(1, width() - (busy() ? 2 : 0)))
const armed = createMemo(() => props.state().interrupt > 0)
const exiting = createMemo(() => props.state().exit > 0)
const usage = createMemo(() => props.state().usage)
const footerDetails = createMemo(() => props.miniSettings().footer === "show")
const contextUsage = createMemo(() => {
const current = usage()
return current && current.tokens > 0 ? formatContextUsage(current.tokens, current.percent) : ""
})
const cost = createMemo(() => (usage()?.cost ? money.format(usage()!.cost!) : ""))
const takeover = createMemo(() => exiting() || (busy() && armed()) || !!props.state().notice.trim())
const footerDetails = createMemo(() => props.miniSettings().footer === "show" && !takeover())
const interruptLabel = createMemo(() => {
if (!interrupt()) {
return
@@ -211,31 +229,22 @@ export function RunFooterView(props: RunFooterViewProps) {
})
const runTheme = createMemo(() => props.theme())
const theme = createMemo(() => runTheme().footer)
const block = createMemo(() => runTheme().block)
const spin = createMemo(() => {
if (props.mono) {
return {
frames: ["-", "\\", "|", "/"],
color: theme().text,
}
}
const options = {
color: theme().highlight,
style: "blocks" as const,
inactiveFactor: 0.6,
minAlpha: 0.3,
}
return {
frames: createFrames(options),
color: createColors(options),
}
const agentColor = createMemo(() => {
const colors = theme().categorical
const index = props
.agents()
.filter((agent) => !agent.hidden)
.findIndex((agent) => agent.id === props.currentAgentID())
return colors[Math.max(0, index) % colors.length]!
})
const block = createMemo(() => runTheme().block)
const footerStatus = createMemo(() => {
const current = model() ?? props.state().model.trim()
const current = model()?.model ?? props.state().model.trim()
const variant = props.currentVariant()
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
if (current) details.push(variant ? `${current} ${variant}` : current)
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
if (contextUsage()) details.push(contextUsage())
if (cost()) details.push(cost())
if (queue().length > 0) details.push(`${queue().length} queued`)
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
return details.join(props.mono ? " - " : " · ")
@@ -363,6 +372,7 @@ export function RunFooterView(props: RunFooterViewProps) {
openTab(next.sessionID)
}
const [promptRows, setPromptRows] = createSignal(1)
const composer = createPromptState({
directory: props.directory,
findFiles: props.findFiles,
@@ -373,8 +383,11 @@ export function RunFooterView(props: RunFooterViewProps) {
view: promptView,
prompt,
width,
statusRows: () => (menu() ? 0 : statusRows()),
theme,
mono: () => props.mono,
imagePreview: props.tuiConfig.prompt?.image_preview,
clipboard: props.clipboard,
history: props.history,
queuedPrompts: queue,
onQueuedPromptSteer: (inboxID) => queuedPromptAction("steer", inboxID),
@@ -387,63 +400,44 @@ export function RunFooterView(props: RunFooterViewProps) {
onExit: props.onExit,
onSkillMenu: openSkillMenu,
onSettings: openSettings,
onRows: props.onRows,
onRows: setPromptRows,
onStatus: props.onStatus,
})
const shell = createMemo(() => prompt() && composer.shell())
const menu = createMemo(() => prompt() && composer.visible())
const stateStatus = createMemo(() => props.state().status.trim())
const notice = createMemo(() => props.state().notice.trim())
const modeLabel = createMemo(() => {
if (exiting()) {
return "EXIT"
}
return shell() ? "SHELL" : undefined
})
const modeColor = createMemo(() => {
if (exiting()) {
return theme().error
}
if (shell()) {
return theme().warning
}
return theme().highlight
})
const statusText = createMemo(() => {
if (exiting()) {
return `Press ${clearShortcut() || "ctrl+c"} again to exit`
if (exiting() || (busy() && armed())) {
const key = exiting() ? clearShortcut() : interruptLabel()
const action = exiting() ? "exit" : "stop"
if (!key) return exiting() ? "Exit pending" : "Stop pending"
const phrases = [
`Press ${key} again to ${exiting() ? "exit" : "interrupt"}`,
`${key} again to ${exiting() ? "exit" : "interrupt"}`,
`${key} again: ${action}`,
`${key} ${action}`,
]
return phrases.find((text) => stringWidth(text) <= statusWidth()) ?? phrases[phrases.length - 1]!
}
if (busy() && armed()) return "again to interrupt"
if (notice()) return notice()
if (!footerDetails()) return shell() ? "Shell mode" : ""
if (busy()) return "interrupt"
if (stateStatus().length > 0) {
return stateStatus()
if (!footerDetails()) return shell() ? "Shell" : ""
if (busy()) {
return interruptLabel() ? `${interruptLabel()} stop` : "Running"
}
return shell() ? "Shell mode" : ""
})
const activityMeta = createMemo(() => {
if (!footerDetails()) return ""
return props.mono ? usage().replaceAll(" · ", " - ") : usage()
return stateStatus() || (shell() ? "Shell" : "")
})
const agentStatus = createMemo(() => {
if (!footerDetails() || !prompt() || shell() || !props.currentAgentExplicit()) return undefined
if (!footerDetails() || !prompt() || shell()) return undefined
return props.currentAgent()
})
const modelStatus = createMemo(() => {
const current = model() ?? props.state().model.trim()
const current = model()?.model ?? props.state().model.trim()
if (!footerDetails() || !prompt() || shell() || !current) return
return {
model: current,
provider: model()?.provider,
variant: props.currentVariant(),
}
})
@@ -453,7 +447,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}
if (armed()) {
return theme().highlight
return theme().warning
}
if (busy() || notice().length > 0 || stateStatus().length > 0) {
@@ -462,79 +456,87 @@ export function RunFooterView(props: RunFooterViewProps) {
return theme().muted
})
const statuslineBackground = createMemo(() => theme().status)
const contextHintCandidates = createMemo(() => {
if (!footerDetails() || !prompt() || shell()) {
return []
}
const items: Array<{ key: string; label: string }> = []
if (foregroundSubagents() && backgroundShortcut()) {
items.push({ key: backgroundShortcut(), label: "background" })
}
const items: Array<{ id: "queued" | "subagents" | "background"; key: string; label: string; expanded?: string }> =
[]
if (queue().length > 0 && queuedShortcut()) {
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
items.push({ id: "queued", key: queuedShortcut(), label: `${queue().length} queued` })
}
if (activeTabs().length > 0 && subagentShortcut()) {
items.push({ key: subagentShortcut(), label: "subagents" })
items.push({
id: "subagents",
key: subagentShortcut(),
label: `${activeTabs().length} sub`,
expanded: `${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`,
})
}
if (foregroundSubagents() && backgroundShortcut()) {
items.push({ id: "background", key: backgroundShortcut(), label: "bg", expanded: "background" })
}
return items
})
const commandHint = createMemo(() => {
if (!prompt()) return
if (shell()) {
return { key: "esc", label: "normal" }
}
if (!prompt() || takeover() || shell()) return
if (command()) {
return { key: command(), label: "cmd" }
return { key: command(), label: "menu" }
}
})
const commandHintWidth = createMemo(() => {
const hint = commandHint()
return hint ? stringWidth(`${hint.key} ${hint.label}`) : 0
})
const statuslineText = createMemo(() =>
busy() && !exiting() && (footerDetails() || armed())
? `${interruptLabel() ? `${interruptLabel()} ` : ""}${statusText()}`
: statusText(),
)
const statuslineMainWidth = createMemo(() => {
const mode = modeLabel()
const modeWidth = mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0
const spinnerWidth = footerDetails() && busy() && !exiting() ? stringWidth(spin().frames[0] ?? "") + 1 : 0
return modeWidth + Math.max(12, (props.mono ? 1 : 2) + spinnerWidth + stringWidth(statuslineText()))
})
const visibleModeLabel = createMemo(() => {
const mode = modeLabel()
if (!mode || width() - commandHintWidth() < stringWidth(mode) + (props.mono ? 1 : 2)) return undefined
return mode
})
const statuslineMainAvailable = createMemo(() => {
const mode = visibleModeLabel()
return width() - commandHintWidth() - (mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0)
})
const statuslineLayout = createMemo(() => {
const agent = agentStatus()
const info = modelStatus()
return footerStatuslinePolicy({
width: width(),
mainWidth: statuslineMainWidth(),
commandWidth: commandHint() ? commandHintWidth() : undefined,
agentWidth: agent ? stringWidth(agent) : undefined,
contextWidths: contextHintCandidates().map((item) => stringWidth(`${item.key} ${item.label}`)),
modelWidth: info ? stringWidth(info.model) : undefined,
variantWidth: info?.variant ? stringWidth(` ${info.variant}`) : undefined,
usageWidth: activityMeta() ? stringWidth(activityMeta()) : undefined,
mono: props.mono,
status: {
text: statusText(),
expanded: footerDetails() && busy() && interruptLabel() ? `${interruptLabel()} interrupt` : undefined,
},
escape: shell() && !takeover() ? { key: "esc", label: "normal" } : undefined,
work: contextHintCandidates(),
model: info ? { name: info.model, variant: info.variant } : undefined,
agent: agentStatus(),
context:
footerDetails() && contextUsage()
? {
compact: usage()?.percent === undefined ? contextUsage() : `${usage()!.percent}% ctx`,
full: contextUsage(),
}
: undefined,
cost: footerDetails() ? cost() : undefined,
provider: info?.provider,
menu: commandHint(),
spinner: busy() ? (props.mono ? "*" : "\u25aa") : undefined,
})
})
const contextHints = createMemo(() => contextHintCandidates().slice(0, statuslineLayout().contextCount))
const hasStatuslineInfo = createMemo(() => {
const layout = statuslineLayout()
return layout.showUsage || layout.showAgent || layout.showModel
const statusSections = createMemo(() => statuslineLayout().groups.filter((group) => group.id !== "spinner"))
const statusColors = createMemo(() => ({
text: theme().text,
muted: theme().muted,
agent: agentColor(),
status: statusColor(),
}))
const statusRows = createMemo(() => {
const text = statusSections()
.map((group) => group.parts.map((part) => part.text).join(""))
.join(props.mono ? " - " : " \u00b7 ")
if (stringWidth(text) <= statusWidth() && !text.includes("\n")) return 1
// Measure outside the clipped footer so wrapped required controls can grow it.
const buffer = TextBuffer.create(renderer.widthMethod)
const view = TextBufferView.create(buffer)
buffer.setText(text)
view.setWrapMode("word")
view.setWrapWidth(statusWidth())
const rows = Math.max(1, view.getVirtualLineCount())
view.destroy()
buffer.destroy()
return rows
})
createEffect(() => {
props.onRows(promptRows() + (!panel() && !menu() && !inspecting() ? statusRows() : 0) - 1)
})
const sectionSeparator = () => <span style={{ fg: theme().muted }}>{props.mono ? "- " : "· "}</span>
createEffect(() => {
props.onRequestExit?.(composer.requestExit)
@@ -692,6 +694,23 @@ export function RunFooterView(props: RunFooterViewProps) {
gap={0}
padding={0}
>
<Show when={startup()}>
{(layout) => (
<box id="mini-startup" height={2} flexShrink={0} paddingTop={1} flexDirection="row">
<Show when={layout().label.startsWith("\u25aa")}>
<OneCellSpinner
animation={SEED_LAUNCH}
color={runTheme().splash.right}
animations={props.tuiConfig.animations}
/>
</Show>
<text fg={runTheme().splash.right} wrapMode="none">
{layout().label.startsWith("\u25aa") ? layout().label.slice(1) : layout().label}
<span style={{ fg: runTheme().splash.left }}>{layout().metadata}</span>
</text>
</box>
)}
</Show>
<Show when={panel() || inspecting()}>
<box width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
</Show>
@@ -706,7 +725,7 @@ export function RunFooterView(props: RunFooterViewProps) {
width="100%"
flexShrink={0}
border={panel() || prompt() ? false : ["left"]}
borderColor={panel() || prompt() ? undefined : theme().highlight}
borderColor={panel() || prompt() ? undefined : theme().border}
customBorderChars={
panel() || prompt()
? undefined
@@ -733,10 +752,16 @@ export function RunFooterView(props: RunFooterViewProps) {
theme={theme}
cursorStyle={props.tuiConfig.cursor}
background={() => runTheme().background}
rail={() => (shell() ? theme().formfieldFocusedText : agentColor())}
mono={props.mono}
placeholder={composer.placeholder}
onSubmit={composer.onSubmit}
onKeyDown={composer.onKeyDown}
onPaste={composer.onPaste}
images={composer.images}
layout={composer.layout}
onContentChange={composer.onContentChange}
onSizeChange={composer.onSizeChange}
bind={composer.bind}
/>
</Match>
@@ -874,6 +899,7 @@ export function RunFooterView(props: RunFooterViewProps) {
onClose={closePanel}
onChange={props.onMiniSettingChange}
mono={props.mono}
animations={props.tuiConfig.animations}
/>
</Match>
<Match when={active().type === "permission"}>
@@ -928,128 +954,48 @@ export function RunFooterView(props: RunFooterViewProps) {
rows={composer.rows}
limit={FOOTER_MENU_ROWS}
border={false}
paddingLeft={0}
paddingLeft={2}
paddingRight={2}
mono={props.mono}
/>
</Show>
<Show when={!panel() && !menu()}>
<box
id="mini-statusline"
width="100%"
height={1}
flexDirection="row"
gap={0}
gap={1}
flexShrink={0}
backgroundColor={statuslineBackground()}
backgroundColor="transparent"
>
<Show when={visibleModeLabel()}>
{(label) => (
<box
paddingLeft={props.mono ? 0 : 1}
paddingRight={1}
backgroundColor={theme().statusAccent}
flexShrink={0}
>
<text wrapMode="none" truncate>
<span style={{ fg: modeColor(), bold: true }}>{label()}</span>
</text>
</box>
)}
<Show when={statuslineLayout().groups.some((group) => group.id === "spinner")}>
<box id="mini-work-spinner" width={1} flexShrink={0}>
<OneCellSpinner
animation={props.mono ? SEED_MONO : WORK_SPINNERS[props.miniSettings().work_spinner]}
color={agentColor()}
animations={props.tuiConfig.animations}
glow={!props.mono}
still={props.mono ? "*" : undefined}
age={performance.now() - (started() ?? performance.now())}
/>
</box>
</Show>
<box
flexDirection="row"
gap={1}
flexGrow={1}
flexShrink={1}
minWidth={0}
paddingLeft={statuslineMainAvailable() >= 2 && !props.mono ? 1 : 0}
paddingRight={statuslineMainAvailable() >= (props.mono ? 1 : 2) ? 1 : 0}
backgroundColor="transparent"
overflow="hidden"
>
<Show
when={
footerDetails() &&
busy() &&
!exiting() &&
statuslineMainAvailable() >=
(props.mono ? 1 : 2) + stringWidth(spin().frames[0] ?? "") + 1 + stringWidth(statuslineText())
}
>
<box flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} />
</box>
</Show>
<text fg={statusColor()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
<Show when={busy() && !exiting() && (footerDetails() || armed())} fallback={statusText()}>
<Show when={interruptLabel()}>
{(label) => <span style={{ fg: armed() ? statusColor() : theme().muted }}>{label()} </span>}
</Show>
{statusText()}
</Show>
<Show when={statusSections().length > 0}>
<text fg={statusColor()} wrapMode="word" width={statusWidth()} flexShrink={0} height={statusRows()}>
<For each={statusSections()}>
{(section, index) => (
<>
<Show when={index() > 0}>
<span style={{ fg: theme().muted }}>{props.mono ? " - " : " · "}</span>
</Show>
<For each={section.parts}>
{(part) => <span style={{ fg: statusColors()[part.tone] }}>{part.text}</span>}
</For>
</>
)}
</For>
</text>
</box>
<Show when={statuslineLayout().showUsage && activityMeta()}>
{(usage) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().muted} wrapMode="none">
{usage()}
</text>
</box>
)}
</Show>
<Show when={statuslineLayout().showAgent && agentStatus()}>
{(agent) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
<Show when={statuslineLayout().showUsage}>{sectionSeparator()}</Show>
{agent()}
</text>
</box>
)}
</Show>
<Show when={statuslineLayout().showModel && modelStatus()}>
{(info) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
<Show when={statuslineLayout().showUsage || statuslineLayout().showAgent}>
{sectionSeparator()}
</Show>
{info().model}
<Show when={statuslineLayout().showVariant && info().variant}>
{(variant) => <span style={{ fg: theme().warning, bold: true }}> {variant()}</span>}
</Show>
</text>
</box>
)}
</Show>
<For each={contextHints()}>
{(hint, index) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
<Show when={index() > 0 || (hasStatuslineInfo() && index() === 0)}>{sectionSeparator()}</Show>
<span style={{ fg: theme().text }}>{hint.key}</span>{" "}
<span style={{ fg: theme().muted }}>{hint.label}</span>
</text>
</box>
)}
</For>
<Show when={commandHint()}>
{(hint) => (
<box backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
<Show when={hasStatuslineInfo() || contextHints().length > 0}>{sectionSeparator()}</Show>
<span style={{ fg: theme().text }}>{hint().key}</span>{" "}
<span style={{ fg: theme().muted }}>{hint().label}</span>
</text>
</box>
)}
</Show>
</box>
</Show>
@@ -1061,7 +1007,7 @@ export function RunFooterView(props: RunFooterViewProps) {
flexGrow={1}
flexShrink={1}
border={["left"]}
borderColor={theme().highlight}
borderColor={theme().border}
customBorderChars={{
...EMPTY_BORDER,
vertical: props.mono ? "|" : "┃",
+112 -38
View File
@@ -1,3 +1,6 @@
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
export function footerWidthPolicy(width: number) {
return {
dialog: {
@@ -6,49 +9,120 @@ export function footerWidthPolicy(width: number) {
}
}
const USAGE_HEADROOM = 8
type FooterAction = {
key: string
label: string
expanded?: string
}
export type FooterStatuslineGroup = {
id:
| "spinner"
| "status"
| "escape"
| "queued"
| "subagents"
| "background"
| "agent"
| "model"
| "context"
| "cost"
| "provider"
| "menu"
parts: Array<{ text: string; tone: "text" | "muted" | "agent" | "status" }>
}
export function footerStatuslinePolicy(input: {
width: number
mainWidth: number
commandWidth?: number
agentWidth?: number
contextWidths: number[]
modelWidth?: number
variantWidth?: number
usageWidth?: number
mono?: boolean
status?: { text: string; expanded?: string }
escape?: FooterAction
work: Array<FooterAction & { id: "queued" | "subagents" | "background" }>
model?: { name: string; variant?: string }
agent?: string
context?: { compact: string; full: string }
cost?: string
provider?: string
menu?: FooterAction
spinner?: string
}) {
let remaining = input.width - input.mainWidth - (input.commandWidth ?? 0)
let hasSection = input.commandWidth !== undefined
const include = (width: number | undefined, headroom = 0) => {
if (width === undefined) return false
const required = width + (hasSection ? 3 : 1)
if (remaining < required + headroom) return false
remaining -= required
hasSection = true
return true
const group = (
id: FooterStatuslineGroup["id"],
text: string,
tone: FooterStatuslineGroup["parts"][number]["tone"] = "muted",
): FooterStatuslineGroup => ({ id, parts: [{ text, tone }] })
const action = (id: FooterStatuslineGroup["id"], value: FooterAction, expanded = false): FooterStatuslineGroup => ({
id,
parts: [
{ text: value.key, tone: "text" },
{ text: ` ${expanded ? (value.expanded ?? value.label) : value.label}`, tone: "muted" },
],
})
const identity = (cells: number) => {
const name = input.model!.name
const ellipsis = input.mono ? "..." : "\u2026"
const text =
stringWidth(name) > cells + stringWidth(ellipsis) ? Locale.takeWidth(name, cells).trimEnd() + ellipsis : name
return group("model", text + (input.model!.variant ? ` [${input.model!.variant}]` : ""), "text")
}
const selected = new Map<FooterStatuslineGroup["id"], FooterStatuslineGroup>()
if (input.spinner) selected.set("spinner", group("spinner", input.spinner, "text"))
if (input.status?.text) selected.set("status", group("status", input.status.text, "status"))
if (input.escape) selected.set("escape", action("escape", input.escape))
const showModel = include(input.modelWidth)
const showAgent = include(input.agentWidth)
const hiddenContext = input.contextWidths.findIndex((width) => !include(width))
const contextCount = hiddenContext === -1 ? input.contextWidths.length : hiddenContext
const contextComplete = contextCount === input.contextWidths.length
const variantWidth = input.variantWidth
const showVariant = showModel && contextComplete && variantWidth !== undefined && remaining >= variantWidth
if (showVariant) remaining -= variantWidth
const showUsage =
(showModel || input.modelWidth === undefined) &&
(showAgent || input.agentWidth === undefined) &&
contextComplete &&
(showVariant || input.variantWidth === undefined) &&
include(input.usageWidth, USAGE_HEADROOM)
return {
showAgent,
contextCount,
showModel,
showVariant,
showUsage,
const place = () => {
const order: FooterStatuslineGroup["id"][] = [
"spinner",
"status",
"escape",
"queued",
"subagents",
"background",
"agent",
"model",
"context",
"cost",
"provider",
"menu",
]
const groups = order.flatMap((id) => selected.get(id) ?? [])
const separator = input.mono ? " - " : " \u00b7 "
return {
groups,
text: groups
.map(
(item, index) =>
(index === 0 ? "" : groups[index - 1]!.id === "spinner" ? " " : separator) +
item.parts.map((part) => part.text).join(""),
)
.join(""),
}
}
let layout = place()
// Required controls may wrap. Optional information never crowds that fallback.
if (stringWidth(layout.text) > input.width || layout.text.includes("\n")) return layout
// Each stage retains earlier information. Stop at the first non-fitting stage:
// backfilling shorter, lower-priority groups would make resizing unstable.
const stages = [
...input.work.map((item) => action(item.id, item)),
...(input.model ? [identity(8)] : []),
...(input.agent ? [group("agent", input.agent, "agent")] : []),
...(input.context ? [group("context", input.context.compact)] : []),
...(input.model ? [identity(24)] : []),
...(input.context ? [group("context", input.context.full)] : []),
...(input.cost ? [group("cost", input.cost)] : []),
...(input.provider ? [group("provider", input.provider)] : []),
...(input.menu ? [action("menu", input.menu)] : []),
...(input.model ? [identity(Infinity)] : []),
...input.work.filter((item) => item.expanded).map((item) => action(item.id, item, true)),
...(input.status?.expanded ? [group("status", input.status.expanded, "status")] : []),
]
for (const stage of stages) {
selected.set(stage.id, stage)
const next = place()
if (stringWidth(next.text) > input.width || next.text.includes("\n")) break
layout = next
}
return layout
}
+4 -2
View File
@@ -58,7 +58,9 @@ export function isCompactCommand(input: string): boolean {
}
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
const list = (items ?? [])
.filter((item) => item.text.trim().length > 0 || item.parts.some((part) => part.type === "file"))
.map(promptCopy)
const next: RunPrompt[] = []
for (const item of list) {
if (next.length > 0 && promptSame(next[next.length - 1], item)) {
@@ -76,7 +78,7 @@ export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
}
export function pushPromptHistory(state: PromptHistoryState, prompt: RunPrompt): PromptHistoryState {
if (!prompt.text.trim()) {
if (!prompt.text.trim() && !prompt.parts.some((part) => part.type === "file")) {
return state
}
+1
View File
@@ -91,6 +91,7 @@ export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }):
turn_summary: config?.mini?.turn_summary ?? "show",
footer: config?.mini?.footer ?? "show",
splash: config?.mini?.splash ?? "show",
work_spinner: config?.mini?.work_spinner ?? "block-soft-slide",
mono: config?.mini?.mono ?? false,
}
}
+35 -44
View File
@@ -9,10 +9,16 @@
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import {
CliRenderEvents,
buildKittyKeyboardFlags,
createCliRenderer,
type CliRenderer,
type ScrollbackWriter,
} from "@opentui/core"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { monoSnapshot } from "./mono"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { entrySplash, exitSplash } from "./splash"
import { resolveRunTheme } from "./theme"
import type {
FooterApi,
@@ -81,7 +87,7 @@ export type Lifecycle = {
onResize(fn: () => void): () => void
refreshTheme(): void
setTitle(title?: string): void
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
resetForReplay(): Promise<void>
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
}
@@ -106,19 +112,9 @@ function shutdown(renderer: CliRenderer): void {
}
}
function splashInfo(title: string | undefined, history: RunPrompt[]) {
if (title && !isFallbackTitle(title)) {
return {
title,
showSession: true,
}
}
const next = history.find((item) => item.text.trim().length > 0)
return {
title: next?.text ?? title,
showSession: !!next,
}
function splashTitle(title: string | undefined, history: RunPrompt[]) {
if (title && !isFallbackTitle(title)) return title
return history.find((item) => item.text.trim().length > 0)?.text ?? title
}
function directoryLabel(directory: string, home: string) {
@@ -188,27 +184,25 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
entry: false,
exit: false,
}
const splash = splashInfo(input.sessionTitle, input.history)
const meta = splashMeta({
title: splash.title,
session_id: input.sessionID,
mono,
})
const startup =
miniSettings.splash === "show" && !mono && tuiConfig.animations !== false
? { version: input.host.version, detail: directoryLabel(input.getDirectory(), input.host.paths.home) }
: undefined
const wrote = queueSplash(
renderer,
state,
"entry",
miniSettings.splash === "show"
miniSettings.splash === "show" && !startup
? entrySplash({
...meta,
version: input.host.version,
theme: theme.splash,
showSession: splash.showSession,
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
mono,
})
: undefined,
)
await renderer.idle().catch(() => {})
if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
const { RunFooter } = await footerTask
let closed = false
@@ -227,15 +221,22 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
first: input.first,
history: input.history,
theme,
mono,
// The transcript always starts one row below the terminal's prior output,
// even when the entry splash itself is hidden.
wrote: wrote || miniSettings.splash === "hide",
wrote: wrote || !!startup || miniSettings.splash === "hide",
startup,
tuiConfig,
miniSettings: {
current: miniSettings,
update: input.onMiniSettingChange,
},
onMonoChange: (mono) => {
if (mono) {
renderer.disableKittyKeyboard()
return
}
renderer.enableKittyKeyboard(buildKittyKeyboardFlags({ events: input.host.platform === "win32" }))
},
onPermissionReply: input.onPermissionReply,
onFormReply: input.onFormReply,
onFormCancel: input.onFormCancel,
@@ -311,23 +312,20 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
let wroteExit = false
try {
footer.finishStartup()
await footer.idle().catch(() => {})
if (!renderer.isDestroyed && next.showExit && footer.currentMiniSettings().splash === "show") {
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
wroteExit = queueSplash(
renderer,
state,
"exit",
exitSplash({
...splashMeta({
title: splash.title,
session_id: sessionID,
mono,
}),
title: splashTitle(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history),
session_id: sessionID,
theme: footer.currentTheme().splash,
mono,
mono: footer.currentMiniSettings().mono,
}),
)
await renderer.idle().catch(() => {})
@@ -337,7 +335,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
await footer.idle().catch(() => {})
footer.destroy()
if (input.host.platform === "linux") renderer.setTerminalTitle("")
if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
shutdown(renderer)
if (!wroteExit) {
input.host.stdout.write("\n")
@@ -366,7 +363,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
renderer.on(CliRenderEvents.RESIZE, resize)
return () => renderer.off(CliRenderEvents.RESIZE, resize)
},
async resetForReplay(next) {
async resetForReplay() {
if (closed || renderer.isDestroyed || footer.isClosed) {
throw new Error("runtime closed")
}
@@ -378,18 +375,12 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
footer.resetForReplay(true)
renderer.resetSplitFooterForReplay({ clearSavedLines: true })
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
renderer.writeToScrollback(
entrySplash({
...splashMeta({
title: splash.title,
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
mono,
}),
version: input.host.version,
theme: footer.currentTheme().splash,
showSession: splash.showSession,
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
mono,
mono: footer.currentMiniSettings().mono,
}),
)
renderer.requestRender()
+5 -2
View File
@@ -172,7 +172,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
break
}
if (sent.mode !== "shell") {
if (sent.mode !== "shell" && sent.text.trim()) {
const commit = {
kind: "user",
text: sent.text,
@@ -252,7 +252,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
}
const submit = (prompt: RunPrompt) => {
if (!prompt.text.trim() || state.closed) {
if (
state.closed ||
(!prompt.text.trim() && (prompt.mode === "shell" || !prompt.parts.some((part) => part.type === "file")))
) {
return
}
+8 -13
View File
@@ -366,6 +366,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
},
onInterrupt: () => {
if (state.demo?.interrupt()) return true
if (!state.sessionID) {
return false
}
@@ -411,7 +412,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
const thinking = () => input.thinking ?? configState.current.thinking === "show"
const footer = shell.footer
const firstPaint = footer.idle().catch(() => {})
const offRuntimeClose = footer.onClose(() => runtimeController.abort())
const offRuntimeClose = footer.onClose(() => {
state.demo?.interrupt()
runtimeController.abort()
})
let clientGeneration = 0
let clientController = new AbortController()
let modelAttempt: AbortController | undefined
@@ -480,11 +484,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
footer.event({ type: "history", history: resumed.history })
footer.event({ type: "first", first: resumed.first })
if (footer.isClosed || runtimeController.signal.aborted) return
await shell.resetForReplay({
sessionTitle: state.sessionTitle,
sessionID: state.sessionID,
history: state.history,
})
await shell.resetForReplay()
})
.catch((error) => {
if (footer.isClosed || runtimeController.signal.aborted) return
@@ -848,12 +848,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
.then((item) =>
item.handle.replayOnResize({
localRows: () => state.localRows,
reset: () =>
shell.resetForReplay({
sessionTitle: state.sessionTitle,
sessionID: state.sessionID,
history: state.history,
}),
reset: () => shell.resetForReplay(),
}),
)
.catch(() => {})
@@ -986,7 +981,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
type: "stream.patch",
patch: {
phase: "idle",
usage: "",
usage: undefined,
first: true,
},
})
@@ -32,7 +32,6 @@ export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: Col
if (commit.phase === "final") {
return {
fg: theme.system.body,
attrs: TextAttributes.DIM,
}
}
@@ -49,7 +48,6 @@ export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: Col
if (commit.kind === "reasoning") {
return {
fg: theme.reasoning.body,
attrs: TextAttributes.DIM,
}
}
+100 -5
View File
@@ -1,11 +1,11 @@
// Retained streaming append logic for direct-mode scrollback.
//
// Static entries are rendered through `scrollback.writer.tsx`. This file only
// keeps the retained-surface machinery needed for streaming assistant,
// reasoning, and tool progress entries that need stable markdown/code layout
// while content is still arriving.
// Static text entries are rendered through `scrollback.writer.tsx`. Retained
// surfaces keep streaming markdown/code layout stable and wait for image loads
// before their snapshots enter scrollback.
import {
CodeRenderable,
ImageRenderable,
MarkdownRenderable,
TextRenderable,
getTreeSitterClient,
@@ -87,10 +87,13 @@ export class RunScrollbackStream {
private tail: StreamCommit | undefined
private rendered: StreamCommit | undefined
private active: ActiveEntry | undefined
private imageSurface: ScrollbackSurface | undefined
private treeSitterClient: TreeSitterClient | undefined
private wrote: boolean
private shellOutput: () => boolean
private mono: boolean
private imagePreview: boolean
private destroyed = false
private pendingThemes: RunTheme[] = []
constructor(
@@ -102,17 +105,34 @@ export class RunScrollbackStream {
onThemeRelease?: (theme: RunTheme) => void
shellOutput?: () => boolean
mono?: boolean
imagePreview?: boolean
} = {},
) {
this.treeSitterClient = options.treeSitterClient
this.wrote = options.wrote ?? false
this.shellOutput = options.shellOutput ?? (() => true)
this.mono = options.mono ?? false
this.imagePreview = options.imagePreview ?? false
this.onThemeRelease = options.onThemeRelease
}
private onThemeRelease: ((theme: RunTheme) => void) | undefined
public async setMono(mono: boolean): Promise<void> {
if (this.mono === mono) return
const active = this.active
if (active?.body.type !== "markdown") await this.complete()
this.mono = mono
if (active?.body.type !== "markdown") return
// Rebuild the Markdown tree, keeping its source and printed block boundary.
// Mono hooks are one-way, and ending the entry would lose open fence/list context.
const next = this.createEntry(active.commit, active.body)
this.active = { ...active, surface: next.surface, renderable: next.renderable }
active.surface.destroy()
this.releasePendingThemes()
}
private releasePendingThemes(): void {
if (this.pendingThemes.length === 0) {
return
@@ -348,11 +368,77 @@ export class RunScrollbackStream {
}
}
private async writeImage(commit: StreamCommit): Promise<void> {
const surface = this.renderer.createScrollbackSurface(entryFlags(commit))
this.imageSurface = surface
try {
const image = new ImageRenderable(surface.renderContext, {
source: commit.image,
fit: "fit",
visible: false,
alignSelf: "flex-start",
flexShrink: 0,
})
surface.root.add(image)
// settle() waits for code highlighting, not image decoding.
await image.loadPromise
if (surface.isDestroyed) return
const body = entryBody(commit, { mono: this.mono })
if (body.type !== "text") return
const style = entryLook(commit, this.theme.entry)
const caption = new TextRenderable(surface.renderContext, {
content: body.content + (image.image ? "" : "\nNo preview"),
width: "100%",
wrapMode: "word",
fg: style.fg,
attributes: style.attrs,
flexShrink: 0,
})
surface.root.add(caption, 0)
surface.render()
if (image.image && !this.mono) {
const resolution = this.renderer.resolution
const fitted = image.getFittedSize(
Math.min(
surface.width,
resolution?.width
? Math.max(1, Math.floor((image.image.width * this.renderer.terminalWidth) / resolution.width))
: Infinity,
),
Math.min(
Math.max(0, this.renderer.terminalHeight - this.renderer.height - caption.height),
resolution?.height
? Math.max(1, Math.floor((image.image.height * this.renderer.terminalHeight) / resolution.height))
: Infinity,
),
image.cellAspectRatio,
)
// fit centers within its rectangle, so the rectangle itself must be fitted.
image.width = fitted.width
image.height = fitted.height
image.visible = fitted.width > 0 && fitted.height > 0
surface.render()
}
this.writeSpacer(separatorRows(this.rendered, commit, body) || (!this.rendered && this.wrote ? 1 : 0))
surface.commitRows(0, surface.height, { trailingNewline: entryFlags(commit).trailingNewline })
this.markRendered(commit)
} finally {
surface.destroy()
this.imageSurface = undefined
}
}
public async append(commit: StreamCommit): Promise<void> {
if (this.destroyed || this.renderer.isDestroyed) return
const same = sameEntryGroup(this.tail, commit)
if (!same) {
if (!same || commit.image) {
this.markRendered(await this.finishActive(false))
}
if (this.destroyed || this.renderer.isDestroyed) return
if (commit.summary) {
this.writeSpacer(1)
@@ -362,6 +448,12 @@ export class RunScrollbackStream {
return
}
if (commit.image && this.imagePreview && !this.mono) {
await this.writeImage(commit)
this.tail = commit
return
}
const body = entryBody(commit, { shellOutput: this.shellOutput(), mono: this.mono })
if (body.type === "none") {
if (entryDone(commit)) {
@@ -426,6 +518,9 @@ export class RunScrollbackStream {
}
public destroy(): void {
this.destroyed = true
this.imageSurface?.destroy()
this.imageSurface = undefined
this.resetActive()
this.releasePendingThemes()
}
+18 -12
View File
@@ -6,7 +6,7 @@ import {
type ScrollbackRenderContext,
type ScrollbackWriter,
} from "@opentui/core"
import { Match, Switch, createMemo } from "solid-js"
import { For, Match, Switch, createMemo } from "solid-js"
import { entryBody, entryFlags } from "./entry.body"
import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
@@ -39,6 +39,7 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm
}
function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
if (commit.image) return "block"
if (commit.kind === "tool") {
if (body.type === "structured" || body.type === "markdown") {
return "block"
@@ -253,17 +254,22 @@ export function RunEntryContent(props: {
</box>
</Match>
<Match when={markdown()}>
<markdown
ref={(renderable: MarkdownRenderable) => {
if (props.opts?.mono) monoMarkdownRenderable(renderable)
}}
width="100%"
syntaxStyle={syntax()}
streaming={streaming()}
content={markdown()!.content}
fg={color()}
tableOptions={props.opts?.mono ? monoMarkdownTableOptions : { widthMode: "content" }}
/>
{/* Mono hooks mutate renderables, so toggling needs a fresh Markdown leaf. */}
<For each={[props.opts?.mono === true]}>
{(mono) => (
<markdown
ref={(renderable: MarkdownRenderable) => {
if (mono) monoMarkdownRenderable(renderable)
}}
width="100%"
syntaxStyle={syntax()}
streaming={streaming()}
content={markdown()!.content}
fg={color()}
tableOptions={mono ? monoMarkdownTableOptions : { widthMode: "content" }}
/>
)}
</For>
</Match>
</Switch>
)
+18 -8
View File
@@ -1,4 +1,5 @@
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { projectedPromptInput } from "../prompt/codec"
import { promptCopy, promptSame } from "./prompt.shared"
import type { RunInput, RunPrompt } from "./types"
@@ -20,30 +21,39 @@ export type RunSession = {
variant?: string
}
function messagePrompt(message: SessionMessageUser): RunPrompt {
export function messagePrompt(message: Pick<SessionMessageUser, "text" | "files" | "agents" | "skills">): RunPrompt {
const input = projectedPromptInput(message)
return {
text: message.text,
text: input.text,
parts: [
...(message.files ?? []).map((file) => ({
...(input.files ?? []).map((file, index) => ({
type: "file" as const,
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
mime: file.mime,
url: file.uri,
mime: message.files?.[index]?.mime,
filename: file.name,
...(file.description === undefined ? {} : { description: file.description }),
source: file.mention
? {
type: "file",
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
path: file.name ?? (file.uri.startsWith("data:") ? "inline attachment" : file.uri),
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
}
: undefined,
})),
...(message.agents ?? []).map((agent) => ({
...(input.agents ?? []).map((agent) => ({
type: "agent" as const,
name: agent.name,
source: agent.mention
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
: undefined,
})),
...(input.skills ?? []).map((skill) => ({
type: "skill" as const,
id: skill.id,
source: skill.mention
? { start: skill.mention.start, end: skill.mention.end, value: skill.mention.text }
: undefined,
})),
],
}
}
@@ -92,7 +102,7 @@ function requestOptions(signal?: AbortSignal): [] | [{ signal: AbortSignal }] {
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
return session.turns
.map((turn) => turn.prompt)
.filter((prompt) => prompt.text.trim())
.filter((prompt) => prompt.text.trim() || prompt.parts.some((part) => part.type === "file"))
.filter((prompt, index, prompts) => index === 0 || !promptSame(prompts[index - 1], prompt))
.map(promptCopy)
.slice(-limit)
+88 -65
View File
@@ -1,25 +1,22 @@
// Entry and exit splash banners for direct interactive mode scrollback.
//
// Renders the full opencode entry logo and a compact [O] exit badge, plus
// session metadata and the resume command. These are scrollback snapshots, so
// they become immutable terminal history once committed.
//
// Both variants use a cell-based renderer. cells() classifies each character
// in the source template as text, full-block, half-block-mix, or
// half-block-top, and draw() renders it with foreground/background shadow
// colors from the theme.
// The entry header is a single flex row; the exit banner retains its cell-based
// logo and resume information. Both become immutable terminal history.
import {
BoxRenderable,
type ColorInput,
TextAttributes,
TextRenderable,
StyledText,
fg,
type ScrollbackRenderContext,
type ScrollbackSnapshot,
type ScrollbackWriter,
} from "@opentui/core"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { go } from "../logo"
import { monoTruncate, monoTruncateMiddle } from "./mono"
import { monoTruncate } from "./mono"
import type { RunSplashTheme } from "./theme"
const SPLASH_TITLE_LIMIT = 50
@@ -34,7 +31,6 @@ type SplashInput = {
type SplashWriterInput = SplashInput & {
theme: RunSplashTheme
showSession?: boolean
detail?: string
}
export type SplashMeta = {
@@ -173,52 +169,23 @@ function draw(
}
}
function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: ScrollbackRenderContext): ScrollbackSnapshot {
function buildExit(input: SplashWriterInput, ctx: ScrollbackRenderContext): ScrollbackSnapshot {
const width = Math.max(1, ctx.width)
const meta = splashMeta(input)
const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
const left = input.theme.left
const right = input.theme.right
const leftShadow = input.theme.leftShadow
let height = 1
if (kind === "entry") {
const mark = input.mono ? ["[O]"] : go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
for (let i = 0; i < mark.length; i += 1) {
draw(lines, mark[i] ?? "", {
left: 0,
top: top + i,
fg: left,
shadow: leftShadow,
})
}
push(lines, body_left, top, "OpenCode", right, undefined, TextAttributes.BOLD)
if (input.detail) {
push(
lines,
body_left,
top + 1,
input.mono
? monoTruncateMiddle(input.detail, Math.max(1, width - body_left), true)
: Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
left,
undefined,
)
}
height = top + Math.max(mark.length, input.detail ? 2 : 1)
}
if (kind === "exit") {
const mark = input.mono ? ["[O]"] : go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
const session = "Session "
const label = "Continue "
const mark = input.mono ? ["[O]"] : go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
const session = "Session "
const label = "Continue "
const command = `opencode mini -s ${meta.session_id}`
const wide = body_left + stringWidth(label + command) <= width
const commandHeight = wide ? 1 : Math.ceil(stringWidth(command) / width)
if (wide) {
for (let i = 0; i < mark.length; i += 1) {
draw(lines, mark[i] ?? "", {
left: 0,
@@ -229,23 +196,13 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
}
if (input.showSession !== false) {
push(lines, body_left, top, session, left, undefined, TextAttributes.DIM)
push(lines, body_left, top, session, left)
push(lines, body_left + session.length, top, meta.title, right, undefined, TextAttributes.BOLD)
}
push(lines, body_left, top + 1, label, left, undefined, TextAttributes.DIM)
push(
lines,
body_left + label.length,
top + 1,
`opencode mini -s ${meta.session_id}`,
right,
undefined,
TextAttributes.BOLD,
)
height = top + Math.max(mark.length, 2)
push(lines, body_left, top + 1, label, left)
}
const height = top + (wide ? Math.max(mark.length, 2) : commandHeight)
const root = new BoxRenderable(ctx.renderContext, {
position: "absolute",
left: 0,
@@ -257,6 +214,19 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
for (const line of lines) {
write(root, ctx, line)
}
root.add(
new TextRenderable(ctx.renderContext, {
position: "absolute",
left: wide ? body_left + label.length : 0,
top: wide ? top + 1 : top,
width: wide ? width - body_left - label.length : width,
height: commandHeight,
wrapMode: "char",
content: command,
fg: right,
attributes: TextAttributes.BOLD,
}),
)
return {
root,
@@ -275,10 +245,63 @@ export function splashMeta(input: SplashInput): SplashMeta {
}
}
export function entrySplash(input: SplashWriterInput): ScrollbackWriter {
return (ctx) => build(input, "entry", ctx)
export function entrySplash(input: {
version: string
detail?: string
mono?: boolean
theme: RunSplashTheme
}): ScrollbackWriter {
return (ctx) => {
const width = Math.max(1, ctx.width)
const layout = entrySplashLayout({ ...input, width })
const root = new BoxRenderable(ctx.renderContext, {
width,
height: 2,
paddingTop: 1,
flexDirection: "row",
overflow: "hidden",
})
root.add(
new TextRenderable(ctx.renderContext, {
content: new StyledText([fg(input.theme.right)(layout.label), fg(input.theme.left)(layout.metadata)]),
width,
height: 1,
wrapMode: "none",
}),
)
return { root, width, height: 2, rowColumns: width, startOnNewLine: true, trailingNewline: false }
}
}
export function entrySplashLayout(input: { width: number; version: string; detail?: string; mono?: boolean }) {
const detail = input.detail ?? ""
const segments = detail.split(/[/\\]/).filter(Boolean)
const leaf = segments.at(-1) ?? detail
const separator = input.mono ? " - " : " · "
const ellipsis = input.mono ? "..." : "…"
const slash = detail.includes("\\") ? "\\" : "/"
const paths = segments
.slice(1)
.map((_, index) => ellipsis + slash + segments.slice(index + 1).join(slash))
.reverse()
.filter((path) => stringWidth(path) < stringWidth(detail))
let layout = { label: Locale.takeWidth("oc mini", input.width), version: "", path: "", metadata: "" }
const stages = [
{ label: `${input.mono ? "[O]" : "▪"} oc mini` },
...(leaf ? [{ path: leaf }] : []),
...(input.version ? [{ version: input.version }] : []),
...paths.concat(detail ? [detail] : []).map((path) => ({ path })),
]
// Stop at the first non-fitting stage instead of backfilling lower-priority metadata.
for (const stage of stages) {
const next = { ...layout, ...stage }
const metadata = (next.version ? ` v${next.version}` : "") + (next.path ? separator + next.path : "")
if (stringWidth(next.label + metadata) > input.width) break
layout = { ...next, metadata }
}
return layout
}
export function exitSplash(input: SplashWriterInput): ScrollbackWriter {
return (ctx) => build(input, "exit", ctx)
return (ctx) => buildExit(input, ctx)
}
+38
View File
@@ -0,0 +1,38 @@
import type { SessionMessageAssistantTool, SessionMessageUser } from "@opencode-ai/client/promise"
import { deduplicateVisibleImages } from "../prompt/attachment"
import { toolDisplayContent } from "../util/tool-display"
import type { StreamCommit } from "./types"
export type ImageCommit = StreamCommit & { image: string; messageID: string; partID: string }
export function userImageCommits(messageID: string, files: SessionMessageUser["files"]): ImageCommit[] {
return deduplicateVisibleImages(files ?? [])
.filter((file) => file.mime.startsWith("image/"))
.map((file, index) => ({
kind: "user",
source: "system",
text: file.name ?? file.mention?.text ?? `[Image ${index + 1}]`,
image: `data:${file.mime};base64,${file.data}`,
phase: "final",
messageID,
partID: `image:${index}`,
}))
}
export function toolImageCommits(part: SessionMessageAssistantTool, messageID: string): ImageCommit[] {
return toolDisplayContent(part.state)
.flatMap((content) =>
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
? [{ uri: content.uri, name: content.name }]
: [],
)
.map((content, index) => ({
kind: "tool",
source: "tool",
text: content.name ?? `[Image ${index + 1}]`,
image: content.uri,
phase: "final",
messageID,
partID: `prt_${part.id}:image:${index}`,
}))
}
+32 -20
View File
@@ -21,9 +21,11 @@ import type {
PermissionRequest,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import { Locale } from "../util/locale"
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
import { toolImageCommits, userImageCommits } from "./stream-v2.image"
import type {
FooterSubagentDetail,
FooterSubagentState,
@@ -115,7 +117,7 @@ type ChildState = {
permissions: MiniPermissionRequest[]
forms: MiniFormRequest[]
messageIDs: Set<string>
prompts: Map<string, string>
prompts: Map<string, Pick<SessionMessageUser, "text" | "files">>
hydrated: boolean
detailStale: boolean
blockersHydrated: boolean
@@ -287,16 +289,19 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (meta.background === true) child.background = true
}
const userFrame = (child: ChildState, messageID: string, value: string) => {
const userFrame = (child: ChildState, messageID: string, prompt: Pick<SessionMessageUser, "text" | "files">) => {
if (child.messageIDs.has(messageID)) return false
child.messageIDs.add(messageID)
setFrame(child, `user:${messageID}`, {
kind: "user",
source: "system",
text: value,
phase: "start",
messageID,
})
if (prompt.text.trim())
setFrame(child, `user:${messageID}`, {
kind: "user",
source: "system",
text: prompt.text,
phase: "start",
messageID,
})
for (const commit of userImageCommits(messageID, prompt.files))
setFrame(child, sourceKey(messageID, commit.partID), commit)
return true
}
@@ -325,12 +330,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
child.finishedTools.add(key)
child.tools.delete(key)
if (part.state.status === "error" && output) {
setFrame(child, frame, toolCommit(part, messageID, "progress", output, input.directory))
setFrame(child, `${frame}:final`, toolCommit(part, messageID, "final", undefined, input.directory))
return
}
setFrame(child, frame, toolCommit(part, messageID, toolFinalPhase(part), undefined, input.directory))
const partial = part.state.status === "error" && output
if (partial) setFrame(child, frame, toolCommit(part, messageID, "progress", output, input.directory))
setFrame(
child,
partial ? `${frame}:final` : frame,
toolCommit(part, messageID, toolFinalPhase(part), undefined, input.directory),
)
for (const commit of toolImageCommits(part, messageID)) setFrame(child, sourceKey(messageID, commit.partID), commit)
}
const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
@@ -342,7 +349,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
for (const message of messages) {
if (message.type === "user") {
child.prompts.delete(message.id)
userFrame(child, message.id, message.text)
userFrame(child, message.id, message)
continue
}
if (message.type !== "assistant") continue
@@ -398,9 +405,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const pendingPrompts = new Map(child.prompts)
const pendingTools = new Map(child.tools)
let retry = false
const task = sdk.message
.list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { signal })
.then((response) => {
const task = Promise.all([
sdk.message.list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { signal }),
sdk.session.inbox.list({ sessionID: child.sessionID }, { signal }),
])
.then(([response, pending]) => {
if (!active(signal)) return
const buffered = hydrationEvents.get(child.sessionID) ?? []
hydrationEvents.delete(child.sessionID)
@@ -410,6 +419,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
for (const item of pending) {
if (item.type === "user") child.prompts.set(item.id, item.payload)
}
for (const [id, prompt] of pendingPrompts) {
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
}
@@ -645,7 +657,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const reduce = (child: ChildState, event: V2Event) => {
if (event.type === "session.inbox.enqueued") {
if (event.data.item.type === "user") child.prompts.set(event.data.inboxID, event.data.item.payload.text)
if (event.data.item.type === "user") child.prompts.set(event.data.inboxID, event.data.item.payload)
return
}
if (event.type === "session.inbox.delivered") {
+61 -16
View File
@@ -6,19 +6,22 @@ import type {
PermissionRequest,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageUser,
SessionInboxInfo,
} from "@opencode-ai/client/promise"
import { Event } from "@opencode-ai/schema/event"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { formatContextUsage } from "../util/session"
import { blockerStatus, pickBlockerView } from "./session-data"
import { writeSessionOutput } from "./stream"
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
import { toolImageCommits, userImageCommits, type ImageCommit } from "./stream-v2.image"
import { messagePrompt } from "./session.shared"
import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
import { normalizeTool, toolOutputText } from "./tool"
import { toolDisplayContent } from "../util/tool-display"
import type {
FooterApi,
FooterPatch,
FooterView,
LocalReplayRow,
MiniPermissionRequest,
@@ -123,12 +126,17 @@ type ToolState = {
started: boolean
}
type PendingPrompt = FooterQueuedPrompt & { files: SessionMessageUser["files"] }
type State = {
permissions: MiniPermissionRequest[]
forms: MiniFormRequest[]
globalForms: MiniFormRequest[]
view: FooterView
messageIDs: Set<string>
// Optimistic text and pending steers can be visible before attachment delivery.
promotedMessages: Set<string>
imageIDs: Set<string>
fragments: FragmentReconciler
tools: Map<string, ToolState>
toolSources: Map<string, SessionMessageAssistantTool>
@@ -147,14 +155,12 @@ type State = {
executionEpoch: number
buffered?: ReplayBuffer
errors: Set<string>
pending: Map<string, FooterQueuedPrompt>
pending: Map<string, PendingPrompt>
admitted: Set<string>
stepModel: RunInput["model"]
activeCompaction?: string
}
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
export function formatUnknownError(error: unknown): string {
if (typeof error === "string") return error
if (error instanceof Error) return error.message || error.name
@@ -184,11 +190,12 @@ function errorMessage(error: { message?: string; _tag?: string }) {
return error.message || error._tag || "Session execution failed"
}
function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined {
function pendingPrompt(item: SessionInboxInfo): PendingPrompt | undefined {
if (item.type !== "user") return undefined
return {
messageID: item.id,
prompt: { messageID: item.id, text: item.payload.text, parts: [] },
prompt: { messageID: item.id, ...messagePrompt(item.payload) },
files: item.payload.files,
delivery: item.delivery,
...(item.payload.skills?.length
? { skills: item.payload.skills.map((skill) => ({ id: skill.id, name: skill.name })) }
@@ -253,6 +260,7 @@ function promptFiles(next: SessionTurnInput) {
{
uri: part.url,
name: part.filename,
...(part.description === undefined ? {} : { description: part.description }),
mention: promptFileMention(part),
},
]
@@ -474,6 +482,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
globalForms: [],
view: { type: "prompt" },
messageIDs: new Set(),
promotedMessages: new Set(),
imageIDs: new Set(),
fragments: createFragmentReconciler(),
tools: new Map(),
toolSources: new Map(),
@@ -532,7 +542,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([], { phase: "idle", status })
}
const write = (commits: StreamCommit[], patch?: { phase?: "idle" | "running"; status?: string; usage?: string }) => {
const write = (commits: StreamCommit[], patch?: Pick<FooterPatch, "phase" | "status" | "usage">) => {
if (state.closed || controller.signal.aborted || input.footer.isClosed) return
if (!state.initial && state.buffered === undefined)
commits.forEach((commit) => {
@@ -562,9 +572,21 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.footer.event({ type: "queued.prompts", prompts })
}
const freshImages = (commits: ImageCommit[], render = true) =>
commits.filter((commit) => {
const key = streamPartKey(commit.messageID, commit.partID)
if (state.imageIDs.has(key)) return false
state.imageIDs.add(key)
return render
})
const mergePending = (item: SessionInboxInfo) => {
const prompt = pendingPrompt(item)
if (!prompt || state.messageIDs.has(prompt.messageID)) return
if (!prompt) return
if (state.promotedMessages.has(prompt.messageID)) {
write(freshImages(userImageCommits(prompt.messageID, prompt.files)))
return
}
state.admitted.add(prompt.messageID)
state.pending.set(prompt.messageID, prompt)
syncPending()
@@ -655,12 +677,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.finishedTools.add(key)
state.tools.delete(key)
if (!sourcePending(key)) state.toolSources.delete(key)
const images = freshImages(toolImageCommits(part, messageID), render)
if (!render) return
const phase = toolFinalPhase(part)
if (part.state.status === "error" && delta)
write([toolCommit(part, messageID, "progress", delta, input.location?.directory, version)])
write([
toolCommit(part, messageID, phase, phase === "progress" ? delta : undefined, input.location?.directory, version),
...images,
])
}
@@ -671,13 +695,17 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (state.wait && (admitted || (waiting && state.wait.failureMessageID === message.id)))
promoteWait(state.wait, false, message.id)
if (state.pending.delete(message.id)) syncPending()
if (state.messageIDs.has(message.id)) return
const visible = state.messageIDs.has(message.id) || (reuseVisibleWait && waiting)
state.messageIDs.add(message.id)
state.promotedMessages.add(message.id)
const images = freshImages(userImageCommits(message.id, message.files), render)
if (!render) return
if (reuseVisibleWait && waiting) return
write([
...skillCommits(message.id, message.skills),
{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
...(!visible ? skillCommits(message.id, message.skills) : []),
...(!visible && message.text.trim()
? [{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id } as const]
: []),
...images,
])
return
}
@@ -960,13 +988,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const waiting = state.wait?.messageID === event.data.inboxID
if (state.wait) promoteWait(state.wait, true, event.data.inboxID)
state.admitted.delete(event.data.inboxID)
state.promotedMessages.add(event.data.inboxID)
const pending = state.pending.get(event.data.inboxID)
state.pending.delete(event.data.inboxID)
syncPending()
const visible = state.messageIDs.has(event.data.inboxID)
if (waiting || pending) state.messageIDs.add(event.data.inboxID)
const commits = pending && !visible ? skillCommits(event.data.inboxID, pending.skills) : []
if (!waiting && pending && !visible)
if (!waiting && pending && !visible && pending.prompt.text.trim())
commits.push({
kind: "user",
source: "system",
@@ -974,6 +1003,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
phase: "start",
messageID: event.data.inboxID,
})
if (pending) commits.push(...freshImages(userImageCommits(event.data.inboxID, pending.files)))
write(commits, { phase: "running", status: "waiting for assistant" })
return
}
@@ -1291,9 +1321,15 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.data.tokens.cache.write
const limit = state.stepModel ? input.contextLimit?.(state.stepModel) : undefined
state.stepModel = undefined
const usage = total > 0 ? formatContextUsage(total, limit ? Math.round((total / limit) * 100) : undefined) : ""
write([], {
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
usage:
total > 0 || event.data.cost
? {
tokens: total,
percent: limit ? Math.round((total / limit) * 100) : undefined,
cost: event.data.cost || undefined,
}
: undefined,
})
return
}
@@ -1583,6 +1619,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (!current(attempt)) return false
reset = true
state.messageIDs.clear()
state.imageIDs.clear()
state.fragments.clear()
state.tools.clear()
state.toolSources.clear()
@@ -1603,6 +1640,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
try {
if (reset) {
for (const row of localRows) {
if (row.commit.image && row.commit.messageID && row.commit.partID) {
const key = streamPartKey(row.commit.messageID, row.commit.partID)
if (state.imageIDs.has(key)) continue
state.imageIDs.add(key)
input.footer.append(row.commit)
continue
}
if (
row.commit.messageID &&
row.commit.partID &&
@@ -1759,7 +1803,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (command) {
await admitPrompt(next, client, next.prompt.delivery ?? "steer")
const pending = await admitPrompt(next, client, next.prompt.delivery ?? "steer")
if (pending) mergePending(pending)
admitted?.()
return
}
+177 -452
View File
@@ -1,19 +1,13 @@
// Theme resolution for direct interactive mode.
//
// Derives scrollback and footer colors from the terminal's actual palette.
// resolveRunTheme() queries the renderer for the terminal's palette,
// detects dark/light mode, builds a small system theme locally, and maps it to
// the run footer + scrollback color model. Falls back to a hardcoded dark-mode
// palette if detection fails.
import { RGBA, SyntaxStyle, type CliRenderer, type ColorInput, type TerminalColors } from "@opentui/core"
import { generateSyntax, resolveThemeDocument, themeModes, type ResolvedTheme } from "@opencode-ai/theme/tui"
import { allThemes, DEFAULT_THEMES, isThemeSource, parseTheme, type ThemeDocumentSource } from "../theme"
import { ansiToRgba } from "../theme/color"
import { resolveThemeColors } from "../theme/resolve"
import { terminalMode } from "../theme/system"
import type { Theme, ThemeV1Json } from "../theme/v1"
import { discoverThemes } from "../theme/discovery"
import { generateSystem, terminalMode } from "../theme/system"
import { configDirectories } from "../util/config-directories"
import { dedupeWith } from "effect/Array"
import type { EntryKind, RunTuiConfig } from "./types"
type ThemeCurrent = Omit<Theme, "_hasSelectedListItemText">
type Tone = {
body: ColorInput
start?: ColorInput
@@ -28,14 +22,24 @@ export type RunSplashTheme = {
}
export type RunFooterTheme = {
highlight: ColorInput
selected: ColorInput
selectedText: ColorInput
actionSecondaryText: ColorInput
actionFocusedBg: ColorInput
actionFocusedText: ColorInput
formfieldText: ColorInput
formfieldFocusedBg: ColorInput
formfieldFocusedText: ColorInput
selection: ColorInput
variant: ColorInput
running: ColorInput
question: ColorInput
permission: ColorInput
success: ColorInput
link: ColorInput
categorical: ColorInput[]
warning: ColorInput
error: ColorInput
muted: ColorInput
text: ColorInput
status: ColorInput
statusAccent: ColorInput
shade: ColorInput
surface: ColorInput
@@ -67,83 +71,22 @@ export type RunTheme = {
block: RunBlockTheme
}
type ThemeColor = Exclude<keyof ThemeCurrent, "thinkingOpacity">
type SharedSyntaxTheme = ThemeCurrent & {
_hasSelectedListItemText: boolean
}
export const transparent = RGBA.fromValues(0, 0, 0, 0)
function alpha(color: RGBA, value: number): RGBA {
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, value)))
}
function rgba(hex: string, value?: number): RGBA {
const color = RGBA.fromHex(hex)
return value === undefined ? color : alpha(color, value)
}
function colorMode(bg: RGBA): "dark" | "light" {
return luminance(bg) > 0.5 ? "light" : "dark"
}
function luminance(color: RGBA): number {
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
}
function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
if (color.a === 0) {
return RGBA.fromValues(color.r, color.g, color.b, Math.max(0, Math.min(1, fallback)))
}
const target = Math.min(limit, color.a * scale)
const mix = Math.min(1, target / color.a)
return RGBA.fromValues(
base.r + (color.r - base.r) * mix,
base.g + (color.g - base.g) * mix,
base.b + (color.b - base.b) * mix,
color.a,
)
}
function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
return RGBA.fromInts(
Math.round((base.r + (overlay.r - base.r) * value) * 255),
Math.round((base.g + (overlay.g - base.g) * value) * 255),
Math.round((base.b + (overlay.b - base.b) * value) * 255),
)
}
function chroma(color: RGBA) {
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
}
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
return Array.from({ length: size }, (_, index) => {
const value = colors.palette[index]
return RGBA.fromIndex(index, value ? RGBA.fromHex(value) : ansiToRgba(index))
})
}
const ansiPalette = Array.from({ length: 256 }, (_, index) => RGBA.fromIndex(index, ansiToRgba(index)))
const palettes = new WeakMap<CliRenderer, TerminalColors>()
function srgbToLinear(value: number): number {
if (value <= 0.04045) {
return value / 12.92
}
return ((value + 0.055) / 1.055) ** 2.4
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
}
function oklab(color: RGBA) {
const r = srgbToLinear(color.r)
const g = srgbToLinear(color.g)
const b = srgbToLinear(color.b)
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
return {
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
@@ -151,8 +94,8 @@ function oklab(color: RGBA) {
}
}
function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
const target = oklab(rgba)
function nearestIndexed(indexed: RGBA[], color: RGBA): RGBA {
const target = oklab(color)
const hit = indexed.reduce(
(best, item) => {
const sample = oklab(item)
@@ -160,352 +103,110 @@ function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
const da = sample.a - target.a
const db = sample.b - target.b
const dist = dl * dl * 2 + da * da + db * db
if (dist >= best.dist) return best
return {
dist,
item,
}
},
{
dist: Number.POSITIVE_INFINITY,
item: indexed[0]!,
return dist >= best.dist ? best : { dist, item }
},
{ dist: Number.POSITIVE_INFINITY, item: indexed[0]! },
)
return RGBA.clone(hit.item)
}
function paletteColor(colors: TerminalColors, index: number): RGBA {
const value = colors.palette[index]
return value ? RGBA.fromHex(value) : ansiToRgba(index)
}
function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
const mixed = tint(base, overlay, value)
return nearestIndexed(indexed, mixed)
}
export function resolveTheme(theme: ThemeV1Json, pick: "dark" | "light"): ThemeCurrent {
const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code)))
return {
...resolved.theme,
thinkingOpacity: resolved.thinkingOpacity,
}
}
function generateGrayScale(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): Record<number, RGBA> {
const r = bg.r * 255
const g = bg.g * 255
const b = bg.b * 255
const lum = 0.299 * r + 0.587 * g + 0.114 * b
const cast = 0.25 * (1 - chroma(bg)) ** 2
const gray = (level: number) => {
const factor = level / 12
if (isDark && lum < 10) {
const value = Math.floor(factor * 0.4 * 255)
return map(RGBA.fromInts(value, value, value))
}
if (!isDark && lum > 245) {
const value = Math.floor(255 - factor * 0.4 * 255)
return map(RGBA.fromInts(value, value, value))
}
const value = isDark ? lum + (255 - lum) * factor * 0.4 : lum * (1 - factor * 0.4)
const tone = RGBA.fromInts(Math.floor(value), Math.floor(value), Math.floor(value))
if (cast === 0) return map(tone)
const ratio = lum === 0 ? 0 : value / lum
return map(
tint(
tone,
RGBA.fromInts(
Math.floor(Math.max(0, Math.min(r * ratio, 255))),
Math.floor(Math.max(0, Math.min(g * ratio, 255))),
Math.floor(Math.max(0, Math.min(b * ratio, 255))),
),
cast,
),
)
}
return Object.fromEntries(Array.from({ length: 12 }, (_, index) => [index + 1, gray(index + 1)]))
}
function generateMutedTextColor(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): RGBA {
const lum = 0.299 * bg.r * 255 + 0.587 * bg.g * 255 + 0.114 * bg.b * 255
const gray = isDark
? lum < 10
? 180
: Math.min(Math.floor(160 + lum * 0.3), 200)
: lum > 245
? 75
: Math.max(Math.floor(100 - (255 - lum) * 0.2), 60)
return map(RGBA.fromInts(gray, gray, gray))
}
export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeV1Json {
const bg_snapshot = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg_snapshot = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const bg = RGBA.defaultBackground(bg_snapshot)
const fg = RGBA.defaultForeground(fg_snapshot)
const isDark = pick === "dark"
const color = (index: number) => paletteColor(colors, index)
const grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
const textMuted = generateMutedTextColor(bg_snapshot, isDark, (rgba) => rgba)
const ansi = {
red: color(1),
green: color(2),
yellow: color(3),
blue: color(4),
magenta: color(5),
cyan: color(6),
red_bright: color(9),
green_bright: color(10),
}
const diff_alpha = isDark ? 0.22 : 0.14
const diff_context_bg = grays[2]
const primary = ansi.cyan
const secondary = ansi.magenta
return {
theme: {
primary,
secondary,
accent: primary,
error: ansi.red,
warning: ansi.yellow,
success: ansi.green,
info: ansi.cyan,
text: fg,
textMuted,
selectedListItemText: bg,
background: alpha(bg, 0),
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],
borderSubtle: grays[6],
border: grays[7],
borderActive: grays[8],
diffAdded: ansi.green,
diffRemoved: ansi.red,
diffContext: grays[7],
diffHunkHeader: grays[7],
diffHighlightAdded: ansi.green_bright,
diffHighlightRemoved: ansi.red_bright,
diffAddedBg: tint(bg_snapshot, ansi.green, diff_alpha),
diffRemovedBg: tint(bg_snapshot, ansi.red, diff_alpha),
diffContextBg: diff_context_bg,
diffLineNumber: textMuted,
diffAddedLineNumberBg: tint(diff_context_bg, ansi.green, diff_alpha),
diffRemovedLineNumberBg: tint(diff_context_bg, ansi.red, diff_alpha),
markdownText: fg,
markdownHeading: fg,
markdownLink: ansi.blue,
markdownLinkText: ansi.cyan,
markdownCode: ansi.green,
markdownBlockQuote: ansi.yellow,
markdownEmph: ansi.yellow,
markdownStrong: fg,
markdownHorizontalRule: grays[7],
markdownListItem: ansi.blue,
markdownListEnumeration: ansi.cyan,
markdownImage: ansi.blue,
markdownImageText: ansi.cyan,
markdownCodeBlock: fg,
syntaxComment: textMuted,
syntaxKeyword: ansi.magenta,
syntaxFunction: ansi.blue,
syntaxVariable: fg,
syntaxString: ansi.green,
syntaxNumber: ansi.yellow,
syntaxType: ansi.cyan,
syntaxOperator: ansi.cyan,
syntaxPunctuation: fg,
},
}
}
function quantizeColor(indexed: RGBA[], rgba: RGBA): RGBA {
if (rgba.a === 0 || rgba.intent === "default" || rgba.intent === "indexed") {
return RGBA.clone(rgba)
}
return nearestIndexed(indexed, rgba)
}
function quantizeTheme(theme: ThemeCurrent, indexed: RGBA[]): ThemeCurrent {
const resolved = Object.fromEntries(
Object.entries(theme)
.filter(([key]) => key !== "thinkingOpacity")
.map(([key, value]) => [key, quantizeColor(indexed, value as RGBA)]),
) as Partial<Record<ThemeColor, RGBA>>
return {
...(resolved as Record<ThemeColor, RGBA>),
thinkingOpacity: theme.thinkingOpacity,
}
}
function splashTheme(theme: ThemeCurrent, indexed: RGBA[]): RunSplashTheme {
const left = nearestIndexed(indexed, theme.textMuted)
const right = nearestIndexed(indexed, theme.text)
return {
left,
right,
leftShadow: splashShadow(indexed, theme.background, left, 0.14),
}
}
function map(
footerTheme: ThemeCurrent,
scrollbackTheme: ThemeCurrent,
splash: RunSplashTheme,
theme: ResolvedTheme,
indexed: RGBA[],
mode: "light" | "dark",
syntax?: SyntaxStyle,
system = false,
): RunTheme {
const footerBackground = alpha(footerTheme.background, 1)
const footerMode = colorMode(footerBackground)
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
const surface = fade(footerTheme.backgroundMenu, footerTheme.background, 0.18, 0.76, 0.9)
const line = fade(footerTheme.backgroundMenu, footerTheme.background, 0.24, 0.9, 0.98)
const statusBase = tint(footerBackground, rgba("#000000"), footerMode === "dark" ? 0.13 : 0.06)
const statusAccentBase =
footerMode === "dark" ? tint(footerBackground, rgba("#ffffff"), 0.06) : tint(statusBase, rgba("#000000"), 0.04)
const collapsedStatus = footerMode === "dark" && luminance(statusBase) <= 0.04
// Pure-black backgrounds need a slight lift or the row disappears into the terminal background.
const status = collapsedStatus ? tint(statusBase, statusAccentBase, 0.7) : statusBase
const statusAccent = collapsedStatus ? tint(status, rgba("#ffffff"), 0.06) : statusAccentBase
const elevated = theme.contextual.elevated
// V1 system migration serializes colors; restore terminal defaults before quantizing scrollback.
const exact = (color: RGBA) => {
if (system && color.equals(theme.text.default)) return RGBA.defaultForeground(color)
if (system && color.equals(theme.background.default)) return RGBA.defaultBackground(color)
return color
}
const scrollback = (color: RGBA) => {
const value = exact(color)
return value.a === 0 || value.intent !== "rgb" ? value : nearestIndexed(indexed, value)
}
syntax?.getAllStyles().forEach((style, name) => {
syntax.registerStyle(name, {
...style,
fg: style.fg && scrollback(style.fg),
bg: style.bg && scrollback(style.bg),
})
})
return {
background: footerTheme.background,
background: RGBA.defaultBackground(theme.background.default),
footer: {
highlight: footerTheme.primary,
selected: footerTheme.backgroundElement,
selectedText: footerTheme.selectedListItemText,
warning: footerTheme.warning,
error: footerTheme.error,
muted: footerTheme.textMuted,
text: footerTheme.text,
status,
statusAccent,
shade,
surface,
pane: footerTheme.backgroundMenu,
border: footerTheme.border,
line,
actionSecondaryText: exact(elevated.text.action.secondary.default),
actionFocusedBg: exact(elevated.background.action.primary.focused),
actionFocusedText: exact(elevated.text.action.primary.focused),
formfieldText: exact(elevated.text.formfield.default),
formfieldFocusedBg: exact(elevated.background.formfield.focused),
formfieldFocusedText: exact(elevated.text.formfield.focused),
selection: exact(elevated.text.formfield.selected),
variant: exact(theme.text.subdued),
running: exact(theme.text.status.running),
question: exact(theme.text.status.question),
permission: exact(theme.text.status.permission),
success: exact(theme.text.feedback.success.default),
link: exact(theme.markdown.link),
categorical: dedupeWith(
theme.categorical.map((scale) => exact(scale[mode === "light" ? 800 : 200])),
(a, b) => a.equals(b),
),
warning: exact(theme.text.feedback.warning.default),
error: exact(theme.text.feedback.error.default),
muted: exact(theme.text.subdued),
text: exact(theme.text.default),
statusAccent: exact(theme.background.surface.overlay),
shade: exact(elevated.background.default),
surface: exact(elevated.background.default),
pane: exact(theme.contextual.overlay.background.default),
border: exact(theme.border.default),
line: exact(theme.background.surface.overlay),
},
entry: {
system: {
body: scrollbackTheme.textMuted,
},
user: {
body: scrollbackTheme.primary,
},
assistant: {
body: scrollbackTheme.text,
},
reasoning: {
body: scrollbackTheme.textMuted,
},
tool: {
body: scrollbackTheme.text,
start: scrollbackTheme.textMuted,
},
error: {
body: scrollbackTheme.error,
},
system: { body: scrollback(theme.text.subdued) },
user: { body: scrollback(theme.text.default) },
assistant: { body: scrollback(theme.markdown.text) },
reasoning: { body: scrollback(theme.text.subdued) },
tool: { body: scrollback(theme.text.subdued), start: scrollback(theme.text.subdued) },
error: { body: scrollback(theme.text.feedback.error.default) },
},
splash: {
left: nearestIndexed(indexed, theme.text.subdued),
right: nearestIndexed(indexed, theme.text.default),
leftShadow: nearestIndexed(indexed, theme.background.surface.offset),
},
splash,
block: {
text: scrollbackTheme.text,
muted: scrollbackTheme.textMuted,
text: scrollback(theme.text.default),
muted: scrollback(theme.text.subdued),
syntax,
diffRemoved: scrollbackTheme.diffRemoved,
diffAddedBg: transparent,
diffRemovedBg: transparent,
diffContextBg: transparent,
diffHighlightAdded: scrollbackTheme.diffHighlightAdded,
diffHighlightRemoved: scrollbackTheme.diffHighlightRemoved,
diffLineNumber: scrollbackTheme.diffLineNumber,
diffAddedLineNumberBg: scrollbackTheme.diffAddedLineNumberBg,
diffRemovedLineNumberBg: scrollbackTheme.diffRemovedLineNumberBg,
diffRemoved: scrollback(theme.diff.text.removed),
diffAddedBg: scrollback(theme.diff.background.added),
diffRemovedBg: scrollback(theme.diff.background.removed),
diffContextBg: scrollback(theme.diff.background.context),
diffHighlightAdded: scrollback(theme.diff.highlight.added),
diffHighlightRemoved: scrollback(theme.diff.highlight.removed),
diffLineNumber: scrollback(theme.diff.lineNumber.text),
diffAddedLineNumberBg: scrollback(theme.diff.lineNumber.background.added),
diffRemovedLineNumberBg: scrollback(theme.diff.lineNumber.background.removed),
},
}
}
const seed = {
highlight: RGBA.fromIndex(6, rgba("#38bdf8")),
muted: RGBA.fromIndex(8, rgba("#64748b")),
text: RGBA.defaultForeground(rgba("#f8fafc")),
panel: rgba("#0f172a"),
success: RGBA.fromIndex(2, rgba("#22c55e")),
warning: RGBA.fromIndex(3, rgba("#f59e0b")),
error: RGBA.fromIndex(1, rgba("#ef4444")),
}
function tone(body: ColorInput, start?: ColorInput): Tone {
return {
body,
start,
}
}
const fallbackSplashIndexed = Array.from({ length: 256 }, (_, index) => RGBA.fromIndex(index))
const fallbackSplashLeft = RGBA.fromIndex(67)
const fallbackSplashRight = RGBA.fromIndex(110)
export const RUN_THEME_FALLBACK: RunTheme = {
background: RGBA.fromValues(0, 0, 0, 0),
footer: {
highlight: seed.highlight,
selected: seed.text,
selectedText: seed.panel,
warning: seed.warning,
error: seed.error,
muted: seed.muted,
text: seed.text,
status: tint(seed.panel, rgba("#000000"), 0.12),
statusAccent: tint(seed.panel, rgba("#ffffff"), 0.06),
shade: alpha(seed.panel, 0.68),
surface: alpha(seed.panel, 0.86),
pane: seed.panel,
border: seed.muted,
line: alpha(seed.panel, 0.96),
},
entry: {
system: tone(seed.muted),
user: tone(seed.highlight),
assistant: tone(seed.text),
reasoning: tone(seed.muted),
tool: tone(seed.text, seed.muted),
error: tone(seed.error),
},
splash: {
left: fallbackSplashLeft,
right: fallbackSplashRight,
leftShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashLeft, 0.14),
},
block: {
text: seed.text,
muted: seed.muted,
diffRemoved: seed.error,
diffAddedBg: alpha(seed.success, 0.18),
diffRemovedBg: alpha(seed.error, 0.18),
diffContextBg: alpha(seed.panel, 0.72),
diffHighlightAdded: seed.success,
diffHighlightRemoved: seed.error,
diffLineNumber: seed.muted,
diffAddedLineNumberBg: alpha(seed.success, 0.12),
diffRemovedLineNumberBg: alpha(seed.error, 0.12),
},
}
export const RUN_THEME_FALLBACK = map(
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "dark"),
ansiPalette,
"dark",
)
export const RUN_THEME_FALLBACK_LIGHT = map(
resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), "light"),
ansiPalette,
"light",
)
function monoTheme(mode: "dark" | "light"): RunTheme {
const foreground = RGBA.defaultForeground(mode === "light" ? "#000000" : "#ffffff")
@@ -513,14 +214,24 @@ function monoTheme(mode: "dark" | "light"): RunTheme {
return {
background,
footer: {
highlight: foreground,
selected: background,
selectedText: foreground,
actionSecondaryText: foreground,
actionFocusedBg: background,
actionFocusedText: foreground,
formfieldText: foreground,
formfieldFocusedBg: background,
formfieldFocusedText: foreground,
selection: foreground,
variant: foreground,
running: foreground,
question: foreground,
permission: foreground,
success: foreground,
link: foreground,
categorical: [foreground],
warning: foreground,
error: foreground,
muted: foreground,
text: foreground,
status: background,
statusAccent: background,
shade: background,
surface: background,
@@ -529,18 +240,14 @@ function monoTheme(mode: "dark" | "light"): RunTheme {
line: background,
},
entry: {
system: tone(foreground),
user: tone(foreground),
assistant: tone(foreground),
reasoning: tone(foreground),
tool: tone(foreground),
error: tone(foreground),
},
splash: {
left: foreground,
right: foreground,
leftShadow: background,
system: { body: foreground },
user: { body: foreground },
assistant: { body: foreground },
reasoning: { body: foreground },
tool: { body: foreground },
error: { body: foreground },
},
splash: { left: foreground, right: foreground, leftShadow: background },
block: {
text: foreground,
muted: foreground,
@@ -566,37 +273,55 @@ export async function resolveRunTheme(
mono = false,
): Promise<RunTheme> {
if (mono) {
const mode =
config?.mode === "light" || config?.mode === "dark"
? config.mode
: (renderer.themeMode ?? (await renderer.waitForThemeMode(300)))
const mode = renderer.themeMode ?? (await renderer.waitForThemeMode(300)) ?? config?.mode
return mode === "light" ? RUN_THEME_MONO_LIGHT : RUN_THEME_MONO
}
try {
const colors = await renderer.getPalette({
size: 256,
})
const bg = colors.defaultBackground ?? colors.palette[0]
if (!bg) {
return RUN_THEME_FALLBACK
}
// Palette-only terminal reloads can leave renderer.themeMode stale, but
// ANSI slot zero is not the terminal background when OSC 11 is absent.
const pick =
config?.mode === "dark" || config?.mode === "light"
? config.mode
: (terminalMode(colors) ?? renderer.themeMode ?? colorMode(RGBA.fromHex(bg)))
const { generateSyntax } = await import("../theme")
const indexed = indexedPalette(colors, 256)
const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
const scrollbackTheme = quantizeTheme(footerTheme, indexed)
const syntaxTheme: SharedSyntaxTheme = {
...scrollbackTheme,
_hasSelectedListItemText: true,
}
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), generateSyntax(syntaxTheme))
} catch {
return RUN_THEME_FALLBACK
const detected = await renderer.getPalette({ size: 256 }).catch(() => undefined)
// A transient OSC timeout must not remap immutable scrollback against an unrelated ANSI palette.
const colors =
(detected?.defaultBackground ?? detected?.palette[0]) && (detected?.defaultForeground ?? detected?.palette[7])
? detected
: palettes.get(renderer)
if (colors) palettes.set(renderer, colors)
// Mini stays transparent: fresh OSC 11 wins, but cached colors must not override a new renderer mode.
const mode =
(detected && terminalMode(detected)) ??
renderer.themeMode ??
(colors && terminalMode(colors)) ??
(config?.mode === "light" ? "light" : "dark")
const name = config?.name ?? "opencode"
if (name === "system" && !colors) {
return mode === "light" ? RUN_THEME_FALLBACK_LIGHT : RUN_THEME_FALLBACK
}
const resolved = await themeSource(name, colors, mode)
.then((source) => {
const document = parseTheme(source, name)
// Mini keeps the terminal background, so the opposite theme mode is not a safe fallback.
if (themeModes(document).includes(mode)) return resolveThemeDocument(document, mode)
})
.catch(() => undefined)
const theme = resolved ?? resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
const indexed = colors
? ansiPalette.map((color, index) => (colors.palette[index] ? RGBA.fromIndex(index, colors.palette[index]!) : color))
: ansiPalette
return {
...map(theme, indexed, mode, generateSyntax(theme, mode), name === "system" && resolved !== undefined),
background: RGBA.defaultBackground(colors?.defaultBackground ?? theme.background.default),
}
}
async function themeSource(
name: string,
colors: TerminalColors | undefined,
mode: "dark" | "light",
): Promise<ThemeDocumentSource> {
if (name === "system" && colors) return generateSystem(colors, mode)
const { Global } = await import("@opencode-ai/util/global")
const custom = await discoverThemes(
configDirectories(process.env.OPENCODE_CONFIG_DIR ?? Global.Path.config, process.cwd()),
)
const source = custom[name] ?? allThemes()[name] ?? DEFAULT_THEMES.opencode
return isThemeSource(source) ? source : DEFAULT_THEMES.opencode
}
+9 -2
View File
@@ -40,6 +40,7 @@ export type RunPromptPart =
url: string
filename?: string
mime?: string
description?: string
source?: {
type: string
text: { start: number; end: number; value: string }
@@ -119,6 +120,7 @@ export type RunInput = {
}
export type MiniHost = {
version: string
terminal: {
stdin: NodeJS.ReadStream
}
@@ -176,7 +178,7 @@ export type FooterState = {
status: string
notice: string
model: string
usage: string
usage: { tokens: number; percent?: number; cost?: number } | undefined
first: boolean
interrupt: number
exit: number
@@ -393,7 +395,10 @@ export type FormCancel = {
location?: LocationRef
}
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini" | "session" | "cursor">
export type RunTuiConfig = Pick<
Config.Resolved,
"keybinds" | "leader" | "theme" | "mini" | "prompt" | "session" | "cursor" | "animations"
>
export type MiniSettings = {
thinking: "show" | "hide"
@@ -401,6 +406,7 @@ export type MiniSettings = {
turn_summary: "show" | "hide"
footer: "show" | "hide"
splash: "show" | "hide"
work_spinner: Config.MiniWorkSpinner
mono: boolean
}
@@ -423,6 +429,7 @@ type StreamToolState = "running" | "completed" | "error"
export type StreamCommit = {
kind: EntryKind
text: string
image?: string
phase: StreamPhase
source: StreamSource
compaction?: true
+111
View File
@@ -0,0 +1,111 @@
import { octantGlyph } from "./subcell"
import type { Config } from "../config"
export type OneCellMotion = {
frames: string[]
interval: number
levels?: number[]
intro?: Pick<OneCellMotion, "frames" | "interval" | "levels">
once?: boolean
pace?: { initial: number; final: number; after: number; duration: number }
}
export function oneCellFrame(animation: OneCellMotion, elapsed: number) {
const introDuration = animation.intro ? animation.intro.frames.length * animation.intro.interval : 0
const intro = elapsed < introDuration
const clip = intro ? animation.intro! : animation
const pace = animation.pace
const progress = pace ? Math.max(0, Math.min(1, (elapsed - pace.after) / pace.duration)) : 0
// Integrate the changing rate so slowing down does not jump or restart the motion.
const time = intro
? elapsed
: !pace
? elapsed - introDuration
: (elapsed - introDuration) * pace.initial +
(pace.final - pace.initial) *
(pace.duration * (progress ** 3 - progress ** 4 / 2) + Math.max(0, elapsed - pace.after - pace.duration))
// Keep cycle boundaries exact when individual frame intervals are fractional.
const frame = Math.floor((time * clip.frames.length) / (clip.interval * clip.frames.length))
const index = !intro && animation.once ? Math.min(frame, clip.frames.length - 1) : frame % clip.frames.length
return {
glyph: clip.frames[index]!,
level: clip.levels?.[index] ?? 1,
complete: !intro && !!animation.once && frame >= clip.frames.length - 1,
}
}
export const SEED_WORK: OneCellMotion = {
frames: Array.from({ length: 40 }, () => "\u25aa"),
interval: 40,
levels: Array.from({ length: 40 }, (_, index) => 0.3 + (0.7 * (1 - Math.cos((index / 40) * 2 * Math.PI))) / 2),
intro: {
frames: Array.from({ length: 80 }, () => "\u25aa"),
interval: 40,
levels: Array.from({ length: 80 }, (_, index) => {
const phase = index / 80
return 0.3 + 0.7 * (phase < 0.125 ? phase / 0.125 : phase < 0.375 ? 1 : ((1 - phase) / 0.625) ** 3)
}),
},
pace: { initial: 1.25, final: 0.5, after: 30_000, duration: 30_000 },
}
const WORK_PACE = { initial: 1.2, final: 0.96, after: 30_000, duration: 30_000 }
const lower = [2, 3, 5, 7, 6, 4]
export const BLOCK_LOW_COMET: OneCellMotion = {
frames: lower.flatMap((point, index, path) => {
const leading = (1 << point) | (1 << path[(index + 5) % 6]!)
const full = leading | (1 << path[(index + 4) % 6]!)
return [full, full, full, leading, leading].map(octantGlyph)
}),
interval: 40,
pace: WORK_PACE,
}
export const BLOCK_SOFT_SWEEP: OneCellMotion = {
frames: [0x14, 0x14, 0x14, 0x1c, 0x3c, 0x38, 0x28, 0x28, 0x28, 0x38, 0x3c, 0x1c].map(octantGlyph),
interval: 100,
pace: WORK_PACE,
}
export const BLOCK_SOFT_SLIDE: OneCellMotion = {
frames: [0x14, 0x14, 0x14, 0x14, 0x14, 0x28, 0x28, 0x28, 0x28, 0x28].map(octantGlyph),
interval: 100,
levels: [0.55, 0.85, 1, 0.85, 0.55, 0.55, 0.85, 1, 0.85, 0.55],
}
export const WORK_SPINNERS = {
"block-soft-slide": BLOCK_SOFT_SLIDE,
"block-soft-sweep": BLOCK_SOFT_SWEEP,
"block-low-comet": BLOCK_LOW_COMET,
"block-low-duet": {
frames: lower.slice(0, 3).flatMap((point, index) => {
const heads = (1 << point) | (1 << lower[(index + 3) % 6]!)
const full = heads | (1 << lower[(index + 2) % 6]!) | (1 << lower[(index + 5) % 6]!)
return [full, full, full, heads].map(octantGlyph)
}),
interval: 60,
},
"block-shuttle": { frames: [0x14, 0x28].map(octantGlyph), interval: 500 },
"block-bridge": { frames: [0x14, 0x14, 0x14, 0x3c, 0x28, 0x28, 0x28, 0x3c].map(octantGlyph), interval: 120 },
"block-squeeze": {
frames: [0x14, 0x14, 0x14, 0x10, 0x20, 0x28, 0x28, 0x28, 0x20, 0x10].map(octantGlyph),
interval: 100,
},
"small-toggle": { frames: Array.from("\u25ab\u25aa"), interval: 320 },
"square-toggle": { frames: Array.from("\u25a1\u25a0"), interval: 500 },
"grow-shrink": { frames: Array.from("\u25ab\u25ab\u25aa\u25a0\u25aa"), interval: 180 },
"quadrant-orbit": { frames: Array.from("\u2598\u259d\u2597\u2596"), interval: 160 },
crosshatch: { frames: Array.from("\u25a7\u25a9\u25a8\u25a9"), interval: 240 },
"density-wave": { frames: Array.from("\u2591\u2591\u2591\u2592\u2593\u2588\u2593\u2592"), interval: 160 },
seed: SEED_WORK,
} satisfies Record<Config.MiniWorkSpinner, OneCellMotion>
export const SEED_LAUNCH: OneCellMotion = {
frames: Array.from({ length: 21 }, (_, index) => (index < 10 ? "\u25ab" : "\u25aa")),
interval: 40,
levels: SEED_WORK.levels!.slice(0, 21),
once: true,
}
export const SEED_MONO: OneCellMotion = { frames: ["-", "\\", "|", "/"], interval: 120 }
+37
View File
@@ -0,0 +1,37 @@
// Masks use row-major bits in a 2x4 grid. Unicode reuses these older glyphs
// instead of duplicating them in the otherwise mask-ordered octant block.
const OCTANTS = new Map<number, string>([
[0x00, " "],
[0x01, "\u{1cea8}"],
[0x02, "\u{1ceab}"],
[0x03, "\u{1fb82}"],
[0x05, "\u2598"],
[0x0a, "\u259d"],
[0x0f, "\u2580"],
[0x14, "\u{1fbe6}"],
[0x28, "\u{1fbe7}"],
[0x3f, "\u{1fb85}"],
[0x40, "\u{1cea3}"],
[0x50, "\u2596"],
[0x55, "\u258c"],
[0x5a, "\u259e"],
[0x5f, "\u259b"],
[0x80, "\u{1cea0}"],
[0xa0, "\u2597"],
[0xa5, "\u259a"],
[0xaa, "\u2590"],
[0xaf, "\u259c"],
[0xc0, "\u2582"],
[0xf0, "\u2584"],
[0xf5, "\u2599"],
[0xfa, "\u259f"],
[0xfc, "\u2586"],
[0xff, "\u2588"],
])
export function octantGlyph(mask: number) {
return (
OCTANTS.get(mask) ??
String.fromCodePoint(0x1cd00 + mask - [...OCTANTS.keys()].filter((value) => value < mask).length)
)
}
@@ -1,11 +1,14 @@
/** @jsxImportSource @opentui/solid */
import { afterAll, expect, test } from "bun:test"
import { once } from "node:events"
import { readdir } from "node:fs/promises"
import path from "node:path"
import {
BoxRenderable,
CliRenderEvents,
DiffRenderable,
ImageRenderable,
InputRenderable,
MouseButton,
type Renderable,
ScrollBoxRenderable,
@@ -315,6 +318,9 @@ test.each(["branch", "committed", "working"] as const)(
expect(viewer.app.captureCharFrame()).toMatch(/●\s+v2/)
expect(viewer.branchesRequests[0].searchParams.get("location[directory]")).toBe("/repo/session")
expect(viewer.branchesRequests[0].searchParams.get("limit")).toBe("100")
// The picker can paint before its deferred input focus.
if (!viewer.app.renderer.currentFocusedEditor) await once(viewer.app.renderer, CliRenderEvents.FOCUSED_EDITOR)
expect(viewer.app.renderer.currentFocusedEditor).toBeInstanceOf(InputRenderable)
await viewer.app.mockInput.typeText("origin/release")
await Bun.sleep(160)
await viewer.app.waitFor(() => viewer.branchesRequests.at(-1)?.searchParams.get("search") === "origin/release")
+5 -1
View File
@@ -17,12 +17,16 @@ test("validates the three explicit diff source defaults", () => {
expect(() => decodeInfo({ diffs: { source: "auto" } })).toThrow()
})
test("validates mini replay settings", () => {
test("validates mini replay and work spinner settings", () => {
expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({
mini: { replay: false, replay_limit: 50 },
})
expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow()
expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow()
expect(decodeInfo({ mini: { work_spinner: "quadrant-orbit" } })).toEqual({
mini: { work_spinner: "quadrant-orbit" },
})
expect(() => decodeInfo({ mini: { work_spinner: "unknown" } })).toThrow()
})
test("validates the session tabs setting", () => {
@@ -0,0 +1,731 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { BoxRenderable, RGBA, ScrollBoxRenderable, TextRenderable, type Renderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
import { Keymap } from "../../src/context/keymap"
import { RunFooter } from "../../src/mini/footer"
import { RunFormBody } from "../../src/mini/footer.form"
import { RunPermissionBody } from "../../src/mini/footer.permission"
import { RunFooterSubagentBody } from "../../src/mini/footer.subagent"
import { createFormBodyState } from "../../src/mini/form.shared"
import { RUN_THEME_FALLBACK, RUN_THEME_MONO } from "../../src/mini/theme"
import type { FormReply, MiniFormRequest, MiniPermissionRequest, PermissionReply } from "../../src/mini/types"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
const permission: MiniPermissionRequest = {
id: "per_responsive",
sessionID: "ses_responsive",
action: "shell",
resources: ["rm -rf /project/build/cache"],
save: ["/project/build/*", "/project/private/GRANT_SENTINEL"],
}
const form: MiniFormRequest = {
id: "frm_responsive",
sessionID: "ses_responsive",
title: "Deployment",
fields: [
{
key: "target",
type: "string",
title: "Target",
required: true,
options: Array.from({ length: 12 }, (_, index) => ({
value: `target-${index + 1}`,
label: `Target ${index + 1}`,
description: `Environment ${index + 1}`,
})),
},
],
}
function descendants(root: Renderable): Renderable[] {
return root.getChildren().flatMap((child) => [child, ...descendants(child)])
}
async function settle(app: Pick<Awaited<ReturnType<typeof testRender>>, "renderOnce">) {
await app.renderOnce()
await Bun.sleep(0)
await app.renderOnce()
await app.renderOnce()
}
async function renderForm(request: MiniFormRequest, width = 80, height = 20) {
const replies: FormReply[] = []
const app = await testRender(
() => (
<RunFormBody
request={request}
theme={RUN_THEME_FALLBACK.footer}
onReply={(reply) => {
replies.push(reply)
}}
onCancel={() => {}}
/>
),
{ width, height, kittyKeyboard: true },
)
return { ...app, replies }
}
test("roomy question forms retain the established spacing and inline descriptions", async () => {
const app = await renderForm({
...form,
title: "Questions",
fields: [
{
key: "layout",
type: "string",
title: "Layout",
description: "Which footer view?",
options: [
{ value: "form", label: "Form", description: "Inspect canonical form" },
{ value: "prompt", label: "Prompt", description: "Return to composer" },
],
},
{ key: "extra", type: "boolean", title: "Extra checks" },
],
})
try {
await settle(app)
for (const [text, x, y] of [
["Questions", 4, 1],
["1/2", 14, 1],
["Which footer view?", 2, 3],
["Form", 5, 5],
["Inspect canonical form", 10, 5],
] as const) {
const node = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === text,
)!
expect({ text, x: node.x, y: node.y }).toEqual({ text, x, y })
}
expect(app.captureCharFrame().split("\n")[18]).toContain("↑↓ select enter choose tab next esc dismiss")
} finally {
app.renderer.destroy()
}
})
test.each([16, 24, 40])(
"complete questions and distinct option details remain readable at %i columns",
async (width) => {
const content = (app: Awaited<ReturnType<typeof renderForm>>) =>
app
.captureCharFrame()
.split("\n")
.map((row) => row.slice(0, width - 1))
.join(" ")
.replace(/\s+/g, " ")
const title = "Delete production database permanently?"
const app = await renderForm({ ...form, fields: [{ key: "yes", type: "boolean", title }] }, width, 8)
try {
await settle(app)
app.mockInput.pressKey("\x1b[5~")
await settle(app)
expect(content(app)).toContain(title)
expect(app.replies).toEqual([])
} finally {
app.renderer.destroy()
}
const options = await renderForm(
{
...form,
fields: [
{
key: "target",
type: "string",
title: "Target",
options: [
{ value: "stage", label: "Deploy api service to staging", description: "STAGING database" },
{ value: "prod", label: "Deploy api service to production", description: "PRODUCTION database" },
],
},
],
},
width,
12,
)
try {
await settle(options)
expect(content(options)).toContain("Deploy api service to staging")
expect(content(options)).toContain("STAGING database")
options.mockInput.pressArrow("down")
await settle(options)
expect(content(options)).toContain("Deploy api service to production")
expect(content(options)).toContain("PRODUCTION database")
options.mockInput.pressEnter()
await settle(options)
expect(options.replies[0]?.answer).toEqual({ target: "prod" })
} finally {
options.renderer.destroy()
}
},
)
test("advancing fields reveals the new choice without a second Enter", async () => {
const app = await renderForm(
{
...form,
fields: [
{ key: "confirm", type: "boolean", title: "Continue?" },
{
key: "target",
type: "string",
title: "Target",
description: "Select your preferred destination for this deployment and double-check it carefully.",
options: [
{ value: "b0", label: "B0" },
{ value: "b1", label: "B1" },
],
},
],
},
16,
8,
)
try {
await settle(app)
app.mockInput.pressEnter()
await settle(app)
expect(app.captureCharFrame()).toContain("B0")
expect(app.replies).toEqual([])
app.mockInput.pressEnter()
await settle(app)
expect(app.captureCharFrame()).toContain("Review")
} finally {
app.renderer.destroy()
}
})
test("roomy permission prompts retain their header and padded action bar", async () => {
const app = await testRender(
() => (
<RunPermissionBody
request={permission}
theme={RUN_THEME_FALLBACK.footer}
block={RUN_THEME_FALLBACK.block}
onReply={() => {}}
/>
),
{ width: 112, height: 20 },
)
try {
await settle(app)
const nodes = descendants(app.renderer.root).filter(
(node): node is TextRenderable => node instanceof TextRenderable,
)
const title = nodes.find((node) => node.plainText.includes("Permission required"))!
const action = nodes.find((node) => node.plainText === "Allow once")!
expect({ x: title.x, y: title.y }).toEqual({ x: 2, y: 1 })
expect({ x: action.x, y: action.y }).toEqual({ x: 3, y: 18 })
expect(app.captureCharFrame().split("\n")[18]).toContain("⇆ select enter confirm esc reject")
const scroll = descendants(app.renderer.root).find((node) => node instanceof ScrollBoxRenderable)!
expect(scroll.x + scroll.width).toBe(109)
app.mockInput.pressEnter()
await settle(app)
expect(app.captureCharFrame()).toContain("Waiting for permission event...")
} finally {
app.renderer.destroy()
}
})
test("roomy rejection keeps the editor and confirmation hints inline", async () => {
const app = await testRender(
() => (
<Keymap.Provider config={createTuiResolvedConfig()}>
<RunPermissionBody
request={permission}
theme={RUN_THEME_FALLBACK.footer}
block={RUN_THEME_FALLBACK.block}
onReply={() => {}}
/>
</Keymap.Provider>
),
{ width: 112, height: 20, kittyKeyboard: true },
)
try {
await settle(app)
app.mockInput.pressArrow("right")
app.mockInput.pressArrow("right")
app.mockInput.pressEnter()
await settle(app)
const editor = app.renderer.currentFocusedEditor!
const confirm = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === "enter confirm",
)!
expect(editor.y).toBe(18)
expect(confirm.y).toBe(editor.y)
expect(confirm.x).toBeGreaterThanOrEqual(editor.x + editor.width)
expect(app.captureCharFrame()).toContain("esc cancel")
expect(app.captureCharFrame()).toContain("Tell OpenCode what to do differently")
} finally {
app.renderer.destroy()
}
})
test.each([false, true])("picked options budget their full ordinal and selection marker (mono=%s)", async (mono) => {
for (const width of [16, 112]) {
const description = width === 16 ? "prod" : "d".repeat(85) + "_END_LAST"
const app = await testRender(
() => (
<RunFormBody
request={{
...form,
fields: [
{
key: "target",
type: "string",
title: "Target",
default: "target-12",
options: Array.from({ length: 12 }, (_, index) => ({
value: `target-${index + 1}`,
label: "Deploy",
description,
})),
},
],
}}
theme={RUN_THEME_FALLBACK.footer}
mono={mono}
onReply={() => {}}
onCancel={() => {}}
/>
),
{ width, height: 20, kittyKeyboard: true },
)
try {
await settle(app)
const label = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === "Deploy *",
)!
const detail = label
.parent!.getChildren()
.find((node) => node instanceof TextRenderable && node.plainText === description)!
const scroll = descendants(app.renderer.root).find((node) => node instanceof ScrollBoxRenderable)!
expect(detail.y).toBeGreaterThan(label.y)
expect(detail.x + detail.width).toBeLessThanOrEqual(scroll.viewport.x + scroll.viewport.width - (mono ? 0 : 1))
expect(app.captureCharFrame()).toContain(description)
} finally {
app.renderer.destroy()
}
}
})
test.each([
{ width: 16, height: 8 },
{ width: 24, height: 8 },
{ width: 32, height: 8 },
{ width: 40, height: 8 },
{ width: 56, height: 8 },
{ width: 56, height: 12 },
{ width: 80, height: 12 },
{ width: 112, height: 20 },
])("permission controls fit the allocated $width x $height viewport", async (size) => {
const replies: PermissionReply[] = []
const app = await testRender(
() => (
<RunPermissionBody
request={permission}
theme={RUN_THEME_FALLBACK.footer}
block={RUN_THEME_FALLBACK.block}
onReply={(reply) => {
replies.push(reply)
}}
/>
),
{ ...size, kittyKeyboard: true },
)
try {
await settle(app)
for (const label of ["Allow once", "Always allow", "Reject"]) {
expect(app.captureCharFrame()).toContain(label)
const node = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === label,
)!
expect(node.x + node.width).toBeLessThanOrEqual(size.width)
expect(node.y + node.height).toBeLessThanOrEqual(size.height)
}
const scroll = descendants(app.renderer.root).find((node) => node instanceof ScrollBoxRenderable)!
expect(scroll.height).toBeGreaterThan(0)
expect(replies).toEqual([])
} finally {
app.renderer.destroy()
}
})
test.each(["once", "always", "reject"] as const)(
"24 x 8 production permission confirms the visible %s choice",
async (choice) => {
const app = await createTestRenderer({
width: 24,
height: 8,
screenMode: "split-footer",
footerHeight: 4,
kittyKeyboard: true,
})
const replies: PermissionReply[] = []
const footer = new RunFooter(app.renderer, {
directory: () => "/project",
findFiles: async () => [],
agents: [],
references: [],
agent: undefined,
modelLabel: "GPT-5",
model: undefined,
variant: undefined,
first: false,
theme: RUN_THEME_FALLBACK,
tuiConfig: createTuiResolvedConfig(),
miniSettings: {
current: {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
footer: "show",
splash: "hide",
work_spinner: "block-soft-slide",
mono: false,
},
},
onPermissionReply: (reply) => {
replies.push(reply)
},
onFormReply: () => {},
onFormCancel: () => {},
onEditorOpen: async () => undefined,
subscribeThemeSignal: () => () => {},
})
try {
await settle(app)
footer.event({ type: "stream.view", view: { type: "permission", request: permission } })
await settle(app)
for (const label of ["Allow once", "Always allow", "Reject"]) expect(app.captureCharFrame()).toContain(label)
for (let index = 0; index < ["once", "always", "reject"].indexOf(choice); index++) app.mockInput.pressTab()
await settle(app)
const label = choice === "once" ? "Allow once" : choice === "always" ? "Always allow" : "Reject"
const node = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === label,
)!
expect((node.parent as BoxRenderable).backgroundColor.toInts()).toEqual(
(RUN_THEME_FALLBACK.footer.actionFocusedBg as RGBA).toInts(),
)
expect(replies).toEqual([])
app.mockInput.pressEnter()
await settle(app)
if (choice !== "once") {
expect(replies).toEqual([])
expect(app.captureCharFrame()).toContain(choice === "always" ? "Confirm" : "enter reject")
app.mockInput.pressEnter()
await settle(app)
}
expect(replies).toEqual([{ sessionID: permission.sessionID, requestID: permission.id, reply: choice }])
} finally {
footer.destroy()
app.renderer.destroy()
}
},
)
test.each([
{ width: 16, height: 8 },
{ width: 24, height: 8 },
{ width: 32, height: 12 },
{ width: 56, height: 8 },
{ width: 56, height: 12 },
{ width: 80, height: 20 },
])("form choices reveal selection at $width x $height", async (size) => {
const app = await renderForm(form, size.width, size.height)
try {
await settle(app)
for (let index = 0; index < 12; index++) {
if (index > 0) app.mockInput.pressKey("ARROW_DOWN")
await settle(app)
expect(app.captureCharFrame()).toContain(`Target ${index + 1}`)
const selected = descendants(app.renderer.root).find(
(node) => node instanceof TextRenderable && node.plainText === `Target ${index + 1}`,
)!
const scroll = descendants(app.renderer.root).find(
(node): node is ScrollBoxRenderable => node instanceof ScrollBoxRenderable,
)!
expect(selected.y).toBeGreaterThanOrEqual(scroll.viewport.y)
expect(selected.y + selected.height).toBeLessThanOrEqual(scroll.viewport.y + scroll.viewport.height)
}
app.mockInput.pressEnter()
await settle(app)
expect(app.replies[0]?.answer).toEqual({ target: "target-12" })
} finally {
app.renderer.destroy()
}
})
test("form review scrolls from the first answer to the last", async () => {
const request: MiniFormRequest = {
...form,
fields: [
{ key: "field-1", type: "boolean", title: "Field 1", default: true },
...Array.from({ length: 11 }, (_, index) => ({
key: `field-${index + 2}`,
type: "boolean" as const,
title: `Field ${index + 2}`,
default: index % 2 !== 0,
})),
],
}
const app = await testRender(
() => (
<RunFormBody
request={request}
theme={RUN_THEME_FALLBACK.footer}
state={{ ...createFormBodyState(request), field: 12 }}
onReply={() => {}}
onCancel={() => {}}
/>
),
{ width: 24, height: 8, kittyKeyboard: true },
)
try {
await settle(app)
expect(app.captureCharFrame()).toContain("Field 1: Yes")
expect(app.captureCharFrame()).not.toContain("Field 12: No")
app.mockInput.pressKey("\x1b[6~")
await settle(app)
expect(app.captureCharFrame()).toContain("Field 12: No")
expect(app.captureCharFrame()).toContain("enter submit")
} finally {
app.renderer.destroy()
}
})
test("long permission paths, diffs and persistent scopes remain keyboard accessible", async () => {
const app = await testRender(
() => (
<RunPermissionBody
request={{
...permission,
action: "edit",
save: [...Array.from({ length: 8 }, (_, index) => `/project/resource-${index}/*`), ...permission.save!],
resources: ["/project/long/path/to/sensitive/configuration/auth.ts"],
metadata: {
diff: "--- a/auth.ts\n+++ b/auth.ts\n@@ -10000,2 +10000,2 @@\n-const allow = false\n+DIFF_SENTINEL\n return allow\n",
},
}}
theme={RUN_THEME_FALLBACK.footer}
block={RUN_THEME_FALLBACK.block}
onReply={() => {}}
/>
),
{ width: 24, height: 8 },
)
try {
await settle(app)
const frames = [app.captureCharFrame()]
for (let index = 0; index < 12; index++) {
app.mockInput.pressKey("\x1b[6~")
await settle(app)
frames.push(app.captureCharFrame())
}
expect(frames.join("\n")).toContain("+DIFF_SENTINEL")
const scroll = descendants(app.renderer.root).find(
(node): node is ScrollBoxRenderable => node instanceof ScrollBoxRenderable,
)!
const top = scroll.scrollTop
expect(top).toBeGreaterThan(0)
app.mockInput.pressKey("ARROW_RIGHT")
await settle(app)
expect(scroll.scrollTop).toBe(top)
app.mockInput.pressEnter()
await settle(app)
const scopes = [app.captureCharFrame()]
for (let index = 0; index < 12; index++) {
app.mockInput.pressKey("\x1b[6~")
await settle(app)
scopes.push(app.captureCharFrame())
}
expect(scopes.join("\n")).toContain("GRANT_SENTINEL")
expect(app.captureCharFrame()).toContain("Confirm")
expect(app.captureCharFrame()).toContain("Cancel")
const end = scroll.scrollTop
app.mockInput.pressKey("\x1b[5~")
await settle(app)
expect(scroll.scrollTop).toBeLessThan(end)
} finally {
app.renderer.destroy()
}
})
test("scrolling a choice offscreen reveals it before submission and survives resize", async () => {
const app = await renderForm(form, 24, 8)
try {
await settle(app)
app.mockInput.pressKey("\x1b[6~")
await settle(app)
expect(app.captureCharFrame()).not.toMatch(/^1\. Target 1(?: |$)/m)
app.mockInput.pressEnter()
await settle(app)
expect(app.replies).toEqual([])
expect(app.captureCharFrame()).toMatch(/^1\. Target 1(?: |$)/m)
for (let index = 0; index < 11; index++) app.mockInput.pressKey("ARROW_DOWN")
await settle(app)
for (const size of [
{ width: 80, height: 20 },
{ width: 16, height: 8 },
{ width: 24, height: 8 },
]) {
app.resize(size.width, size.height)
await settle(app)
expect(app.captureCharFrame()).toContain("Target 12")
}
app.mockInput.pressEnter()
await settle(app)
expect(app.replies[0]?.answer).toEqual({ target: "target-12" })
} finally {
app.renderer.destroy()
}
})
test.each([16, 24, 80])("text form keeps its editor, error and controls at %s x 8", async (width) => {
const app = await testRender(
() => (
<Keymap.Provider config={createTuiResolvedConfig()}>
<RunFormBody
request={{
...form,
fields: [
{
key: "service",
type: "string",
title: "Service",
required: true,
description:
"Choose the service name for deployment. This name will be used to create production resources and route incoming requests to the correct application.",
},
],
}}
theme={RUN_THEME_FALLBACK.footer}
onReply={() => {}}
onCancel={() => {}}
/>
</Keymap.Provider>
),
{ width, height: 8, kittyKeyboard: true },
)
try {
await settle(app)
app.mockInput.pressEnter()
await settle(app)
expect(app.captureCharFrame()).toContain("Answer required")
expect(app.captureCharFrame()).toContain("enter save")
expect(app.captureCharFrame()).toContain("esc dismiss")
const editor = app.renderer.currentFocusedEditor!
expect(editor.y).toBeGreaterThan(1)
expect(editor.y + editor.height).toBeLessThan(8)
await app.mockInput.typeText("api")
await settle(app)
expect(app.captureCharFrame()).toContain("api")
} finally {
app.renderer.destroy()
}
})
test("external form exposes its complete URL and state-specific actions", async () => {
const opened: string[] = []
const url = "https://identity.example.test/oauth/authorize?client_id=opencode&redirect_uri=URL_SENTINEL"
const app = await testRender(
() => (
<RunFormBody
request={{ ...form, fields: [{ key: "auth", type: "external", title: "Sign in", url }] }}
theme={RUN_THEME_FALLBACK.footer}
openExternal={async (value) => {
opened.push(value)
}}
onReply={() => {}}
onCancel={() => {}}
/>
),
{ width: 24, height: 8, kittyKeyboard: true },
)
try {
await settle(app)
expect(app.captureCharFrame()).toContain("enter open URL")
expect(app.captureCharFrame()).not.toContain("choose")
for (let index = 0; index < 10; index++) app.mockInput.pressKey("\x1b[6~")
await settle(app)
expect(app.captureCharFrame()).toContain("URL_SENTINEL")
app.mockInput.pressEnter()
await settle(app)
expect(opened).toEqual([url])
expect(app.captureCharFrame()).toContain("enter acknowledge")
} finally {
app.renderer.destroy()
}
})
test.each([false, true])("wrapped permission characters stay outside the scrollbar (mono=%s)", async (mono) => {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const theme = mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK
const app = await testRender(
() => (
<RunPermissionBody
request={{ ...permission, resources: [alphabet] }}
theme={theme.footer}
block={theme.block}
mono={mono}
onReply={() => {}}
/>
),
{ width: 24, height: 20 },
)
try {
await settle(app)
const scroll = descendants(app.renderer.root).find(
(node): node is ScrollBoxRenderable => node instanceof ScrollBoxRenderable,
)!
const rows = app
.captureCharFrame()
.split("\n")
.slice(scroll.viewport.y, scroll.viewport.y + scroll.viewport.height)
expect(rows.map((row) => row.slice(0, mono ? 24 : 23).trim()).join("")).toContain(alphabet)
} finally {
app.renderer.destroy()
}
})
test.each([
{ width: 16, height: 8 },
{ width: 56, height: 8 },
{ width: 56, height: 12 },
{ width: 112, height: 20 },
])("inspector keeps task identity and controls at $width x $height", async (size) => {
let closed = 0
const app = await testRender(
() => (
<RunFooterSubagentBody
active={() => true}
theme={() => RUN_THEME_FALLBACK}
tab={() => ({ sessionID: "child", label: "Explore", description: "Inspect authentication", status: "running" })}
index={() => 12}
total={() => 12}
detail={() => ({ commits: [{ kind: "system", source: "system", phase: "final", text: "Activity" }] })}
interrupt={() => "ctrl+d"}
onCycle={() => {}}
onClose={() => {
closed++
}}
/>
),
{ ...size, kittyKeyboard: true },
)
try {
await settle(app)
expect(app.captureCharFrame()).toContain("Inspect")
if (size.width < 56 || size.height < 12) expect(app.captureCharFrame()).toContain("esc back")
else expect(app.captureCharFrame()).toContain("12 of 12")
expect(app.captureCharFrame()).toContain("ctrl+d interrupt")
expect(app.captureCharFrame()).toContain("Activity")
app.mockInput.pressEscape()
await settle(app)
expect(closed).toBe(1)
} finally {
app.renderer.destroy()
}
})
+5 -2
View File
@@ -56,9 +56,12 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
commits,
calls,
promptReady,
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
submit(text: string | RunPrompt, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
if (prompts.size === 0) return false
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
const prompt: RunPrompt =
typeof text === "string"
? { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
: text
for (const fn of [...prompts]) fn(prompt)
return true
},
@@ -14,7 +14,7 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
status: "",
notice: "",
model: "gpt-5",
usage: "",
usage: undefined,
first: false,
interrupt: 0,
exit: 0,
@@ -57,7 +57,6 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
providers={() => undefined}
currentAgent={() => "Build"}
currentAgentID={() => "build"}
currentAgentExplicit={() => false}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
@@ -72,6 +71,7 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
turn_summary: "show",
footer: "show",
splash: "show",
work_spinner: "block-soft-slide",
mono: false,
})}
mono={false}
@@ -0,0 +1,409 @@
import { expect, test } from "bun:test"
import { RGBA, TextAttributes } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { RunFooter } from "../../src/mini/footer"
import { resolveMiniSettings } from "../../src/mini/runtime.boot"
import { RUN_THEME_FALLBACK, RUN_THEME_MONO } from "../../src/mini/theme"
import type { RunPrompt } from "../../src/mini/types"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
const sizes = [
[112, 30],
[24, 8],
[40, 12],
[112, 30],
] as const
async function setup(mono = false) {
const app = await createTestRenderer({
width: 112,
height: 30,
screenMode: "split-footer",
footerHeight: 4,
externalOutputMode: "capture-stdout",
})
const theme = mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK
const prompts: RunPrompt[] = []
const footer = new RunFooter(app.renderer, {
directory: () => "/project",
findFiles: async () => ["packages/tui/src/mini/footer.view.tsx", "packages/tui/src/mini/", "src/\u6587\u4ef6.ts"],
agents: [{ id: "build", name: "Build", mode: "primary", hidden: false }],
references: [],
agent: "build",
modelLabel: "GPT-5",
model: undefined,
variant: undefined,
first: false,
theme,
tuiConfig: createTuiResolvedConfig(),
miniSettings: {
current: { ...resolveMiniSettings(), mono },
},
onPermissionReply: () => {},
onFormReply: () => {},
onFormCancel: () => {},
onEditorOpen: async () => undefined,
subscribeThemeSignal: () => () => {},
})
footer.onPrompt((prompt) => prompts.push(prompt))
footer.event({ type: "catalog", agents: [], references: [], commands: [] })
return {
...app,
footer,
prompts,
selected() {
return app
.captureSpans()
.lines.flatMap((line) => line.spans)
.filter((span) =>
mono
? (span.attributes & TextAttributes.INVERSE) !== 0
: span.bg.toInts().join() === (theme.footer.actionFocusedBg as RGBA).toInts().join(),
)
.map((span) => span.text)
.join("")
.trim()
},
async settle() {
await app.renderOnce()
await app.renderOnce()
},
cleanup() {
footer.destroy()
app.renderer.destroy()
},
}
}
test.each([56, 160])("production footer confirms exit with a visible menu hint at %i columns", async (width) => {
const app = await setup()
try {
app.resize(width, 30)
app.footer.event({ type: "stream.patch", patch: { phase: "running", usage: { tokens: 14100, percent: 1 } } })
await app.settle()
expect(app.captureCharFrame()).toContain("ctrl+p menu")
app.footer.requestExit()
await app.settle()
expect(app.captureCharFrame()).toContain("Press ctrl+c again to exit")
expect(app.captureCharFrame()).not.toContain("menu")
expect(app.footer.isClosed).toBe(false)
app.footer.requestExit()
expect(app.footer.isClosed).toBe(true)
} finally {
app.cleanup()
}
})
test.each([false, true])(
"production statusline restores identity and trailing menu after resizes (mono=%s)",
async (mono) => {
const app = await setup(mono)
const theme = mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK
try {
app.footer.event({
type: "models",
providers: [
{ id: "opencode", name: "Anomaly / OpenCode", models: { "gpt-5.6-sol": { name: "GPT-5.6 Sol (50% Off)" } } },
],
})
app.footer.event({
type: "model",
model: "GPT-5.6 Sol (50% Off)",
selection: { providerID: "opencode", modelID: "gpt-5.6-sol" },
})
app.footer.event({ type: "variants", variants: ["max"], current: "max" })
app.footer.event({ type: "stream.patch", patch: { usage: { tokens: 14100, percent: 1, cost: 0.04 } } })
await app.settle()
const initial = app.captureCharFrame()
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
const statusline = app.renderer.root.findDescendantById("mini-statusline")!
const frame = app.captureCharFrame()
const row = frame.split("\n")[statusline.y].trimEnd()
const model = width === 112 ? "GPT-5.6 Sol (50% Off) [max]" : mono ? "GPT-5.6... [max]" : "GPT-5.6\u2026 [max]"
expect(row).toBe(
(width === 112
? ["Build", model, "14.1K (1%)", "$0.04", "Anomaly / OpenCode", "ctrl+p menu"]
: width === 40
? ["Build", model, "1% ctx"]
: ["Build", model]
).join(mono ? " - " : " \u00b7 "),
)
expect(statusline.height).toBe(1)
expect(app.renderer.footerHeight).toBe(height === 30 ? 4 : 2)
const identity = app.captureSpans().lines[statusline.y].spans.find((span) => span.text.includes("[max]"))!
expect(identity.text).toContain(model)
expect(identity.fg.toInts()).toEqual((theme.footer.text as RGBA).toInts())
if (width === 112) expect(frame).toBe(initial)
if (mono) expect(frame).not.toMatch(/[^\x00-\x7f]/)
}
} finally {
app.cleanup()
}
},
)
test.each([false, true])("production command menu keeps navigation visible across resizes (mono=%s)", async (mono) => {
const app = await setup(mono)
try {
await app.settle()
app.mockInput.pressKey("p", { ctrl: true })
await app.settle()
app.mockInput.pressKey("END")
await app.settle()
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height)
expect(app.captureCharFrame()).toContain("esc")
expect(app.selected()).toContain("Exit")
expect(app.footer.isClosed).toBe(false)
}
for (const [key, title] of [
["ARROW_UP", "Settings"],
["ARROW_DOWN", "Exit"],
["HOME", "Open editor"],
["ARROW_DOWN", "Show status"],
["END", "Exit"],
]) {
app.mockInput.pressKey(key)
await app.settle()
expect(app.selected()).toContain(title)
}
app.mockInput.pressEnter()
expect(app.footer.isClosed).toBe(true)
} finally {
app.cleanup()
}
})
test.each([false, true])(
"production model menu keeps labels and current state before metadata (mono=%s)",
async (mono) => {
const app = await setup(mono)
const title = "\u6a21\u578b\u6d4b\u8bd5 Alpha"
try {
app.footer.event({
type: "models",
providers: [
{
id: "provider-with-long-id",
name: "Provider with a very long name",
models: Object.fromEntries(
Array.from({ length: 22 }, (_, index) => [
"very-long-internal-model-id-" + index,
{ name: index === 0 ? title : `Model ${String(index).padStart(2, "0")}` },
]),
),
},
],
})
await app.settle()
app.mockInput.pressKey("p", { ctrl: true })
await app.settle()
await app.mockInput.typeText("switch model")
app.mockInput.pressEnter()
await app.settle()
for (const [key, text] of [
["HOME", "Model 01"],
["ARROW_DOWN", "Model 02"],
["ARROW_UP", "Model 01"],
["END", title],
]) {
app.mockInput.pressKey(key)
await app.settle()
expect(app.selected()).toContain(text)
}
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
expect(app.selected()).toContain(title)
expect(app.captureCharFrame()).toContain("esc")
}
app.resize(24, 8)
await app.settle()
expect(app.selected()).toBe(title)
expect(app.captureCharFrame()).not.toContain("very-long")
app.mockInput.pressEnter()
await app.settle()
app.mockInput.pressKey("p", { ctrl: true })
await app.settle()
await app.mockInput.typeText("switch model")
app.mockInput.pressEnter()
await app.settle()
expect(app.selected()).toContain("current")
expect(app.selected()).toContain("\u6a21\u578b\u6d4b\u8bd5")
expect(app.selected()).not.toContain("very-long")
for (const width of [16, 20, 32, 40]) {
app.resize(width, 8)
await app.settle()
expect(app.captureCharFrame()).toContain("esc")
expect(app.selected()).toContain(title)
expect(app.selected().includes("current")).toBe(width >= 24)
}
} finally {
app.cleanup()
}
},
)
test("subagent menu recalculates its twelve-row window after shrink, filter, and growth", async () => {
const app = await setup()
try {
app.footer.event({
type: "stream.subagent",
state: {
tabs: Array.from({ length: 24 }, (_, index) => ({
sessionID: `child-${index}`,
label: "Worker",
description: `Task ${String(index).padStart(2, "0")}`,
status: "running",
})),
details: {},
permissions: [],
forms: [],
},
})
await app.settle()
app.mockInput.pressKey("ARROW_DOWN")
await app.settle()
for (const [key, title] of [
["END", "Task 23"],
["ARROW_UP", "Task 22"],
["HOME", "Task 00"],
["ARROW_DOWN", "Task 01"],
["END", "Task 23"],
]) {
app.mockInput.pressKey(key)
await app.settle()
expect(app.selected()).toContain(title)
}
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height)
expect(app.selected()).toContain("Task 23")
expect(app.selected()).toContain("running")
}
await app.mockInput.typeText("Task 23")
await app.settle()
expect(app.selected()).toContain("Task 23")
app.mockInput.pressKey("u", { ctrl: true })
await app.settle()
app.mockInput.pressKey("HOME")
for (const [height, title] of [
[8, "Task 04"],
[12, "Task 08"],
[30, "Task 11"],
] as const) {
app.resize(24, height)
await app.settle()
app.mockInput.pressKey("\x1b[6~")
await app.settle()
expect(app.selected()).toContain(title)
app.mockInput.pressKey("\x1b[5~")
await app.settle()
expect(app.selected()).toContain("Task 00")
}
} finally {
app.cleanup()
}
})
test.each([false, true])("wrapped notices reserve space beside a six-line draft (mono=%s)", async (mono) => {
const app = await setup(mono)
const draft = "first\nsecond\nthird\nfourth\nfifth\nsixth"
try {
await app.settle()
app.mockInput.pasteBracketedText(draft)
app.footer.event({ type: "stream.patch", patch: { notice: "failed to save settings" } })
for (const width of [16, 24, 40]) {
app.resize(width, 8)
await app.flush()
expect(app.renderer.footerHeight).toBeLessThanOrEqual(7)
expect(app.renderer.currentFocusedEditor?.plainText).toBe(draft)
expect(app.captureCharFrame().replace(/\s+/g, " ")).toContain("failed to save settings")
expect(app.captureCharFrame()).toContain("sixth")
}
app.footer.event({ type: "stream.patch", patch: { notice: "" } })
await app.flush()
expect(app.captureCharFrame()).toContain("Build")
expect(app.renderer.currentFocusedEditor?.plainText).toBe(draft)
} finally {
app.cleanup()
}
})
test.each([false, true])("composer and autocomplete share the physical height budget (mono=%s)", async (mono) => {
const app = await setup(mono)
const draft = "first\nsecond\nthird\nfourth\nfifth\nsixth"
try {
await app.settle()
app.mockInput.pasteBracketedText(draft)
await app.flush()
await app.mockInput.typeText(" @f")
await app.settle()
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height - 1)
expect(app.captureCharFrame()).toContain("sixth @f")
expect(app.selected()).toContain(width === 24 ? "@mini/footer.view.tsx" : "footer.view.tsx")
app.mockInput.pressKey("ARROW_DOWN")
await app.settle()
expect(app.selected()).toContain("mini/")
app.mockInput.pressKey("ARROW_UP")
await app.settle()
expect(app.selected()).toContain("footer.view.tsx")
}
app.resize(24, 8)
await app.settle()
app.mockInput.pressEnter()
await app.settle()
const text = draft + " @packages/tui/src/mini/footer.view.tsx "
expect(app.renderer.currentFocusedEditor?.plainText).toBe(text)
app.mockInput.pressEnter()
await app.settle()
expect(app.prompts).toHaveLength(1)
expect(app.prompts[0].text).toBe(text)
expect(app.prompts[0].parts[0]).toMatchObject({
type: "file",
filename: "packages/tui/src/mini/footer.view.tsx",
source: {
type: "file",
path: "packages/tui/src/mini/footer.view.tsx",
text: {
start: draft.length + 1,
end: text.length - 1,
value: "@packages/tui/src/mini/footer.view.tsx",
},
},
})
app.footer.event({
type: "catalog",
agents: [],
references: [],
commands: Array.from({ length: 24 }, (_, index) => ({
name: `cmd-${String(index).padStart(2, "0")}`,
description: "Optional command description",
})),
})
await app.mockInput.typeText("/")
for (const [width, height] of sizes) {
app.resize(width, height)
await app.settle()
expect(app.renderer.footerHeight).toBeLessThanOrEqual(height - 1)
for (let index = 1; index <= 12; index++) {
app.mockInput.pressKey("ARROW_DOWN")
await app.settle()
expect(app.selected()).toContain(`/cmd-${String(index).padStart(2, "0")}`)
}
for (let index = 0; index < 12; index++) app.mockInput.pressKey("ARROW_UP")
await app.settle()
expect(app.selected()).toContain("/cmd-00")
}
} finally {
app.cleanup()
}
})
+303 -2
View File
@@ -1,6 +1,15 @@
import { expect, test } from "bun:test"
import { coalesceProgressCommit, resolveRunAgent } from "../../src/mini/footer"
import type { RunAgent, StreamCommit } from "../../src/mini/types"
import { createTestRenderer } from "@opentui/core/testing"
import { CliRenderEvents, RGBA, TextRenderable } from "@opentui/core"
import path from "node:path"
import { coalesceProgressCommit, resolveRunAgent, RunFooter } from "../../src/mini/footer"
import { createRunDemo } from "../../src/mini/demo"
import { resolveMiniSettings } from "../../src/mini/runtime.boot"
import { RUN_THEME_FALLBACK, RUN_THEME_FALLBACK_LIGHT, RUN_THEME_MONO } from "../../src/mini/theme"
import type { MiniSettingChange, MiniSettings, RunAgent, RunTuiConfig, StreamCommit } from "../../src/mini/types"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
import { tmpdir } from "../fixture/fixture"
import { createFooterApiFixture } from "./fixture/footer-api"
function progress(input: Partial<StreamCommit> = {}): StreamCommit {
return {
@@ -36,3 +45,295 @@ test("falls back only when no agent is selected", () => {
expect(resolveRunAgent(agents, "plan")?.id).toBe("plan")
expect(resolveRunAgent(agents, "missing")).toBeUndefined()
})
async function setup(
input: {
mono?: boolean
theme?: RunTuiConfig["theme"]
startup?: { version: string; detail: string }
update?: (change: MiniSettingChange) => Promise<MiniSettings>
} = {},
) {
const mono = input.mono ?? true
const app = await createTestRenderer({
width: 112,
height: 24,
screenMode: "split-footer",
footerHeight: 4,
externalOutputMode: "capture-stdout",
})
const footer = new RunFooter(app.renderer, {
directory: () => "/project",
findFiles: async () => [],
agents: [{ id: "build", name: "Build", mode: "primary", hidden: false }],
references: [],
agent: "build",
modelLabel: "GPT-5",
model: undefined,
variant: undefined,
first: true,
startup: input.startup,
wrote: !!input.startup,
theme: mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK,
tuiConfig: createTuiResolvedConfig({ theme: input.theme }),
miniSettings: {
current: { ...resolveMiniSettings(), mono },
update: input.update,
},
onPermissionReply: () => {},
onFormReply: () => {},
onFormCancel: () => {},
onEditorOpen: async () => undefined,
subscribeThemeSignal: () => () => {},
})
return { ...app, footer }
}
test.each(["timer", "output", "close", "panel"] as const)(
"startup stays editable and settles exactly once before %s",
async (finish) => {
const app = await setup({ mono: false, startup: { version: "test", detail: "/project" } })
try {
await app.renderOnce()
expect(app.renderer.root.findDescendantById("mini-startup")).toBeDefined()
const editor = app.renderer.currentFocusedEditor
await app.mockInput.typeText("draft")
expect(editor?.plainText).toBe("draft")
expect(app.externalOutput.take()).toEqual([])
if (finish === "timer") await Bun.sleep(850)
if (finish === "output")
app.footer.append({ kind: "system", text: "first output", phase: "start", source: "system" })
if (finish === "close") app.footer.close()
if (finish === "panel") app.mockInput.pressKey("p", { ctrl: true })
await app.footer.idle()
await app.renderOnce()
expect(app.renderer.root.findDescendantById("mini-startup")).toBeUndefined()
const rows = app.externalOutput.take().flatMap((event) => event.rows)
expect(rows.filter((row) => row.includes("oc mini"))).toEqual(["\u25aa oc mini vtest \u00b7 /project"])
if (finish === "output")
expect(rows.findIndex((row) => row.includes("first output"))).toBeGreaterThan(
rows.findIndex((row) => row.includes("oc mini")),
)
if (finish !== "panel") expect(app.renderer.currentFocusedEditor).toBe(editor)
app.footer.finishStartup()
await app.renderOnce()
expect(app.externalOutput.take()).toEqual([])
} finally {
app.footer.destroy()
app.renderer.destroy()
}
},
)
test("footer usage survives unrelated patches and clears when explicitly undefined", async () => {
const app = await setup()
try {
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("7.5K")
app.footer.event({ type: "stream.patch", patch: { first: false, usage: { tokens: 7_508, percent: 5 } } })
await app.renderOnce()
expect(app.captureCharFrame()).toContain("7.5K")
app.footer.event({ type: "stream.patch", patch: { model: "GPT-5.1" } })
await app.renderOnce()
expect(app.captureCharFrame()).toContain("7.5K")
app.footer.event({ type: "stream.patch", patch: { usage: undefined } })
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("7.5K")
} finally {
app.footer.destroy()
app.renderer.destroy()
}
})
test("motion demo waits for work and can be interrupted without a model call", async () => {
const footer = createFooterApiFixture()
const controller = new AbortController()
const demo = createRunDemo({ sessionID: "seed-demo", thinking: false, footer: footer.api })
let finished = false
try {
expect(demo.interrupt()).toBe(false)
const run = demo.prompt({ text: "/fmt motion", parts: [] }, controller.signal).then((handled) => {
finished = true
return handled
})
await footer.api.idle()
expect(finished).toBe(false)
expect(demo.interrupt()).toBe(true)
expect(await run).toBe(true)
expect(demo.interrupt()).toBe(false)
} finally {
controller.abort()
}
})
test.each([false, true])("command menu uses its full height on first open (mono=%s)", async (mono) => {
const app = await setup({ mono })
try {
await app.renderOnce()
app.mockInput.pressKey("p", { ctrl: true })
await app.renderOnce()
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Open editor")
expect(frame).toContain("Show status")
expect(frame).toContain("Compact session")
expect(frame).toContain("New session")
expect(frame).toContain("Skills")
} finally {
app.footer.destroy()
app.renderer.destroy()
}
})
test.each([false, true])("monochrome toggles live without replacing the footer (initial=%s)", async (mono) => {
const changes: MiniSettingChange[] = []
const app = await setup({
mono,
theme: { name: "system" },
update: async (change) => {
changes.push(change)
return { ...resolveMiniSettings(), [change.key]: change.value }
},
})
app.renderer.getPalette = async () => {
throw new Error("no OSC response")
}
const output: string[] = []
app.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, (event) => {
output.push(new TextDecoder().decode(event.snapshot.getRealCharBytes(true)))
})
try {
await app.mockInput.pressKeys(["\x1b]10;rgb:ffff/ffff/ffff\x07", "\x1b]11;rgb:0000/0000/0000\x07"])
expect(app.renderer.themeMode).toBe("dark")
await app.renderOnce()
await app.mockInput.typeText("draft")
app.mockInput.pressKey("p", { ctrl: true })
await app.renderOnce()
await app.mockInput.typeText("settings")
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
await app.mockInput.typeText("monochrome")
await app.renderOnce()
const search = app.renderer.currentFocusedRenderable
for (const next of [!mono, mono]) {
app.mockInput.pressKey("ARROW_RIGHT")
await app.flush()
await app.footer.idle()
await app.renderOnce()
expect(app.footer.currentMiniSettings().mono).toBe(next)
expect(app.footer.currentTheme()).toBe(next ? RUN_THEME_MONO : RUN_THEME_FALLBACK)
expect(app.renderer.currentFocusedRenderable).toBe(search)
expect(app.captureCharFrame()).toContain("monochrome")
expect(app.captureCharFrame()).not.toContain("restart")
app.footer.append({ kind: "user", text: "new output", phase: "start", source: "system" })
await app.footer.idle()
expect(output.at(-1)).toContain(next ? "> new output" : "\u203a new output")
app.renderer.writeToScrollback((ctx) => ({
root: new TextRenderable(ctx.renderContext, { content: "external \u2192", width: ctx.width, height: 1 }),
height: 1,
}))
expect(output.at(-1)).toContain(next ? "external ?" : "external \u2192")
}
expect(changes).toEqual([
{ key: "mono", value: !mono },
{ key: "mono", value: mono },
])
expect(output.filter((text) => text.includes("new output"))).toHaveLength(2)
app.mockInput.pressKey("c", { ctrl: true })
await app.renderOnce()
expect(app.renderer.currentFocusedEditor?.plainText).toBe("draft")
expect(app.captureCharFrame()).toContain(mono ? "| draft" : "\u2503 draft")
} finally {
app.footer.destroy()
app.renderer.destroy()
}
})
test.each([false, true])("production footer preserves wrapped input and status on resize (mono=%s)", async (mono) => {
const app = await setup({ mono })
try {
await app.renderOnce()
const draft =
"Explain how this project is organized, then outline a small change and the checks needed to verify it. Do not modify files."
await app.mockInput.typeText(draft)
for (const width of [56, 112, 40]) {
app.resize(width, 24)
await app.renderOnce()
await app.renderOnce()
expect(app.renderer.currentFocusedEditor!.plainText).toBe(draft)
const frame = app.captureCharFrame()
expect(frame.split("\n").filter((line) => line.startsWith(mono ? "| " : "┃ "))).toHaveLength(
app.renderer.currentFocusedEditor!.virtualLineCount,
)
expect(app.renderer.currentFocusedEditor!.virtualLineCount).toBeGreaterThan(1)
expect(frame).toContain("Build")
expect(frame).toContain("GPT-5")
}
} finally {
app.footer.destroy()
app.renderer.destroy()
}
})
test("explicit theme refresh reloads custom colors without a palette event", async () => {
await using tmp = await tmpdir()
const previous = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = tmp.path
const app = await setup({ mono: false, theme: { name: "mini-refresh", mode: "dark" } })
app.renderer.getPalette = async () => {
throw new Error("no OSC response")
}
try {
await app.renderOnce()
await app.mockInput.typeText("draft")
for (const color of ["#123456", "#abcdef"]) {
await Bun.write(
path.join(tmp.path, "themes", "mini-refresh.json"),
JSON.stringify({ version: 2, dark: { text: { default: color } } }),
)
await app.footer.refreshTheme()
await app.renderOnce()
expect(
app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.includes("draft"))
?.fg.toInts(),
).toEqual(RGBA.fromHex(color).toInts())
}
} finally {
app.footer.destroy()
app.renderer.destroy()
if (previous === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = previous
}
})
test("system fallback follows physical mode changes when palette queries remain unavailable", async () => {
const app = await setup({ mono: false, theme: { name: "system" } })
app.renderer.getPalette = async () => {
throw new Error("no OSC palette response")
}
try {
await app.renderOnce()
await app.mockInput.typeText("draft")
expect(app.footer.currentTheme()).toBe(RUN_THEME_FALLBACK)
await app.mockInput.pressKeys(["\x1b]10;rgb:0000/0000/0000\x07", "\x1b]11;rgb:ffff/ffff/ffff\x07"])
expect(app.renderer.themeMode).toBe("light")
await app.waitFor(() => app.footer.currentTheme() === RUN_THEME_FALLBACK_LIGHT)
await app.flush()
expect(app.footer.currentTheme()).toBe(RUN_THEME_FALLBACK_LIGHT)
expect(
app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.includes("draft"))
?.fg.toInts(),
).toEqual((RUN_THEME_FALLBACK_LIGHT.footer.text as RGBA).toInts())
} finally {
app.footer.destroy()
app.renderer.destroy()
}
})
File diff suppressed because it is too large Load Diff
+112 -1
View File
@@ -1,9 +1,120 @@
import { describe, expect, test } from "bun:test"
import { footerWidthPolicy } from "../../src/mini/footer.width"
import { footerStatuslinePolicy, footerWidthPolicy, type FooterStatuslineGroup } from "../../src/mini/footer.width"
import { stringWidth } from "../../src/util/string-width"
const screenshot = {
work: [],
model: { name: "GPT-5.6 Sol (50% Off)", variant: "max" },
agent: "Build",
context: { compact: "1% ctx", full: "14.1K (1%)" },
cost: "$0.04",
provider: "Anomaly / OpenCode",
menu: { key: "ctrl+p", label: "menu" },
} satisfies Omit<Parameters<typeof footerStatuslinePolicy>[0], "width">
describe("run footer width", () => {
test("preserves the dialog breakpoint", () => {
expect(footerWidthPolicy(79).dialog.narrow).toBe(true)
expect(footerWidthPolicy(80).dialog.narrow).toBe(false)
})
test.each([false, true])("progressive layouts fit and preserve information at every width (mono=%s)", (mono) => {
const fixtures: Array<Omit<Parameters<typeof footerStatuslinePolicy>[0], "width">> = [
{ work: [] },
screenshot,
{ ...screenshot, model: { name: "GPT-5", variant: "xhigh" } },
{ ...screenshot, model: { name: "a model name with a very long and informative suffix".repeat(3) } },
{
...screenshot,
model: { name: "\u6a21\u578b\ud83d\ude80e\u0301\u5f00\u53d1 Model".repeat(3), variant: "\u9ad8\u7ea7" },
},
{ ...screenshot, model: { name: "GPT-5", variant: "an-unusually-long-but-complete-variant" } },
{ ...screenshot, status: { text: "esc stop", expanded: "esc interrupt" }, spinner: mono ? "*" : "\u25aa" },
{
...screenshot,
status: { text: "ctrl+shift+alt+i stop", expanded: "ctrl+shift+alt+i interrupt" },
work: [
{ id: "queued", key: "ctrl+x q", label: "12 queued" },
{ id: "subagents", key: mono ? "down" : "\u2193", label: "2 sub", expanded: "2 subagents" },
{ id: "background", key: "ctrl+b", label: "bg", expanded: "background" },
],
},
{ work: [], status: { text: "Shell" }, escape: { key: "esc", label: "normal" }, context: screenshot.context },
]
for (const fixture of fixtures) {
let previous: FooterStatuslineGroup["id"][] = []
for (let width = 0; width <= 280; width++) {
const layout = footerStatuslinePolicy({ ...fixture, mono, width })
const ids = layout.groups.map((group) => group.id)
if (fixture.spinner) {
expect(ids[0]).toBe("spinner")
expect(layout.text).toStartWith(`${fixture.spinner} `)
if (width >= stringWidth(`${fixture.spinner} ${fixture.status!.text}`)) {
expect(stringWidth(layout.text)).toBeLessThanOrEqual(width)
}
}
if (stringWidth(layout.text) > width) {
expect(ids.every((id) => id === "spinner" || id === "status" || id === "escape")).toBe(true)
}
for (const id of previous) expect(ids).toContain(id)
expect(new Set(ids).size).toBe(ids.length)
if (ids.includes("menu")) expect(ids.at(-1)).toBe("menu")
const identity = layout.groups.find((group) => group.id === "model")
if (identity && fixture.model?.variant) {
expect(identity.parts.map((part) => part.text).join("")).toEndWith(` [${fixture.model.variant}]`)
}
expect(layout.text).not.toContain("\ufffd")
previous = ids
}
}
})
test("identity admission cannot separate the selected variant from its model", () => {
const input = { work: [], model: { name: "GPT-5", variant: "max" } }
expect(footerStatuslinePolicy({ ...input, width: 10 }).groups).toEqual([])
expect(footerStatuslinePolicy({ ...input, width: 11 }).text).toBe("GPT-5 [max]")
})
test("allocation priority is separate from placement", () => {
expect(footerStatuslinePolicy({ ...screenshot, width: 15 }).groups.map((group) => group.id)).toEqual(["model"])
const full = footerStatuslinePolicy({ ...screenshot, width: 112 })
expect(full.text).toBe(
"Build \u00b7 GPT-5.6 Sol (50% Off) [max] \u00b7 14.1K (1%) \u00b7 $0.04 \u00b7 Anomaly / OpenCode \u00b7 ctrl+p menu",
)
})
test("a non-fitting high-priority stage does not backfill with shorter facts", () => {
const input = {
...screenshot,
work: [{ id: "queued" as const, key: "ctrl+shift+alt+q", label: "12 queued" }],
}
expect(footerStatuslinePolicy({ ...input, width: 24 }).groups).toEqual([])
expect(footerStatuslinePolicy({ ...input, width: 26 }).groups.map((group) => group.id)).toEqual(["queued"])
})
test("context and cost are distinct additions", () => {
const input = { work: [], context: screenshot.context, cost: screenshot.cost }
expect(footerStatuslinePolicy({ ...input, width: 6 }).text).toBe("1% ctx")
expect(footerStatuslinePolicy({ ...input, width: 10 }).text).toBe("14.1K (1%)")
expect(footerStatuslinePolicy({ ...input, width: 18 }).text).toBe("14.1K (1%) \u00b7 $0.04")
})
test("stop-label enhancement cannot remove identity at the former 56-column breakpoint", () => {
const input = { ...screenshot, status: { text: "esc stop", expanded: "esc interrupt" } }
for (const width of [55, 56, 57, 79, 80, 81]) {
const layout = footerStatuslinePolicy({ ...input, width })
expect(layout.text).toContain("GPT-5.6 Sol (50% Off) [max]")
expect(layout.text).toStartWith("esc stop")
}
expect(footerStatuslinePolicy({ ...input, width: 160 }).text).toStartWith("esc interrupt")
})
test("required controls use an explicit wrapping fallback without optional groups", () => {
expect(footerStatuslinePolicy({ ...screenshot, width: 16, status: { text: "ctrl+shift+alt+x exit" } }).text).toBe(
"ctrl+shift+alt+x exit",
)
expect(
footerStatuslinePolicy({ ...screenshot, width: 80, status: { text: "Please confirm\nExit now?" } }).text,
).toBe("Please confirm\nExit now?")
})
})
@@ -43,6 +43,7 @@ describe("run runtime boot", () => {
turn_summary: "show",
footer: "show",
splash: "show",
work_spinner: "block-soft-slide",
mono: false,
})
expect(
@@ -53,6 +54,7 @@ describe("run runtime boot", () => {
turn_summary: "hide",
footer: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
},
}),
@@ -62,6 +64,7 @@ describe("run runtime boot", () => {
turn_summary: "hide",
footer: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
})
})
@@ -30,6 +30,37 @@ describe("run runtime queue", () => {
expect(calls).toBe(0)
})
test("runs and queues image-only prompts without optimistic image rows", async () => {
const ui = createFooterApiFixture()
const active = Promise.withResolvers<void>()
const queued = Promise.withResolvers<RunPrompt>()
const prompt: RunPrompt = {
text: "",
parts: [{ type: "file", url: "data:image/png;base64,cG5n", mime: "image/png", filename: "image.png" }],
}
const task = runPromptQueue({
footer: ui.api,
run: async (input, _signal, admitted) => {
expect(input.parts).toEqual(prompt.parts)
admitted()
await active.promise
},
admit: async (input, delivery) => {
expect(delivery).toBe("queue")
queued.resolve(input)
},
settle: async () => ui.api.close(),
})
ui.submit({ ...prompt, mode: "shell" })
ui.submit(prompt)
ui.submit(prompt)
expect((await queued.promise).parts).toEqual(prompt.parts)
expect(ui.commits).toEqual([])
active.resolve()
await task
})
test("treats /exit as a close command", async () => {
const ui = createFooterApiFixture()
let calls = 0
+1
View File
@@ -21,6 +21,7 @@ function ok<T>(data: T) {
function host(): MiniHost {
return {
version: "local",
terminal: { stdin: process.stdin },
platform: "linux",
stdout: { write() {} },
@@ -0,0 +1,210 @@
import { afterEach, expect, test } from "bun:test"
import {
CliRenderEvents,
ImageRenderable,
TextRenderable,
type NativeImage,
type ScrollbackSurface,
} from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
import { RUN_THEME_FALLBACK, RUN_THEME_MONO } from "../../src/mini/theme"
import type { StreamCommit } from "../../src/mini/types"
import { diffImageFixture } from "../fixture/diff-image"
const wide = `data:image/png;base64,${Buffer.from(diffImageFixture).toString("base64")}`
// A 48 x 192 PNG with four colored quadrants.
const tall =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAADACAIAAADN8nfJAAAA+klEQVR4nO3bUQ0AIAxDwUmZEETMf+YFTDR8XYIAQmBs7WvdM5HVoVU25ITcIa9MHVIYfR0+V+2HBk3HqKc2dZjLDIpGaWIDOYZgRUGjMVJh6dSEc9YC84U9xcDjKPJcudJ8eyAB1AKMAtcBNCGsMGgoPRwjsBJ6Cs6FLwO8EeeYfKkFuQ5BE1EcYaU/ca6dyOrQsiEn5A55ZeqQwujr8LlqPzRoOkY9tanDXGZQNEoTG8gxBCsKGo2RCkunJpyzFpgv7CkGHkeR58qV5tsDCaAWYBS4DqAJYYVBQ+nhGIGV0FNwLnwZ4I04x+RLLch1CJqI4ggr7Y841wMtbiD+34CxQgAAAABJRU5ErkJggg=="
type Preview = {
surface: ScrollbackSurface
image: ImageRenderable
native: NativeImage | null
size: { width: number; height: number }
x: number
y: number
captionHeight: number
}
const cleanups: Array<() => void> = []
afterEach(() => {
cleanups.splice(0).forEach((cleanup) => cleanup())
})
async function setup(
options: { width?: number; height?: number; footerHeight?: number; imagePreview?: boolean; mono?: boolean } = {},
) {
const renderer = await createTestRenderer({
width: options.width ?? 80,
height: options.height ?? 24,
footerHeight: options.footerHeight ?? 6,
screenMode: "split-footer",
externalOutputMode: "capture-stdout",
})
const out = {
...renderer,
scrollback: new RunScrollbackStream(renderer.renderer, options.mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK, options),
previews: [] as Preview[],
}
cleanups.push(() => {
out.scrollback.destroy()
out.renderer.destroy()
})
out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, () => {
const surface = Reflect.get(out.scrollback, "imageSurface") as ScrollbackSurface | undefined
if (!surface || out.previews.some((preview) => preview.surface === surface)) return
const image = surface.root.getChildren().find((child) => child instanceof ImageRenderable)
const caption = surface.root.getChildren().find((child) => child instanceof TextRenderable)
if (!image || !caption) throw new Error("Missing image or caption on image surface")
out.previews.push({
surface,
image,
native: image.image,
size: { width: image.width, height: image.height },
x: image.x,
y: image.y,
captionHeight: caption.height,
})
})
return out
}
function image(source = wide, text = "landscape.png", partID = "image-1"): StreamCommit {
return { kind: "user", source: "system", phase: "final", messageID: "message-1", partID, text, image: source }
}
test.each([
{
name: "wide",
image: wide,
width: 40,
height: 30,
size: { width: 40, height: 10 },
},
{
name: "tall",
image: tall,
width: 80,
height: 24,
size: { width: 9, height: 17 },
},
])("fits a $name image at the left edge and commits only its fitted rows", async (options) => {
const out = await setup({ ...options, imagePreview: true })
const listeners = out.renderer.listenerCount(CliRenderEvents.DESTROY)
await out.scrollback.append(image(options.image))
expect(out.previews).toHaveLength(1)
const preview = out.previews[0]!
expect(preview.size).toEqual(options.size)
expect(preview.x).toBe(0)
expect(preview.y).toBe(preview.captionHeight)
const commits = out.externalOutput.take()
expect(commits).toHaveLength(1)
expect(commits[0]!.rows[0]).toBe("\u203a landscape.png")
expect(commits[0]!.height).toBe(options.size.height + preview.captionHeight)
expect(preview.surface.isDestroyed).toBe(true)
expect(preview.image.isDestroyed).toBe(true)
expect(preview.image.image).toBeNull()
expect(() => preview.native!.info()).toThrow()
expect(out.renderer.listenerCount(CliRenderEvents.DESTROY)).toBe(listeners)
})
test("reserves wrapped caption rows and allows caption-only output in a short terminal", async () => {
const out = await setup({ width: 20, height: 10, footerHeight: 6, imagePreview: true })
await out.scrollback.append(image(tall, "first caption wraps here\nsecond caption line"))
expect(out.previews[0]!.captionHeight).toBe(3)
expect(out.previews[0]!.size.height).toBe(1)
expect(out.externalOutput.take()[0]!.height).toBe(4)
out.resize(20, 8)
await out.scrollback.append(image(wide, "first caption wraps here\nsecond caption line", "image-2"))
expect(out.previews[1]!.image.visible).toBe(false)
expect(out.externalOutput.take().at(-1)!.height).toBe(3)
})
test("uses physical cell geometry without enlarging a small image", async () => {
const out = await setup({ imagePreview: true })
Reflect.set(out.renderer, "_resolution", { width: 800, height: 720 })
await out.scrollback.append(image(wide))
expect(out.previews[0]!.size).toEqual({ width: 6, height: 1 })
expect(out.previews[0]!.x).toBe(0)
})
test("finishes active text before every image and renders more than three images in order", async () => {
const out = await setup({ imagePreview: true })
const first = { ...image(wide, "image 1"), kind: "tool", source: "tool", tool: "read" } as const
await out.scrollback.append({ ...first, image: undefined, tool: "shell", phase: "progress", text: "before images" })
expect(out.externalOutput.take()).toEqual([])
await out.scrollback.append(first)
for (const index of [2, 3, 4, 5]) {
await out.scrollback.append({ ...first, partID: `image-${index}`, text: `image ${index}`, image: tall })
}
await out.scrollback.append({ kind: "system", source: "system", phase: "final", text: "after images" })
await out.scrollback.complete()
expect(out.previews).toHaveLength(5)
expect(
out.externalOutput
.take()
.filter((commit) => commit.text.trim())
.map((commit) => commit.rows[0]),
).toEqual(["before images", "image 1", "image 2", "image 3", "image 4", "image 5", "after images"])
expect(out.previews.every((preview) => preview.image.isDestroyed && preview.surface.isDestroyed)).toBe(true)
})
test.each([{}, { imagePreview: false }, { imagePreview: true, mono: true }])(
"keeps user and tool captions without loading images when previews are disabled or monochrome (%j)",
async (options) => {
const out = await setup(options)
const commit = image("data:image/png;base64,AQID", "attachment.png")
await out.scrollback.append(commit)
await out.scrollback.append({ ...commit, kind: "tool", source: "tool", tool: "read", partID: "image-2" })
expect(out.previews).toEqual([])
expect(
out.externalOutput
.take()
.filter((commit) => commit.text.trim())
.map((commit) => commit.rows[0]),
).toEqual([`${options.mono ? ">" : "\u203a"} attachment.png`, "attachment.png"])
},
)
test("prints a failed decode caption and continues with text", async () => {
const out = await setup({ imagePreview: true })
await expect(out.scrollback.append(image("data:image/png;base64,AQID", "broken.png"))).resolves.toBeUndefined()
await out.scrollback.append({ kind: "system", source: "system", phase: "final", text: "still running" })
const output = out.externalOutput.takeText()
expect(output).toContain("\u203a broken.png\nNo preview")
expect(output).toContain("still running")
expect(out.previews[0]!.image.loadError).not.toBeNull()
expect(out.previews[0]!.image.isDestroyed).toBe(true)
expect(out.previews[0]!.surface.isDestroyed).toBe(true)
})
test("remeasures at load completion", async () => {
const out = await setup({ imagePreview: true })
const pending = out.scrollback.append(image(tall))
await Promise.resolve()
out.resize(40, 18)
await pending
expect(out.previews[0]!.size).toEqual({ width: 6, height: 11 })
})
test.each(["stream", "renderer"])("disposes an image load when the %s is destroyed", async (owner) => {
const out = await setup({ imagePreview: true })
const pending = out.scrollback.append(image())
await Promise.resolve()
const surface = Reflect.get(out.scrollback, "imageSurface") as ScrollbackSurface
const preview = surface.root.getChildren().find((child) => child instanceof ImageRenderable)!
expect(preview.loading).toBe(true)
if (owner === "stream") out.scrollback.destroy()
if (owner === "renderer") out.renderer.destroy()
await expect(pending).resolves.toBeUndefined()
expect(surface.isDestroyed).toBe(true)
expect(preview.isDestroyed).toBe(true)
expect(preview.image).toBeNull()
expect(out.externalOutput.take()).toEqual([])
})
@@ -1,11 +1,12 @@
import { afterEach, expect, test } from "bun:test"
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { CliRenderEvents, MarkdownRenderable, RGBA, SyntaxStyle, TextRenderable } from "@opentui/core"
import { CliRenderEvents, MarkdownRenderable, RGBA, SyntaxStyle, TextAttributes, TextRenderable } from "@opentui/core"
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { monoSnapshot } from "../../src/mini/mono"
import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
import { entryLook } from "../../src/mini/scrollback.shared"
import { entryGroupKey } from "../../src/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
import { RUN_THEME_FALLBACK, RUN_THEME_MONO, type RunTheme } from "../../src/mini/theme"
import type { StreamCommit } from "../../src/mini/types"
import { canonicalToolPart } from "./fixture/tool-part"
@@ -213,6 +214,64 @@ test("theme swaps preserve streamed markdown parser state", async () => {
}
})
test.each([false, true])("monochrome switches preserve printed blocks and open fences (initial=%s)", async (mono) => {
const out = await setup()
try {
await out.scrollback.setMono(mono)
out.scrollback.setTheme(mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK)
await out.scrollback.append(assistant('Printed block\n\n```ts\nconst arrow = "'))
const printed = claim(out.renderer)
try {
expect(render(printed)).toContain("Printed block")
expect(render(printed)).not.toContain("const arrow")
await out.scrollback.setMono(!mono)
out.scrollback.setTheme(mono ? RUN_THEME_FALLBACK : RUN_THEME_MONO)
expect(render(claim(out.renderer))).toBe("")
await out.scrollback.append(assistant('\u2192"\n```\n\nNext block'))
await out.scrollback.complete()
const next = claim(out.renderer)
try {
expect(render(next)).toContain(mono ? 'const arrow = "\u2192"' : 'const arrow = "->"')
expect(render(next)).toContain("Next block")
expect(render(next)).not.toContain("Printed block")
expect(render(next)).not.toContain("```")
expect(render(printed)).toContain("Printed block")
} finally {
destroy(next)
}
} finally {
destroy(printed)
}
} finally {
out.scrollback.destroy()
destroy(claim(out.renderer))
}
})
test.each([false, true])(
"monochrome switches finish pending reasoning without repeating it (initial=%s)",
async (mono) => {
const out = await setup()
const output: string[] = []
out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, (event) => {
output.push(decoder.decode(event.snapshot.getRealCharBytes(true)))
})
try {
await out.scrollback.setMono(mono)
await out.scrollback.append(reasoning("Before switch"))
await out.scrollback.setMono(!mono)
expect(output.join("")).toContain("Before switch")
await out.scrollback.append(reasoning(" after switch"))
await out.scrollback.complete()
expect(output.join("").match(/Before switch/g)).toHaveLength(1)
expect(output.join("").match(/after switch/g)).toHaveLength(1)
} finally {
out.scrollback.destroy()
destroy(claim(out.renderer))
}
},
)
test("renders monochrome scrollback as ASCII markdown", async () => {
const out = await setup({ mono: true, width: 60 })
const output: string[] = []
@@ -370,6 +429,22 @@ function toolCommit(input: {
}
}
test("entry looks preserve semantic colors without dimming and keep errors bold", () => {
const theme = {
...RUN_THEME_FALLBACK.entry,
system: { body: "#123456" },
reasoning: { body: "#abcdef" },
}
expect(entryLook(reasoning("Thinking: next steps"), theme)).toEqual({ fg: theme.reasoning.body })
expect(entryLook(reasoning("", "final"), theme)).toEqual({ fg: theme.system.body })
expect(entryLook(error("failed"), theme)).toEqual({ fg: theme.error.body, attrs: TextAttributes.BOLD })
expect(entryLook(toolCommit({ tool: "shell", phase: "final", toolState: "error" }), theme)).toEqual({
fg: theme.error.body,
attrs: TextAttributes.BOLD,
})
})
test("scopes repeated tool part IDs to their assistant messages", () => {
const first = toolCommit({
tool: "read",
@@ -133,6 +133,50 @@ describe("run session shared", () => {
expect(out[0]?.parts[0]).not.toBe(parts[0])
})
test("keeps image-only history and preserves inline attachment metadata and skills", () => {
const message = userMessage("msg_image", "", {
files: [
{
data: "cG5n",
mime: "image/png",
source: { type: "inline" },
name: "image.png",
description: "Diagram",
},
{
data: "c2VydmVy",
mime: "image/png",
source: { type: "uri", uri: "file:///remote/image.png" },
},
],
skills: [{ id: "effect", name: "Effect", mention: { text: "@effect", start: 0, end: 7 } }],
})
expect(sessionHistory(createSession([message]))).toEqual([
{
text: "",
parts: [
{
type: "file",
url: "data:image/png;base64,cG5n",
mime: "image/png",
filename: "image.png",
description: "Diagram",
source: undefined,
},
{
type: "file",
url: "file:///remote/image.png",
mime: "image/png",
filename: undefined,
source: undefined,
},
{ type: "skill", id: "effect", source: { value: "@effect", start: 0, end: 7 } },
],
},
])
})
test("returns the latest matching variant for the active model", () => {
const session: RunSession = {
first: false,
+196
View File
@@ -0,0 +1,196 @@
import { expect, test } from "bun:test"
import { RGBA, TextAttributes, type ScrollbackWriter } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { entrySplash, entrySplashLayout, exitSplash } from "../../src/mini/splash"
import { stringWidth } from "../../src/util/string-width"
const preview = "1.18.4-preview.abcd1234567890"
const marker = "▪"
const sessionID = "ses_fac1eb1b0ffeOk15TDttH2E1Oy"
const theme = {
left: RGBA.fromIndex(8, "#666666"),
right: RGBA.defaultForeground("#cccccc"),
leftShadow: RGBA.defaultBackground("#111111"),
}
async function renderSplash(writer: ScrollbackWriter, width: number) {
const app = await createTestRenderer({ width, height: 8, footerHeight: 4 })
try {
const snapshot = writer({
width,
widthMethod: app.renderer.widthMethod,
tailColumn: 0,
renderContext: app.renderer,
})
app.renderer.root.add(snapshot.root)
await app.renderOnce()
const frame = app
.captureCharFrame()
.split("\n")
.map((row) => row.trimEnd())
const rows = frame.slice(0, snapshot.height)
const spans = app.captureSpans().lines.flatMap((line) => line.spans)
expect(frame.slice(snapshot.height).every((row) => row === "")).toBe(true)
expect(rows[0]).toBe("")
expect(spans.every((span) => !(span.attributes & TextAttributes.DIM))).toBe(true)
return { rows, spans }
} finally {
app.renderer.destroy()
}
}
test.each([
{ width: 80, mono: false, detail: "~/src/wt/oc-mini-v2", metadata: " vlocal · ~/src/wt/oc-mini-v2" },
{ width: 80, mono: true, detail: "~/src/wt/oc-mini-v2", metadata: " vlocal - ~/src/wt/oc-mini-v2" },
{ width: 20, mono: false, detail: "/home/研究/長いディレクトリ/画面/設定/界e\u0301🙂", metadata: " · 界e\u0301🙂" },
])("entry renders metadata with distinct foreground roles (%o)", async (input) => {
const result = await renderSplash(entrySplash({ ...input, version: "local", theme }), input.width)
const label = `${input.mono ? "[O]" : marker} oc mini`
expect(result.rows).toEqual(["", label + input.metadata])
const labelStyle = result.spans.find((span) => span.text === label)
expect(labelStyle?.width).toBe(stringWidth(label))
expect(labelStyle?.fg.intent).toBe("default")
expect(labelStyle?.fg.toInts()).toEqual(theme.right.toInts())
const metadata = result.spans.find((span) => span.fg.intent === "indexed")
expect(metadata?.width).toBe(stringWidth(input.metadata))
expect(metadata?.fg.toInts()).toEqual(theme.left.toInts())
expect(result.spans.every((span) => !(span.attributes & TextAttributes.BOLD))).toBe(true)
})
test.each([
{ width: 20, mono: false, version: "local", expected: `${marker} oc mini` },
{ width: 22, mono: false, version: "local", expected: `${marker} oc mini · oc-mini-v2` },
{ width: 29, mono: false, version: "local", expected: `${marker} oc mini vlocal · oc-mini-v2` },
{ width: 31, mono: false, version: "local", expected: `${marker} oc mini vlocal · …/oc-mini-v2` },
{ width: 20, mono: false, version: preview, expected: `${marker} oc mini` },
{ width: 24, mono: false, version: preview, expected: `${marker} oc mini · oc-mini-v2` },
{ width: 24, mono: true, version: preview, expected: "[O] oc mini - oc-mini-v2" },
{ width: 32, mono: false, version: preview, expected: `${marker} oc mini · oc-mini-v2` },
])("entry progressively admits the basename, whole version, and parent directories (%o)", (input) => {
const layout = entrySplashLayout({ ...input, detail: "~/src/wt/oc-mini-v2" })
expect(layout.label + layout.metadata).toBe(input.expected)
})
test.each([undefined, "", "/", "~/", "C:\\projects\\mini\\"])(
"entry handles absent and root locations (%s)",
(detail) => {
const layout = entrySplashLayout({ width: 80, version: "local", detail })
expect(layout.metadata).toBe(" vlocal" + (detail ? ` · ${detail}` : ""))
},
)
test.each([false, true])("entry layout preserves admitted information at every width (mono=%s)", (mono) => {
const suffix = (value: string) =>
value
.replace(/^(?:…|\.\.\.)[/\\]/, "")
.replace(/[\\/]+/g, "/")
.replace(/\/$/, "")
for (const detail of [
undefined,
"",
"/",
"~/",
"x/y",
"a/b/c",
"~/src/wt/oc-mini-v2",
"C:\\projects\\mini\\",
"/home/研究/画面/界e\u0301🙂",
"~/project-directory-that-cannot-meaningfully-fit",
]) {
for (const version of ["", "local", preview]) {
let previous = entrySplashLayout({ width: 0, detail, version, mono })
for (let width = 1; width <= 160; width++) {
const layout = entrySplashLayout({ width, detail, version, mono })
expect(stringWidth(layout.label + layout.metadata)).toBeLessThanOrEqual(width)
expect(layout.version === "" || layout.version === version).toBe(true)
if (previous.version) expect(layout.version).toBe(previous.version)
if (previous.path) {
expect(layout.path).not.toBe("")
expect(suffix(layout.path)).toEndWith(suffix(previous.path))
}
if (layout.version && detail) expect(layout.path).not.toBe("")
if (layout.path) expect(suffix(detail!)).toEndWith(suffix(layout.path))
const marked = `${mono ? "[O]" : marker} oc mini`
expect(layout.label).toBe(stringWidth(marked) <= width ? marked : "oc mini".slice(0, width))
previous = layout
}
expect(previous.path).toBe(detail ?? "")
expect(previous.version).toBe(version)
}
}
})
test.each([false, true])("entry skips abbreviated paths that are longer than the full path (mono=%s)", (mono) => {
const label = `${mono ? "[O]" : marker} oc mini`
const metadata = mono ? " vlocal - a/b/c" : " vlocal · a/b/c"
expect(
entrySplashLayout({ width: stringWidth(label + metadata), version: "local", detail: "a/b/c", mono }),
).toMatchObject({ label, metadata, path: "a/b/c" })
})
test.each(["entry", "exit"])("%s commits one scrollback snapshot without reflow on resize", async (kind) => {
const writer =
kind === "entry"
? entrySplash({ version: "local", detail: "~/src/wt/oc-mini-v2", theme })
: exitSplash({ title: "Review mini layout", session_id: sessionID, theme })
const result = await renderSplash(writer, 24)
const app = await createTestRenderer({
width: 24,
height: 8,
footerHeight: 4,
screenMode: "split-footer",
externalOutputMode: "capture-stdout",
})
try {
app.renderer.writeToScrollback(writer)
await app.renderOnce()
expect(app.externalOutput.take()).toMatchObject([
{
width: 24,
height: result.rows.length,
rowColumns: 24,
rows: result.rows,
startOnNewLine: true,
trailingNewline: false,
},
])
app.resize(112, 8)
await app.renderOnce()
expect(app.externalOutput.take()).toEqual([])
} finally {
app.renderer.destroy()
}
})
test.each(
[
{ width: 16, showSession: true },
{ width: 56, showSession: true },
{ width: 80, showSession: true },
{ width: 80, showSession: false },
].flatMap((size) => [false, true].map((mono) => ({ ...size, mono }))),
)("exit retains the complete resume command (%o)", async (input) => {
const result = await renderSplash(
exitSplash({ ...input, title: "Review mini layout", session_id: sessionID, theme }),
input.width,
)
const command = `opencode mini -s ${sessionID}`
const commandRows =
input.width >= 80 ? [result.rows[2].slice(result.rows[2].indexOf("opencode"))] : result.rows.slice(1)
const reconstructed = commandRows
.map((row, index) => (index < commandRows.length - 1 ? row.padEnd(input.width) : row))
.join("")
expect(reconstructed).toBe(command)
if (input.width >= 80) {
expect(result.rows[1].startsWith(input.mono ? "[O]" : "█▀▀█")).toBe(true)
expect(result.rows[1].includes("Session Review mini layout")).toBe(input.showSession)
expect(result.rows[2]).toContain("Continue " + command)
} else {
expect(result.rows.join("\n")).not.toMatch(/Session|Continue|Review mini layout|█|\[O\]/)
expect(result.rows).toHaveLength(1 + Math.ceil(command.length / input.width))
}
if (input.mono) expect(result.rows.join("")).not.toMatch(/[^\x20-\x7e]/)
expect(result.spans.find((span) => span.text.includes("opencode"))?.fg.intent).toBe("default")
expect(result.spans.find((span) => span.text.includes("opencode"))?.fg.toInts()).toEqual(theme.right.toInts())
})
@@ -9,6 +9,8 @@ import {
type MessageListOutput,
type OpenCodeClient,
type PermissionRequest,
type SessionInboxInfo,
type ToolContent,
} from "@opencode-ai/client/promise"
import { createSessionTransport } from "../../src/mini/stream-v2.transport"
import { entryBody } from "../../src/mini/entry.body"
@@ -98,6 +100,15 @@ function footer() {
type SessionMessages = MessageListOutput["data"]
const image = {
data: "cG5n",
mime: "image/png",
source: { type: "inline" },
name: "image.png",
description: "Diagram",
mention: { start: 5, end: 14, text: "[Image 1]" },
} as const
function compaction(status: "running" | "completed", summary: string): SessionMessages[number] {
const message = {
id: "msg_compaction",
@@ -217,6 +228,175 @@ afterEach(() => {
})
describe("V2 mini transport", () => {
test.each(["before", "after"])("renders materialized prompt images when delivery is %s the ack", async (order) => {
const events = feed()
events.push(connected())
const requested = defer()
const ack = defer()
const admitted = defer()
const idle = defer()
const messages: SessionMessages = []
const client = sdk({ streams: [events], messages: { ses_1: messages }, wait: () => idle.promise })
const ui = footer()
const live: StreamCommit[] = []
const files = [
{ ...image, data: "c2VydmVy", name: "remote.png", source: { type: "uri" as const, uri: "file:///remote.png" } },
image,
]
const pending = {
id: "msg_prompt",
sessionID: "ses_1",
type: "user",
payload: { text: "look [Image 1]", files },
delivery: "steer",
timeCreated: 1,
} satisfies SessionInboxInfo
const prompt = spyOn(client.session, "prompt").mockImplementation(() => {
requested.resolve()
return ack.promise.then(() => pending) as never
})
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
replay: true,
footer: ui.api,
onCommit: (commit) => live.push(commit),
})
ui.api.append({ kind: "user", source: "system", text: pending.payload.text, messageID: pending.id, phase: "start" })
const turn = transport.runPromptTurn(
{
agent: undefined,
model: undefined,
variant: undefined,
prompt: {
messageID: pending.id,
text: pending.payload.text,
parts: [
{
type: "file",
url: "data:image/png;base64,cG5n",
filename: image.name,
mime: image.mime,
description: image.description,
source: { type: "file", text: { start: 5, end: 14, value: "[Image 1]" } },
},
],
},
files: [{ type: "file", url: "file:///remote.png", filename: "remote.png", mime: "image/png" }],
includeFiles: true,
},
admitted.resolve,
)
await requested.promise
if (order === "after") {
ack.resolve()
await admitted.promise
}
expect(ui.commits.filter((commit) => commit.image)).toEqual([])
events.push({
id: "evt_delivered",
created: 1,
type: "session.inbox.delivered",
durable: durable("ses_1"),
data: { sessionID: "ses_1", inboxID: pending.id },
})
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.status === "waiting for assistant"))
await Bun.sleep(0)
ack.resolve()
await admitted.promise
expect(prompt).toHaveBeenCalledWith(
expect.objectContaining({
files: [
{ uri: "file:///remote.png", name: "remote.png" },
{
uri: "data:image/png;base64,cG5n",
name: image.name,
description: image.description,
mention: image.mention,
},
],
}),
expect.anything(),
)
const images = ui.commits.filter((commit) => commit.image)
expect(images).toEqual([
{
kind: "user",
source: "system",
text: "remote.png",
image: "data:image/png;base64,c2VydmVy",
messageID: pending.id,
partID: "image:0",
phase: "final",
},
{
kind: "user",
source: "system",
text: image.name,
image: "data:image/png;base64,cG5n",
messageID: pending.id,
partID: "image:1",
phase: "final",
},
])
messages.push({ id: pending.id, type: "user", ...pending.payload, time: { created: 1 } })
idle.resolve()
await turn
expect(ui.commits.filter((commit) => commit.image)).toEqual(images)
expect(ui.commits.filter((commit) => commit.kind === "user" && !commit.image)).toHaveLength(1)
await transport.replayOnResize({
localRows: () => live.map((commit) => ({ commit })),
reset: async () => {
ui.commits.length = 0
},
})
expect(ui.commits.filter((commit) => commit.image)).toEqual(images)
await transport.close()
})
test.each([true, false])(
"preserves visible image identity and suppresses unreplayed images with replay=%s",
async (replay) => {
const events = feed()
events.push(connected())
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
streams: [events],
messages: {
ses_1: [
{
id: "msg_images",
type: "user",
text: "",
files: [
image,
image,
{ ...image, description: "Another diagram" },
{ ...image, source: { type: "uri", uri: "file:///image.png" } },
],
time: { created: 1 },
},
],
},
}),
sessionID: "ses_1",
thinking: false,
replay,
footer: ui.api,
})
await transport.waitForIdle()
expect(ui.commits).toHaveLength(replay ? 3 : 0)
expect(ui.commits.every((commit) => commit.image === "data:image/png;base64,cG5n")).toBe(true)
expect(new Set(ui.commits.map((commit) => commit.partID)).size).toBe(replay ? 3 : 0)
await transport.close()
},
)
test("renders projected compactions as labeled transcript boundaries", async () => {
const events = feed()
events.push(connected())
@@ -307,7 +487,7 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("formats footer usage with compact tokens and context percentage", async () => {
test("preserves numeric footer tokens, context percentage, and cost-only usage", async () => {
const events = feed()
events.push(connected())
const ui = footer()
@@ -346,7 +526,26 @@ describe("V2 mini transport", () => {
})
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.usage)) await Bun.sleep(0)
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { usage: "7.5K (5%)" } })
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { usage: { tokens: 7_508, percent: 5 } } })
events.push({
id: "evt_cost_only",
created: 3,
type: "session.step.ended",
durable: durable("ses_1", 3),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_cost_only",
finish: "stop",
cost: 0.1234,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.usage?.cost)) await Bun.sleep(0)
expect(ui.events).toContainEqual({
type: "stream.patch",
patch: { usage: { tokens: 0, percent: undefined, cost: 0.1234 } },
})
await transport.close()
})
@@ -669,6 +868,7 @@ describe("V2 mini transport", () => {
type: "user",
payload: {
text: "follow up",
files: [image],
skills: [
{ id: "effect", name: "Effect", text: "Use Effect services" },
{ id: "effect", name: "Effect" },
@@ -681,7 +881,7 @@ describe("V2 mini transport", () => {
sessionID: "ses_1",
timeCreated: 2,
type: "user",
payload: { text: "remove me" },
payload: { text: "remove me", files: [image] },
delivery: "queue",
},
],
@@ -717,6 +917,7 @@ describe("V2 mini transport", () => {
expect.objectContaining({ kind: "user", text: "follow up" }),
])
expect(pending()).toEqual([["msg_cancelled", "queue"]])
expect(ui.commits.some((commit) => commit.image)).toBe(false)
events.push({
id: "evt_queued",
created: 4,
@@ -746,7 +947,10 @@ describe("V2 mini transport", () => {
data: { sessionID: "ses_1", inboxID: "msg_queued" },
})
while (pending()?.length !== 0) await Bun.sleep(0)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(2)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(3)
expect(ui.commits.filter((commit) => commit.image)).toEqual([
expect.objectContaining({ kind: "user", messageID: "msg_queued", image: "data:image/png;base64,cG5n" }),
])
const prompt = spyOn(client.session, "prompt").mockImplementation(
(request) => ok(promptAdmission(request)) as never,
)
@@ -1277,10 +1481,12 @@ describe("V2 mini transport", () => {
const second = feed()
first.push(connected("evt_connected_1"))
second.push(connected("evt_connected_2"))
const idle = defer()
let running = true
let projected = false
const client = sdk({
streams: [first, second],
wait: () => idle.promise,
active: () => {
const active: Record<string, { type: "running" }> = {}
if (running) active.ses_1 = { type: "running" }
@@ -1295,7 +1501,7 @@ describe("V2 mini transport", () => {
id: "msg_prompt",
type: "user",
text: "hello",
files: [],
files: [image],
agents: [],
time: { created: 2 },
},
@@ -1332,9 +1538,16 @@ describe("V2 mini transport", () => {
projected = true
running = false
first.close()
while (!ui.commits.some((commit) => commit.image)) await Bun.sleep(0)
idle.resolve()
await turn
expect(ui.commits.filter((item) => item.kind === "user" && item.messageID === "msg_prompt")).toHaveLength(1)
expect(
ui.commits.filter((item) => item.kind === "user" && !item.image && item.messageID === "msg_prompt"),
).toHaveLength(1)
expect(ui.commits.filter((item) => item.image)).toEqual([
expect.objectContaining({ kind: "user", messageID: "msg_prompt", image: "data:image/png;base64,cG5n" }),
])
await transport.close()
})
@@ -2209,6 +2422,114 @@ describe("V2 mini transport", () => {
await transport.close()
})
test.each(["completed", "error"] as const)(
"renders only terminal inline tool images for %s results across resize",
async (status) => {
const events = feed()
events.push(connected())
const messages: SessionMessages = []
const client = sdk({ streams: [events], messages: { ses_1: messages } })
const ui = footer()
const live: StreamCommit[] = []
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
replay: true,
footer: ui.api,
onCommit: (commit) => live.push(commit),
})
const content = [
{ type: "text", text: "Captured screenshot" },
{ type: "file", mime: "image/png", uri: "data:image/png;base64,cG5n" },
{ type: "file", mime: "image/png", uri: "https://example.com/remote.png" },
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,dGV4dA==" },
{ type: "file", mime: "application/pdf", uri: "data:image/png;base64,cG5n" },
] satisfies [ToolContent, ...ToolContent[]]
const error = { type: "unknown", message: "Tool failed after capturing image" } as const
const data = { sessionID: "ses_1", assistantMessageID: "msg_tool", id: "call_image", executed: true, content }
const terminal: RunV2Event =
status === "completed"
? { id: "evt_result", created: 3, type: "session.tool.success", durable: durable("ses_1", 3, 2), data }
: {
id: "evt_result",
created: 3,
type: "session.tool.failed",
durable: durable("ses_1", 3, 2),
data: { ...data, error },
}
events.push({
id: "evt_input",
created: 1,
type: "session.tool.input.started",
durable: durable("ses_1", 1),
data: { sessionID: "ses_1", assistantMessageID: "msg_tool", id: "call_image", name: "read" },
})
events.push({
id: "evt_called",
created: 2,
type: "session.tool.called",
durable: durable("ses_1", 2),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_tool",
id: "call_image",
input: { file_path: "image.png" },
executed: true,
},
})
while (!ui.commits.some((commit) => commit.toolState === "running")) await Bun.sleep(0)
expect(ui.commits.some((commit) => commit.image)).toBe(false)
events.push(terminal)
events.push(terminal)
while (!ui.commits.some((commit) => commit.image)) await Bun.sleep(0)
const images = ui.commits.filter((commit) => commit.image)
expect(images).toEqual([
{
kind: "tool",
source: "tool",
text: "[Image 1]",
image: "data:image/png;base64,cG5n",
messageID: "msg_tool",
partID: "prt_call_image:image:0",
phase: "final",
},
])
const message = {
id: "msg_tool",
type: "assistant" as const,
agent: "build",
model: { providerID: "test", id: "model" },
content: [
canonicalToolPart(
"read",
status === "completed" ? { status, input: {}, content } : { status, input: {}, content, error },
"call_image",
),
],
time: { created: 1 },
}
messages.push({ ...message, content: [] })
await transport.replayOnResize({
localRows: () => live.map((commit) => ({ commit })),
reset: async () => {
ui.commits.length = 0
},
})
expect(ui.commits.filter((commit) => commit.image)).toEqual(images)
messages[0] = message
await transport.replayOnResize({
localRows: () => live.map((commit) => ({ commit })),
reset: async () => {
ui.commits.length = 0
},
})
expect(ui.commits.filter((commit) => commit.image)).toEqual(images)
await transport.close()
},
)
test("waits for the attempted web search provider before rendering its title", async () => {
const events = feed()
events.push(connected())
@@ -3300,7 +3621,10 @@ describe("V2 mini transport", () => {
id: "call_child_shell",
error: { type: "unknown", message: "child boom" },
metadata: { checkpoint: "child" },
content: [{ type: "text", text: "child partial" }],
content: [
{ type: "text", text: "child partial" },
{ type: "file", mime: "image/png", uri: "data:image/png;base64,cG5n" },
],
executed: true,
},
})
@@ -3327,8 +3651,19 @@ describe("V2 mini transport", () => {
).toMatchObject({
status: "error",
metadata: { checkpoint: "child" },
content: [{ type: "text", text: "child partial" }],
content: [
{ type: "text", text: "child partial" },
{ type: "file", mime: "image/png", uri: "data:image/png;base64,cG5n" },
],
})
expect(commits.filter((commit) => commit.image)).toEqual([
expect.objectContaining({
kind: "tool",
messageID: "msg_child_tool",
image: "data:image/png;base64,cG5n",
phase: "final",
}),
])
expect(
ui.events.find(
(event) =>
@@ -3491,7 +3826,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child",
inboxID: "msg_child_prompt",
item: { type: "user", payload: { text: "actual child prompt" }, delivery: "steer" },
item: { type: "user", payload: { text: "actual child prompt", files: [image] }, delivery: "steer" },
},
})
await Bun.sleep(0)
@@ -3517,6 +3852,84 @@ describe("V2 mini transport", () => {
)
await Bun.sleep(0)
expect(
states()
.at(-1)
?.details.ses_child?.commits.filter((commit) => commit.image),
).toEqual([
expect.objectContaining({ kind: "user", messageID: "msg_child_prompt", image: "data:image/png;base64,cG5n" }),
])
await transport.close()
})
test("hydrates child images and keeps pending attachments hidden until delivery", async () => {
const events = feed()
events.push(connected())
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({
streams: [events],
sessions: [{ id: "ses_child", parentID: "ses_1", time: { updated: 1 } }],
messages: {
ses_child: [
{ id: "msg_old_image", type: "user", text: "", files: [image, image], time: { created: 1 } },
{
id: "msg_child_tool",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [
canonicalToolPart("read", {
status: "completed",
input: {},
content: [{ type: "file", mime: "image/png", uri: "data:image/png;base64,cG5n" }],
}),
],
time: { created: 2 },
},
],
},
pending: {
ses_child: [
{
id: "msg_pending_image",
sessionID: "ses_child",
type: "user",
payload: { text: "", files: [image] },
delivery: "queue",
timeCreated: 3,
},
],
},
}),
sessionID: "ses_1",
thinking: false,
footer: ui.api,
})
const images = () =>
ui.events
.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
.at(-1)
?.details.ses_child?.commits.filter((commit) => commit.image) ?? []
transport.selectSubagent("ses_child")
while (images().length < 2) await Bun.sleep(0)
expect(
images()
.map((commit) => commit.kind)
.sort(),
).toEqual(["tool", "user"])
expect(images().some((commit) => commit.messageID === "msg_pending_image")).toBe(false)
const delivered: RunV2Event = {
id: "evt_child_image_delivered",
created: 3,
type: "session.inbox.delivered",
durable: durable("ses_child", 3),
data: { sessionID: "ses_child", inboxID: "msg_pending_image" },
}
events.push(delivered)
events.push(delivered)
while (images().length < 3) await Bun.sleep(0)
expect(images().filter((commit) => commit.messageID === "msg_pending_image")).toHaveLength(1)
await transport.close()
})
@@ -3555,7 +3968,11 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child",
inboxID: "msg_child_race",
item: { type: "user", payload: { text: "prompt admitted before hydration" }, delivery: "steer" },
item: {
type: "user",
payload: { text: "prompt admitted before hydration", files: [image] },
delivery: "steer",
},
},
})
await Bun.sleep(0)
@@ -3581,6 +3998,13 @@ describe("V2 mini transport", () => {
)
await Bun.sleep(0)
expect(
states()
.at(-1)
?.details.ses_child?.commits.filter((commit) => commit.image),
).toEqual([
expect.objectContaining({ kind: "user", messageID: "msg_child_race", image: "data:image/png;base64,cG5n" }),
])
await transport.close()
})
+334 -132
View File
@@ -1,25 +1,56 @@
import { expect, test } from "bun:test"
import { afterAll, beforeAll, expect, test } from "bun:test"
import path from "node:path"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { RUN_THEME_MONO, RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
import { DEFAULT_THEMES } from "../../src/theme"
import { DEFAULT_THEME, resolveThemeDocument, type ResolvedTheme } from "@opencode-ai/theme/tui"
import {
RUN_THEME_MONO,
RUN_THEME_FALLBACK,
RUN_THEME_FALLBACK_LIGHT,
resolveRunTheme,
type RunTheme,
} from "../../src/mini/theme"
import { DEFAULT_THEMES, parseTheme } from "../../src/theme"
import { generateSystem } from "../../src/theme/system"
import { tmpdir } from "../fixture/fixture"
const tmp = await tmpdir()
const previousConfig = process.env.OPENCODE_CONFIG_DIR
beforeAll(() => {
process.env.OPENCODE_CONFIG_DIR = tmp.path
})
afterAll(async () => {
if (previousConfig === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = previousConfig
await tmp[Symbol.asyncDispose]()
})
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
function terminalColors(input: Partial<TerminalColors> = {}, mode: "light" | "dark" = "dark"): TerminalColors {
return {
palette: Array.from({ length: 256 }, (_, index) => input.palette?.[index] ?? palette[index % palette.length]!),
defaultBackground: input.defaultBackground ?? "#1a1b26",
defaultForeground: input.defaultForeground ?? "#c0caf5",
cursorColor: input.cursorColor ?? "#ff9e64",
mouseForeground: input.mouseForeground ?? null,
mouseBackground: input.mouseBackground ?? null,
tekForeground: input.tekForeground ?? null,
tekBackground: input.tekBackground ?? null,
highlightBackground: input.highlightBackground ?? "#33467c",
highlightForeground: input.highlightForeground ?? "#c0caf5",
palette: Array.from({ length: 256 }, (_, index) => palette[index % palette.length]!),
defaultBackground: mode === "light" ? "#fbf1c7" : "#1a1b26",
defaultForeground: mode === "light" ? "#3c3836" : "#c0caf5",
cursorColor: "#ff9e64",
mouseForeground: null,
mouseBackground: null,
tekForeground: null,
tekBackground: null,
highlightBackground: "#33467c",
highlightForeground: "#c0caf5",
...input,
}
}
const emptyColors = terminalColors({
palette: Array.from({ length: 256 }, () => null),
defaultBackground: null,
defaultForeground: null,
cursorColor: null,
highlightBackground: null,
highlightForeground: null,
})
function renderer(
input: {
themeMode?: "dark" | "light"
@@ -29,166 +60,337 @@ function renderer(
} = {},
) {
return {
themeMode: input.themeMode,
get themeMode() {
return input.themeMode
},
waitForThemeMode: async () => input.resolvedThemeMode ?? input.themeMode ?? null,
getPalette: async () => {
if (input.fail) {
throw new Error("boom")
}
if (input.fail) throw new Error("boom")
return input.colors ?? terminalColors()
},
} as CliRenderer
}
function expectRgba(color: unknown) {
function rgba(color: unknown) {
expect(color).toBeInstanceOf(RGBA)
if (!(color instanceof RGBA)) {
throw new Error("expected RGBA")
}
if (!(color instanceof RGBA)) throw new Error("expected RGBA")
return color
}
function expectIndexed(color: unknown) {
const rgba = expectRgba(color)
expect(rgba.intent).toBe("indexed")
expect(rgba.slot).toBeLessThan(256)
function expectFooter(actual: RunTheme, theme: ResolvedTheme) {
const expected = {
text: theme.text.default,
muted: theme.text.subdued,
warning: theme.text.feedback.warning.default,
error: theme.text.feedback.error.default,
actionSecondaryText: theme.contextual.elevated.text.action.secondary.default,
actionFocusedBg: theme.contextual.elevated.background.action.primary.focused,
actionFocusedText: theme.contextual.elevated.text.action.primary.focused,
formfieldText: theme.contextual.elevated.text.formfield.default,
formfieldFocusedBg: theme.contextual.elevated.background.formfield.focused,
formfieldFocusedText: theme.contextual.elevated.text.formfield.focused,
selection: theme.contextual.elevated.text.formfield.selected,
variant: theme.text.subdued,
running: theme.text.status.running,
question: theme.text.status.question,
permission: theme.text.status.permission,
success: theme.text.feedback.success.default,
link: theme.markdown.link,
statusAccent: theme.background.surface.overlay,
shade: theme.contextual.elevated.background.default,
surface: theme.contextual.elevated.background.default,
pane: theme.contextual.overlay.background.default,
border: theme.border.default,
line: theme.background.surface.overlay,
}
Object.entries(expected).forEach(([key, color]) => {
expect(rgba(actual.footer[key as keyof typeof expected]).toInts()).toEqual(color.toInts())
})
expect(rgba(actual.background).intent).toBe("default")
}
function spread(color: RGBA) {
const [r, g, b] = color.toInts()
return Math.max(r, g, b) - Math.min(r, g, b)
}
test("falls back when palette lookup fails", async () => {
expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
expect(await resolveRunTheme(renderer({ fail: true }), undefined, true)).toBe(RUN_THEME_MONO)
const light = await resolveRunTheme(renderer({ resolvedThemeMode: "light" }), undefined, true)
expect(expectRgba(light.footer.text).toInts().slice(0, 3)).toEqual([0, 0, 0])
expect(RUN_THEME_MONO.block.syntax).toBeUndefined()
test.each(["light", "dark"] as const)("preserves %s monochrome terminal defaults", async (mode) => {
const theme = await resolveRunTheme(renderer({ fail: true }), { name: "unknown", mode }, true)
expect(theme.block.syntax).toBeUndefined()
expect(rgba(theme.footer.text).toInts().slice(0, 3)).toEqual(mode === "light" ? [0, 0, 0] : [255, 255, 255])
for (const color of [
RUN_THEME_MONO.background,
...Object.values(RUN_THEME_MONO.footer),
...Object.values(RUN_THEME_MONO.splash),
...Object.values(RUN_THEME_MONO.entry).flatMap((tone) => [tone.body, tone.start].filter(Boolean)),
...Object.entries(RUN_THEME_MONO.block)
.filter(([key]) => key !== "syntax")
.map(([, value]) => value),
theme.background,
...Object.values(theme.footer).flat(),
...Object.values(theme.splash),
...Object.values(theme.entry).flatMap((tone) => [tone.body, tone.start].filter(Boolean)),
...Object.values(theme.block),
]) {
expect(expectRgba(color).intent).toBe("default")
expect(rgba(color).intent).toBe("default")
}
if (mode === "dark") expect(await resolveRunTheme(renderer(), undefined, true)).toBe(RUN_THEME_MONO)
expect(await resolveRunTheme(renderer({ fail: true, resolvedThemeMode: mode }), undefined, true)).toBe(theme)
const term = renderer({ themeMode: mode })
let queries = 0
term.getPalette = async () => {
queries++
return terminalColors()
}
expect(await resolveRunTheme(term, { mode: mode === "light" ? "dark" : "light" }, true)).toBe(theme)
expect(queries).toBe(0)
})
test.each(["light", "dark"] as const)("uses shared %s defaults and named built-in themes", async (mode) => {
const colors = terminalColors({}, mode)
for (const name of [undefined, "opencode", "tokyonight"] as const) {
const theme = await resolveRunTheme(renderer({ colors }), { name, mode })
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES[name ?? "opencode"]), mode)
try {
expectFooter(theme, expected)
expect(rgba(theme.background).toInts()).toEqual(RGBA.fromHex(colors.defaultBackground!).toInts())
expect(theme.footer.categorical.map((color) => rgba(color).toInts())).toEqual(
expected.categorical.map((scale) => scale[mode === "light" ? 800 : 200].toInts()),
)
expect(theme.block.syntax?.getAllStyles().size).toBeGreaterThan(0)
for (const color of [
theme.entry.user.body,
theme.entry.assistant.body,
theme.block.text,
...Object.values(theme.splash),
]) {
expect(rgba(color).intent).toBe("indexed")
expect(rgba(color).slot).toBeLessThan(256)
}
expect(rgba(theme.footer.text).intent).toBe("rgb")
for (const style of theme.block.syntax!.getAllStyles().values()) {
if (style.fg && style.fg.a !== 0) expect(style.fg.intent).toBe("indexed")
if (style.bg && style.bg.a !== 0) expect(style.bg.intent).toBe("indexed")
}
} finally {
theme.block.syntax?.destroy()
}
}
})
test("resolveTheme preserves Mini indexed color and result shape semantics", () => {
const item = structuredClone(DEFAULT_THEMES.opencode)
item.theme.primary = 6
delete item.theme.selectedListItemText
const theme = resolveTheme(item, "dark")
expect(theme.primary.intent).toBe("indexed")
expect(theme.primary.slot).toBe(6)
expect(theme.selectedListItemText).toBe(theme.background)
expect("_hasSelectedListItemText" in theme).toBe(false)
})
test("returns syntax styles and indexed splash colors", async () => {
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))
test.each(["light", "dark"] as const)("shares the %s system scheme while retaining terminal intent", async (mode) => {
const colors = terminalColors({
defaultBackground: mode === "light" ? "#fbf1c7" : "#0f172a",
defaultForeground: mode === "light" ? "#3c3836" : "#e2e8f0",
})
const theme = await resolveRunTheme(renderer({ colors }), { name: "system", mode })
try {
expect(theme.block.syntax).toBeDefined()
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
expectIndexed(theme.splash.left)
expectIndexed(theme.splash.right)
expectIndexed(theme.splash.leftShadow)
expectRgba(theme.footer.highlight)
expectRgba(theme.footer.statusAccent)
expectRgba(theme.footer.surface)
expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
expectFooter(theme, resolveThemeDocument(parseTheme(generateSystem(colors, mode)), mode))
expect(rgba(theme.footer.text).intent).toBe("default")
expect(rgba(theme.entry.user.body).intent).toBe("default")
expect(rgba(theme.entry.assistant.body).intent).toBe("default")
expect(theme.block.syntax?.getStyle("default")?.fg?.intent).toBe("default")
expect(rgba(theme.footer.surface).intent).toBe("rgb")
expect(rgba(theme.entry.reasoning.body).intent).toBe("indexed")
Object.values(theme.splash).forEach((color) => expect(rgba(color).intent).toBe("indexed"))
} finally {
theme.block.syntax?.destroy()
}
})
test("keeps footer surfaces exact while scrollback stays palette matched", async () => {
const colors = terminalColors({
defaultBackground: "#0f172a",
defaultForeground: "#e2e8f0",
})
const theme = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
const exact = resolveTheme(generateSystem(colors, "dark"), "dark")
test.each(["light", "dark"] as const)(
"loads %s custom files with shared partial and standalone fallback",
async (mode) => {
for (const standalone of [false, true]) {
const source = {
version: 2,
standalone,
[mode]: {
hue: DEFAULT_THEME[mode].hue,
text: {
default: "#123456",
formfield: { default: "#234567", $selected: "#345678" },
feedback: { warning: { default: "#456789" } },
},
"@context:elevated": {
text: { action: { primary: { $focused: "#56789a" } }, formfield: { $focused: "#6789ab" } },
background: { action: { primary: { $focused: "#789abc" } }, formfield: { $focused: "#89abcd" } },
},
},
}
await Bun.write(path.join(tmp.path, "themes", "mini-custom.json"), JSON.stringify(source))
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode) }), { name: "mini-custom", mode })
try {
expectFooter(theme, resolveThemeDocument(parseTheme(source), mode))
} finally {
theme.block.syntax?.destroy()
}
}
},
)
test.each(["light", "dark"] as const)(
"falls back to shared %s defaults for unknown or invalid themes",
async (mode) => {
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
for (const source of [
{ version: 2, [mode]: { categorical: [] } },
{ version: 2, [mode]: { text: { default: "$missing" } } },
undefined,
]) {
if (source) await Bun.write(path.join(tmp.path, "themes", "mini-invalid.json"), JSON.stringify(source))
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode) }), {
name: source ? "mini-invalid" : "mini-unknown",
mode,
})
try {
expectFooter(theme, expected)
} finally {
theme.block.syntax?.destroy()
}
}
},
)
test.each(["light", "dark"] as const)("resolves dark-only Aura on an automatic %s terminal", async (mode) => {
const colors = terminalColors({
defaultBackground: mode === "light" ? "#ffffff" : "#0f0f0f",
defaultForeground: mode === "light" ? "#000000" : "#edecee",
})
const theme = await resolveRunTheme(renderer({ colors }), { name: "aura" })
try {
expect(expectRgba(theme.footer.selected).toInts()).toEqual(expectRgba(exact.backgroundElement).toInts())
expect(expectRgba(theme.footer.border).toInts()).toEqual(expectRgba(exact.border).toInts())
expect(expectRgba(theme.footer.pane).toInts()).toEqual(expectRgba(exact.backgroundMenu).toInts())
expect(expectRgba(theme.footer.selected).intent).toBe("rgb")
expectFooter(theme, resolveThemeDocument(parseTheme(DEFAULT_THEMES[mode === "light" ? "opencode" : "aura"]), mode))
expect(rgba(theme.background).toInts()).toEqual(RGBA.fromHex(colors.defaultBackground!).toInts())
} finally {
theme.block.syntax?.destroy()
}
})
test("uses refreshed background brightness when cached renderer mode is stale", async () => {
const colors = terminalColors({
defaultBackground: "#fbf1c7",
defaultForeground: "#3c3836",
})
const stale = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
test.each(["light", "dark"] as const)(
"falls back only for unsupported modes of a %s-only custom theme",
async (mode) => {
const source = { version: 2, [mode]: { text: { default: "#123456" } } }
await Bun.write(path.join(tmp.path, "themes", "mini-one-mode.json"), JSON.stringify(source))
for (const requested of ["light", "dark"] as const) {
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, requested) }), {
name: "mini-one-mode",
mode: requested,
})
try {
expectFooter(
theme,
resolveThemeDocument(parseTheme(requested === mode ? source : DEFAULT_THEMES.opencode), requested),
)
} finally {
theme.block.syntax?.destroy()
}
}
},
)
test.each(["light", "dark"] as const)("handles unavailable palettes in %s mode", async (mode) => {
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
for (const input of [
{ fail: true },
{ colors: emptyColors },
{ colors: { ...emptyColors, defaultBackground: terminalColors({}, mode).defaultBackground } },
{ colors: { ...emptyColors, defaultForeground: terminalColors({}, mode).defaultForeground } },
]) {
const fallback = await resolveRunTheme(renderer(input), { name: "system", mode })
expect(fallback).toBe(mode === "light" ? RUN_THEME_FALLBACK_LIGHT : RUN_THEME_FALLBACK)
expectFooter(fallback, expected)
}
const theme = await resolveRunTheme(renderer({ fail: true }), { name: "opencode", mode })
try {
expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts())
expectFooter(theme, expected)
} finally {
stale.block.syntax?.destroy()
light.block.syntax?.destroy()
theme.block.syntax?.destroy()
}
})
test("keeps renderer mode when refreshed default background is unavailable", async () => {
const colors = {
...terminalColors(),
defaultBackground: null,
palette: ["#000000", ...terminalColors().palette.slice(1)],
}
const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
const dark = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
try {
expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts())
} finally {
light.block.syntax?.destroy()
dark.block.syntax?.destroy()
test("uses refreshed background brightness rather than stale mode or ANSI slot zero", async () => {
for (const colors of [
terminalColors({}, "light"),
terminalColors({ defaultBackground: null, palette: ["#000000", ...terminalColors().palette.slice(1)] }),
]) {
const theme = await resolveRunTheme(renderer({ themeMode: colors.defaultBackground ? "dark" : "light", colors }), {
name: "system",
})
try {
expectFooter(theme, resolveThemeDocument(parseTheme(generateSystem(colors, "light")), "light"))
} finally {
theme.block.syntax?.destroy()
}
}
})
test("keeps dark surfaces neutral on saturated backgrounds", () => {
const theme = resolveTheme(
generateSystem(
terminalColors({
defaultBackground: "#0000ff",
defaultForeground: "#ffffff",
}),
"dark",
),
"dark",
)
expect(spread(theme.backgroundPanel)).toBeLessThan(10)
expect(spread(theme.backgroundElement)).toBeLessThan(10)
test.each(["light", "dark"] as const)("follows physical %s mode over opposite configuration", async (mode) => {
const expected = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
for (const fail of [false, true]) {
const theme = await resolveRunTheme(renderer({ colors: terminalColors({}, mode), themeMode: mode, fail }), {
mode: mode === "light" ? "dark" : "light",
})
try {
expectFooter(theme, expected)
} finally {
theme.block.syntax?.destroy()
}
}
})
test("keeps light surfaces close to neutral on warm backgrounds", () => {
const theme = resolveTheme(
generateSystem(
terminalColors({
defaultBackground: "#fbf1c7",
defaultForeground: "#3c3836",
}),
test.each(["system", "opencode"])(
"prefers a new renderer mode over cached %s colors after failed probes",
async (name) => {
const colors = terminalColors()
const expected = resolveThemeDocument(
parseTheme(name === "system" ? generateSystem(colors, "light") : DEFAULT_THEMES.opencode),
"light",
),
"light",
)
)
const input = { colors, fail: false, themeMode: "dark" as "dark" | "light" }
const term = renderer(input)
const initial = await resolveRunTheme(term, { name })
initial.block.syntax?.destroy()
input.themeMode = "light"
input.colors = emptyColors
for (const fail of [true, false]) {
input.fail = fail
const theme = await resolveRunTheme(term, { name })
try {
expectFooter(theme, expected)
} finally {
theme.block.syntax?.destroy()
}
}
},
)
expect(spread(theme.backgroundPanel)).toBeLessThan(60)
expect(spread(theme.backgroundElement)).toBeLessThan(60)
test("retains a usable system palette after background-only and empty probes", async () => {
const input = { colors: terminalColors() }
const expected = resolveThemeDocument(parseTheme(generateSystem(input.colors, "dark")), "dark")
const term = renderer(input)
const initial = await resolveRunTheme(term, { name: "system" })
initial.block.syntax?.destroy()
for (const colors of [{ ...emptyColors, defaultBackground: "#202020" }, emptyColors]) {
input.colors = colors
const theme = await resolveRunTheme(term, { name: "system" })
try {
expectFooter(theme, expected)
expect(rgba(theme.footer.text).intent).toBe("default")
expect(rgba(theme.entry.reasoning.body).intent).toBe("indexed")
expect(rgba(theme.entry.reasoning.body).toInts()).toEqual(rgba(initial.entry.reasoning.body).toInts())
} finally {
theme.block.syntax?.destroy()
}
}
})
test.each(["system", "opencode"])(
"retains and refreshes the %s scrollback palette across probe failures",
async (name) => {
const input = { fail: false, colors: terminalColors() }
const term = renderer(input)
const initial = await resolveRunTheme(term, { name })
input.fail = true
const retained = await resolveRunTheme(term, { name, mode: "light" })
input.fail = false
input.colors = terminalColors({ palette: Array.from({ length: 256 }, () => "#ff00ff") })
const refreshed = await resolveRunTheme(term, { name })
try {
expect(rgba(retained.footer.surface).toInts()).toEqual(rgba(initial.footer.surface).toInts())
expect(rgba(retained.entry.reasoning.body).toInts()).toEqual(rgba(initial.entry.reasoning.body).toInts())
expect(rgba(refreshed.entry.reasoning.body).toInts()).not.toEqual(rgba(initial.entry.reasoning.body).toInts())
} finally {
initial.block.syntax?.destroy()
retained.block.syntax?.destroy()
refreshed.block.syntax?.destroy()
}
},
)
@@ -1,6 +1,14 @@
import { describe, expect, test } from "bun:test"
import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
import path from "node:path"
import { pathToFileURL } from "node:url"
import {
MAX_LOCAL_ATTACHMENT_BYTES,
parsePastedFilepaths,
readLocalAttachmentWith,
resolvePastedAttachments,
} from "../../src/component/prompt/local-attachment"
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
import { tmpdir } from "../fixture/fixture"
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
return {
@@ -81,4 +89,90 @@ describe("prompt local attachments", () => {
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
).toBeUndefined()
})
test("resolves a single image path before splitting spaces", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "one image.png")
await Bun.write(file, new Uint8Array([1, 2, 3]))
for (const input of [file, `'${file}'`, pathToFileURL(file).href]) {
expect(await resolvePastedAttachments(input, "linux")).toEqual([
{ type: "file", uri: "data:image/png;base64,AQID", filename: "one image.png" },
])
}
})
test("resolves quoted paths and URI lists as ordered attachments", async () => {
await using tmp = await tmpdir()
const image = path.join(tmp.path, "one image.png")
const pdf = path.join(tmp.path, "two file.pdf")
await Promise.all([Bun.write(image, new Uint8Array([1, 2, 3])), Bun.write(pdf, new Uint8Array([4, 5, 6]))])
for (const input of [
`'${image}' "${pdf}"`,
`# dropped files\r\n${pathToFileURL(image).href}\r\n${pathToFileURL(pdf).href}`,
]) {
expect(await resolvePastedAttachments(input, "linux")).toEqual([
{ type: "file", uri: "data:image/png;base64,AQID", filename: "one image.png" },
{ type: "file", uri: "data:application/pdf;base64,BAUG", filename: "two file.pdf" },
])
}
})
test("falls back to plain text for unsupported or incomplete drops", async () => {
await using tmp = await tmpdir()
const image = path.join(tmp.path, "image.png")
const text = path.join(tmp.path, "notes.txt")
await Promise.all([Bun.write(image, new Uint8Array([1])), Bun.write(text, "notes")])
for (const input of [
"",
"plain\r\ntext",
"https://example.com/image.png",
text,
`${image} ${text}`,
`${image} ${path.join(tmp.path, "missing.png")}`,
]) {
expect(await resolvePastedAttachments(input, "linux")).toBeUndefined()
}
})
test("resolves SVG files as text with the original content", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "image.svg")
const content = "<svg />\r\n"
await Bun.write(file, content)
expect(await resolvePastedAttachments(file, "linux")).toEqual([{ type: "text", content, filename: "image.svg" }])
})
test("shares the byte budget across binary and SVG attachments", async () => {
await using tmp = await tmpdir()
const image = path.join(tmp.path, "image.png")
const svg = path.join(tmp.path, "image.svg")
const content = "<svg>\u00e9</svg>"
await Promise.all([
Bun.write(image, new Uint8Array(MAX_LOCAL_ATTACHMENT_BYTES - Buffer.byteLength(content))),
Bun.write(svg, content),
])
expect(await resolvePastedAttachments(`${image} ${svg}`, "linux")).toMatchObject([
{ type: "file", filename: "image.png" },
{ type: "text", content, filename: "image.svg" },
])
await Bun.write(svg, content + " ")
expect(await resolvePastedAttachments(`${image} ${svg}`, "linux")).toBeUndefined()
await Bun.write(image, new Uint8Array(MAX_LOCAL_ATTACHMENT_BYTES + 1))
expect(await resolvePastedAttachments(image, "linux")).toBeUndefined()
})
test("bounds the number of resolved paths", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "image.png")
await Bun.write(file, new Uint8Array([1]))
expect(await resolvePastedAttachments(Array(32).fill(file).join(" "), "linux")).toHaveLength(32)
expect(await resolvePastedAttachments(Array(33).fill(file).join(" "), "linux")).toBeUndefined()
})
})
+26 -18
View File
@@ -7,27 +7,35 @@ export default $config({
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "cloudflare",
providers: {
aws: {
version: "7.30.0",
region: "us-east-1",
profile: process.env.GITHUB_ACTIONS
? undefined
: input.stage === "production"
? "opencode-production"
: "opencode-dev",
},
stripe: {
version: "0.0.28",
apiKey: process.env.STRIPE_SECRET_KEY!,
},
random: "4.19.2",
planetscale: "0.4.1",
honeycomb: "0.49.0",
},
providers:
input.stage === "beta"
? {}
: {
aws: {
version: "7.30.0",
region: "us-east-1",
profile: process.env.GITHUB_ACTIONS
? undefined
: input.stage === "production"
? "opencode-production"
: "opencode-dev",
},
stripe: {
version: "0.0.28",
apiKey: process.env.STRIPE_SECRET_KEY!,
},
random: "4.19.2",
planetscale: "0.4.1",
honeycomb: "0.49.0",
},
}
},
async run() {
if ($app.stage === "beta") {
const { createWebApp } = await import("./infra/webapp.js")
return { WebAppUrl: createWebApp("beta.opencode.ai").url }
}
const stage = await import("./infra/stage.js")
await import("./infra/app.js")
const lake = stage.deployAws ? await import("./infra/lake.js") : undefined