mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b2ce14ff3 | |||
| 294a11752e | |||
| 1cf1d1f634 | |||
| 2ce694d41f | |||
| d6eff3b3a3 | |||
| e63a6d45c1 | |||
| 93686519ba | |||
| f593792fb5 | |||
| 2cdb37c32b | |||
| 535d79b64c | |||
| b4e4c3f662 | |||
| ba676e7ae0 | |||
| a1c8e5af45 | |||
| f1e7e7c138 | |||
| 80b77caec0 | |||
| 86a2ea44b5 | |||
| a2002c88c6 | |||
| d8bcf4f4e7 | |||
| 31e0326f78 | |||
| a53d2ea356 | |||
| 229a280652 | |||
| 8d0350d923 | |||
| 4192d7eacc | |||
| 7b8b4cf8c7 | |||
| 1f4de75348 | |||
| 457755c690 | |||
| 052a1e7514 | |||
| 139d6e2818 | |||
| 06554efdf4 | |||
| 67e9bda94f | |||
| 53bb6b4c4f | |||
| 73d54c7068 | |||
| 90d6c4ab41 | |||
| 736396fc70 | |||
| 177bfed93e | |||
| 91f8477ef5 | |||
| f04a5e50ee | |||
| bb28b70700 | |||
| 7361a02ef3 | |||
| d465f150fc | |||
| 17fa8c117b | |||
| 9aa0c40a00 | |||
| fd4648da17 | |||
| aadca5013a | |||
| 5c3d490e59 | |||
| 1254f48135 | |||
| 1729c310d9 | |||
| 0130190bbd | |||
| 97a31ddffc | |||
| 3249420ad1 | |||
| 4bb8536d34 | |||
| c73d4a137e | |||
| 57ac8f2741 | |||
| 2f1acee5a1 | |||
| 9ca54020ac | |||
| f7d44b178b | |||
| b4950a157c | |||
| dfbef066c7 | |||
| 26fd76fbee | |||
| 04769d8a26 | |||
| 34b576d9b5 | |||
| 22b244f847 | |||
| 7e1fc275e7 | |||
| 3b9b391320 | |||
| 766bfd025c | |||
| c7f30e1065 | |||
| 1c4fd7f28f | |||
| 85805d2c38 | |||
| 982cb3e71a | |||
| 294d0e7ee3 | |||
| 8be1ca836c | |||
| 2e5f96fa41 | |||
| c056b0add9 | |||
| b00bb3c083 | |||
| d9befd3aa6 | |||
| 49de703ba1 | |||
| 22988894c8 | |||
| 34b1754f25 | |||
| 54fe3504ba | |||
| d2c862e32d | |||
| afc53afb35 | |||
| b56e49c5dc | |||
| 8b2a909e1f | |||
| e9c954d45e | |||
| 6f449d13af | |||
| 6e375bef0d | |||
| 67106a6967 | |||
| b5d690620d | |||
| 9db3ce1d0b | |||
| 1cc55b68ef | |||
| 469f667774 | |||
| 6603d9a9f0 | |||
| 5dc1920a4c | |||
| d3e5f3f3a8 | |||
| ce4cb820f7 |
@@ -0,0 +1,25 @@
|
||||
name: opencode
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
opencode:
|
||||
if: startsWith(github.event.comment.body, 'hey opencode')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run opencode
|
||||
uses: sst/opencode/sdks/github@dev
|
||||
#uses: ./github-actions
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
with:
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
@@ -0,0 +1,15 @@
|
||||
# TUI Agent Guidelines
|
||||
|
||||
## Style
|
||||
|
||||
- prefer single word variable/function names
|
||||
- avoid try catch where possible - prefer to let exceptions bubble up
|
||||
- avoid else statements where possible
|
||||
- do not make useless helper functions - inline functionality unless the
|
||||
function is reusable or composable
|
||||
- prefer Bun apis
|
||||
|
||||
## Workflow
|
||||
|
||||
- you can regenerate the golang sdk by calling ./scripts/stainless.ts
|
||||
- we use bun for everything
|
||||
@@ -38,10 +38,24 @@ For more info on how to configure opencode [**head over to our docs**](https://o
|
||||
|
||||
### Contributing
|
||||
|
||||
For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes.
|
||||
WE DO NOT ACCEPT PRs FOR CORE FEATURES
|
||||
|
||||
> **Note**: Please talk to us via github issues before spending time working on
|
||||
> a new feature
|
||||
opencode is an opinionated tool so any fundamental feature needs to go through a
|
||||
design process with the core team.
|
||||
|
||||
However we still merge a ton of PRs - you can contribute:
|
||||
|
||||
- bug fixes
|
||||
- improvements to LLM performance
|
||||
- support for new providers
|
||||
- fixes for env specific quirks
|
||||
- missing standard behavior
|
||||
- documentation
|
||||
|
||||
Take a look at the git history to see what kind of PRs we end up merging.
|
||||
|
||||
> **Note**: If you do not follow the above guidelines we might close your PR
|
||||
> that you worked really hard on.
|
||||
|
||||
To run opencode locally you need.
|
||||
|
||||
|
||||
@@ -12,3 +12,7 @@
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-10 | 43,796 (+5,744) | 71,402 (+6,934) | 115,198 (+12,678) |
|
||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"jose": "6.0.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "4.20250522.0",
|
||||
"@types/node": "catalog:",
|
||||
@@ -28,6 +33,7 @@
|
||||
"@flystorage/file-storage": "1.1.0",
|
||||
"@flystorage/local-fs": "1.1.0",
|
||||
"@hono/zod-validator": "0.5.0",
|
||||
"@modelcontextprotocol/sdk": "1.15.1",
|
||||
"@openauthjs/openauth": "0.4.3",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"ai": "catalog:",
|
||||
@@ -98,7 +104,7 @@
|
||||
],
|
||||
"catalog": {
|
||||
"@types/node": "22.13.9",
|
||||
"ai": "5.0.0-beta.7",
|
||||
"ai": "5.0.0-beta.15",
|
||||
"typescript": "5.8.2",
|
||||
"zod": "3.25.49",
|
||||
},
|
||||
@@ -107,7 +113,7 @@
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-beta.3", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-g49gMSkXy94lYvl5LRh438OR/0JCG6ol0jV+iLot7cy5HLltZlGocEuauETBu4b10mDXOd7XIjTEZoQpYFMYLQ=="],
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0-beta.5", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-+SgaqoxfFRpFQwgvCK5rh4kznz09x//n9Xtm/l3sjJwlUPLrj+wOeKCCJRWdp1Lpl5cbfdz9qWXrK7Ul+qfUJg=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0-beta.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Z8SPncMtS3RsoXITmT7NVwrAq6M44dmw0DoUOYJqNNtCu8iMWuxB8Nxsoqpa0uEEy9R1V1ZThJAXTYgjTUxl3w=="],
|
||||
|
||||
@@ -335,7 +341,43 @@
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.15.1", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w=="],
|
||||
|
||||
"@octokit/auth-app": ["@octokit/auth-app@8.0.1", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.1", "@octokit/auth-oauth-user": "^6.0.0", "@octokit/request": "^10.0.2", "@octokit/request-error": "^7.0.0", "@octokit/types": "^14.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-P2J5pB3pjiGwtJX4WqJVYCtNkcZ+j5T2Wm14aJAEIC3WJOrv12jvBley3G1U/XI8q9o1A7QMG54LiFED2BiFlg=="],
|
||||
|
||||
"@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.1", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.1", "@octokit/auth-oauth-user": "^6.0.0", "@octokit/request": "^10.0.2", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-TthWzYxuHKLAbmxdFZwFlmwVyvynpyPmjwc+2/cI3cvbT7mHtsAW9b1LvQaNnAuWL+pFnqtxdmrU8QpF633i1g=="],
|
||||
|
||||
"@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.1", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.0", "@octokit/request": "^10.0.2", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-TOqId/+am5yk9zor0RGibmlqn4V0h8vzjxlw/wYr3qzkQxl8aBPur384D1EyHtqvfz0syeXji4OUvKkHvxk/Gw=="],
|
||||
|
||||
"@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.0", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.1", "@octokit/oauth-methods": "^6.0.0", "@octokit/request": "^10.0.2", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-GV9IW134PHsLhtUad21WIeP9mlJ+QNpFd6V9vuPWmaiN25HEJeEQUcS4y5oRuqCm9iWDLtfIs+9K8uczBXKr6A=="],
|
||||
|
||||
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
|
||||
|
||||
"@octokit/core": ["@octokit/core@7.0.3", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", "@octokit/request": "^10.0.2", "@octokit/request-error": "^7.0.0", "@octokit/types": "^14.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ=="],
|
||||
|
||||
"@octokit/endpoint": ["@octokit/endpoint@11.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ=="],
|
||||
|
||||
"@octokit/graphql": ["@octokit/graphql@9.0.1", "", { "dependencies": { "@octokit/request": "^10.0.2", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg=="],
|
||||
|
||||
"@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="],
|
||||
|
||||
"@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.0", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.2", "@octokit/request-error": "^7.0.0", "@octokit/types": "^14.0.0" } }, "sha512-Q8nFIagNLIZgM2odAraelMcDssapc+lF+y3OlcIPxyAU+knefO8KmozGqfnma1xegRDP4z5M73ABsamn72bOcA=="],
|
||||
|
||||
"@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@13.1.1", "", { "dependencies": { "@octokit/types": "^14.1.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-q9iQGlZlxAVNRN2jDNskJW/Cafy7/XE52wjZ5TTvyhyOD904Cvx//DNyoO3J/MXJ0ve3rPoNWKEg5iZrisQSuw=="],
|
||||
|
||||
"@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="],
|
||||
|
||||
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@16.0.0", "", { "dependencies": { "@octokit/types": "^14.1.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-kJVUQk6/dx/gRNLWUnAWKFs1kVPn5O5CYZyssyEoNYaFedqZxsfYs7DwI3d67hGz4qOwaJ1dpm07hOAD1BXx6g=="],
|
||||
|
||||
"@octokit/request": ["@octokit/request@10.0.3", "", { "dependencies": { "@octokit/endpoint": "^11.0.0", "@octokit/request-error": "^7.0.0", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA=="],
|
||||
|
||||
"@octokit/request-error": ["@octokit/request-error@7.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg=="],
|
||||
|
||||
"@octokit/rest": ["@octokit/rest@22.0.0", "", { "dependencies": { "@octokit/core": "^7.0.2", "@octokit/plugin-paginate-rest": "^13.0.1", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^16.0.0" } }, "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA=="],
|
||||
|
||||
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
|
||||
|
||||
@@ -505,7 +547,9 @@
|
||||
|
||||
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
|
||||
|
||||
"ai": ["ai@5.0.0-beta.7", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-beta.3", "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-oC4KzUJCQPMB7v9rCqL/rVk2ogZvI6lYiXfKjzPYHwa1zIgy329qqRLmAd3mKEDTTG6By1r0zasQu7FKmG+4gw=="],
|
||||
"ai": ["ai@5.0.0-beta.15", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0-beta.5", "@ai-sdk/provider": "2.0.0-beta.1", "@ai-sdk/provider-utils": "3.0.0-beta.2", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.49" } }, "sha512-+RpdNV+E551QzWXSFqIjoVkgfMH30sgliTL2yCu4PS2hqWK03CY57Pi0oHcVplw5TLOVeMhs0ax83+dKIJbGIg=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
||||
|
||||
@@ -575,6 +619,8 @@
|
||||
|
||||
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
@@ -671,6 +717,8 @@
|
||||
|
||||
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
|
||||
|
||||
"css-selector-parser": ["css-selector-parser@3.1.2", "", {}, "sha512-WfUcL99xWDs7b3eZPoRszWVfbNo8ErCF15PTvVROjkShGlAfjIkG6hlfj/sl6/rfo5Q9x9ryJ3VqVnAZDA+gcw=="],
|
||||
@@ -807,10 +855,14 @@
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-redact": ["fast-redact@3.5.0", "", {}, "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="],
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
@@ -989,11 +1041,13 @@
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"isomorphic-git": ["isomorphic-git@1.32.1", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "path-browserify": "^1.0.1", "pify": "^4.0.1", "readable-stream": "^3.4.0", "sha.js": "^2.4.9", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-NZCS7qpLkCZ1M/IrujYBD31sM6pd/fMVArK4fz4I7h6m0rUW2AsYU7S7zXeABuHL6HIfW6l53b4UQ/K441CQjg=="],
|
||||
|
||||
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
|
||||
|
||||
"jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
||||
"jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
|
||||
|
||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||
|
||||
@@ -1009,6 +1063,8 @@
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-schema-walker": ["json-schema-walker@2.0.0", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.1.0", "clone": "^2.1.2" } }, "sha512-nXN2cMky0Iw7Af28w061hmxaPDaML5/bQD9nwm1lOoIKEGjHcRGxqWe4MfrkYThYAPjSUhmsp4bJNoLAyVn9Xw=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
@@ -1265,6 +1321,8 @@
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
@@ -1285,7 +1343,7 @@
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@4.0.0", "", {}, "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
|
||||
"pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
@@ -1429,6 +1487,10 @@
|
||||
|
||||
"sharp": ["sharp@0.32.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
@@ -1527,6 +1589,8 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
|
||||
@@ -1597,12 +1661,18 @@
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="],
|
||||
|
||||
"universal-github-app-jwt": ["universal-github-app-jwt@2.2.2", "", {}, "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw=="],
|
||||
|
||||
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"unstorage": ["unstorage@1.16.0", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^4.0.3", "destr": "^2.0.5", "h3": "^1.15.2", "lru-cache": "^10.4.3", "node-fetch-native": "^1.6.6", "ofetch": "^1.4.1", "ufo": "^1.6.1" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-WQ37/H5A7LcRPWfYOrDa1Ys02xAbpPJq6q5GkO88FBXVSQzHd7+BjEwfRqyaSWCv9MbsJy058GWjjPjcJ16GGA=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
|
||||
|
||||
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
|
||||
@@ -1641,6 +1711,8 @@
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="],
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
|
||||
@@ -1669,6 +1741,8 @@
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="],
|
||||
|
||||
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
@@ -1727,8 +1801,6 @@
|
||||
|
||||
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
|
||||
|
||||
"@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
|
||||
|
||||
"@openauthjs/openauth/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
@@ -1787,6 +1859,8 @@
|
||||
|
||||
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||
|
||||
"opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
|
||||
|
||||
"opencontrol/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
|
||||
@@ -1815,12 +1889,16 @@
|
||||
|
||||
"sitemap/sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
|
||||
|
||||
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
||||
|
||||
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
|
||||
"unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="],
|
||||
|
||||
"wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
||||
@@ -1845,6 +1923,10 @@
|
||||
|
||||
"bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
|
||||
|
||||
"pino-pretty/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||
|
||||
+3
-1
@@ -4,6 +4,8 @@ export const domain = (() => {
|
||||
return `${$app.stage}.dev.opencode.ai`
|
||||
})()
|
||||
|
||||
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
|
||||
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
|
||||
const bucket = new sst.cloudflare.Bucket("Bucket")
|
||||
|
||||
export const api = new sst.cloudflare.Worker("Api", {
|
||||
@@ -13,7 +15,7 @@ export const api = new sst.cloudflare.Worker("Api", {
|
||||
WEB_DOMAIN: domain,
|
||||
},
|
||||
url: true,
|
||||
link: [bucket],
|
||||
link: [bucket, GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY],
|
||||
transform: {
|
||||
worker: (args) => {
|
||||
args.logpush = true
|
||||
|
||||
@@ -48,7 +48,7 @@ if [ -z "$requested_version" ]; then
|
||||
url="https://github.com/sst/opencode/releases/latest/download/$filename"
|
||||
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
||||
echo "${RED}Failed to fetch version information${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -5,21 +5,5 @@
|
||||
"type": "local",
|
||||
"command": ["opencode", "x", "@h1deya/mcp-server-weather"]
|
||||
}
|
||||
},
|
||||
"experimental": {
|
||||
"hook": {
|
||||
"file_edited": {
|
||||
".json": [
|
||||
{
|
||||
"command": ["bun", "run", "prettier", "$FILE"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"session_completed": [
|
||||
{
|
||||
"command": ["touch", "./node_modules/foo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
"typescript": "5.8.2",
|
||||
"@types/node": "22.13.9",
|
||||
"zod": "3.25.49",
|
||||
"ai": "5.0.0-beta.7"
|
||||
"ai": "5.0.0-beta.15"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,5 +8,10 @@
|
||||
"@cloudflare/workers-types": "4.20250522.0",
|
||||
"typescript": "catalog:",
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"jose": "6.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { DurableObject } from "cloudflare:workers"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { jwtVerify, createRemoteJWKSet } from "jose"
|
||||
import { createAppAuth } from "@octokit/auth-app"
|
||||
import { Octokit } from "@octokit/rest"
|
||||
import { Resource } from "sst"
|
||||
|
||||
type Env = {
|
||||
SYNC_SERVER: DurableObjectNamespace<SyncServer>
|
||||
@@ -38,7 +42,11 @@ export class SyncServer extends DurableObject<Env> {
|
||||
|
||||
async publish(key: string, content: any) {
|
||||
const sessionID = await this.getSessionID()
|
||||
if (!key.startsWith(`session/info/${sessionID}`) && !key.startsWith(`session/message/${sessionID}/`))
|
||||
if (
|
||||
!key.startsWith(`session/info/${sessionID}`) &&
|
||||
!key.startsWith(`session/message/${sessionID}/`) &&
|
||||
!key.startsWith(`session/part/${sessionID}/`)
|
||||
)
|
||||
return new Response("Error: Invalid key", { status: 400 })
|
||||
|
||||
// store message
|
||||
@@ -67,7 +75,7 @@ export class SyncServer extends DurableObject<Env> {
|
||||
}
|
||||
|
||||
public async getData() {
|
||||
const data = await this.ctx.storage.list()
|
||||
const data = (await this.ctx.storage.list()) as Map<string, any>
|
||||
return Array.from(data.entries())
|
||||
.filter(([key, _]) => key.startsWith("session/"))
|
||||
.map(([key, content]) => ({ key, content }))
|
||||
@@ -104,7 +112,7 @@ export class SyncServer extends DurableObject<Env> {
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
const splits = url.pathname.split("/")
|
||||
const method = splits[1]
|
||||
@@ -203,8 +211,13 @@ export default {
|
||||
return
|
||||
}
|
||||
if (type === "message") {
|
||||
const [, messageID] = splits
|
||||
messages[messageID] = d.content
|
||||
messages[d.content.id] = {
|
||||
parts: [],
|
||||
...d.content,
|
||||
}
|
||||
}
|
||||
if (type === "part") {
|
||||
messages[d.content.messageID].parts.push(d.content)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -218,5 +231,60 @@ export default {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (request.method === "POST" && method === "exchange_github_app_token") {
|
||||
const EXPECTED_AUDIENCE = "opencode-github-action"
|
||||
const GITHUB_ISSUER = "https://token.actions.githubusercontent.com"
|
||||
const JWKS_URL = `${GITHUB_ISSUER}/.well-known/jwks`
|
||||
|
||||
// get Authorization header
|
||||
const authHeader = request.headers.get("Authorization")
|
||||
const token = authHeader?.replace(/^Bearer /, "")
|
||||
if (!token)
|
||||
return new Response(JSON.stringify({ error: "Authorization header is required" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
// verify token
|
||||
const JWKS = createRemoteJWKSet(new URL(JWKS_URL))
|
||||
let owner, repo
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer: GITHUB_ISSUER,
|
||||
audience: EXPECTED_AUDIENCE,
|
||||
})
|
||||
const sub = payload.sub // e.g. 'repo:my-org/my-repo:ref:refs/heads/main'
|
||||
const parts = sub.split(":")[1].split("/")
|
||||
owner = parts[0]
|
||||
repo = parts[1]
|
||||
} catch (err) {
|
||||
console.error("Token verification failed:", err)
|
||||
return new Response(JSON.stringify({ error: "Invalid or expired token" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
// Create app JWT token
|
||||
const auth = createAppAuth({
|
||||
appId: Resource.GITHUB_APP_ID.value,
|
||||
privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value,
|
||||
})
|
||||
const appAuth = await auth({ type: "app" })
|
||||
|
||||
// Lookup installation
|
||||
const octokit = new Octokit({ auth: appAuth.token })
|
||||
const { data: installation } = await octokit.apps.getRepoInstallation({ owner, repo })
|
||||
|
||||
// Get installation token
|
||||
const installationAuth = await auth({ type: "installation", installationId: installation.id })
|
||||
|
||||
return new Response(JSON.stringify({ token: installationAuth.token }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 })
|
||||
},
|
||||
}
|
||||
|
||||
Vendored
+16
-8
@@ -6,20 +6,28 @@
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
Web: {
|
||||
type: "sst.cloudflare.Astro"
|
||||
url: string
|
||||
"GITHUB_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_PRIVATE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Web": {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types"
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
Api: cloudflare.Service
|
||||
Bucket: cloudflare.R2Bucket
|
||||
"Api": cloudflare.Service
|
||||
"Bucket": cloudflare.R2Bucket
|
||||
}
|
||||
}
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
export {}
|
||||
@@ -26,28 +26,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "0.11.0",
|
||||
"@flystorage/file-storage": "1.1.0",
|
||||
"@flystorage/local-fs": "1.1.0",
|
||||
"@hono/zod-validator": "0.5.0",
|
||||
"@modelcontextprotocol/sdk": "1.15.1",
|
||||
"@openauthjs/openauth": "0.4.3",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"ai": "catalog:",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "8.0.2",
|
||||
"env-paths": "3.0.0",
|
||||
"hono": "4.7.10",
|
||||
"hono-openapi": "0.4.8",
|
||||
"isomorphic-git": "1.32.1",
|
||||
"open": "10.1.2",
|
||||
"remeda": "2.22.3",
|
||||
"ts-lsp-client": "1.0.3",
|
||||
"turndown": "7.2.0",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"vscode-languageclient": "8",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
"zod-openapi": "4.2.4",
|
||||
"zod-validation-error": "3.5.2"
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const snapshot = process.argv.includes("--snapshot")
|
||||
|
||||
const version = snapshot
|
||||
? `0.0.0-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}`
|
||||
: await $`git describe --tags --exact-match HEAD`
|
||||
: await $`git describe --tags --abbrev=0`
|
||||
.text()
|
||||
.then((x) => x.substring(1).trim())
|
||||
.catch(() => {
|
||||
|
||||
@@ -12,7 +12,6 @@ export namespace App {
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
user: z.string(),
|
||||
hostname: z.string(),
|
||||
git: z.boolean(),
|
||||
path: z.object({
|
||||
@@ -70,7 +69,6 @@ export namespace App {
|
||||
const root = git ?? input.cwd
|
||||
|
||||
const info: Info = {
|
||||
user: os.userInfo().username,
|
||||
hostname: os.hostname(),
|
||||
time: {
|
||||
initialized: state.initialized,
|
||||
|
||||
@@ -4,9 +4,13 @@ import { Auth } from "./index"
|
||||
export namespace AuthAnthropic {
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
|
||||
export async function authorize() {
|
||||
export async function authorize(mode: "max" | "console") {
|
||||
const pkce = await generatePKCE()
|
||||
const url = new URL("https://claude.ai/oauth/authorize", import.meta.url)
|
||||
|
||||
const url = new URL(
|
||||
`https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`,
|
||||
import.meta.url,
|
||||
)
|
||||
url.searchParams.set("code", "true")
|
||||
url.searchParams.set("client_id", CLIENT_ID)
|
||||
url.searchParams.set("response_type", "code")
|
||||
@@ -39,12 +43,11 @@ export namespace AuthAnthropic {
|
||||
})
|
||||
if (!result.ok) throw new ExchangeFailed()
|
||||
const json = await result.json()
|
||||
await Auth.set("anthropic", {
|
||||
type: "oauth",
|
||||
return {
|
||||
refresh: json.refresh_token as string,
|
||||
access: json.access_token as string,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function access() {
|
||||
|
||||
@@ -60,15 +60,26 @@ export namespace BunProc {
|
||||
export async function install(pkg: string, version = "latest") {
|
||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
|
||||
const parsed = await pkgjson.json().catch(() => ({
|
||||
dependencies: {},
|
||||
}))
|
||||
const parsed = await pkgjson.json().catch(async () => {
|
||||
const result = { dependencies: {} }
|
||||
await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2))
|
||||
return result
|
||||
})
|
||||
if (parsed.dependencies[pkg] === version) return mod
|
||||
parsed.dependencies[pkg] = version
|
||||
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
|
||||
await BunProc.run(["install", "--cwd", Global.Path.cache, "--registry=https://registry.npmjs.org"], {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch((e) => {
|
||||
await BunProc.run(
|
||||
[
|
||||
"add",
|
||||
"--force",
|
||||
"--exact",
|
||||
"--cwd",
|
||||
Global.Path.cache,
|
||||
"--registry=https://registry.npmjs.org",
|
||||
pkg + "@" + version,
|
||||
],
|
||||
{
|
||||
cwd: Global.Path.cache,
|
||||
},
|
||||
).catch((e) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg, version },
|
||||
{
|
||||
@@ -76,6 +87,8 @@ export namespace BunProc {
|
||||
},
|
||||
)
|
||||
})
|
||||
parsed.dependencies[pkg] = version
|
||||
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
|
||||
return mod
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,20 +132,24 @@ export const AuthLoginCommand = cmd({
|
||||
options: [
|
||||
{
|
||||
label: "Claude Pro/Max",
|
||||
value: "oauth",
|
||||
value: "max",
|
||||
},
|
||||
{
|
||||
label: "API Key",
|
||||
label: "Create API Key",
|
||||
value: "console",
|
||||
},
|
||||
{
|
||||
label: "Manually enter API Key",
|
||||
value: "api",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
||||
|
||||
if (method === "oauth") {
|
||||
if (method === "max") {
|
||||
// some weird bug where program exits without this
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const { url, verifier } = await AuthAnthropic.authorize()
|
||||
const { url, verifier } = await AuthAnthropic.authorize("max")
|
||||
prompts.note("Trying to open browser...")
|
||||
try {
|
||||
await open(url)
|
||||
@@ -162,13 +166,66 @@ export const AuthLoginCommand = cmd({
|
||||
})
|
||||
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
||||
|
||||
await AuthAnthropic.exchange(code, verifier)
|
||||
.then(() => {
|
||||
prompts.log.success("Login successful")
|
||||
try {
|
||||
const credentials = await AuthAnthropic.exchange(code, verifier)
|
||||
await Auth.set("anthropic", {
|
||||
type: "oauth",
|
||||
refresh: credentials.refresh,
|
||||
access: credentials.access,
|
||||
expires: credentials.expires,
|
||||
})
|
||||
.catch(() => {
|
||||
prompts.log.error("Invalid code")
|
||||
prompts.log.success("Login successful")
|
||||
} catch {
|
||||
prompts.log.error("Invalid code")
|
||||
}
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (method === "console") {
|
||||
// some weird bug where program exits without this
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const { url, verifier } = await AuthAnthropic.authorize("console")
|
||||
prompts.note("Trying to open browser...")
|
||||
try {
|
||||
await open(url)
|
||||
} catch (e) {
|
||||
prompts.log.error(
|
||||
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
|
||||
)
|
||||
}
|
||||
prompts.log.info(url)
|
||||
|
||||
const code = await prompts.text({
|
||||
message: "Paste the authorization code here: ",
|
||||
validate: (x) => (x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
||||
|
||||
try {
|
||||
const credentials = await AuthAnthropic.exchange(code, verifier)
|
||||
const accessToken = credentials.access
|
||||
const response = await fetch("https://api.anthropic.com/api/oauth/claude_cli/create_api_key", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json, text/plain, */*",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create API key")
|
||||
}
|
||||
const json = await response.json()
|
||||
await Auth.set("anthropic", {
|
||||
type: "api",
|
||||
key: json.raw_key,
|
||||
})
|
||||
|
||||
prompts.log.success("Login successful - API key created and saved")
|
||||
} catch (error) {
|
||||
prompts.log.error("Invalid code or failed to create API key")
|
||||
}
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cmd } from "../cmd"
|
||||
import { FileCommand } from "./file"
|
||||
import { LSPCommand } from "./lsp"
|
||||
import { RipgrepCommand } from "./ripgrep"
|
||||
import { ScrapCommand } from "./scrap"
|
||||
import { SnapshotCommand } from "./snapshot"
|
||||
|
||||
export const DebugCommand = cmd({
|
||||
@@ -12,6 +13,7 @@ export const DebugCommand = cmd({
|
||||
.command(LSPCommand)
|
||||
.command(RipgrepCommand)
|
||||
.command(FileCommand)
|
||||
.command(ScrapCommand)
|
||||
.command(SnapshotCommand)
|
||||
.command({
|
||||
command: "wait",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const ScrapCommand = cmd({
|
||||
command: "scrap",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {},
|
||||
})
|
||||
@@ -4,11 +4,11 @@ import { cmd } from "../cmd"
|
||||
|
||||
export const SnapshotCommand = cmd({
|
||||
command: "snapshot",
|
||||
builder: (yargs) => yargs.command(SnapshotCreateCommand).command(SnapshotRestoreCommand).demandCommand(),
|
||||
builder: (yargs) => yargs.command(CreateCommand).command(RestoreCommand).command(DiffCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const SnapshotCreateCommand = cmd({
|
||||
const CreateCommand = cmd({
|
||||
command: "create",
|
||||
async handler() {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
@@ -18,7 +18,7 @@ export const SnapshotCreateCommand = cmd({
|
||||
},
|
||||
})
|
||||
|
||||
export const SnapshotRestoreCommand = cmd({
|
||||
const RestoreCommand = cmd({
|
||||
command: "restore <commit>",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("commit", {
|
||||
@@ -33,3 +33,20 @@ export const SnapshotRestoreCommand = cmd({
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const DiffCommand = cmd({
|
||||
command: "diff <commit>",
|
||||
describe: "diff",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("commit", {
|
||||
type: "string",
|
||||
description: "commit",
|
||||
demandOption: true,
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
const diff = await Snapshot.diff("test", args.commit)
|
||||
console.log(diff)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
|
||||
export const McpCommand = cmd({
|
||||
command: "mcp",
|
||||
builder: (yargs) => yargs.command(McpAddCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const McpAddCommand = cmd({
|
||||
command: "add",
|
||||
describe: "add an MCP server",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
prompts.intro("Add MCP server")
|
||||
|
||||
const name = await prompts.text({
|
||||
message: "Enter MCP server name",
|
||||
validate: (x) => (x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(name)) throw new UI.CancelledError()
|
||||
|
||||
const type = await prompts.select({
|
||||
message: "Select MCP server type",
|
||||
options: [
|
||||
{
|
||||
label: "Local",
|
||||
value: "local",
|
||||
hint: "Run a local command",
|
||||
},
|
||||
{
|
||||
label: "Remote",
|
||||
value: "remote",
|
||||
hint: "Connect to a remote URL",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(type)) throw new UI.CancelledError()
|
||||
|
||||
if (type === "local") {
|
||||
const command = await prompts.text({
|
||||
message: "Enter command to run",
|
||||
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
|
||||
validate: (x) => (x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||
|
||||
prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
|
||||
prompts.outro("MCP server added successfully")
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "remote") {
|
||||
const url = await prompts.text({
|
||||
message: "Enter MCP server URL",
|
||||
placeholder: "e.g., https://example.com/mcp",
|
||||
validate: (x) => {
|
||||
if (x.length === 0) return "Required"
|
||||
const isValid = URL.canParse(x)
|
||||
return isValid ? undefined : "Invalid URL"
|
||||
},
|
||||
})
|
||||
if (prompts.isCancel(url)) throw new UI.CancelledError()
|
||||
|
||||
const client = new Client({
|
||||
name: "opencode",
|
||||
version: "1.0.0",
|
||||
})
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url))
|
||||
await client.connect(transport)
|
||||
prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
|
||||
}
|
||||
|
||||
prompts.outro("MCP server added successfully")
|
||||
},
|
||||
})
|
||||
@@ -8,6 +8,8 @@ import { Flag } from "../../flag/flag"
|
||||
import { Config } from "../../config/config"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { Mode } from "../../session/mode"
|
||||
import { Identifier } from "../../id/id"
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
@@ -52,6 +54,10 @@ export const RunCommand = cmd({
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("mode", {
|
||||
type: "string",
|
||||
describe: "mode to use",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
let message = args.message.join(" ")
|
||||
@@ -61,7 +67,9 @@ export const RunCommand = cmd({
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
const session = await (async () => {
|
||||
if (args.continue) {
|
||||
const first = await Session.list().next()
|
||||
const list = Session.list()
|
||||
const first = await list.next()
|
||||
await list.return()
|
||||
if (first.done) return
|
||||
return first.value
|
||||
}
|
||||
@@ -76,19 +84,22 @@ export const RunCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
const isPiped = !process.stdout.isTTY
|
||||
|
||||
UI.empty()
|
||||
UI.println(UI.logo())
|
||||
UI.empty()
|
||||
const displayMessage = message.length > 300 ? message.slice(0, 300) + "..." : message
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", displayMessage)
|
||||
UI.empty()
|
||||
|
||||
const cfg = await Config.get()
|
||||
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
||||
await Session.share(session.id)
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + session.id.slice(-8))
|
||||
if (cfg.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
||||
try {
|
||||
await Session.share(session.id)
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + session.id.slice(-8))
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("disabled")) {
|
||||
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
UI.empty()
|
||||
|
||||
@@ -105,8 +116,10 @@ export const RunCommand = cmd({
|
||||
)
|
||||
}
|
||||
|
||||
let text = ""
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
||||
if (evt.properties.sessionID !== session.id) return
|
||||
if (evt.properties.part.sessionID !== session.id) return
|
||||
if (evt.properties.part.messageID === messageID) return
|
||||
const part = evt.properties.part
|
||||
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
@@ -115,13 +128,15 @@ export const RunCommand = cmd({
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
if (part.text.includes("\n")) {
|
||||
text = part.text
|
||||
|
||||
if (part.time?.end) {
|
||||
UI.empty()
|
||||
UI.println(part.text)
|
||||
UI.println(UI.markdown(text))
|
||||
UI.empty()
|
||||
text = ""
|
||||
return
|
||||
}
|
||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -139,21 +154,34 @@ export const RunCommand = cmd({
|
||||
UI.error(err)
|
||||
})
|
||||
|
||||
const mode = args.mode ? await Mode.get(args.mode) : await Mode.list().then((x) => x[0])
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
const result = await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
modelID,
|
||||
messageID,
|
||||
...(mode.model
|
||||
? mode.model
|
||||
: {
|
||||
providerID,
|
||||
modelID,
|
||||
}),
|
||||
mode: mode.name,
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: session.id,
|
||||
messageID: messageID,
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const isPiped = !process.stdout.isTTY
|
||||
if (isPiped) {
|
||||
const match = result.parts.findLast((x) => x.type === "text")
|
||||
if (match) process.stdout.write(match.text)
|
||||
if (match) process.stdout.write(UI.markdown(match.text))
|
||||
if (errorMsg) process.stdout.write(errorMsg)
|
||||
}
|
||||
UI.empty()
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
interface SessionStats {
|
||||
totalSessions: number
|
||||
totalMessages: number
|
||||
totalCost: number
|
||||
totalTokens: {
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: {
|
||||
read: number
|
||||
write: number
|
||||
}
|
||||
}
|
||||
toolUsage: Record<string, number>
|
||||
dateRange: {
|
||||
earliest: number
|
||||
latest: number
|
||||
}
|
||||
days: number
|
||||
costPerDay: number
|
||||
}
|
||||
|
||||
export const StatsCommand = cmd({
|
||||
command: "stats",
|
||||
handler: async () => {},
|
||||
})
|
||||
|
||||
export function displayStats(stats: SessionStats) {
|
||||
const width = 56
|
||||
|
||||
function renderRow(label: string, value: string): string {
|
||||
const availableWidth = width - 1
|
||||
const paddingNeeded = availableWidth - label.length - value.length
|
||||
const padding = Math.max(0, paddingNeeded)
|
||||
return `│${label}${" ".repeat(padding)}${value} │`
|
||||
}
|
||||
|
||||
// Overview section
|
||||
console.log("┌────────────────────────────────────────────────────────┐")
|
||||
console.log("│ OVERVIEW │")
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
console.log(renderRow("Sessions", stats.totalSessions.toLocaleString()))
|
||||
console.log(renderRow("Messages", stats.totalMessages.toLocaleString()))
|
||||
console.log(renderRow("Days", stats.days.toString()))
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
console.log()
|
||||
|
||||
// Cost & Tokens section
|
||||
console.log("┌────────────────────────────────────────────────────────┐")
|
||||
console.log("│ COST & TOKENS │")
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
const cost = isNaN(stats.totalCost) ? 0 : stats.totalCost
|
||||
const costPerDay = isNaN(stats.costPerDay) ? 0 : stats.costPerDay
|
||||
console.log(renderRow("Total Cost", `$${cost.toFixed(2)}`))
|
||||
console.log(renderRow("Cost/Day", `$${costPerDay.toFixed(2)}`))
|
||||
console.log(renderRow("Input", formatNumber(stats.totalTokens.input)))
|
||||
console.log(renderRow("Output", formatNumber(stats.totalTokens.output)))
|
||||
console.log(renderRow("Cache Read", formatNumber(stats.totalTokens.cache.read)))
|
||||
console.log(renderRow("Cache Write", formatNumber(stats.totalTokens.cache.write)))
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
console.log()
|
||||
|
||||
// Tool Usage section
|
||||
if (Object.keys(stats.toolUsage).length > 0) {
|
||||
const sortedTools = Object.entries(stats.toolUsage)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 10)
|
||||
|
||||
console.log("┌────────────────────────────────────────────────────────┐")
|
||||
console.log("│ TOOL USAGE │")
|
||||
console.log("├────────────────────────────────────────────────────────┤")
|
||||
|
||||
const maxCount = Math.max(...sortedTools.map(([, count]) => count))
|
||||
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
|
||||
|
||||
for (const [tool, count] of sortedTools) {
|
||||
const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
|
||||
const bar = "█".repeat(barLength)
|
||||
const percentage = ((count / totalToolUsage) * 100).toFixed(1)
|
||||
|
||||
const content = ` ${tool.padEnd(10)} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
|
||||
const padding = Math.max(0, width - content.length)
|
||||
console.log(`│${content}${" ".repeat(padding)} │`)
|
||||
}
|
||||
console.log("└────────────────────────────────────────────────────────┘")
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + "M"
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + "K"
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Config } from "../../config/config"
|
||||
import { Bus } from "../../bus"
|
||||
import { Log } from "../../util/log"
|
||||
import { FileWatcher } from "../../file/watch"
|
||||
import { Mode } from "../../session/mode"
|
||||
|
||||
export const TuiCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
@@ -30,6 +31,10 @@ export const TuiCommand = cmd({
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "prompt to use",
|
||||
})
|
||||
.option("mode", {
|
||||
type: "string",
|
||||
describe: "mode to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
while (true) {
|
||||
@@ -77,6 +82,7 @@ export const TuiCommand = cmd({
|
||||
...cmd,
|
||||
...(args.model ? ["--model", args.model] : []),
|
||||
...(args.prompt ? ["--prompt", args.prompt] : []),
|
||||
...(args.mode ? ["--mode", args.mode] : []),
|
||||
],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
@@ -87,6 +93,7 @@ export const TuiCommand = cmd({
|
||||
CGO_ENABLED: "0",
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||
OPENCODE_MODES: JSON.stringify(await Mode.list()),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
|
||||
@@ -76,4 +76,8 @@ export namespace UI {
|
||||
export function error(message: string) {
|
||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||
}
|
||||
|
||||
export function markdown(text: string): string {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod"
|
||||
import { App } from "../app/app"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { mergeDeep, pipe } from "remeda"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import { lazy } from "../util/lazy"
|
||||
@@ -21,6 +21,17 @@ export namespace Config {
|
||||
result = mergeDeep(result, await load(resolved))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle migration from autoshare to share field
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
}
|
||||
|
||||
if (!result.username) {
|
||||
const os = await import("os")
|
||||
result.username = os.userInfo().username
|
||||
}
|
||||
|
||||
log.info("loaded", result)
|
||||
|
||||
return result
|
||||
@@ -70,6 +81,7 @@ export namespace Config {
|
||||
.object({
|
||||
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
|
||||
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
|
||||
switch_mode: z.string().optional().default("tab").describe("Switch mode"),
|
||||
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
|
||||
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
|
||||
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
|
||||
@@ -116,10 +128,21 @@ export namespace Config {
|
||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
||||
theme: z.string().optional().describe("Theme name to use for the interface"),
|
||||
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
|
||||
autoshare: z.boolean().optional().describe("Share newly created sessions automatically"),
|
||||
share: z
|
||||
.enum(["auto", "disabled"])
|
||||
.optional()
|
||||
.describe("Control sharing behavior: 'auto' enables automatic sharing, 'disabled' disables all sharing"),
|
||||
autoshare: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
|
||||
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
|
||||
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
|
||||
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
|
||||
username: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Custom username to display in conversations instead of system username"),
|
||||
mode: z
|
||||
.object({
|
||||
build: Mode.optional(),
|
||||
@@ -174,7 +197,11 @@ export namespace Config {
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export const global = lazy(async () => {
|
||||
let result = await load(path.join(Global.Path.config, "config.json"))
|
||||
let result = pipe(
|
||||
{},
|
||||
mergeDeep(await load(path.join(Global.Path.config, "config.json"))),
|
||||
mergeDeep(await load(path.join(Global.Path.config, "opencode.json"))),
|
||||
)
|
||||
|
||||
await import(path.join(Global.Path.config, "config"), {
|
||||
with: {
|
||||
@@ -194,19 +221,47 @@ export namespace Config {
|
||||
return result
|
||||
})
|
||||
|
||||
async function load(path: string) {
|
||||
const data = await Bun.file(path)
|
||||
.json()
|
||||
async function load(configPath: string) {
|
||||
let text = await Bun.file(configPath)
|
||||
.text()
|
||||
.catch((err) => {
|
||||
if (err.code === "ENOENT") return {}
|
||||
throw new JsonError({ path }, { cause: err })
|
||||
if (err.code === "ENOENT") return
|
||||
throw new JsonError({ path: configPath }, { cause: err })
|
||||
})
|
||||
if (!text) return {}
|
||||
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
||||
const fileMatches = text.match(/"?\{file:([^}]+)\}"?/g)
|
||||
if (fileMatches) {
|
||||
const configDir = path.dirname(configPath)
|
||||
for (const match of fileMatches) {
|
||||
const filePath = match.replace(/^"?\{file:/, "").replace(/\}"?$/, "")
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = await Bun.file(resolvedPath).text()
|
||||
text = text.replace(match, JSON.stringify(fileContent))
|
||||
}
|
||||
}
|
||||
|
||||
let data: any
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (err) {
|
||||
throw new JsonError({ path: configPath }, { cause: err as Error })
|
||||
}
|
||||
|
||||
const parsed = Info.safeParse(data)
|
||||
if (parsed.success) return parsed.data
|
||||
throw new InvalidError({ path, issues: parsed.error.issues })
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
await Bun.write(configPath, JSON.stringify(parsed.data, null, 2))
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
throw new InvalidError({ path: configPath, issues: parsed.error.issues })
|
||||
}
|
||||
|
||||
export const JsonError = NamedError.create(
|
||||
"ConfigJsonError",
|
||||
z.object({
|
||||
|
||||
@@ -46,7 +46,7 @@ export namespace FileWatcher {
|
||||
)
|
||||
|
||||
export function init() {
|
||||
if (Flag.OPENCODE_DISABLE_WATCHER) return
|
||||
if (Flag.OPENCODE_DISABLE_WATCHER || true) return
|
||||
state()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,17 @@ export namespace Global {
|
||||
await Promise.all([
|
||||
fs.mkdir(Global.Path.data, { recursive: true }),
|
||||
fs.mkdir(Global.Path.config, { recursive: true }),
|
||||
fs.mkdir(Global.Path.cache, { recursive: true }),
|
||||
fs.mkdir(Global.Path.providers, { recursive: true }),
|
||||
fs.mkdir(Global.Path.state, { recursive: true }),
|
||||
])
|
||||
|
||||
const CACHE_VERSION = "2"
|
||||
|
||||
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
||||
.text()
|
||||
.catch(() => "0")
|
||||
|
||||
if (version !== CACHE_VERSION) {
|
||||
await fs.rm(Global.Path.cache, { recursive: true, force: true })
|
||||
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export namespace Identifier {
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
user: "usr",
|
||||
part: "prt",
|
||||
} as const
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
|
||||
@@ -14,6 +14,8 @@ import { FormatError } from "./cli/error"
|
||||
import { ServeCommand } from "./cli/cmd/serve"
|
||||
import { TuiCommand } from "./cli/cmd/tui"
|
||||
import { DebugCommand } from "./cli/cmd/debug"
|
||||
import { StatsCommand } from "./cli/cmd/stats"
|
||||
import { McpCommand } from "./cli/cmd/mcp"
|
||||
|
||||
const cancel = new AbortController()
|
||||
|
||||
@@ -64,6 +66,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
})
|
||||
})
|
||||
.usage("\n" + UI.logo())
|
||||
.command(McpCommand)
|
||||
.command(TuiCommand)
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
@@ -72,6 +75,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
.command(UpgradeCommand)
|
||||
.command(ServeCommand)
|
||||
.command(ModelsCommand)
|
||||
.command(StatsCommand)
|
||||
.fail((msg) => {
|
||||
if (msg.startsWith("Unknown argument") || msg.startsWith("Not enough non-option arguments")) {
|
||||
cli.showHelp("log")
|
||||
|
||||
@@ -140,6 +140,12 @@ export namespace Installation {
|
||||
export async function latest() {
|
||||
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.tag_name.slice(1) as string)
|
||||
.then((data) => {
|
||||
if (typeof data.tag_name !== "string") {
|
||||
log.error("GitHub API error", data)
|
||||
throw new Error("failed to fetch latest version")
|
||||
}
|
||||
return data.tag_name.slice(1) as string
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { AuthCopilot } from "../auth/copilot"
|
||||
import { ModelsDev } from "./models"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Auth } from "../auth"
|
||||
// import { TaskTool } from "../tool/task"
|
||||
import { TaskTool } from "../tool/task"
|
||||
|
||||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
@@ -234,7 +234,7 @@ export namespace Provider {
|
||||
if (!provider) {
|
||||
const info = database[id]
|
||||
if (!info) return
|
||||
if (info.api) options["baseURL"] = info.api
|
||||
if (info.api && !options["baseURL"]) options["baseURL"] = info.api
|
||||
providers[id] = {
|
||||
source,
|
||||
info,
|
||||
@@ -456,7 +456,7 @@ export namespace Provider {
|
||||
WriteTool,
|
||||
TodoWriteTool,
|
||||
TodoReadTool,
|
||||
// TaskTool,
|
||||
TaskTool,
|
||||
]
|
||||
|
||||
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
|
||||
@@ -531,12 +531,4 @@ export namespace Provider {
|
||||
providerID: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const AuthError = NamedError.create(
|
||||
"ProviderAuthError",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ export namespace Server {
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
messageID: z.string(),
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
}),
|
||||
@@ -405,7 +406,14 @@ export namespace Server {
|
||||
description: "List of messages",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MessageV2.Info.array()),
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
info: MessageV2.Info,
|
||||
parts: MessageV2.Part.array(),
|
||||
})
|
||||
.array(),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -446,9 +454,11 @@ export namespace Server {
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
messageID: z.string(),
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
parts: MessageV2.UserPart.array(),
|
||||
mode: z.string(),
|
||||
parts: z.union([MessageV2.FilePart, MessageV2.TextPart]).array(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ProviderMetadata,
|
||||
type ModelMessage,
|
||||
stepCountIs,
|
||||
type StreamTextResult,
|
||||
} from "ai"
|
||||
|
||||
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
|
||||
@@ -42,6 +43,8 @@ import { ReadTool } from "../tool/read"
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
|
||||
const OUTPUT_TOKEN_MAX = 32_000
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: Identifier.schema("session"),
|
||||
@@ -143,12 +146,16 @@ export namespace Session {
|
||||
state().sessions.set(result.id, result)
|
||||
await Storage.writeJSON("session/info/" + result.id, result)
|
||||
const cfg = await Config.get()
|
||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.autoshare))
|
||||
share(result.id).then((share) => {
|
||||
update(result.id, (draft) => {
|
||||
draft.share = share
|
||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
|
||||
share(result.id)
|
||||
.then((share) => {
|
||||
update(result.id, (draft) => {
|
||||
draft.share = share
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore sharing errors during session creation
|
||||
})
|
||||
})
|
||||
Bus.publish(Event.Updated, {
|
||||
info: result,
|
||||
})
|
||||
@@ -170,6 +177,11 @@ export namespace Session {
|
||||
}
|
||||
|
||||
export async function share(id: string) {
|
||||
const cfg = await Config.get()
|
||||
if (cfg.share === "disabled") {
|
||||
throw new Error("Sharing is disabled in configuration")
|
||||
}
|
||||
|
||||
const session = await get(id)
|
||||
if (session.share) return session.share
|
||||
const share = await Share.create(id)
|
||||
@@ -181,7 +193,10 @@ export namespace Session {
|
||||
await Storage.writeJSON<ShareInfo>("session/share/" + id, share)
|
||||
await Share.sync("session/info/" + id, session)
|
||||
for (const msg of await messages(id)) {
|
||||
await Share.sync("session/message/" + id + "/" + msg.id, msg)
|
||||
await Share.sync("session/message/" + id + "/" + msg.info.id, msg.info)
|
||||
for (const part of msg.parts) {
|
||||
await Share.sync("session/part/" + id + "/" + msg.info.id + "/" + part.id, part)
|
||||
}
|
||||
}
|
||||
return share
|
||||
}
|
||||
@@ -211,13 +226,18 @@ export namespace Session {
|
||||
}
|
||||
|
||||
export async function messages(sessionID: string) {
|
||||
const result = [] as MessageV2.Info[]
|
||||
const list = Storage.list("session/message/" + sessionID)
|
||||
for await (const p of list) {
|
||||
const result = [] as {
|
||||
info: MessageV2.Info
|
||||
parts: MessageV2.Part[]
|
||||
}[]
|
||||
for (const p of await Storage.list("session/message/" + sessionID)) {
|
||||
const read = await Storage.readJSON<MessageV2.Info>(p)
|
||||
result.push(read)
|
||||
result.push({
|
||||
info: read,
|
||||
parts: await parts(sessionID, read.id),
|
||||
})
|
||||
}
|
||||
result.sort((a, b) => (a.id > b.id ? 1 : -1))
|
||||
result.sort((a, b) => (a.info.id > b.info.id ? 1 : -1))
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -225,8 +245,18 @@ export namespace Session {
|
||||
return Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID)
|
||||
}
|
||||
|
||||
export async function parts(sessionID: string, messageID: string) {
|
||||
const result = [] as MessageV2.Part[]
|
||||
for (const item of await Storage.list("session/part/" + sessionID + "/" + messageID)) {
|
||||
const read = await Storage.readJSON<MessageV2.Part>(item)
|
||||
result.push(read)
|
||||
}
|
||||
result.sort((a, b) => (a.id > b.id ? 1 : -1))
|
||||
return result
|
||||
}
|
||||
|
||||
export async function* list() {
|
||||
for await (const item of Storage.list("session/info")) {
|
||||
for (const item of await Storage.list("session/info")) {
|
||||
const sessionID = path.basename(item, ".json")
|
||||
yield get(sessionID)
|
||||
}
|
||||
@@ -234,7 +264,7 @@ export namespace Session {
|
||||
|
||||
export async function children(parentID: string) {
|
||||
const result = [] as Session.Info[]
|
||||
for await (const item of Storage.list("session/info")) {
|
||||
for (const item of await Storage.list("session/info")) {
|
||||
const sessionID = path.basename(item, ".json")
|
||||
const session = await get(sessionID)
|
||||
if (session.parentID !== parentID) continue
|
||||
@@ -280,12 +310,21 @@ export namespace Session {
|
||||
})
|
||||
}
|
||||
|
||||
async function updatePart(part: MessageV2.Part) {
|
||||
await Storage.writeJSON(["session", "part", part.sessionID, part.messageID, part.id].join("/"), part)
|
||||
Bus.publish(MessageV2.Event.PartUpdated, {
|
||||
part,
|
||||
})
|
||||
return part
|
||||
}
|
||||
|
||||
export async function chat(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
mode?: string
|
||||
parts: MessageV2.UserPart[]
|
||||
parts: (MessageV2.TextPart | MessageV2.FilePart)[]
|
||||
}) {
|
||||
const l = log.clone().tag("session", input.sessionID)
|
||||
l.info("chatting")
|
||||
@@ -297,16 +336,19 @@ export namespace Session {
|
||||
if (session.revert) {
|
||||
const trimmed = []
|
||||
for (const msg of msgs) {
|
||||
if (msg.id > session.revert.messageID || (msg.id === session.revert.messageID && session.revert.part === 0)) {
|
||||
await Storage.remove("session/message/" + input.sessionID + "/" + msg.id)
|
||||
if (
|
||||
msg.info.id > session.revert.messageID ||
|
||||
(msg.info.id === session.revert.messageID && session.revert.part === 0)
|
||||
) {
|
||||
await Storage.remove("session/message/" + input.sessionID + "/" + msg.info.id)
|
||||
await Bus.publish(MessageV2.Event.Removed, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: msg.id,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.id === session.revert.messageID) {
|
||||
if (msg.info.id === session.revert.messageID) {
|
||||
if (session.revert.part === 0) break
|
||||
msg.parts = msg.parts.slice(0, session.revert.part)
|
||||
}
|
||||
@@ -318,16 +360,14 @@ export namespace Session {
|
||||
})
|
||||
}
|
||||
|
||||
const previous = msgs.at(-1) as MessageV2.Assistant
|
||||
const previous = msgs.filter((x) => x.info.role === "assistant").at(-1)?.info as MessageV2.Assistant
|
||||
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
|
||||
|
||||
// auto summarize if too long
|
||||
if (previous) {
|
||||
if (previous && previous.tokens) {
|
||||
const tokens =
|
||||
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
|
||||
if (
|
||||
model.info.limit.context &&
|
||||
tokens > Math.max((model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9, 0)
|
||||
) {
|
||||
if (model.info.limit.context && tokens > Math.max((model.info.limit.context - outputLimit) * 0.9, 0)) {
|
||||
await summarize({
|
||||
sessionID: input.sessionID,
|
||||
providerID: input.providerID,
|
||||
@@ -339,18 +379,29 @@ export namespace Session {
|
||||
|
||||
using abort = lock(input.sessionID)
|
||||
|
||||
const lastSummary = msgs.findLast((msg) => msg.role === "assistant" && msg.summary === true)
|
||||
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
|
||||
const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true)
|
||||
if (lastSummary) msgs = msgs.filter((msg) => msg.info.id >= lastSummary.info.id)
|
||||
|
||||
const userMsg: MessageV2.Info = {
|
||||
id: input.messageID,
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
}
|
||||
|
||||
const app = App.info()
|
||||
input.parts = await Promise.all(
|
||||
input.parts.map(async (part): Promise<MessageV2.UserPart[]> => {
|
||||
const userParts = await Promise.all(
|
||||
input.parts.map(async (part): Promise<MessageV2.Part[]> => {
|
||||
if (part.type === "file") {
|
||||
const url = new URL(part.url)
|
||||
switch (url.protocol) {
|
||||
case "file:":
|
||||
// have to normalize, symbol search returns absolute paths
|
||||
const relativePath = url.pathname.replace(app.path.cwd, ".")
|
||||
// Decode the pathname since URL constructor doesn't automatically decode it
|
||||
const pathname = decodeURIComponent(url.pathname)
|
||||
const relativePath = pathname.replace(app.path.cwd, ".")
|
||||
const filePath = path.join(app.path.cwd, relativePath)
|
||||
|
||||
if (part.mime === "text/plain") {
|
||||
@@ -392,16 +443,22 @@ export namespace Session {
|
||||
const result = await ReadTool.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: abort.signal,
|
||||
messageID: "", // read tool doesn't use message ID
|
||||
messageID: userMsg.id,
|
||||
metadata: async () => {},
|
||||
})
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
||||
},
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: result.output,
|
||||
@@ -413,11 +470,17 @@ export namespace Session {
|
||||
FileTime.read(input.sessionID, filePath)
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
text: `Called the Read tool with the following input: {\"filePath\":\"${url.pathname}\"}`,
|
||||
text: `Called the Read tool with the following input: {\"filePath\":\"${pathname}\"}`,
|
||||
synthetic: true,
|
||||
},
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "file",
|
||||
url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"),
|
||||
mime: part.mime,
|
||||
@@ -431,7 +494,10 @@ export namespace Session {
|
||||
).then((x) => x.flat())
|
||||
|
||||
if (input.mode === "plan")
|
||||
input.parts.push({
|
||||
userParts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
text: PROMPT_PLAN,
|
||||
synthetic: true,
|
||||
@@ -450,13 +516,15 @@ export namespace Session {
|
||||
),
|
||||
...MessageV2.toModelMessage([
|
||||
{
|
||||
id: Identifier.ascending("message"),
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
parts: input.parts,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
info: {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
},
|
||||
parts: userParts,
|
||||
},
|
||||
]),
|
||||
],
|
||||
@@ -470,17 +538,11 @@ export namespace Session {
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
const msg: MessageV2.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
parts: input.parts,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
await updateMessage(userMsg)
|
||||
for (const part of userParts) {
|
||||
await updatePart(part)
|
||||
}
|
||||
await updateMessage(msg)
|
||||
msgs.push(msg)
|
||||
msgs.push({ info: userMsg, parts: userParts })
|
||||
|
||||
const mode = await Mode.get(input.mode ?? "build")
|
||||
let system = mode.prompt ? [mode.prompt] : SystemPrompt.provider(input.providerID, input.modelID)
|
||||
@@ -490,10 +552,9 @@ export namespace Session {
|
||||
const [first, ...rest] = system
|
||||
system = [first, rest.join("\n")]
|
||||
|
||||
const next: MessageV2.Info = {
|
||||
const assistantMsg: MessageV2.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "assistant",
|
||||
parts: [],
|
||||
system,
|
||||
path: {
|
||||
cwd: app.path.cwd,
|
||||
@@ -513,29 +574,39 @@ export namespace Session {
|
||||
},
|
||||
sessionID: input.sessionID,
|
||||
}
|
||||
await updateMessage(next)
|
||||
await updateMessage(assistantMsg)
|
||||
const tools: Record<string, AITool> = {}
|
||||
|
||||
const processor = createProcessor(assistantMsg, model.info)
|
||||
|
||||
for (const item of await Provider.tools(input.providerID)) {
|
||||
if (mode.tools[item.id] === false) continue
|
||||
if (session.parentID && item.id === "task") continue
|
||||
tools[item.id] = tool({
|
||||
id: item.id as any,
|
||||
description: item.description,
|
||||
inputSchema: item.parameters as ZodSchema,
|
||||
async execute(args, opts) {
|
||||
async execute(args, options) {
|
||||
const result = await item.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: abort.signal,
|
||||
messageID: next.id,
|
||||
messageID: assistantMsg.id,
|
||||
metadata: async (val) => {
|
||||
const match = next.parts.find(
|
||||
(p): p is MessageV2.ToolPart => p.type === "tool" && p.id === opts.toolCallId,
|
||||
)
|
||||
const match = processor.partFromToolCall(options.toolCallId)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state.title = val.title
|
||||
match.state.metadata = val.metadata
|
||||
await updatePart({
|
||||
...match,
|
||||
state: {
|
||||
title: val.title,
|
||||
metadata: val.metadata,
|
||||
status: "running",
|
||||
input: args,
|
||||
time: {
|
||||
start: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
await updateMessage(next)
|
||||
},
|
||||
})
|
||||
return result
|
||||
@@ -573,14 +644,10 @@ export namespace Session {
|
||||
tools[key] = item
|
||||
}
|
||||
|
||||
let text: MessageV2.TextPart = {
|
||||
type: "text",
|
||||
text: "",
|
||||
}
|
||||
const result = streamText({
|
||||
const stream = streamText({
|
||||
onError() {},
|
||||
maxRetries: 10,
|
||||
maxOutputTokens: input.modelID.includes("grok-4") ? undefined : Math.max(0, model.info.limit.output) || undefined,
|
||||
maxOutputTokens: outputLimit,
|
||||
abortSignal: abort.signal,
|
||||
stopWhen: stepCountIs(1000),
|
||||
providerOptions: model.info.options,
|
||||
@@ -610,201 +677,260 @@ export namespace Session {
|
||||
],
|
||||
}),
|
||||
})
|
||||
try {
|
||||
for await (const value of result.fullStream) {
|
||||
l.info("part", {
|
||||
type: value.type,
|
||||
})
|
||||
switch (value.type) {
|
||||
case "start":
|
||||
break
|
||||
const result = await processor.process(stream)
|
||||
return result
|
||||
}
|
||||
|
||||
case "tool-input-start":
|
||||
next.parts.push({
|
||||
type: "tool",
|
||||
tool: value.toolName,
|
||||
id: value.id,
|
||||
function createProcessor(assistantMsg: MessageV2.Assistant, model: ModelsDev.Model) {
|
||||
const toolCalls: Record<string, MessageV2.ToolPart> = {}
|
||||
return {
|
||||
partFromToolCall(toolCallID: string) {
|
||||
return toolCalls[toolCallID]
|
||||
},
|
||||
async process(stream: StreamTextResult<Record<string, AITool>, never>) {
|
||||
try {
|
||||
let currentText: MessageV2.TextPart | undefined
|
||||
|
||||
for await (const value of stream.fullStream) {
|
||||
log.info("part", {
|
||||
type: value.type,
|
||||
})
|
||||
switch (value.type) {
|
||||
case "start":
|
||||
const snapshot = await Snapshot.create(assistantMsg.sessionID)
|
||||
if (snapshot)
|
||||
await updatePart({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID: assistantMsg.sessionID,
|
||||
type: "snapshot",
|
||||
snapshot,
|
||||
})
|
||||
break
|
||||
|
||||
case "tool-input-start":
|
||||
const part = await updatePart({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID: assistantMsg.sessionID,
|
||||
type: "tool",
|
||||
tool: value.toolName,
|
||||
callID: value.id,
|
||||
state: {
|
||||
status: "pending",
|
||||
},
|
||||
})
|
||||
toolCalls[value.id] = part as MessageV2.ToolPart
|
||||
break
|
||||
|
||||
case "tool-input-delta":
|
||||
break
|
||||
|
||||
case "tool-call": {
|
||||
const match = toolCalls[value.toolCallId]
|
||||
if (match) {
|
||||
const part = await updatePart({
|
||||
...match,
|
||||
state: {
|
||||
status: "running",
|
||||
input: value.input,
|
||||
time: {
|
||||
start: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
toolCalls[value.toolCallId] = part as MessageV2.ToolPart
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool-result": {
|
||||
const match = toolCalls[value.toolCallId]
|
||||
if (match && match.state.status === "running") {
|
||||
await updatePart({
|
||||
...match,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: value.input,
|
||||
output: value.output.output,
|
||||
metadata: value.output.metadata,
|
||||
title: value.output.title,
|
||||
time: {
|
||||
start: match.state.time.start,
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
delete toolCalls[value.toolCallId]
|
||||
const snapshot = await Snapshot.create(assistantMsg.sessionID)
|
||||
if (snapshot)
|
||||
await updatePart({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID: assistantMsg.sessionID,
|
||||
type: "snapshot",
|
||||
snapshot,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "tool-error": {
|
||||
const match = toolCalls[value.toolCallId]
|
||||
if (match && match.state.status === "running") {
|
||||
await updatePart({
|
||||
...match,
|
||||
state: {
|
||||
status: "error",
|
||||
input: value.input,
|
||||
error: (value.error as any).toString(),
|
||||
time: {
|
||||
start: match.state.time.start,
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
delete toolCalls[value.toolCallId]
|
||||
const snapshot = await Snapshot.create(assistantMsg.sessionID)
|
||||
if (snapshot)
|
||||
await updatePart({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID: assistantMsg.sessionID,
|
||||
type: "snapshot",
|
||||
snapshot,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "error":
|
||||
throw value.error
|
||||
|
||||
case "start-step":
|
||||
await updatePart({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID: assistantMsg.sessionID,
|
||||
type: "step-start",
|
||||
})
|
||||
break
|
||||
|
||||
case "finish-step":
|
||||
const usage = getUsage(model, value.usage, value.providerMetadata)
|
||||
assistantMsg.cost += usage.cost
|
||||
assistantMsg.tokens = usage.tokens
|
||||
await updatePart({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID: assistantMsg.sessionID,
|
||||
type: "step-finish",
|
||||
tokens: usage.tokens,
|
||||
cost: usage.cost,
|
||||
})
|
||||
await updateMessage(assistantMsg)
|
||||
break
|
||||
|
||||
case "text-start":
|
||||
currentText = {
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID: assistantMsg.sessionID,
|
||||
type: "text",
|
||||
text: "",
|
||||
time: {
|
||||
start: Date.now(),
|
||||
},
|
||||
}
|
||||
break
|
||||
|
||||
case "text":
|
||||
if (currentText) {
|
||||
currentText.text += value.text
|
||||
await updatePart(currentText)
|
||||
}
|
||||
break
|
||||
|
||||
case "text-end":
|
||||
if (currentText && currentText.text) {
|
||||
currentText.time = {
|
||||
start: Date.now(),
|
||||
end: Date.now(),
|
||||
}
|
||||
await updatePart(currentText)
|
||||
}
|
||||
currentText = undefined
|
||||
break
|
||||
|
||||
case "finish":
|
||||
assistantMsg.time.completed = Date.now()
|
||||
await updateMessage(assistantMsg)
|
||||
break
|
||||
|
||||
default:
|
||||
log.info("unhandled", {
|
||||
...value,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.error("", {
|
||||
error: e,
|
||||
})
|
||||
switch (true) {
|
||||
case e instanceof DOMException && e.name === "AbortError":
|
||||
assistantMsg.error = new MessageV2.AbortedError(
|
||||
{ message: e.message },
|
||||
{
|
||||
cause: e,
|
||||
},
|
||||
).toObject()
|
||||
break
|
||||
case MessageV2.OutputLengthError.isInstance(e):
|
||||
assistantMsg.error = e
|
||||
break
|
||||
case LoadAPIKeyError.isInstance(e):
|
||||
assistantMsg.error = new MessageV2.AuthError(
|
||||
{
|
||||
providerID: model.id,
|
||||
message: e.message,
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
break
|
||||
case e instanceof Error:
|
||||
assistantMsg.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
|
||||
break
|
||||
default:
|
||||
assistantMsg.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e })
|
||||
}
|
||||
Bus.publish(Event.Error, {
|
||||
sessionID: assistantMsg.sessionID,
|
||||
error: assistantMsg.error,
|
||||
})
|
||||
}
|
||||
const p = await parts(assistantMsg.sessionID, assistantMsg.id)
|
||||
for (const part of p) {
|
||||
if (part.type === "tool" && part.state.status !== "completed") {
|
||||
updatePart({
|
||||
...part,
|
||||
state: {
|
||||
status: "pending",
|
||||
},
|
||||
})
|
||||
Bus.publish(MessageV2.Event.PartUpdated, {
|
||||
part: next.parts[next.parts.length - 1],
|
||||
sessionID: next.sessionID,
|
||||
messageID: next.id,
|
||||
})
|
||||
break
|
||||
|
||||
case "tool-input-delta":
|
||||
break
|
||||
|
||||
case "tool-call": {
|
||||
const match = next.parts.find(
|
||||
(p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId,
|
||||
)
|
||||
if (match) {
|
||||
match.state = {
|
||||
status: "running",
|
||||
input: value.input,
|
||||
status: "error",
|
||||
error: "Tool execution aborted",
|
||||
time: {
|
||||
start: Date.now(),
|
||||
},
|
||||
}
|
||||
Bus.publish(MessageV2.Event.PartUpdated, {
|
||||
part: match,
|
||||
sessionID: next.sessionID,
|
||||
messageID: next.id,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool-result": {
|
||||
const match = next.parts.find(
|
||||
(p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId,
|
||||
)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: value.input,
|
||||
output: value.output.output,
|
||||
metadata: value.output.metadata,
|
||||
title: value.output.title,
|
||||
time: {
|
||||
start: match.state.time.start,
|
||||
end: Date.now(),
|
||||
},
|
||||
}
|
||||
Bus.publish(MessageV2.Event.PartUpdated, {
|
||||
part: match,
|
||||
sessionID: next.sessionID,
|
||||
messageID: next.id,
|
||||
})
|
||||
}
|
||||
break
|
||||
input: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case "tool-error": {
|
||||
const match = next.parts.find(
|
||||
(p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId,
|
||||
)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state = {
|
||||
status: "error",
|
||||
input: value.input,
|
||||
error: (value.error as any).toString(),
|
||||
time: {
|
||||
start: match.state.time.start,
|
||||
end: Date.now(),
|
||||
},
|
||||
}
|
||||
Bus.publish(MessageV2.Event.PartUpdated, {
|
||||
part: match,
|
||||
sessionID: next.sessionID,
|
||||
messageID: next.id,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "error":
|
||||
throw value.error
|
||||
|
||||
case "start-step":
|
||||
next.parts.push({
|
||||
type: "step-start",
|
||||
})
|
||||
break
|
||||
|
||||
case "finish-step":
|
||||
const usage = getUsage(model.info, value.usage, value.providerMetadata)
|
||||
next.cost += usage.cost
|
||||
next.tokens = usage.tokens
|
||||
break
|
||||
|
||||
case "text-start":
|
||||
text = {
|
||||
type: "text",
|
||||
text: "",
|
||||
}
|
||||
break
|
||||
|
||||
case "text":
|
||||
if (text.text === "") next.parts.push(text)
|
||||
text.text += value.text
|
||||
break
|
||||
|
||||
case "text-end":
|
||||
Bus.publish(MessageV2.Event.PartUpdated, {
|
||||
part: text,
|
||||
sessionID: next.sessionID,
|
||||
messageID: next.id,
|
||||
})
|
||||
break
|
||||
|
||||
case "finish":
|
||||
next.time.completed = Date.now()
|
||||
break
|
||||
|
||||
default:
|
||||
l.info("unhandled", {
|
||||
...value,
|
||||
})
|
||||
continue
|
||||
}
|
||||
await updateMessage(next)
|
||||
}
|
||||
} catch (e) {
|
||||
log.error("", {
|
||||
error: e,
|
||||
})
|
||||
switch (true) {
|
||||
case e instanceof DOMException && e.name === "AbortError":
|
||||
next.error = new MessageV2.AbortedError(
|
||||
{ message: e.message },
|
||||
{
|
||||
cause: e,
|
||||
},
|
||||
).toObject()
|
||||
break
|
||||
case MessageV2.OutputLengthError.isInstance(e):
|
||||
next.error = e
|
||||
break
|
||||
case LoadAPIKeyError.isInstance(e):
|
||||
next.error = new Provider.AuthError(
|
||||
{
|
||||
providerID: input.providerID,
|
||||
message: e.message,
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
break
|
||||
case e instanceof Error:
|
||||
next.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
|
||||
break
|
||||
default:
|
||||
next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e })
|
||||
}
|
||||
Bus.publish(Event.Error, {
|
||||
sessionID: next.sessionID,
|
||||
error: next.error,
|
||||
})
|
||||
assistantMsg.time.completed = Date.now()
|
||||
await updateMessage(assistantMsg)
|
||||
return { info: assistantMsg, parts: p }
|
||||
},
|
||||
}
|
||||
for (const part of next.parts) {
|
||||
if (part.type === "tool" && part.state.status !== "completed") {
|
||||
part.state = {
|
||||
status: "error",
|
||||
error: "Tool execution aborted",
|
||||
time: {
|
||||
start: Date.now(),
|
||||
end: Date.now(),
|
||||
},
|
||||
input: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
next.time.completed = Date.now()
|
||||
await updateMessage(next)
|
||||
return next
|
||||
}
|
||||
|
||||
export async function revert(_input: { sessionID: string; messageID: string; part: number }) {
|
||||
@@ -853,8 +979,8 @@ export namespace Session {
|
||||
export async function summarize(input: { sessionID: string; providerID: string; modelID: string }) {
|
||||
using abort = lock(input.sessionID)
|
||||
const msgs = await messages(input.sessionID)
|
||||
const lastSummary = msgs.findLast((msg) => msg.role === "assistant" && msg.summary === true)?.id
|
||||
const filtered = msgs.filter((msg) => !lastSummary || msg.id >= lastSummary)
|
||||
const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true)
|
||||
const filtered = msgs.filter((msg) => !lastSummary || msg.info.id >= lastSummary.info.id)
|
||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||
const app = App.info()
|
||||
const system = SystemPrompt.summarize(input.providerID)
|
||||
@@ -862,7 +988,6 @@ export namespace Session {
|
||||
const next: MessageV2.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
role: "assistant",
|
||||
parts: [],
|
||||
sessionID: input.sessionID,
|
||||
system,
|
||||
path: {
|
||||
@@ -885,8 +1010,8 @@ export namespace Session {
|
||||
}
|
||||
await updateMessage(next)
|
||||
|
||||
let text: MessageV2.TextPart | undefined
|
||||
const result = streamText({
|
||||
const processor = createProcessor(next, model.info)
|
||||
const stream = streamText({
|
||||
abortSignal: abort.signal,
|
||||
model: model.language,
|
||||
messages: [
|
||||
@@ -907,81 +1032,10 @@ export namespace Session {
|
||||
],
|
||||
},
|
||||
],
|
||||
onStepFinish: async (step) => {
|
||||
const usage = getUsage(model.info, step.usage, step.providerMetadata)
|
||||
next.cost += usage.cost
|
||||
next.tokens = usage.tokens
|
||||
await updateMessage(next)
|
||||
if (text) {
|
||||
Bus.publish(MessageV2.Event.PartUpdated, {
|
||||
part: text,
|
||||
messageID: next.id,
|
||||
sessionID: next.sessionID,
|
||||
})
|
||||
}
|
||||
text = undefined
|
||||
},
|
||||
async onFinish(input) {
|
||||
const usage = getUsage(model.info, input.usage, input.providerMetadata)
|
||||
next.cost += usage.cost
|
||||
next.tokens = usage.tokens
|
||||
next.time.completed = Date.now()
|
||||
await updateMessage(next)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
for await (const value of result.fullStream) {
|
||||
switch (value.type) {
|
||||
case "text":
|
||||
if (!text) {
|
||||
text = {
|
||||
type: "text",
|
||||
text: value.text,
|
||||
}
|
||||
next.parts.push(text)
|
||||
} else text.text += value.text
|
||||
await updateMessage(next)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error("summarize stream error", {
|
||||
error: e,
|
||||
})
|
||||
switch (true) {
|
||||
case e instanceof DOMException && e.name === "AbortError":
|
||||
next.error = new MessageV2.AbortedError(
|
||||
{ message: e.message },
|
||||
{
|
||||
cause: e,
|
||||
},
|
||||
).toObject()
|
||||
break
|
||||
case MessageV2.OutputLengthError.isInstance(e):
|
||||
next.error = e
|
||||
break
|
||||
case LoadAPIKeyError.isInstance(e):
|
||||
next.error = new Provider.AuthError(
|
||||
{
|
||||
providerID: input.providerID,
|
||||
message: e.message,
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
break
|
||||
case e instanceof Error:
|
||||
next.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
|
||||
break
|
||||
default:
|
||||
next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }).toObject()
|
||||
}
|
||||
Bus.publish(Event.Error, {
|
||||
error: next.error,
|
||||
})
|
||||
}
|
||||
next.time.completed = Date.now()
|
||||
await updateMessage(next)
|
||||
const result = await processor.process(stream)
|
||||
return result
|
||||
}
|
||||
|
||||
function lock(sessionID: string) {
|
||||
@@ -1031,14 +1085,23 @@ export namespace Session {
|
||||
}
|
||||
}
|
||||
|
||||
export async function initialize(input: { sessionID: string; modelID: string; providerID: string }) {
|
||||
export async function initialize(input: {
|
||||
sessionID: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
messageID: string
|
||||
}) {
|
||||
const app = App.info()
|
||||
await Session.chat({
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
providerID: input.providerID,
|
||||
modelID: input.modelID,
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
type: "text",
|
||||
text: PROMPT_INITIALIZE.replace("${path}", app.path.root),
|
||||
},
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Message } from "./message"
|
||||
import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai"
|
||||
import { Identifier } from "../id/id"
|
||||
|
||||
export namespace MessageV2 {
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
||||
export const AbortedError = NamedError.create("MessageAbortedError", z.object({}))
|
||||
export const AuthError = NamedError.create(
|
||||
"ProviderAuthError",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const ToolStatePending = z
|
||||
.object({
|
||||
@@ -72,63 +79,83 @@ export namespace MessageV2 {
|
||||
ref: "ToolState",
|
||||
})
|
||||
|
||||
export const TextPart = z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
synthetic: z.boolean().optional(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "TextPart",
|
||||
})
|
||||
const PartBase = z.object({
|
||||
id: z.string(),
|
||||
sessionID: z.string(),
|
||||
messageID: z.string(),
|
||||
})
|
||||
|
||||
export const SnapshotPart = PartBase.extend({
|
||||
type: z.literal("snapshot"),
|
||||
snapshot: z.string(),
|
||||
})
|
||||
|
||||
export const TextPart = PartBase.extend({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
synthetic: z.boolean().optional(),
|
||||
time: z
|
||||
.object({
|
||||
start: z.number(),
|
||||
end: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}).openapi({
|
||||
ref: "TextPart",
|
||||
})
|
||||
export type TextPart = z.infer<typeof TextPart>
|
||||
|
||||
export const ToolPart = z
|
||||
.object({
|
||||
type: z.literal("tool"),
|
||||
id: z.string(),
|
||||
tool: z.string(),
|
||||
state: ToolState,
|
||||
})
|
||||
.openapi({
|
||||
ref: "ToolPart",
|
||||
})
|
||||
export const ToolPart = PartBase.extend({
|
||||
type: z.literal("tool"),
|
||||
callID: z.string(),
|
||||
tool: z.string(),
|
||||
state: ToolState,
|
||||
}).openapi({
|
||||
ref: "ToolPart",
|
||||
})
|
||||
export type ToolPart = z.infer<typeof ToolPart>
|
||||
|
||||
export const FilePart = z
|
||||
.object({
|
||||
type: z.literal("file"),
|
||||
mime: z.string(),
|
||||
filename: z.string().optional(),
|
||||
url: z.string(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "FilePart",
|
||||
})
|
||||
export const FilePart = PartBase.extend({
|
||||
type: z.literal("file"),
|
||||
mime: z.string(),
|
||||
filename: z.string().optional(),
|
||||
url: z.string(),
|
||||
}).openapi({
|
||||
ref: "FilePart",
|
||||
})
|
||||
export type FilePart = z.infer<typeof FilePart>
|
||||
|
||||
export const StepStartPart = z
|
||||
.object({
|
||||
type: z.literal("step-start"),
|
||||
})
|
||||
.openapi({
|
||||
ref: "StepStartPart",
|
||||
})
|
||||
export const StepStartPart = PartBase.extend({
|
||||
type: z.literal("step-start"),
|
||||
}).openapi({
|
||||
ref: "StepStartPart",
|
||||
})
|
||||
export type StepStartPart = z.infer<typeof StepStartPart>
|
||||
|
||||
export const StepFinishPart = PartBase.extend({
|
||||
type: z.literal("step-finish"),
|
||||
cost: z.number(),
|
||||
tokens: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
reasoning: z.number(),
|
||||
cache: z.object({
|
||||
read: z.number(),
|
||||
write: z.number(),
|
||||
}),
|
||||
}),
|
||||
}).openapi({
|
||||
ref: "StepFinishPart",
|
||||
})
|
||||
export type StepFinishPart = z.infer<typeof StepFinishPart>
|
||||
|
||||
const Base = z.object({
|
||||
id: z.string(),
|
||||
sessionID: z.string(),
|
||||
})
|
||||
|
||||
export const UserPart = z.discriminatedUnion("type", [TextPart, FilePart]).openapi({
|
||||
ref: "UserMessagePart",
|
||||
})
|
||||
export type UserPart = z.infer<typeof UserPart>
|
||||
|
||||
export const User = Base.extend({
|
||||
role: z.literal("user"),
|
||||
parts: z.array(UserPart),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
}),
|
||||
@@ -137,21 +164,22 @@ export namespace MessageV2 {
|
||||
})
|
||||
export type User = z.infer<typeof User>
|
||||
|
||||
export const AssistantPart = z.discriminatedUnion("type", [TextPart, ToolPart, StepStartPart]).openapi({
|
||||
ref: "AssistantMessagePart",
|
||||
})
|
||||
export type AssistantPart = z.infer<typeof AssistantPart>
|
||||
export const Part = z
|
||||
.discriminatedUnion("type", [TextPart, FilePart, ToolPart, StepStartPart, StepFinishPart, SnapshotPart])
|
||||
.openapi({
|
||||
ref: "Part",
|
||||
})
|
||||
export type Part = z.infer<typeof Part>
|
||||
|
||||
export const Assistant = Base.extend({
|
||||
role: z.literal("assistant"),
|
||||
parts: z.array(AssistantPart),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
completed: z.number().optional(),
|
||||
}),
|
||||
error: z
|
||||
.discriminatedUnion("name", [
|
||||
Provider.AuthError.Schema,
|
||||
AuthError.Schema,
|
||||
NamedError.Unknown.Schema,
|
||||
OutputLengthError.Schema,
|
||||
AbortedError.Schema,
|
||||
@@ -164,8 +192,8 @@ export namespace MessageV2 {
|
||||
cwd: z.string(),
|
||||
root: z.string(),
|
||||
}),
|
||||
cost: z.number(),
|
||||
summary: z.boolean().optional(),
|
||||
cost: z.number(),
|
||||
tokens: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
@@ -202,16 +230,14 @@ export namespace MessageV2 {
|
||||
PartUpdated: Bus.event(
|
||||
"message.part.updated",
|
||||
z.object({
|
||||
part: AssistantPart,
|
||||
sessionID: z.string(),
|
||||
messageID: z.string(),
|
||||
part: Part,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export function fromV1(v1: Message.Info) {
|
||||
if (v1.role === "assistant") {
|
||||
const result: Assistant = {
|
||||
const info: Assistant = {
|
||||
id: v1.id,
|
||||
sessionID: v1.metadata.sessionID,
|
||||
role: "assistant",
|
||||
@@ -227,109 +253,135 @@ export namespace MessageV2 {
|
||||
providerID: v1.metadata.assistant!.providerID,
|
||||
system: v1.metadata.assistant!.system,
|
||||
error: v1.metadata.error,
|
||||
parts: v1.parts.flatMap((part): AssistantPart[] => {
|
||||
if (part.type === "text") {
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: part.text,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type === "step-start") {
|
||||
return [
|
||||
{
|
||||
type: "step-start",
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type === "tool-invocation") {
|
||||
return [
|
||||
{
|
||||
type: "tool",
|
||||
id: part.toolInvocation.toolCallId,
|
||||
tool: part.toolInvocation.toolName,
|
||||
state: (() => {
|
||||
if (part.toolInvocation.state === "partial-call") {
|
||||
return {
|
||||
status: "pending",
|
||||
}
|
||||
}
|
||||
|
||||
const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId] ?? {}
|
||||
if (part.toolInvocation.state === "call") {
|
||||
return {
|
||||
status: "running",
|
||||
input: part.toolInvocation.args,
|
||||
time: {
|
||||
start: time?.start,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (part.toolInvocation.state === "result") {
|
||||
return {
|
||||
status: "completed",
|
||||
input: part.toolInvocation.args,
|
||||
output: part.toolInvocation.result,
|
||||
title,
|
||||
time,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
throw new Error("unknown tool invocation state")
|
||||
})(),
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
}),
|
||||
}
|
||||
return result
|
||||
const parts = v1.parts.flatMap((part): Part[] => {
|
||||
const base = {
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: v1.id,
|
||||
sessionID: v1.metadata.sessionID,
|
||||
}
|
||||
if (part.type === "text") {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
type: "text",
|
||||
text: part.text,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type === "step-start") {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
type: "step-start",
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type === "tool-invocation") {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
type: "tool",
|
||||
callID: part.toolInvocation.toolCallId,
|
||||
tool: part.toolInvocation.toolName,
|
||||
state: (() => {
|
||||
if (part.toolInvocation.state === "partial-call") {
|
||||
return {
|
||||
status: "pending",
|
||||
}
|
||||
}
|
||||
|
||||
const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId] ?? {}
|
||||
if (part.toolInvocation.state === "call") {
|
||||
return {
|
||||
status: "running",
|
||||
input: part.toolInvocation.args,
|
||||
time: {
|
||||
start: time?.start,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (part.toolInvocation.state === "result") {
|
||||
return {
|
||||
status: "completed",
|
||||
input: part.toolInvocation.args,
|
||||
output: part.toolInvocation.result,
|
||||
title,
|
||||
time,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
throw new Error("unknown tool invocation state")
|
||||
})(),
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
return {
|
||||
info,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
if (v1.role === "user") {
|
||||
const result: User = {
|
||||
const info: User = {
|
||||
id: v1.id,
|
||||
sessionID: v1.metadata.sessionID,
|
||||
role: "user",
|
||||
time: {
|
||||
created: v1.metadata.time.created,
|
||||
},
|
||||
parts: v1.parts.flatMap((part): UserPart[] => {
|
||||
if (part.type === "text") {
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: part.text,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type === "file") {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
mime: part.mediaType,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
}),
|
||||
}
|
||||
return result
|
||||
const parts = v1.parts.flatMap((part): Part[] => {
|
||||
const base = {
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: v1.id,
|
||||
sessionID: v1.metadata.sessionID,
|
||||
}
|
||||
if (part.type === "text") {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
type: "text",
|
||||
text: part.text,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type === "file") {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
type: "file",
|
||||
mime: part.mediaType,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
return { info, parts }
|
||||
}
|
||||
|
||||
throw new Error("unknown message type")
|
||||
}
|
||||
|
||||
export function toModelMessage(input: Info[]): ModelMessage[] {
|
||||
export function toModelMessage(
|
||||
input: {
|
||||
info: Info
|
||||
parts: Part[]
|
||||
}[],
|
||||
): ModelMessage[] {
|
||||
const result: UIMessage[] = []
|
||||
|
||||
for (const msg of input) {
|
||||
if (msg.parts.length === 0) continue
|
||||
if (msg.role === "user") {
|
||||
|
||||
if (msg.info.role === "user") {
|
||||
result.push({
|
||||
id: msg.id,
|
||||
id: msg.info.id,
|
||||
role: "user",
|
||||
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
|
||||
if (part.type === "text")
|
||||
@@ -353,9 +405,9 @@ export namespace MessageV2 {
|
||||
})
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
if (msg.info.role === "assistant") {
|
||||
result.push({
|
||||
id: msg.id,
|
||||
id: msg.info.id,
|
||||
role: "assistant",
|
||||
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
|
||||
if (part.type === "text")
|
||||
@@ -377,7 +429,7 @@ export namespace MessageV2 {
|
||||
{
|
||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||
state: "output-available",
|
||||
toolCallId: part.id,
|
||||
toolCallId: part.callID,
|
||||
input: part.state.input,
|
||||
output: part.state.output,
|
||||
},
|
||||
@@ -387,7 +439,7 @@ export namespace MessageV2 {
|
||||
{
|
||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||
state: "output-error",
|
||||
toolCallId: part.id,
|
||||
toolCallId: part.callID,
|
||||
input: part.state.input,
|
||||
errorText: part.state.error,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import z from "zod"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace Message {
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
||||
export const AuthError = NamedError.create(
|
||||
"ProviderAuthError",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const ToolCall = z
|
||||
.object({
|
||||
@@ -134,11 +140,7 @@ export namespace Message {
|
||||
completed: z.number().optional(),
|
||||
}),
|
||||
error: z
|
||||
.discriminatedUnion("name", [
|
||||
Provider.AuthError.Schema,
|
||||
NamedError.Unknown.Schema,
|
||||
OutputLengthError.Schema,
|
||||
])
|
||||
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
|
||||
.optional(),
|
||||
sessionID: z.string(),
|
||||
tool: z.record(
|
||||
|
||||
@@ -30,6 +30,7 @@ export namespace Mode {
|
||||
write: false,
|
||||
edit: false,
|
||||
patch: false,
|
||||
bash: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -52,7 +53,7 @@ export namespace Mode {
|
||||
providerID,
|
||||
}
|
||||
}
|
||||
if (value.prompt) item.prompt = await Bun.file(value.prompt).text()
|
||||
if (value.prompt) item.prompt = value.prompt
|
||||
if (value.tools) item.tools = value.tools
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
Generate a short title based on the first message a user begins a conversation with. CRITICAL: Your response must be EXACTLY one line with NO line breaks, newlines, or multiple sentences.
|
||||
<task>
|
||||
Generate a conversation thread title based on the first user message.
|
||||
</task>
|
||||
|
||||
Requirements:
|
||||
<requirements>
|
||||
- Maximum 50 characters
|
||||
- Single line only - NO newlines or line breaks
|
||||
- Summary of the user's message
|
||||
- Create a descriptive thread name that captures the topic
|
||||
- No quotes, colons, or special formatting
|
||||
- Do not include explanatory text like "summary:" or similar
|
||||
- Your entire response becomes the title
|
||||
- Do not include explanatory text like "Title:" or similar prefixes
|
||||
</requirements>
|
||||
|
||||
IMPORTANT: Return only the title text on a single line. Do not add any explanations, formatting, or additional text.
|
||||
<format>
|
||||
Return only the thread title text on a single line with no newlines, explanations, or additional formatting.
|
||||
You should NEVER reply to the user's message. You can only generate titles.
|
||||
</format>
|
||||
|
||||
@@ -9,10 +9,8 @@ export namespace Snapshot {
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
|
||||
export async function create(sessionID: string) {
|
||||
return
|
||||
log.info("creating snapshot")
|
||||
const app = App.info()
|
||||
const git = gitdir(sessionID)
|
||||
|
||||
// not a git repo, check if too big to snapshot
|
||||
if (!app.git) {
|
||||
@@ -24,6 +22,7 @@ export namespace Snapshot {
|
||||
if (files.length > 1000) return
|
||||
}
|
||||
|
||||
const git = gitdir(sessionID)
|
||||
if (await fs.mkdir(git, { recursive: true })) {
|
||||
await $`git init`
|
||||
.env({
|
||||
@@ -39,23 +38,27 @@ export namespace Snapshot {
|
||||
await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow()
|
||||
log.info("added files")
|
||||
|
||||
const result =
|
||||
await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --author="opencode <mail@opencode.ai>"`
|
||||
.quiet()
|
||||
.cwd(app.path.cwd)
|
||||
.nothrow()
|
||||
log.info("commit")
|
||||
const result = await $`git --git-dir ${git} commit -m "snapshot" --author="opencode <mail@opencode.ai>"`
|
||||
.quiet()
|
||||
.cwd(app.path.cwd)
|
||||
.nothrow()
|
||||
|
||||
const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/)
|
||||
if (!match) return
|
||||
return match![1]
|
||||
}
|
||||
|
||||
export async function restore(sessionID: string, commit: string) {
|
||||
log.info("restore", { commit })
|
||||
export async function restore(sessionID: string, snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const app = App.info()
|
||||
const git = gitdir(sessionID)
|
||||
await $`git --git-dir=${git} checkout ${commit} --force`.quiet().cwd(app.path.root)
|
||||
await $`git --git-dir=${git} checkout ${snapshot} --force`.quiet().cwd(app.path.root)
|
||||
}
|
||||
|
||||
export async function diff(sessionID: string, commit: string) {
|
||||
const git = gitdir(sessionID)
|
||||
const result = await $`git --git-dir=${git} diff -R ${commit}`.quiet().cwd(App.info().path.root)
|
||||
return result.stdout.toString("utf8")
|
||||
}
|
||||
|
||||
function gitdir(sessionID: string) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "path"
|
||||
import z from "zod"
|
||||
import fs from "fs/promises"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Identifier } from "../id/id"
|
||||
|
||||
export namespace Storage {
|
||||
const log = Log.create({ service: "storage" })
|
||||
@@ -28,13 +29,49 @@ export namespace Storage {
|
||||
log.info("migrating to v2 message", { file })
|
||||
try {
|
||||
const result = MessageV2.fromV1(content)
|
||||
await Bun.write(file, JSON.stringify(result, null, 2))
|
||||
await Bun.write(
|
||||
file,
|
||||
JSON.stringify(
|
||||
{
|
||||
...result.info,
|
||||
parts: result.parts,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
} catch (e) {
|
||||
await fs.rename(file, file.replace("storage", "broken"))
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
async (dir: string) => {
|
||||
const files = new Bun.Glob("session/message/*/*.json").scanSync({
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
})
|
||||
for (const file of files) {
|
||||
try {
|
||||
const { parts, ...info } = await Bun.file(file).json()
|
||||
if (!parts) continue
|
||||
for (const part of parts) {
|
||||
const id = Identifier.ascending("part")
|
||||
await Bun.write(
|
||||
[dir, "session", "part", info.sessionID, info.id, id + ".json"].join("/"),
|
||||
JSON.stringify({
|
||||
...part,
|
||||
id,
|
||||
sessionID: info.sessionID,
|
||||
messageID: info.id,
|
||||
...(part.type === "tool" ? { callID: part.id } : {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
await Bun.write(file, JSON.stringify(info, null, 2))
|
||||
} catch (e) {}
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
const state = App.state("storage", async () => {
|
||||
@@ -84,18 +121,19 @@ export namespace Storage {
|
||||
}
|
||||
|
||||
const glob = new Bun.Glob("**/*")
|
||||
export async function* list(prefix: string) {
|
||||
export async function list(prefix: string) {
|
||||
const dir = await state().then((x) => x.dir)
|
||||
try {
|
||||
for await (const item of glob.scan({
|
||||
cwd: path.join(dir, prefix),
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
const result = path.join(prefix, item.slice(0, -5))
|
||||
yield result
|
||||
}
|
||||
const result = await Array.fromAsync(
|
||||
glob.scan({
|
||||
cwd: path.join(dir, prefix),
|
||||
onlyFiles: true,
|
||||
}),
|
||||
).then((items) => items.map((item) => path.join(prefix, item.slice(0, -5))))
|
||||
result.sort()
|
||||
return result
|
||||
} catch {
|
||||
return
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ export const EditTool = Tool.define({
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from old_string)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of old_string (default false)"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
if (!params.filePath) {
|
||||
|
||||
@@ -2,8 +2,8 @@ Performs exact string replacements in files.
|
||||
|
||||
Usage:
|
||||
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.
|
||||
- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
- The edit will FAIL if `oldString` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
|
||||
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
|
||||
@@ -18,6 +18,17 @@ export const IGNORE_PATTERNS = [
|
||||
".vscode/",
|
||||
".zig-cache/",
|
||||
"zig-out",
|
||||
".coverage",
|
||||
"coverage/",
|
||||
"vendor/",
|
||||
"tmp/",
|
||||
"temp/",
|
||||
".cache/",
|
||||
"cache/",
|
||||
"logs/",
|
||||
".venv/",
|
||||
"venv/",
|
||||
"env/",
|
||||
]
|
||||
|
||||
const LIMIT = 100
|
||||
|
||||
@@ -8,9 +8,9 @@ Before using this tool:
|
||||
To make multiple file edits, provide the following:
|
||||
1. file_path: The absolute path to the file to modify (must be absolute, not relative)
|
||||
2. edits: An array of edit operations to perform, where each edit contains:
|
||||
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
|
||||
- new_string: The edited text to replace the old_string
|
||||
- replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
|
||||
- oldString: The text to replace (must match the file contents exactly, including all whitespace and indentation)
|
||||
- newString: The edited text to replace the oldString
|
||||
- replaceAll: Replace all occurrences of oldString. This parameter is optional and defaults to false.
|
||||
|
||||
IMPORTANT:
|
||||
- All edits are applied in sequence, in the order they are provided
|
||||
@@ -24,8 +24,8 @@ CRITICAL REQUIREMENTS:
|
||||
3. Plan your edits carefully to avoid conflicts between sequential operations
|
||||
|
||||
WARNING:
|
||||
- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
|
||||
- The tool will fail if edits.old_string and edits.new_string are the same
|
||||
- The tool will fail if edits.oldString doesn't match the file contents exactly (including whitespace)
|
||||
- The tool will fail if edits.oldString and edits.newString are the same
|
||||
- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
|
||||
|
||||
When making edits:
|
||||
@@ -33,9 +33,9 @@ When making edits:
|
||||
- Do not leave the code in a broken state
|
||||
- Always use absolute file paths (starting with /)
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
- Use replaceAll for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
|
||||
If you want to create a new file, use:
|
||||
- A new file path, including dir name if needed
|
||||
- First edit: empty old_string and the new file's contents as new_string
|
||||
- First edit: empty oldString and the new file's contents as newString
|
||||
- Subsequent edits: normal edit operations on the created content
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod"
|
||||
import { Session } from "../session"
|
||||
import { Bus } from "../bus"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Identifier } from "../id/id"
|
||||
|
||||
export const TaskTool = Tool.define({
|
||||
id: "task",
|
||||
@@ -14,24 +15,20 @@ export const TaskTool = Tool.define({
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const session = await Session.create(ctx.sessionID)
|
||||
const msg = (await Session.getMessage(ctx.sessionID, ctx.messageID)) as MessageV2.Assistant
|
||||
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
|
||||
if (msg.role !== "assistant") throw new Error("Not an assistant message")
|
||||
|
||||
function summary(input: MessageV2.Info) {
|
||||
const result = []
|
||||
for (const part of input.parts) {
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
result.push(part)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const unsub = Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
|
||||
if (evt.properties.info.sessionID !== session.id) return
|
||||
const messageID = Identifier.ascending("message")
|
||||
const parts: Record<string, MessageV2.ToolPart> = {}
|
||||
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
||||
if (evt.properties.part.sessionID !== session.id) return
|
||||
if (evt.properties.part.messageID === messageID) return
|
||||
if (evt.properties.part.type !== "tool") return
|
||||
parts[evt.properties.part.id] = evt.properties.part
|
||||
ctx.metadata({
|
||||
title: params.description,
|
||||
metadata: {
|
||||
summary: summary(evt.properties.info),
|
||||
summary: Object.values(parts).sort((a, b) => a.id?.localeCompare(b.id)),
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -40,11 +37,15 @@ export const TaskTool = Tool.define({
|
||||
Session.abort(session.id)
|
||||
})
|
||||
const result = await Session.chat({
|
||||
messageID,
|
||||
sessionID: session.id,
|
||||
modelID: msg.modelID,
|
||||
providerID: msg.providerID,
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
messageID,
|
||||
sessionID: session.id,
|
||||
type: "text",
|
||||
text: params.prompt,
|
||||
},
|
||||
@@ -54,7 +55,7 @@ export const TaskTool = Tool.define({
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
summary: summary(result),
|
||||
summary: result.parts.filter((x) => x.type === "tool"),
|
||||
},
|
||||
output: result.parts.findLast((x) => x.type === "text")!.text,
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -6,4 +6,4 @@
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
export {}
|
||||
@@ -1,25 +0,0 @@
|
||||
# TUI Agent Guidelines
|
||||
|
||||
## Build/Test Commands
|
||||
|
||||
- **Build**: `go build ./cmd/opencode` (builds main binary)
|
||||
- **Test**: `go test ./...` (runs all tests)
|
||||
- **Single test**: `go test ./internal/theme -run TestLoadThemesFromJSON` (specific test)
|
||||
- **Release build**: Uses `.goreleaser.yml` configuration
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Language**: Go 1.24+ with standard formatting (`gofmt`)
|
||||
- **Imports**: Group standard, third-party, local packages with blank lines
|
||||
- **Naming**: Go conventions - PascalCase exports, camelCase private, ALL_CAPS constants
|
||||
- **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping
|
||||
- **Structs**: Define clear interfaces, embed when appropriate
|
||||
- **Testing**: Use table-driven tests, `t.TempDir()` for file operations
|
||||
|
||||
## Architecture
|
||||
|
||||
- **TUI Framework**: Bubble Tea v2 with Lipgloss v2 for styling
|
||||
- **Client**: Generated OpenAPI client communicates with TypeScript server
|
||||
- **Components**: Reusable UI components in `internal/components/`
|
||||
- **Themes**: JSON-based theming system with override hierarchy
|
||||
- **State**: Centralized app state with message passing
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
flag "github.com/spf13/pflag"
|
||||
@@ -27,6 +29,7 @@ func main() {
|
||||
|
||||
var model *string = flag.String("model", "", "model to begin with")
|
||||
var prompt *string = flag.String("prompt", "", "prompt to begin with")
|
||||
var mode *string = flag.String("mode", "", "mode to begin with")
|
||||
flag.Parse()
|
||||
|
||||
url := os.Getenv("OPENCODE_SERVER")
|
||||
@@ -39,6 +42,14 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
modesStr := os.Getenv("OPENCODE_MODES")
|
||||
var modes []opencode.Mode
|
||||
err = json.Unmarshal([]byte(modesStr), &modes)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal modes", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
httpClient := opencode.NewClient(
|
||||
option.WithBaseURL(url),
|
||||
)
|
||||
@@ -47,7 +58,7 @@ func main() {
|
||||
logger := slog.New(apiHandler)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Debug("TUI launched", "app", appInfo)
|
||||
slog.Debug("TUI launched", "app", appInfoStr, "modes", modesStr)
|
||||
|
||||
go func() {
|
||||
err = clipboard.Init()
|
||||
@@ -60,7 +71,7 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app_, err := app.New(ctx, version, appInfo, httpClient, model, prompt)
|
||||
app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -68,10 +79,14 @@ func main() {
|
||||
program := tea.NewProgram(
|
||||
tui.NewModel(app_),
|
||||
tea.WithAltScreen(),
|
||||
tea.WithKeyboardEnhancements(),
|
||||
// tea.WithKeyboardEnhancements(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
// Set up signal handling for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
||||
|
||||
go func() {
|
||||
stream := httpClient.Event.ListStreaming(ctx)
|
||||
for stream.Next() {
|
||||
@@ -84,6 +99,13 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle signals in a separate goroutine
|
||||
go func() {
|
||||
sig := <-sigChan
|
||||
slog.Info("Received signal, shutting down gracefully", "signal", sig)
|
||||
program.Quit()
|
||||
}()
|
||||
|
||||
// Run the TUI
|
||||
result, err := program.Run()
|
||||
if err != nil {
|
||||
|
||||
+9
-6
@@ -6,10 +6,11 @@ require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/alecthomas/chroma/v2 v2.18.0
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/x/ansi v0.8.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/x/ansi v0.9.3
|
||||
github.com/charmbracelet/x/input v0.3.7
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
@@ -21,7 +22,10 @@ require (
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
replace github.com/sst/opencode-sdk-go => ./sdk
|
||||
replace (
|
||||
github.com/charmbracelet/x/input => ./input
|
||||
github.com/sst/opencode-sdk-go => ./sdk
|
||||
)
|
||||
|
||||
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
|
||||
@@ -30,7 +34,6 @@ require (
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.1 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
@@ -65,7 +68,7 @@ require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
|
||||
+8
-10
@@ -22,26 +22,24 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3/go.mod h1:ZFDg5oPjyRYrPAa3iFrtP1DO8xy+LUQxd9JFHEcuwJY=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA=
|
||||
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
||||
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81/go.mod h1:poPFOXFTsJsnLbkV3H2KxAAXT7pdjxxLujLocWjkyzM=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 h1:fsWj8NF5njyMVzELc7++HsvRDvgz3VcgGAUgWBDWWWM=
|
||||
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197/go.mod h1:xseGeVftoP9rVI+/8WKYrJFH6ior6iERGvklwwHz5+s=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/muesli/cancelreader"
|
||||
)
|
||||
|
||||
func newCancelreader(r io.Reader, _ int) (cancelreader.CancelReader, error) {
|
||||
return cancelreader.NewReader(r) //nolint:wrapcheck
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
xwindows "github.com/charmbracelet/x/windows"
|
||||
"github.com/muesli/cancelreader"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type conInputReader struct {
|
||||
cancelMixin
|
||||
conin windows.Handle
|
||||
originalMode uint32
|
||||
}
|
||||
|
||||
var _ cancelreader.CancelReader = &conInputReader{}
|
||||
|
||||
func newCancelreader(r io.Reader, flags int) (cancelreader.CancelReader, error) {
|
||||
fallback := func(io.Reader) (cancelreader.CancelReader, error) {
|
||||
return cancelreader.NewReader(r)
|
||||
}
|
||||
|
||||
var dummy uint32
|
||||
if f, ok := r.(cancelreader.File); !ok || f.Fd() != os.Stdin.Fd() ||
|
||||
// If data was piped to the standard input, it does not emit events
|
||||
// anymore. We can detect this if the console mode cannot be set anymore,
|
||||
// in this case, we fallback to the default cancelreader implementation.
|
||||
windows.GetConsoleMode(windows.Handle(f.Fd()), &dummy) != nil {
|
||||
return fallback(r)
|
||||
}
|
||||
|
||||
conin, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
|
||||
if err != nil {
|
||||
return fallback(r)
|
||||
}
|
||||
|
||||
// Discard any pending input events.
|
||||
if err := xwindows.FlushConsoleInputBuffer(conin); err != nil {
|
||||
return fallback(r)
|
||||
}
|
||||
|
||||
modes := []uint32{
|
||||
windows.ENABLE_WINDOW_INPUT,
|
||||
windows.ENABLE_EXTENDED_FLAGS,
|
||||
}
|
||||
|
||||
// Enabling mouse mode implicitly blocks console text selection. Thus, we
|
||||
// need to enable it only if the mouse mode is requested.
|
||||
// In order to toggle mouse mode, the caller must recreate the reader with
|
||||
// the appropriate flag toggled.
|
||||
if flags&FlagMouseMode != 0 {
|
||||
modes = append(modes, windows.ENABLE_MOUSE_INPUT)
|
||||
}
|
||||
|
||||
originalMode, err := prepareConsole(conin, modes...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare console input: %w", err)
|
||||
}
|
||||
|
||||
return &conInputReader{
|
||||
conin: conin,
|
||||
originalMode: originalMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Cancel implements cancelreader.CancelReader.
|
||||
func (r *conInputReader) Cancel() bool {
|
||||
r.setCanceled()
|
||||
|
||||
return windows.CancelIoEx(r.conin, nil) == nil || windows.CancelIo(r.conin) == nil
|
||||
}
|
||||
|
||||
// Close implements cancelreader.CancelReader.
|
||||
func (r *conInputReader) Close() error {
|
||||
if r.originalMode != 0 {
|
||||
err := windows.SetConsoleMode(r.conin, r.originalMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reset console mode: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read implements cancelreader.CancelReader.
|
||||
func (r *conInputReader) Read(data []byte) (int, error) {
|
||||
if r.isCanceled() {
|
||||
return 0, cancelreader.ErrCanceled
|
||||
}
|
||||
|
||||
var n uint32
|
||||
if err := windows.ReadFile(r.conin, data, &n, nil); err != nil {
|
||||
return int(n), fmt.Errorf("read console input: %w", err)
|
||||
}
|
||||
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) {
|
||||
err = windows.GetConsoleMode(input, &originalMode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get console mode: %w", err)
|
||||
}
|
||||
|
||||
var newMode uint32
|
||||
for _, mode := range modes {
|
||||
newMode |= mode
|
||||
}
|
||||
|
||||
err = windows.SetConsoleMode(input, newMode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("set console mode: %w", err)
|
||||
}
|
||||
|
||||
return originalMode, nil
|
||||
}
|
||||
|
||||
// cancelMixin represents a goroutine-safe cancelation status.
|
||||
type cancelMixin struct {
|
||||
unsafeCanceled bool
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func (c *cancelMixin) setCanceled() {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
c.unsafeCanceled = true
|
||||
}
|
||||
|
||||
func (c *cancelMixin) isCanceled() bool {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
return c.unsafeCanceled
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package input
|
||||
|
||||
import "github.com/charmbracelet/x/ansi"
|
||||
|
||||
// ClipboardSelection represents a clipboard selection. The most common
|
||||
// clipboard selections are "system" and "primary" and selections.
|
||||
type ClipboardSelection = byte
|
||||
|
||||
// Clipboard selections.
|
||||
const (
|
||||
SystemClipboard ClipboardSelection = ansi.SystemClipboard
|
||||
PrimaryClipboard ClipboardSelection = ansi.PrimaryClipboard
|
||||
)
|
||||
|
||||
// ClipboardEvent is a clipboard read message event. This message is emitted when
|
||||
// a terminal receives an OSC52 clipboard read message event.
|
||||
type ClipboardEvent struct {
|
||||
Content string
|
||||
Selection ClipboardSelection
|
||||
}
|
||||
|
||||
// String returns the string representation of the clipboard message.
|
||||
func (e ClipboardEvent) String() string {
|
||||
return e.Content
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ForegroundColorEvent represents a foreground color event. This event is
|
||||
// emitted when the terminal requests the terminal foreground color using
|
||||
// [ansi.RequestForegroundColor].
|
||||
type ForegroundColorEvent struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e ForegroundColorEvent) String() string {
|
||||
return colorToHex(e.Color)
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e ForegroundColorEvent) IsDark() bool {
|
||||
return isDarkColor(e.Color)
|
||||
}
|
||||
|
||||
// BackgroundColorEvent represents a background color event. This event is
|
||||
// emitted when the terminal requests the terminal background color using
|
||||
// [ansi.RequestBackgroundColor].
|
||||
type BackgroundColorEvent struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e BackgroundColorEvent) String() string {
|
||||
return colorToHex(e)
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e BackgroundColorEvent) IsDark() bool {
|
||||
return isDarkColor(e.Color)
|
||||
}
|
||||
|
||||
// CursorColorEvent represents a cursor color change event. This event is
|
||||
// emitted when the program requests the terminal cursor color using
|
||||
// [ansi.RequestCursorColor].
|
||||
type CursorColorEvent struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e CursorColorEvent) String() string {
|
||||
return colorToHex(e)
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e CursorColorEvent) IsDark() bool {
|
||||
return isDarkColor(e)
|
||||
}
|
||||
|
||||
type shiftable interface {
|
||||
~uint | ~uint16 | ~uint32 | ~uint64
|
||||
}
|
||||
|
||||
func shift[T shiftable](x T) T {
|
||||
if x > 0xff {
|
||||
x >>= 8
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func colorToHex(c color.Color) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
r, g, b, _ := c.RGBA()
|
||||
return fmt.Sprintf("#%02x%02x%02x", shift(r), shift(g), shift(b))
|
||||
}
|
||||
|
||||
func getMaxMin(a, b, c float64) (ma, mi float64) {
|
||||
if a > b {
|
||||
ma = a
|
||||
mi = b
|
||||
} else {
|
||||
ma = b
|
||||
mi = a
|
||||
}
|
||||
if c > ma {
|
||||
ma = c
|
||||
} else if c < mi {
|
||||
mi = c
|
||||
}
|
||||
return ma, mi
|
||||
}
|
||||
|
||||
func round(x float64) float64 {
|
||||
return math.Round(x*1000) / 1000
|
||||
}
|
||||
|
||||
// rgbToHSL converts an RGB triple to an HSL triple.
|
||||
func rgbToHSL(r, g, b uint8) (h, s, l float64) {
|
||||
// convert uint32 pre-multiplied value to uint8
|
||||
// The r,g,b values are divided by 255 to change the range from 0..255 to 0..1:
|
||||
Rnot := float64(r) / 255
|
||||
Gnot := float64(g) / 255
|
||||
Bnot := float64(b) / 255
|
||||
Cmax, Cmin := getMaxMin(Rnot, Gnot, Bnot)
|
||||
Δ := Cmax - Cmin
|
||||
// Lightness calculation:
|
||||
l = (Cmax + Cmin) / 2
|
||||
// Hue and Saturation Calculation:
|
||||
if Δ == 0 {
|
||||
h = 0
|
||||
s = 0
|
||||
} else {
|
||||
switch Cmax {
|
||||
case Rnot:
|
||||
h = 60 * (math.Mod((Gnot-Bnot)/Δ, 6))
|
||||
case Gnot:
|
||||
h = 60 * (((Bnot - Rnot) / Δ) + 2)
|
||||
case Bnot:
|
||||
h = 60 * (((Rnot - Gnot) / Δ) + 4)
|
||||
}
|
||||
if h < 0 {
|
||||
h += 360
|
||||
}
|
||||
|
||||
s = Δ / (1 - math.Abs((2*l)-1))
|
||||
}
|
||||
|
||||
return h, round(s), round(l)
|
||||
}
|
||||
|
||||
// isDarkColor returns whether the given color is dark.
|
||||
func isDarkColor(c color.Color) bool {
|
||||
if c == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
r, g, b, _ := c.RGBA()
|
||||
_, _, l := rgbToHSL(uint8(r>>8), uint8(g>>8), uint8(b>>8)) //nolint:gosec
|
||||
return l < 0.5
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package input
|
||||
|
||||
import "image"
|
||||
|
||||
// CursorPositionEvent represents a cursor position event. Where X is the
|
||||
// zero-based column and Y is the zero-based row.
|
||||
type CursorPositionEvent image.Point
|
||||
@@ -0,0 +1,18 @@
|
||||
package input
|
||||
|
||||
import "github.com/charmbracelet/x/ansi"
|
||||
|
||||
// PrimaryDeviceAttributesEvent is an event that represents the terminal
|
||||
// primary device attributes.
|
||||
type PrimaryDeviceAttributesEvent []int
|
||||
|
||||
func parsePrimaryDevAttrs(params ansi.Params) Event {
|
||||
// Primary Device Attributes
|
||||
da1 := make(PrimaryDeviceAttributesEvent, len(params))
|
||||
for i, p := range params {
|
||||
if !p.HasMore() {
|
||||
da1[i] = p.Param(0)
|
||||
}
|
||||
}
|
||||
return da1
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Package input provides a set of utilities for handling input events in a
|
||||
// terminal environment. It includes support for reading input events, parsing
|
||||
// escape sequences, and handling clipboard events.
|
||||
// The package is designed to work with various terminal types and supports
|
||||
// customization through flags and options.
|
||||
package input
|
||||
@@ -0,0 +1,196 @@
|
||||
//nolint:unused,revive,nolintlint
|
||||
package input
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/muesli/cancelreader"
|
||||
)
|
||||
|
||||
// Logger is a simple logger interface.
|
||||
type Logger interface {
|
||||
Printf(format string, v ...any)
|
||||
}
|
||||
|
||||
// win32InputState is a state machine for parsing key events from the Windows
|
||||
// Console API into escape sequences and utf8 runes, and keeps track of the last
|
||||
// control key state to determine modifier key changes. It also keeps track of
|
||||
// the last mouse button state and window size changes to determine which mouse
|
||||
// buttons were released and to prevent multiple size events from firing.
|
||||
type win32InputState struct {
|
||||
ansiBuf [256]byte
|
||||
ansiIdx int
|
||||
utf16Buf [2]rune
|
||||
utf16Half bool
|
||||
lastCks uint32 // the last control key state for the previous event
|
||||
lastMouseBtns uint32 // the last mouse button state for the previous event
|
||||
lastWinsizeX, lastWinsizeY int16 // the last window size for the previous event to prevent multiple size events from firing
|
||||
}
|
||||
|
||||
// Reader represents an input event reader. It reads input events and parses
|
||||
// escape sequences from the terminal input buffer and translates them into
|
||||
// human-readable events.
|
||||
type Reader struct {
|
||||
rd cancelreader.CancelReader
|
||||
table map[string]Key // table is a lookup table for key sequences.
|
||||
|
||||
term string // term is the terminal name $TERM.
|
||||
|
||||
// paste is the bracketed paste mode buffer.
|
||||
// When nil, bracketed paste mode is disabled.
|
||||
paste []byte
|
||||
|
||||
buf [256]byte // do we need a larger buffer?
|
||||
|
||||
// partialSeq holds incomplete escape sequences that need more data
|
||||
partialSeq []byte
|
||||
|
||||
// keyState keeps track of the current Windows Console API key events state.
|
||||
// It is used to decode ANSI escape sequences and utf16 sequences.
|
||||
keyState win32InputState
|
||||
|
||||
parser Parser
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewReader returns a new input event reader. The reader reads input events
|
||||
// from the terminal and parses escape sequences into human-readable events. It
|
||||
// supports reading Terminfo databases. See [Parser] for more information.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// r, _ := input.NewReader(os.Stdin, os.Getenv("TERM"), 0)
|
||||
// defer r.Close()
|
||||
// events, _ := r.ReadEvents()
|
||||
// for _, ev := range events {
|
||||
// log.Printf("%v", ev)
|
||||
// }
|
||||
func NewReader(r io.Reader, termType string, flags int) (*Reader, error) {
|
||||
d := new(Reader)
|
||||
cr, err := newCancelreader(r, flags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.rd = cr
|
||||
d.table = buildKeysTable(flags, termType)
|
||||
d.term = termType
|
||||
d.parser.flags = flags
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SetLogger sets a logger for the reader.
|
||||
func (d *Reader) SetLogger(l Logger) {
|
||||
d.logger = l
|
||||
}
|
||||
|
||||
// Read implements [io.Reader].
|
||||
func (d *Reader) Read(p []byte) (int, error) {
|
||||
return d.rd.Read(p) //nolint:wrapcheck
|
||||
}
|
||||
|
||||
// Cancel cancels the underlying reader.
|
||||
func (d *Reader) Cancel() bool {
|
||||
return d.rd.Cancel()
|
||||
}
|
||||
|
||||
// Close closes the underlying reader.
|
||||
func (d *Reader) Close() error {
|
||||
return d.rd.Close() //nolint:wrapcheck
|
||||
}
|
||||
|
||||
func (d *Reader) readEvents() ([]Event, error) {
|
||||
nb, err := d.rd.Read(d.buf[:])
|
||||
if err != nil {
|
||||
return nil, err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
var events []Event
|
||||
|
||||
// Combine any partial sequence from previous read with new data
|
||||
var buf []byte
|
||||
if len(d.partialSeq) > 0 {
|
||||
buf = make([]byte, len(d.partialSeq)+nb)
|
||||
copy(buf, d.partialSeq)
|
||||
copy(buf[len(d.partialSeq):], d.buf[:nb])
|
||||
d.partialSeq = nil // clear the partial sequence
|
||||
} else {
|
||||
buf = d.buf[:nb]
|
||||
}
|
||||
|
||||
// Lookup table first
|
||||
if bytes.HasPrefix(buf, []byte{'\x1b'}) {
|
||||
if k, ok := d.table[string(buf)]; ok {
|
||||
if d.logger != nil {
|
||||
d.logger.Printf("input: %q", buf)
|
||||
}
|
||||
events = append(events, KeyPressEvent(k))
|
||||
return events, nil
|
||||
}
|
||||
}
|
||||
|
||||
var i int
|
||||
for i < len(buf) {
|
||||
nb, ev := d.parser.parseSequence(buf[i:])
|
||||
if d.logger != nil && nb > 0 {
|
||||
d.logger.Printf("input: %q", buf[i:i+nb])
|
||||
}
|
||||
|
||||
// Handle incomplete sequences - when parseSequence returns (0, nil)
|
||||
// it means we need more data to complete the sequence
|
||||
if nb == 0 && ev == nil {
|
||||
// Store the remaining data for the next read
|
||||
remaining := len(buf) - i
|
||||
if remaining > 0 {
|
||||
d.partialSeq = make([]byte, remaining)
|
||||
copy(d.partialSeq, buf[i:])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Handle bracketed-paste
|
||||
if d.paste != nil {
|
||||
if _, ok := ev.(PasteEndEvent); !ok {
|
||||
d.paste = append(d.paste, buf[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
switch ev.(type) {
|
||||
// case UnknownEvent:
|
||||
// // If the sequence is not recognized by the parser, try looking it up.
|
||||
// if k, ok := d.table[string(buf[i:i+nb])]; ok {
|
||||
// ev = KeyPressEvent(k)
|
||||
// }
|
||||
case PasteStartEvent:
|
||||
d.paste = []byte{}
|
||||
case PasteEndEvent:
|
||||
// Decode the captured data into runes.
|
||||
var paste []rune
|
||||
for len(d.paste) > 0 {
|
||||
r, w := utf8.DecodeRune(d.paste)
|
||||
if r != utf8.RuneError {
|
||||
paste = append(paste, r)
|
||||
}
|
||||
d.paste = d.paste[w:]
|
||||
}
|
||||
d.paste = nil // reset the buffer
|
||||
events = append(events, PasteEvent(paste))
|
||||
case nil:
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if mevs, ok := ev.(MultiEvent); ok {
|
||||
events = append(events, []Event(mevs)...)
|
||||
} else {
|
||||
events = append(events, ev)
|
||||
}
|
||||
i += nb
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package input
|
||||
|
||||
// ReadEvents reads input events from the terminal.
|
||||
//
|
||||
// It reads the events available in the input buffer and returns them.
|
||||
func (d *Reader) ReadEvents() ([]Event, error) {
|
||||
return d.readEvents()
|
||||
}
|
||||
|
||||
// parseWin32InputKeyEvent parses a Win32 input key events. This function is
|
||||
// only available on Windows.
|
||||
func (p *Parser) parseWin32InputKeyEvent(*win32InputState, uint16, uint16, rune, bool, uint32, uint16) Event {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkDriver(b *testing.B) {
|
||||
input := "\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~"
|
||||
rdr := strings.NewReader(input)
|
||||
drv, err := NewReader(rdr, "dumb", 0)
|
||||
if err != nil {
|
||||
b.Fatalf("could not create driver: %v", err)
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
rdr.Reset(input)
|
||||
if _, err := drv.ReadEvents(); err != nil && err != io.EOF {
|
||||
b.Errorf("error reading input: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
xwindows "github.com/charmbracelet/x/windows"
|
||||
"github.com/muesli/cancelreader"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// ReadEvents reads input events from the terminal.
|
||||
//
|
||||
// It reads the events available in the input buffer and returns them.
|
||||
func (d *Reader) ReadEvents() ([]Event, error) {
|
||||
events, err := d.handleConInput()
|
||||
if errors.Is(err, errNotConInputReader) {
|
||||
return d.readEvents()
|
||||
}
|
||||
return events, err
|
||||
}
|
||||
|
||||
var errNotConInputReader = fmt.Errorf("handleConInput: not a conInputReader")
|
||||
|
||||
func (d *Reader) handleConInput() ([]Event, error) {
|
||||
cc, ok := d.rd.(*conInputReader)
|
||||
if !ok {
|
||||
return nil, errNotConInputReader
|
||||
}
|
||||
|
||||
var (
|
||||
events []xwindows.InputRecord
|
||||
err error
|
||||
)
|
||||
for {
|
||||
// Peek up to 256 events, this is to allow for sequences events reported as
|
||||
// key events.
|
||||
events, err = peekNConsoleInputs(cc.conin, 256)
|
||||
if cc.isCanceled() {
|
||||
return nil, cancelreader.ErrCanceled
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peek coninput events: %w", err)
|
||||
}
|
||||
if len(events) > 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Sleep for a bit to avoid busy waiting.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
events, err = readNConsoleInputs(cc.conin, uint32(len(events)))
|
||||
if cc.isCanceled() {
|
||||
return nil, cancelreader.ErrCanceled
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read coninput events: %w", err)
|
||||
}
|
||||
|
||||
var evs []Event
|
||||
for _, event := range events {
|
||||
if e := d.parser.parseConInputEvent(event, &d.keyState); e != nil {
|
||||
if multi, ok := e.(MultiEvent); ok {
|
||||
evs = append(evs, multi...)
|
||||
} else {
|
||||
evs = append(evs, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseConInputEvent(event xwindows.InputRecord, keyState *win32InputState) Event {
|
||||
switch event.EventType {
|
||||
case xwindows.KEY_EVENT:
|
||||
kevent := event.KeyEvent()
|
||||
return p.parseWin32InputKeyEvent(keyState, kevent.VirtualKeyCode, kevent.VirtualScanCode,
|
||||
kevent.Char, kevent.KeyDown, kevent.ControlKeyState, kevent.RepeatCount)
|
||||
|
||||
case xwindows.WINDOW_BUFFER_SIZE_EVENT:
|
||||
wevent := event.WindowBufferSizeEvent()
|
||||
if wevent.Size.X != keyState.lastWinsizeX || wevent.Size.Y != keyState.lastWinsizeY {
|
||||
keyState.lastWinsizeX, keyState.lastWinsizeY = wevent.Size.X, wevent.Size.Y
|
||||
return WindowSizeEvent{
|
||||
Width: int(wevent.Size.X),
|
||||
Height: int(wevent.Size.Y),
|
||||
}
|
||||
}
|
||||
case xwindows.MOUSE_EVENT:
|
||||
mevent := event.MouseEvent()
|
||||
Event := mouseEvent(keyState.lastMouseBtns, mevent)
|
||||
keyState.lastMouseBtns = mevent.ButtonState
|
||||
return Event
|
||||
case xwindows.FOCUS_EVENT:
|
||||
fevent := event.FocusEvent()
|
||||
if fevent.SetFocus {
|
||||
return FocusEvent{}
|
||||
}
|
||||
return BlurEvent{}
|
||||
case xwindows.MENU_EVENT:
|
||||
// ignore
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mouseEventButton(p, s uint32) (MouseButton, bool) {
|
||||
var isRelease bool
|
||||
button := MouseNone
|
||||
btn := p ^ s
|
||||
if btn&s == 0 {
|
||||
isRelease = true
|
||||
}
|
||||
|
||||
if btn == 0 {
|
||||
switch {
|
||||
case s&xwindows.FROM_LEFT_1ST_BUTTON_PRESSED > 0:
|
||||
button = MouseLeft
|
||||
case s&xwindows.FROM_LEFT_2ND_BUTTON_PRESSED > 0:
|
||||
button = MouseMiddle
|
||||
case s&xwindows.RIGHTMOST_BUTTON_PRESSED > 0:
|
||||
button = MouseRight
|
||||
case s&xwindows.FROM_LEFT_3RD_BUTTON_PRESSED > 0:
|
||||
button = MouseBackward
|
||||
case s&xwindows.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
|
||||
button = MouseForward
|
||||
}
|
||||
return button, isRelease
|
||||
}
|
||||
|
||||
switch btn {
|
||||
case xwindows.FROM_LEFT_1ST_BUTTON_PRESSED: // left button
|
||||
button = MouseLeft
|
||||
case xwindows.RIGHTMOST_BUTTON_PRESSED: // right button
|
||||
button = MouseRight
|
||||
case xwindows.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button
|
||||
button = MouseMiddle
|
||||
case xwindows.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward)
|
||||
button = MouseBackward
|
||||
case xwindows.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward)
|
||||
button = MouseForward
|
||||
}
|
||||
|
||||
return button, isRelease
|
||||
}
|
||||
|
||||
func mouseEvent(p uint32, e xwindows.MouseEventRecord) (ev Event) {
|
||||
var mod KeyMod
|
||||
var isRelease bool
|
||||
if e.ControlKeyState&(xwindows.LEFT_ALT_PRESSED|xwindows.RIGHT_ALT_PRESSED) != 0 {
|
||||
mod |= ModAlt
|
||||
}
|
||||
if e.ControlKeyState&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_CTRL_PRESSED) != 0 {
|
||||
mod |= ModCtrl
|
||||
}
|
||||
if e.ControlKeyState&(xwindows.SHIFT_PRESSED) != 0 {
|
||||
mod |= ModShift
|
||||
}
|
||||
|
||||
m := Mouse{
|
||||
X: int(e.MousePositon.X),
|
||||
Y: int(e.MousePositon.Y),
|
||||
Mod: mod,
|
||||
}
|
||||
|
||||
wheelDirection := int16(highWord(e.ButtonState)) //nolint:gosec
|
||||
switch e.EventFlags {
|
||||
case 0, xwindows.DOUBLE_CLICK:
|
||||
m.Button, isRelease = mouseEventButton(p, e.ButtonState)
|
||||
case xwindows.MOUSE_WHEELED:
|
||||
if wheelDirection > 0 {
|
||||
m.Button = MouseWheelUp
|
||||
} else {
|
||||
m.Button = MouseWheelDown
|
||||
}
|
||||
case xwindows.MOUSE_HWHEELED:
|
||||
if wheelDirection > 0 {
|
||||
m.Button = MouseWheelRight
|
||||
} else {
|
||||
m.Button = MouseWheelLeft
|
||||
}
|
||||
case xwindows.MOUSE_MOVED:
|
||||
m.Button, _ = mouseEventButton(p, e.ButtonState)
|
||||
return MouseMotionEvent(m)
|
||||
}
|
||||
|
||||
if isWheel(m.Button) {
|
||||
return MouseWheelEvent(m)
|
||||
} else if isRelease {
|
||||
return MouseReleaseEvent(m)
|
||||
}
|
||||
|
||||
return MouseClickEvent(m)
|
||||
}
|
||||
|
||||
func highWord(data uint32) uint16 {
|
||||
return uint16((data & 0xFFFF0000) >> 16) //nolint:gosec
|
||||
}
|
||||
|
||||
func readNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
|
||||
if maxEvents == 0 {
|
||||
return nil, fmt.Errorf("maxEvents cannot be zero")
|
||||
}
|
||||
|
||||
records := make([]xwindows.InputRecord, maxEvents)
|
||||
n, err := readConsoleInput(console, records)
|
||||
return records[:n], err
|
||||
}
|
||||
|
||||
func readConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
|
||||
if len(inputRecords) == 0 {
|
||||
return 0, fmt.Errorf("size of input record buffer cannot be zero")
|
||||
}
|
||||
|
||||
var read uint32
|
||||
|
||||
err := xwindows.ReadConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
|
||||
|
||||
return read, err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
func peekConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
|
||||
if len(inputRecords) == 0 {
|
||||
return 0, fmt.Errorf("size of input record buffer cannot be zero")
|
||||
}
|
||||
|
||||
var read uint32
|
||||
|
||||
err := xwindows.PeekConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
|
||||
|
||||
return read, err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
func peekNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
|
||||
if maxEvents == 0 {
|
||||
return nil, fmt.Errorf("maxEvents cannot be zero")
|
||||
}
|
||||
|
||||
records := make([]xwindows.InputRecord, maxEvents)
|
||||
n, err := peekConsoleInput(console, records)
|
||||
return records[:n], err
|
||||
}
|
||||
|
||||
// parseWin32InputKeyEvent parses a single key event from either the Windows
|
||||
// Console API or win32-input-mode events. When state is nil, it means this is
|
||||
// an event from win32-input-mode. Otherwise, it's a key event from the Windows
|
||||
// Console API and needs a state to decode ANSI escape sequences and utf16
|
||||
// runes.
|
||||
func (p *Parser) parseWin32InputKeyEvent(state *win32InputState, vkc uint16, _ uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) (event Event) {
|
||||
defer func() {
|
||||
// Respect the repeat count.
|
||||
if repeatCount > 1 {
|
||||
var multi MultiEvent
|
||||
for i := 0; i < int(repeatCount); i++ {
|
||||
multi = append(multi, event)
|
||||
}
|
||||
event = multi
|
||||
}
|
||||
}()
|
||||
if state != nil {
|
||||
defer func() {
|
||||
state.lastCks = cks
|
||||
}()
|
||||
}
|
||||
|
||||
var utf8Buf [utf8.UTFMax]byte
|
||||
var key Key
|
||||
if state != nil && state.utf16Half {
|
||||
state.utf16Half = false
|
||||
state.utf16Buf[1] = r
|
||||
codepoint := utf16.DecodeRune(state.utf16Buf[0], state.utf16Buf[1])
|
||||
rw := utf8.EncodeRune(utf8Buf[:], codepoint)
|
||||
r, _ = utf8.DecodeRune(utf8Buf[:rw])
|
||||
key.Code = r
|
||||
key.Text = string(r)
|
||||
key.Mod = translateControlKeyState(cks)
|
||||
key = ensureKeyCase(key, cks)
|
||||
if keyDown {
|
||||
return KeyPressEvent(key)
|
||||
}
|
||||
return KeyReleaseEvent(key)
|
||||
}
|
||||
|
||||
var baseCode rune
|
||||
switch {
|
||||
case vkc == 0:
|
||||
// Zero means this event is either an escape code or a unicode
|
||||
// codepoint.
|
||||
if state != nil && state.ansiIdx == 0 && r != ansi.ESC {
|
||||
// This is a unicode codepoint.
|
||||
baseCode = r
|
||||
break
|
||||
}
|
||||
|
||||
if state != nil {
|
||||
// Collect ANSI escape code.
|
||||
state.ansiBuf[state.ansiIdx] = byte(r)
|
||||
state.ansiIdx++
|
||||
if state.ansiIdx <= 2 {
|
||||
// We haven't received enough bytes to determine if this is an
|
||||
// ANSI escape code.
|
||||
return nil
|
||||
}
|
||||
if r == ansi.ESC {
|
||||
// We're expecting a closing String Terminator [ansi.ST].
|
||||
return nil
|
||||
}
|
||||
|
||||
n, event := p.parseSequence(state.ansiBuf[:state.ansiIdx])
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, ok := event.(UnknownEvent); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.ansiIdx = 0
|
||||
return event
|
||||
}
|
||||
case vkc == xwindows.VK_BACK:
|
||||
baseCode = KeyBackspace
|
||||
case vkc == xwindows.VK_TAB:
|
||||
baseCode = KeyTab
|
||||
case vkc == xwindows.VK_RETURN:
|
||||
baseCode = KeyEnter
|
||||
case vkc == xwindows.VK_SHIFT:
|
||||
//nolint:nestif
|
||||
if cks&xwindows.SHIFT_PRESSED != 0 {
|
||||
if cks&xwindows.ENHANCED_KEY != 0 {
|
||||
baseCode = KeyRightShift
|
||||
} else {
|
||||
baseCode = KeyLeftShift
|
||||
}
|
||||
} else if state != nil {
|
||||
if state.lastCks&xwindows.SHIFT_PRESSED != 0 {
|
||||
if state.lastCks&xwindows.ENHANCED_KEY != 0 {
|
||||
baseCode = KeyRightShift
|
||||
} else {
|
||||
baseCode = KeyLeftShift
|
||||
}
|
||||
}
|
||||
}
|
||||
case vkc == xwindows.VK_CONTROL:
|
||||
if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyLeftCtrl
|
||||
} else if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyRightCtrl
|
||||
} else if state != nil {
|
||||
if state.lastCks&xwindows.LEFT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyLeftCtrl
|
||||
} else if state.lastCks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyRightCtrl
|
||||
}
|
||||
}
|
||||
case vkc == xwindows.VK_MENU:
|
||||
if cks&xwindows.LEFT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyLeftAlt
|
||||
} else if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyRightAlt
|
||||
} else if state != nil {
|
||||
if state.lastCks&xwindows.LEFT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyLeftAlt
|
||||
} else if state.lastCks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyRightAlt
|
||||
}
|
||||
}
|
||||
case vkc == xwindows.VK_PAUSE:
|
||||
baseCode = KeyPause
|
||||
case vkc == xwindows.VK_CAPITAL:
|
||||
baseCode = KeyCapsLock
|
||||
case vkc == xwindows.VK_ESCAPE:
|
||||
baseCode = KeyEscape
|
||||
case vkc == xwindows.VK_SPACE:
|
||||
baseCode = KeySpace
|
||||
case vkc == xwindows.VK_PRIOR:
|
||||
baseCode = KeyPgUp
|
||||
case vkc == xwindows.VK_NEXT:
|
||||
baseCode = KeyPgDown
|
||||
case vkc == xwindows.VK_END:
|
||||
baseCode = KeyEnd
|
||||
case vkc == xwindows.VK_HOME:
|
||||
baseCode = KeyHome
|
||||
case vkc == xwindows.VK_LEFT:
|
||||
baseCode = KeyLeft
|
||||
case vkc == xwindows.VK_UP:
|
||||
baseCode = KeyUp
|
||||
case vkc == xwindows.VK_RIGHT:
|
||||
baseCode = KeyRight
|
||||
case vkc == xwindows.VK_DOWN:
|
||||
baseCode = KeyDown
|
||||
case vkc == xwindows.VK_SELECT:
|
||||
baseCode = KeySelect
|
||||
case vkc == xwindows.VK_SNAPSHOT:
|
||||
baseCode = KeyPrintScreen
|
||||
case vkc == xwindows.VK_INSERT:
|
||||
baseCode = KeyInsert
|
||||
case vkc == xwindows.VK_DELETE:
|
||||
baseCode = KeyDelete
|
||||
case vkc >= '0' && vkc <= '9':
|
||||
baseCode = rune(vkc)
|
||||
case vkc >= 'A' && vkc <= 'Z':
|
||||
// Convert to lowercase.
|
||||
baseCode = rune(vkc) + 32
|
||||
case vkc == xwindows.VK_LWIN:
|
||||
baseCode = KeyLeftSuper
|
||||
case vkc == xwindows.VK_RWIN:
|
||||
baseCode = KeyRightSuper
|
||||
case vkc == xwindows.VK_APPS:
|
||||
baseCode = KeyMenu
|
||||
case vkc >= xwindows.VK_NUMPAD0 && vkc <= xwindows.VK_NUMPAD9:
|
||||
baseCode = rune(vkc-xwindows.VK_NUMPAD0) + KeyKp0
|
||||
case vkc == xwindows.VK_MULTIPLY:
|
||||
baseCode = KeyKpMultiply
|
||||
case vkc == xwindows.VK_ADD:
|
||||
baseCode = KeyKpPlus
|
||||
case vkc == xwindows.VK_SEPARATOR:
|
||||
baseCode = KeyKpComma
|
||||
case vkc == xwindows.VK_SUBTRACT:
|
||||
baseCode = KeyKpMinus
|
||||
case vkc == xwindows.VK_DECIMAL:
|
||||
baseCode = KeyKpDecimal
|
||||
case vkc == xwindows.VK_DIVIDE:
|
||||
baseCode = KeyKpDivide
|
||||
case vkc >= xwindows.VK_F1 && vkc <= xwindows.VK_F24:
|
||||
baseCode = rune(vkc-xwindows.VK_F1) + KeyF1
|
||||
case vkc == xwindows.VK_NUMLOCK:
|
||||
baseCode = KeyNumLock
|
||||
case vkc == xwindows.VK_SCROLL:
|
||||
baseCode = KeyScrollLock
|
||||
case vkc == xwindows.VK_LSHIFT:
|
||||
baseCode = KeyLeftShift
|
||||
case vkc == xwindows.VK_RSHIFT:
|
||||
baseCode = KeyRightShift
|
||||
case vkc == xwindows.VK_LCONTROL:
|
||||
baseCode = KeyLeftCtrl
|
||||
case vkc == xwindows.VK_RCONTROL:
|
||||
baseCode = KeyRightCtrl
|
||||
case vkc == xwindows.VK_LMENU:
|
||||
baseCode = KeyLeftAlt
|
||||
case vkc == xwindows.VK_RMENU:
|
||||
baseCode = KeyRightAlt
|
||||
case vkc == xwindows.VK_VOLUME_MUTE:
|
||||
baseCode = KeyMute
|
||||
case vkc == xwindows.VK_VOLUME_DOWN:
|
||||
baseCode = KeyLowerVol
|
||||
case vkc == xwindows.VK_VOLUME_UP:
|
||||
baseCode = KeyRaiseVol
|
||||
case vkc == xwindows.VK_MEDIA_NEXT_TRACK:
|
||||
baseCode = KeyMediaNext
|
||||
case vkc == xwindows.VK_MEDIA_PREV_TRACK:
|
||||
baseCode = KeyMediaPrev
|
||||
case vkc == xwindows.VK_MEDIA_STOP:
|
||||
baseCode = KeyMediaStop
|
||||
case vkc == xwindows.VK_MEDIA_PLAY_PAUSE:
|
||||
baseCode = KeyMediaPlayPause
|
||||
case vkc == xwindows.VK_OEM_1:
|
||||
baseCode = ';'
|
||||
case vkc == xwindows.VK_OEM_PLUS:
|
||||
baseCode = '+'
|
||||
case vkc == xwindows.VK_OEM_COMMA:
|
||||
baseCode = ','
|
||||
case vkc == xwindows.VK_OEM_MINUS:
|
||||
baseCode = '-'
|
||||
case vkc == xwindows.VK_OEM_PERIOD:
|
||||
baseCode = '.'
|
||||
case vkc == xwindows.VK_OEM_2:
|
||||
baseCode = '/'
|
||||
case vkc == xwindows.VK_OEM_3:
|
||||
baseCode = '`'
|
||||
case vkc == xwindows.VK_OEM_4:
|
||||
baseCode = '['
|
||||
case vkc == xwindows.VK_OEM_5:
|
||||
baseCode = '\\'
|
||||
case vkc == xwindows.VK_OEM_6:
|
||||
baseCode = ']'
|
||||
case vkc == xwindows.VK_OEM_7:
|
||||
baseCode = '\''
|
||||
}
|
||||
|
||||
if utf16.IsSurrogate(r) {
|
||||
if state != nil {
|
||||
state.utf16Buf[0] = r
|
||||
state.utf16Half = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AltGr is left ctrl + right alt. On non-US keyboards, this is used to type
|
||||
// special characters and produce printable events.
|
||||
// XXX: Should this be a KeyMod?
|
||||
altGr := cks&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED) == xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED
|
||||
|
||||
var text string
|
||||
keyCode := baseCode
|
||||
if !unicode.IsControl(r) {
|
||||
rw := utf8.EncodeRune(utf8Buf[:], r)
|
||||
keyCode, _ = utf8.DecodeRune(utf8Buf[:rw])
|
||||
if unicode.IsPrint(keyCode) && (cks == 0 ||
|
||||
cks == xwindows.SHIFT_PRESSED ||
|
||||
cks == xwindows.CAPSLOCK_ON ||
|
||||
altGr) {
|
||||
// If the control key state is 0, shift is pressed, or caps lock
|
||||
// then the key event is a printable event i.e. [text] is not empty.
|
||||
text = string(keyCode)
|
||||
}
|
||||
}
|
||||
|
||||
key.Code = keyCode
|
||||
key.Text = text
|
||||
key.Mod = translateControlKeyState(cks)
|
||||
key.BaseCode = baseCode
|
||||
key = ensureKeyCase(key, cks)
|
||||
if keyDown {
|
||||
return KeyPressEvent(key)
|
||||
}
|
||||
|
||||
return KeyReleaseEvent(key)
|
||||
}
|
||||
|
||||
// ensureKeyCase ensures that the key's text is in the correct case based on the
|
||||
// control key state.
|
||||
func ensureKeyCase(key Key, cks uint32) Key {
|
||||
if len(key.Text) == 0 {
|
||||
return key
|
||||
}
|
||||
|
||||
hasShift := cks&xwindows.SHIFT_PRESSED != 0
|
||||
hasCaps := cks&xwindows.CAPSLOCK_ON != 0
|
||||
if hasShift || hasCaps {
|
||||
if unicode.IsLower(key.Code) {
|
||||
key.ShiftedCode = unicode.ToUpper(key.Code)
|
||||
key.Text = string(key.ShiftedCode)
|
||||
}
|
||||
} else {
|
||||
if unicode.IsUpper(key.Code) {
|
||||
key.ShiftedCode = unicode.ToLower(key.Code)
|
||||
key.Text = string(key.ShiftedCode)
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// translateControlKeyState translates the control key state from the Windows
|
||||
// Console API into a Mod bitmask.
|
||||
func translateControlKeyState(cks uint32) (m KeyMod) {
|
||||
if cks&xwindows.LEFT_CTRL_PRESSED != 0 || cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
m |= ModCtrl
|
||||
}
|
||||
if cks&xwindows.LEFT_ALT_PRESSED != 0 || cks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
m |= ModAlt
|
||||
}
|
||||
if cks&xwindows.SHIFT_PRESSED != 0 {
|
||||
m |= ModShift
|
||||
}
|
||||
if cks&xwindows.CAPSLOCK_ON != 0 {
|
||||
m |= ModCapsLock
|
||||
}
|
||||
if cks&xwindows.NUMLOCK_ON != 0 {
|
||||
m |= ModNumLock
|
||||
}
|
||||
if cks&xwindows.SCROLLLOCK_ON != 0 {
|
||||
m |= ModScrollLock
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func keyEventString(vkc, sc uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) string {
|
||||
var s strings.Builder
|
||||
s.WriteString("vkc: ")
|
||||
s.WriteString(fmt.Sprintf("%d, 0x%02x", vkc, vkc))
|
||||
s.WriteString(", sc: ")
|
||||
s.WriteString(fmt.Sprintf("%d, 0x%02x", sc, sc))
|
||||
s.WriteString(", r: ")
|
||||
s.WriteString(fmt.Sprintf("%q", r))
|
||||
s.WriteString(", down: ")
|
||||
s.WriteString(fmt.Sprintf("%v", keyDown))
|
||||
s.WriteString(", cks: [")
|
||||
if cks&xwindows.LEFT_ALT_PRESSED != 0 {
|
||||
s.WriteString("left alt, ")
|
||||
}
|
||||
if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
s.WriteString("right alt, ")
|
||||
}
|
||||
if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
|
||||
s.WriteString("left ctrl, ")
|
||||
}
|
||||
if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
s.WriteString("right ctrl, ")
|
||||
}
|
||||
if cks&xwindows.SHIFT_PRESSED != 0 {
|
||||
s.WriteString("shift, ")
|
||||
}
|
||||
if cks&xwindows.CAPSLOCK_ON != 0 {
|
||||
s.WriteString("caps lock, ")
|
||||
}
|
||||
if cks&xwindows.NUMLOCK_ON != 0 {
|
||||
s.WriteString("num lock, ")
|
||||
}
|
||||
if cks&xwindows.SCROLLLOCK_ON != 0 {
|
||||
s.WriteString("scroll lock, ")
|
||||
}
|
||||
if cks&xwindows.ENHANCED_KEY != 0 {
|
||||
s.WriteString("enhanced key, ")
|
||||
}
|
||||
s.WriteString("], repeat count: ")
|
||||
s.WriteString(fmt.Sprintf("%d", repeatCount))
|
||||
return s.String()
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"image/color"
|
||||
"reflect"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
xwindows "github.com/charmbracelet/x/windows"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestWindowsInputEvents(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
events []xwindows.InputRecord
|
||||
expected []Event
|
||||
sequence bool // indicates that the input events are ANSI sequence or utf16
|
||||
}{
|
||||
{
|
||||
name: "single key event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: 'a',
|
||||
VirtualKeyCode: 'A',
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Text: "a"}},
|
||||
},
|
||||
{
|
||||
name: "single key event with control key",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: 'a',
|
||||
VirtualKeyCode: 'A',
|
||||
ControlKeyState: xwindows.LEFT_CTRL_PRESSED,
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Mod: ModCtrl}},
|
||||
},
|
||||
{
|
||||
name: "escape alt key event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: ansi.ESC,
|
||||
VirtualKeyCode: ansi.ESC,
|
||||
ControlKeyState: xwindows.LEFT_ALT_PRESSED,
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: ansi.ESC, BaseCode: ansi.ESC, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
name: "single shifted key event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: 'A',
|
||||
VirtualKeyCode: 'A',
|
||||
ControlKeyState: xwindows.SHIFT_PRESSED,
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: 'A', BaseCode: 'a', Text: "A", Mod: ModShift}},
|
||||
},
|
||||
{
|
||||
name: "utf16 rune",
|
||||
events: encodeUtf16Rune('😊'), // smiley emoji '😊'
|
||||
expected: []Event{
|
||||
KeyPressEvent{Code: '😊', Text: "😊"},
|
||||
},
|
||||
sequence: true,
|
||||
},
|
||||
{
|
||||
name: "background color response",
|
||||
events: encodeSequence("\x1b]11;rgb:ff/ff/ff\x07"),
|
||||
expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
|
||||
sequence: true,
|
||||
},
|
||||
{
|
||||
name: "st terminated background color response",
|
||||
events: encodeSequence("\x1b]11;rgb:ffff/ffff/ffff\x1b\\"),
|
||||
expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
|
||||
sequence: true,
|
||||
},
|
||||
{
|
||||
name: "simple mouse event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeMouseEvent(xwindows.MouseEventRecord{
|
||||
MousePositon: windows.Coord{X: 10, Y: 20},
|
||||
ButtonState: xwindows.FROM_LEFT_1ST_BUTTON_PRESSED,
|
||||
EventFlags: 0,
|
||||
}),
|
||||
encodeMouseEvent(xwindows.MouseEventRecord{
|
||||
MousePositon: windows.Coord{X: 10, Y: 20},
|
||||
EventFlags: 0,
|
||||
}),
|
||||
},
|
||||
expected: []Event{
|
||||
MouseClickEvent{Button: MouseLeft, X: 10, Y: 20},
|
||||
MouseReleaseEvent{Button: MouseLeft, X: 10, Y: 20},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "focus event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeFocusEvent(xwindows.FocusEventRecord{
|
||||
SetFocus: true,
|
||||
}),
|
||||
encodeFocusEvent(xwindows.FocusEventRecord{
|
||||
SetFocus: false,
|
||||
}),
|
||||
},
|
||||
expected: []Event{
|
||||
FocusEvent{},
|
||||
BlurEvent{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "window size event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeWindowBufferSizeEvent(xwindows.WindowBufferSizeRecord{
|
||||
Size: windows.Coord{X: 10, Y: 20},
|
||||
}),
|
||||
},
|
||||
expected: []Event{
|
||||
WindowSizeEvent{Width: 10, Height: 20},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// p is the parser to parse the input events
|
||||
var p Parser
|
||||
|
||||
// keep track of the state of the driver to handle ANSI sequences and utf16
|
||||
var state win32InputState
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.sequence {
|
||||
var Event Event
|
||||
for _, ev := range tc.events {
|
||||
if ev.EventType != xwindows.KEY_EVENT {
|
||||
t.Fatalf("expected key event, got %v", ev.EventType)
|
||||
}
|
||||
|
||||
key := ev.KeyEvent()
|
||||
Event = p.parseWin32InputKeyEvent(&state, key.VirtualKeyCode, key.VirtualScanCode, key.Char, key.KeyDown, key.ControlKeyState, key.RepeatCount)
|
||||
}
|
||||
if len(tc.expected) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(tc.expected))
|
||||
}
|
||||
if !reflect.DeepEqual(Event, tc.expected[0]) {
|
||||
t.Errorf("expected %v, got %v", tc.expected[0], Event)
|
||||
}
|
||||
} else {
|
||||
if len(tc.events) != len(tc.expected) {
|
||||
t.Fatalf("expected %d events, got %d", len(tc.expected), len(tc.events))
|
||||
}
|
||||
for j, ev := range tc.events {
|
||||
Event := p.parseConInputEvent(ev, &state)
|
||||
if !reflect.DeepEqual(Event, tc.expected[j]) {
|
||||
t.Errorf("expected %#v, got %#v", tc.expected[j], Event)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func boolToUint32(b bool) uint32 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func encodeMenuEvent(menu xwindows.MenuEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint32(bts[0:4], menu.CommandID)
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.MENU_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeWindowBufferSizeEvent(size xwindows.WindowBufferSizeRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint16(bts[0:2], uint16(size.Size.X))
|
||||
binary.LittleEndian.PutUint16(bts[2:4], uint16(size.Size.Y))
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.WINDOW_BUFFER_SIZE_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeFocusEvent(focus xwindows.FocusEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
if focus.SetFocus {
|
||||
bts[0] = 1
|
||||
}
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.FOCUS_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeMouseEvent(mouse xwindows.MouseEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint16(bts[0:2], uint16(mouse.MousePositon.X))
|
||||
binary.LittleEndian.PutUint16(bts[2:4], uint16(mouse.MousePositon.Y))
|
||||
binary.LittleEndian.PutUint32(bts[4:8], mouse.ButtonState)
|
||||
binary.LittleEndian.PutUint32(bts[8:12], mouse.ControlKeyState)
|
||||
binary.LittleEndian.PutUint32(bts[12:16], mouse.EventFlags)
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.MOUSE_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeKeyEvent(key xwindows.KeyEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint32(bts[0:4], boolToUint32(key.KeyDown))
|
||||
binary.LittleEndian.PutUint16(bts[4:6], key.RepeatCount)
|
||||
binary.LittleEndian.PutUint16(bts[6:8], key.VirtualKeyCode)
|
||||
binary.LittleEndian.PutUint16(bts[8:10], key.VirtualScanCode)
|
||||
binary.LittleEndian.PutUint16(bts[10:12], uint16(key.Char))
|
||||
binary.LittleEndian.PutUint32(bts[12:16], key.ControlKeyState)
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.KEY_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
// encodeSequence encodes a string of ANSI escape sequences into a slice of
|
||||
// Windows input key records.
|
||||
func encodeSequence(s string) (evs []xwindows.InputRecord) {
|
||||
var state byte
|
||||
for len(s) > 0 {
|
||||
seq, _, n, newState := ansi.DecodeSequence(s, state, nil)
|
||||
for i := 0; i < n; i++ {
|
||||
evs = append(evs, encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: rune(seq[i]),
|
||||
}))
|
||||
}
|
||||
state = newState
|
||||
s = s[n:]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func encodeUtf16Rune(r rune) []xwindows.InputRecord {
|
||||
r1, r2 := utf16.EncodeRune(r)
|
||||
return encodeUtf16Pair(r1, r2)
|
||||
}
|
||||
|
||||
func encodeUtf16Pair(r1, r2 rune) []xwindows.InputRecord {
|
||||
return []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: r1,
|
||||
}),
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: r2,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package input
|
||||
|
||||
// FocusEvent represents a terminal focus event.
|
||||
// This occurs when the terminal gains focus.
|
||||
type FocusEvent struct{}
|
||||
|
||||
// BlurEvent represents a terminal blur event.
|
||||
// This occurs when the terminal loses focus.
|
||||
type BlurEvent struct{}
|
||||
@@ -0,0 +1,27 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFocus(t *testing.T) {
|
||||
var p Parser
|
||||
_, e := p.parseSequence([]byte("\x1b[I"))
|
||||
switch e.(type) {
|
||||
case FocusEvent:
|
||||
// ok
|
||||
default:
|
||||
t.Error("invalid sequence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlur(t *testing.T) {
|
||||
var p Parser
|
||||
_, e := p.parseSequence([]byte("\x1b[O"))
|
||||
switch e.(type) {
|
||||
case BlurEvent:
|
||||
// ok
|
||||
default:
|
||||
t.Error("invalid sequence")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module github.com/charmbracelet/x/input
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/x/ansi v0.9.3
|
||||
github.com/charmbracelet/x/windows v0.2.1
|
||||
github.com/muesli/cancelreader v0.2.2
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
|
||||
golang.org/x/sys v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
|
||||
github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
@@ -0,0 +1,45 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Event represents a terminal event.
|
||||
type Event any
|
||||
|
||||
// UnknownEvent represents an unknown event.
|
||||
type UnknownEvent string
|
||||
|
||||
// String returns a string representation of the unknown event.
|
||||
func (e UnknownEvent) String() string {
|
||||
return fmt.Sprintf("%q", string(e))
|
||||
}
|
||||
|
||||
// MultiEvent represents multiple messages event.
|
||||
type MultiEvent []Event
|
||||
|
||||
// String returns a string representation of the multiple messages event.
|
||||
func (e MultiEvent) String() string {
|
||||
var sb strings.Builder
|
||||
for _, ev := range e {
|
||||
sb.WriteString(fmt.Sprintf("%v\n", ev))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// WindowSizeEvent is used to report the terminal size. Note that Windows does
|
||||
// not have support for reporting resizes via SIGWINCH signals and relies on
|
||||
// the Windows Console API to report window size changes.
|
||||
type WindowSizeEvent struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// WindowOpEvent is a window operation (XTWINOPS) report event. This is used to
|
||||
// report various window operations such as reporting the window size or cell
|
||||
// size.
|
||||
type WindowOpEvent struct {
|
||||
Op int
|
||||
Args []int
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
// KeyExtended is a special key code used to signify that a key event
|
||||
// contains multiple runes.
|
||||
KeyExtended = unicode.MaxRune + 1
|
||||
)
|
||||
|
||||
// Special key symbols.
|
||||
const (
|
||||
|
||||
// Special keys.
|
||||
|
||||
KeyUp rune = KeyExtended + iota + 1
|
||||
KeyDown
|
||||
KeyRight
|
||||
KeyLeft
|
||||
KeyBegin
|
||||
KeyFind
|
||||
KeyInsert
|
||||
KeyDelete
|
||||
KeySelect
|
||||
KeyPgUp
|
||||
KeyPgDown
|
||||
KeyHome
|
||||
KeyEnd
|
||||
|
||||
// Keypad keys.
|
||||
|
||||
KeyKpEnter
|
||||
KeyKpEqual
|
||||
KeyKpMultiply
|
||||
KeyKpPlus
|
||||
KeyKpComma
|
||||
KeyKpMinus
|
||||
KeyKpDecimal
|
||||
KeyKpDivide
|
||||
KeyKp0
|
||||
KeyKp1
|
||||
KeyKp2
|
||||
KeyKp3
|
||||
KeyKp4
|
||||
KeyKp5
|
||||
KeyKp6
|
||||
KeyKp7
|
||||
KeyKp8
|
||||
KeyKp9
|
||||
|
||||
//nolint:godox
|
||||
// The following are keys defined in the Kitty keyboard protocol.
|
||||
// TODO: Investigate the names of these keys.
|
||||
|
||||
KeyKpSep
|
||||
KeyKpUp
|
||||
KeyKpDown
|
||||
KeyKpLeft
|
||||
KeyKpRight
|
||||
KeyKpPgUp
|
||||
KeyKpPgDown
|
||||
KeyKpHome
|
||||
KeyKpEnd
|
||||
KeyKpInsert
|
||||
KeyKpDelete
|
||||
KeyKpBegin
|
||||
|
||||
// Function keys.
|
||||
|
||||
KeyF1
|
||||
KeyF2
|
||||
KeyF3
|
||||
KeyF4
|
||||
KeyF5
|
||||
KeyF6
|
||||
KeyF7
|
||||
KeyF8
|
||||
KeyF9
|
||||
KeyF10
|
||||
KeyF11
|
||||
KeyF12
|
||||
KeyF13
|
||||
KeyF14
|
||||
KeyF15
|
||||
KeyF16
|
||||
KeyF17
|
||||
KeyF18
|
||||
KeyF19
|
||||
KeyF20
|
||||
KeyF21
|
||||
KeyF22
|
||||
KeyF23
|
||||
KeyF24
|
||||
KeyF25
|
||||
KeyF26
|
||||
KeyF27
|
||||
KeyF28
|
||||
KeyF29
|
||||
KeyF30
|
||||
KeyF31
|
||||
KeyF32
|
||||
KeyF33
|
||||
KeyF34
|
||||
KeyF35
|
||||
KeyF36
|
||||
KeyF37
|
||||
KeyF38
|
||||
KeyF39
|
||||
KeyF40
|
||||
KeyF41
|
||||
KeyF42
|
||||
KeyF43
|
||||
KeyF44
|
||||
KeyF45
|
||||
KeyF46
|
||||
KeyF47
|
||||
KeyF48
|
||||
KeyF49
|
||||
KeyF50
|
||||
KeyF51
|
||||
KeyF52
|
||||
KeyF53
|
||||
KeyF54
|
||||
KeyF55
|
||||
KeyF56
|
||||
KeyF57
|
||||
KeyF58
|
||||
KeyF59
|
||||
KeyF60
|
||||
KeyF61
|
||||
KeyF62
|
||||
KeyF63
|
||||
|
||||
//nolint:godox
|
||||
// The following are keys defined in the Kitty keyboard protocol.
|
||||
// TODO: Investigate the names of these keys.
|
||||
|
||||
KeyCapsLock
|
||||
KeyScrollLock
|
||||
KeyNumLock
|
||||
KeyPrintScreen
|
||||
KeyPause
|
||||
KeyMenu
|
||||
|
||||
KeyMediaPlay
|
||||
KeyMediaPause
|
||||
KeyMediaPlayPause
|
||||
KeyMediaReverse
|
||||
KeyMediaStop
|
||||
KeyMediaFastForward
|
||||
KeyMediaRewind
|
||||
KeyMediaNext
|
||||
KeyMediaPrev
|
||||
KeyMediaRecord
|
||||
|
||||
KeyLowerVol
|
||||
KeyRaiseVol
|
||||
KeyMute
|
||||
|
||||
KeyLeftShift
|
||||
KeyLeftAlt
|
||||
KeyLeftCtrl
|
||||
KeyLeftSuper
|
||||
KeyLeftHyper
|
||||
KeyLeftMeta
|
||||
KeyRightShift
|
||||
KeyRightAlt
|
||||
KeyRightCtrl
|
||||
KeyRightSuper
|
||||
KeyRightHyper
|
||||
KeyRightMeta
|
||||
KeyIsoLevel3Shift
|
||||
KeyIsoLevel5Shift
|
||||
|
||||
// Special names in C0.
|
||||
|
||||
KeyBackspace = rune(ansi.DEL)
|
||||
KeyTab = rune(ansi.HT)
|
||||
KeyEnter = rune(ansi.CR)
|
||||
KeyReturn = KeyEnter
|
||||
KeyEscape = rune(ansi.ESC)
|
||||
KeyEsc = KeyEscape
|
||||
|
||||
// Special names in G0.
|
||||
|
||||
KeySpace = rune(ansi.SP)
|
||||
)
|
||||
|
||||
// KeyPressEvent represents a key press event.
|
||||
type KeyPressEvent Key
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. For details, on what this returns see [Key.String].
|
||||
func (k KeyPressEvent) String() string {
|
||||
return Key(k).String()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k KeyPressEvent) Keystroke() string {
|
||||
return Key(k).Keystroke()
|
||||
}
|
||||
|
||||
// Key returns the underlying key event. This is a syntactic sugar for casting
|
||||
// the key event to a [Key].
|
||||
func (k KeyPressEvent) Key() Key {
|
||||
return Key(k)
|
||||
}
|
||||
|
||||
// KeyReleaseEvent represents a key release event.
|
||||
type KeyReleaseEvent Key
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. For details, on what this returns see [Key.String].
|
||||
func (k KeyReleaseEvent) String() string {
|
||||
return Key(k).String()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k KeyReleaseEvent) Keystroke() string {
|
||||
return Key(k).Keystroke()
|
||||
}
|
||||
|
||||
// Key returns the underlying key event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [KeyEvent] interface, and cast the key event to
|
||||
// [Key].
|
||||
func (k KeyReleaseEvent) Key() Key {
|
||||
return Key(k)
|
||||
}
|
||||
|
||||
// KeyEvent represents a key event. This can be either a key press or a key
|
||||
// release event.
|
||||
type KeyEvent interface {
|
||||
fmt.Stringer
|
||||
|
||||
// Key returns the underlying key event.
|
||||
Key() Key
|
||||
}
|
||||
|
||||
// Key represents a Key press or release event. It contains information about
|
||||
// the Key pressed, like the runes, the type of Key, and the modifiers pressed.
|
||||
// There are a couple general patterns you could use to check for key presses
|
||||
// or releases:
|
||||
//
|
||||
// // Switch on the string representation of the key (shorter)
|
||||
// switch ev := ev.(type) {
|
||||
// case KeyPressEvent:
|
||||
// switch ev.String() {
|
||||
// case "enter":
|
||||
// fmt.Println("you pressed enter!")
|
||||
// case "a":
|
||||
// fmt.Println("you pressed a!")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Switch on the key type (more foolproof)
|
||||
// switch ev := ev.(type) {
|
||||
// case KeyEvent:
|
||||
// // catch both KeyPressEvent and KeyReleaseEvent
|
||||
// switch key := ev.Key(); key.Code {
|
||||
// case KeyEnter:
|
||||
// fmt.Println("you pressed enter!")
|
||||
// default:
|
||||
// switch key.Text {
|
||||
// case "a":
|
||||
// fmt.Println("you pressed a!")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Note that [Key.Text] will be empty for special keys like [KeyEnter],
|
||||
// [KeyTab], and for keys that don't represent printable characters like key
|
||||
// combos with modifier keys. In other words, [Key.Text] is populated only for
|
||||
// keys that represent printable characters shifted or unshifted (like 'a',
|
||||
// 'A', '1', '!', etc.).
|
||||
type Key struct {
|
||||
// Text contains the actual characters received. This usually the same as
|
||||
// [Key.Code]. When [Key.Text] is non-empty, it indicates that the key
|
||||
// pressed represents printable character(s).
|
||||
Text string
|
||||
|
||||
// Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on.
|
||||
Mod KeyMod
|
||||
|
||||
// Code represents the key pressed. This is usually a special key like
|
||||
// [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'.
|
||||
Code rune
|
||||
|
||||
// ShiftedCode is the actual, shifted key pressed by the user. For example,
|
||||
// if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will
|
||||
// be 'A' and [Key.Code] will be 'a'.
|
||||
//
|
||||
// In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the
|
||||
// unshifted key on the keyboard.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
ShiftedCode rune
|
||||
|
||||
// BaseCode is the key pressed according to the standard PC-101 key layout.
|
||||
// On international keyboards, this is the key that would be pressed if the
|
||||
// keyboard was set to US PC-101 layout.
|
||||
//
|
||||
// For example, if the user presses 'q' on a French AZERTY keyboard,
|
||||
// [Key.BaseCode] will be 'q'.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
BaseCode rune
|
||||
|
||||
// IsRepeat indicates whether the key is being held down and sending events
|
||||
// repeatedly.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
IsRepeat bool
|
||||
}
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. It will return the textual representation of the [Key] if there is
|
||||
// one, otherwise, it will fallback to [Key.Keystroke].
|
||||
//
|
||||
// For example, you'll always get "?" and instead of "shift+/" on a US ANSI
|
||||
// keyboard.
|
||||
func (k Key) String() string {
|
||||
if len(k.Text) > 0 && k.Text != " " {
|
||||
return k.Text
|
||||
}
|
||||
return k.Keystroke()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k Key) Keystroke() string {
|
||||
var sb strings.Builder
|
||||
if k.Mod.Contains(ModCtrl) && k.Code != KeyLeftCtrl && k.Code != KeyRightCtrl {
|
||||
sb.WriteString("ctrl+")
|
||||
}
|
||||
if k.Mod.Contains(ModAlt) && k.Code != KeyLeftAlt && k.Code != KeyRightAlt {
|
||||
sb.WriteString("alt+")
|
||||
}
|
||||
if k.Mod.Contains(ModShift) && k.Code != KeyLeftShift && k.Code != KeyRightShift {
|
||||
sb.WriteString("shift+")
|
||||
}
|
||||
if k.Mod.Contains(ModMeta) && k.Code != KeyLeftMeta && k.Code != KeyRightMeta {
|
||||
sb.WriteString("meta+")
|
||||
}
|
||||
if k.Mod.Contains(ModHyper) && k.Code != KeyLeftHyper && k.Code != KeyRightHyper {
|
||||
sb.WriteString("hyper+")
|
||||
}
|
||||
if k.Mod.Contains(ModSuper) && k.Code != KeyLeftSuper && k.Code != KeyRightSuper {
|
||||
sb.WriteString("super+")
|
||||
}
|
||||
|
||||
if kt, ok := keyTypeString[k.Code]; ok {
|
||||
sb.WriteString(kt)
|
||||
} else {
|
||||
code := k.Code
|
||||
if k.BaseCode != 0 {
|
||||
// If a [Key.BaseCode] is present, use it to represent a key using the standard
|
||||
// PC-101 key layout.
|
||||
code = k.BaseCode
|
||||
}
|
||||
|
||||
switch code {
|
||||
case KeySpace:
|
||||
// Space is the only invisible printable character.
|
||||
sb.WriteString("space")
|
||||
case KeyExtended:
|
||||
// Write the actual text of the key when the key contains multiple
|
||||
// runes.
|
||||
sb.WriteString(k.Text)
|
||||
default:
|
||||
sb.WriteRune(code)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var keyTypeString = map[rune]string{
|
||||
KeyEnter: "enter",
|
||||
KeyTab: "tab",
|
||||
KeyBackspace: "backspace",
|
||||
KeyEscape: "esc",
|
||||
KeySpace: "space",
|
||||
KeyUp: "up",
|
||||
KeyDown: "down",
|
||||
KeyLeft: "left",
|
||||
KeyRight: "right",
|
||||
KeyBegin: "begin",
|
||||
KeyFind: "find",
|
||||
KeyInsert: "insert",
|
||||
KeyDelete: "delete",
|
||||
KeySelect: "select",
|
||||
KeyPgUp: "pgup",
|
||||
KeyPgDown: "pgdown",
|
||||
KeyHome: "home",
|
||||
KeyEnd: "end",
|
||||
KeyKpEnter: "kpenter",
|
||||
KeyKpEqual: "kpequal",
|
||||
KeyKpMultiply: "kpmul",
|
||||
KeyKpPlus: "kpplus",
|
||||
KeyKpComma: "kpcomma",
|
||||
KeyKpMinus: "kpminus",
|
||||
KeyKpDecimal: "kpperiod",
|
||||
KeyKpDivide: "kpdiv",
|
||||
KeyKp0: "kp0",
|
||||
KeyKp1: "kp1",
|
||||
KeyKp2: "kp2",
|
||||
KeyKp3: "kp3",
|
||||
KeyKp4: "kp4",
|
||||
KeyKp5: "kp5",
|
||||
KeyKp6: "kp6",
|
||||
KeyKp7: "kp7",
|
||||
KeyKp8: "kp8",
|
||||
KeyKp9: "kp9",
|
||||
|
||||
// Kitty keyboard extension
|
||||
KeyKpSep: "kpsep",
|
||||
KeyKpUp: "kpup",
|
||||
KeyKpDown: "kpdown",
|
||||
KeyKpLeft: "kpleft",
|
||||
KeyKpRight: "kpright",
|
||||
KeyKpPgUp: "kppgup",
|
||||
KeyKpPgDown: "kppgdown",
|
||||
KeyKpHome: "kphome",
|
||||
KeyKpEnd: "kpend",
|
||||
KeyKpInsert: "kpinsert",
|
||||
KeyKpDelete: "kpdelete",
|
||||
KeyKpBegin: "kpbegin",
|
||||
|
||||
KeyF1: "f1",
|
||||
KeyF2: "f2",
|
||||
KeyF3: "f3",
|
||||
KeyF4: "f4",
|
||||
KeyF5: "f5",
|
||||
KeyF6: "f6",
|
||||
KeyF7: "f7",
|
||||
KeyF8: "f8",
|
||||
KeyF9: "f9",
|
||||
KeyF10: "f10",
|
||||
KeyF11: "f11",
|
||||
KeyF12: "f12",
|
||||
KeyF13: "f13",
|
||||
KeyF14: "f14",
|
||||
KeyF15: "f15",
|
||||
KeyF16: "f16",
|
||||
KeyF17: "f17",
|
||||
KeyF18: "f18",
|
||||
KeyF19: "f19",
|
||||
KeyF20: "f20",
|
||||
KeyF21: "f21",
|
||||
KeyF22: "f22",
|
||||
KeyF23: "f23",
|
||||
KeyF24: "f24",
|
||||
KeyF25: "f25",
|
||||
KeyF26: "f26",
|
||||
KeyF27: "f27",
|
||||
KeyF28: "f28",
|
||||
KeyF29: "f29",
|
||||
KeyF30: "f30",
|
||||
KeyF31: "f31",
|
||||
KeyF32: "f32",
|
||||
KeyF33: "f33",
|
||||
KeyF34: "f34",
|
||||
KeyF35: "f35",
|
||||
KeyF36: "f36",
|
||||
KeyF37: "f37",
|
||||
KeyF38: "f38",
|
||||
KeyF39: "f39",
|
||||
KeyF40: "f40",
|
||||
KeyF41: "f41",
|
||||
KeyF42: "f42",
|
||||
KeyF43: "f43",
|
||||
KeyF44: "f44",
|
||||
KeyF45: "f45",
|
||||
KeyF46: "f46",
|
||||
KeyF47: "f47",
|
||||
KeyF48: "f48",
|
||||
KeyF49: "f49",
|
||||
KeyF50: "f50",
|
||||
KeyF51: "f51",
|
||||
KeyF52: "f52",
|
||||
KeyF53: "f53",
|
||||
KeyF54: "f54",
|
||||
KeyF55: "f55",
|
||||
KeyF56: "f56",
|
||||
KeyF57: "f57",
|
||||
KeyF58: "f58",
|
||||
KeyF59: "f59",
|
||||
KeyF60: "f60",
|
||||
KeyF61: "f61",
|
||||
KeyF62: "f62",
|
||||
KeyF63: "f63",
|
||||
|
||||
// Kitty keyboard extension
|
||||
KeyCapsLock: "capslock",
|
||||
KeyScrollLock: "scrolllock",
|
||||
KeyNumLock: "numlock",
|
||||
KeyPrintScreen: "printscreen",
|
||||
KeyPause: "pause",
|
||||
KeyMenu: "menu",
|
||||
KeyMediaPlay: "mediaplay",
|
||||
KeyMediaPause: "mediapause",
|
||||
KeyMediaPlayPause: "mediaplaypause",
|
||||
KeyMediaReverse: "mediareverse",
|
||||
KeyMediaStop: "mediastop",
|
||||
KeyMediaFastForward: "mediafastforward",
|
||||
KeyMediaRewind: "mediarewind",
|
||||
KeyMediaNext: "medianext",
|
||||
KeyMediaPrev: "mediaprev",
|
||||
KeyMediaRecord: "mediarecord",
|
||||
KeyLowerVol: "lowervol",
|
||||
KeyRaiseVol: "raisevol",
|
||||
KeyMute: "mute",
|
||||
KeyLeftShift: "leftshift",
|
||||
KeyLeftAlt: "leftalt",
|
||||
KeyLeftCtrl: "leftctrl",
|
||||
KeyLeftSuper: "leftsuper",
|
||||
KeyLeftHyper: "lefthyper",
|
||||
KeyLeftMeta: "leftmeta",
|
||||
KeyRightShift: "rightshift",
|
||||
KeyRightAlt: "rightalt",
|
||||
KeyRightCtrl: "rightctrl",
|
||||
KeyRightSuper: "rightsuper",
|
||||
KeyRightHyper: "righthyper",
|
||||
KeyRightMeta: "rightmeta",
|
||||
KeyIsoLevel3Shift: "isolevel3shift",
|
||||
KeyIsoLevel5Shift: "isolevel5shift",
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"io"
|
||||
"math/rand"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/ansi/kitty"
|
||||
)
|
||||
|
||||
var sequences = buildKeysTable(FlagTerminfo, "dumb")
|
||||
|
||||
func TestKeyString(t *testing.T) {
|
||||
t.Run("alt+space", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: KeySpace, Mod: ModAlt}
|
||||
if got := k.String(); got != "alt+space" {
|
||||
t.Fatalf(`expected a "alt+space", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("runes", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: 'a', Text: "a"}
|
||||
if got := k.String(); got != "a" {
|
||||
t.Fatalf(`expected an "a", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: 99999}
|
||||
if got := k.String(); got != "𘚟" {
|
||||
t.Fatalf(`expected a "unknown", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("space", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: KeySpace, Text: " "}
|
||||
if got := k.String(); got != "space" {
|
||||
t.Fatalf(`expected a "space", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("shift+space", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: KeySpace, Mod: ModShift}
|
||||
if got := k.String(); got != "shift+space" {
|
||||
t.Fatalf(`expected a "shift+space", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("?", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: '/', Mod: ModShift, Text: "?"}
|
||||
if got := k.String(); got != "?" {
|
||||
t.Fatalf(`expected a "?", got %q`, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type seqTest struct {
|
||||
seq []byte
|
||||
Events []Event
|
||||
}
|
||||
|
||||
var f3CurPosRegexp = regexp.MustCompile(`\x1b\[1;(\d+)R`)
|
||||
|
||||
// buildBaseSeqTests returns sequence tests that are valid for the
|
||||
// detectSequence() function.
|
||||
func buildBaseSeqTests() []seqTest {
|
||||
td := []seqTest{}
|
||||
for seq, key := range sequences {
|
||||
k := KeyPressEvent(key)
|
||||
st := seqTest{seq: []byte(seq), Events: []Event{k}}
|
||||
|
||||
// XXX: This is a special case to handle F3 key sequence and cursor
|
||||
// position report having the same sequence. See [parseCsi] for more
|
||||
// information.
|
||||
if f3CurPosRegexp.MatchString(seq) {
|
||||
st.Events = []Event{k, CursorPositionEvent{Y: 0, X: int(key.Mod)}}
|
||||
}
|
||||
td = append(td, st)
|
||||
}
|
||||
|
||||
// Additional special cases.
|
||||
td = append(td,
|
||||
// Unrecognized CSI sequence.
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
|
||||
[]Event{
|
||||
UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}),
|
||||
},
|
||||
},
|
||||
// A lone space character.
|
||||
seqTest{
|
||||
[]byte{' '},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Text: " "},
|
||||
},
|
||||
},
|
||||
// An escape character with the alt modifier.
|
||||
seqTest{
|
||||
[]byte{'\x1b', ' '},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
)
|
||||
return td
|
||||
}
|
||||
|
||||
func TestParseSequence(t *testing.T) {
|
||||
td := buildBaseSeqTests()
|
||||
td = append(td,
|
||||
// Background color.
|
||||
seqTest{
|
||||
[]byte("\x1b]11;rgb:1234/1234/1234\x07"),
|
||||
[]Event{BackgroundColorEvent{
|
||||
Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b]11;rgb:1234/1234/1234\x1b\\"),
|
||||
[]Event{BackgroundColorEvent{
|
||||
Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b]11;rgb:1234/1234/1234\x1b"), // Incomplete sequences are ignored.
|
||||
[]Event{
|
||||
UnknownEvent("\x1b]11;rgb:1234/1234/1234\x1b"),
|
||||
},
|
||||
},
|
||||
|
||||
// Kitty Graphics response.
|
||||
seqTest{
|
||||
[]byte("\x1b_Ga=t;OK\x1b\\"),
|
||||
[]Event{KittyGraphicsEvent{
|
||||
Options: kitty.Options{Action: kitty.Transmit},
|
||||
Payload: []byte("OK"),
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b_Gi=99,I=13;OK\x1b\\"),
|
||||
[]Event{KittyGraphicsEvent{
|
||||
Options: kitty.Options{ID: 99, Number: 13},
|
||||
Payload: []byte("OK"),
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b_Gi=1337,q=1;EINVAL:your face\x1b\\"),
|
||||
[]Event{KittyGraphicsEvent{
|
||||
Options: kitty.Options{ID: 1337, Quite: 1},
|
||||
Payload: []byte("EINVAL:your face"),
|
||||
}},
|
||||
},
|
||||
|
||||
// Xterm modifyOtherKeys CSI 27 ; <modifier> ; <code> ~
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;20320~"),
|
||||
[]Event{KeyPressEvent{Code: '你', Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;65~"),
|
||||
[]Event{KeyPressEvent{Code: 'A', Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;8~"),
|
||||
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;27~"),
|
||||
[]Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;127~"),
|
||||
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
|
||||
},
|
||||
|
||||
// Xterm report window text area size.
|
||||
seqTest{
|
||||
[]byte("\x1b[4;24;80t"),
|
||||
[]Event{
|
||||
WindowOpEvent{Op: 4, Args: []int{24, 80}},
|
||||
},
|
||||
},
|
||||
|
||||
// Kitty keyboard / CSI u (fixterms)
|
||||
seqTest{
|
||||
[]byte("\x1b[1B"),
|
||||
[]Event{KeyPressEvent{Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;B"),
|
||||
[]Event{KeyPressEvent{Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4B"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4:1B"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4:2B"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown, IsRepeat: true}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4:3B"),
|
||||
[]Event{KeyReleaseEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[8~"),
|
||||
[]Event{KeyPressEvent{Code: KeyEnd}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[8;~"),
|
||||
[]Event{KeyPressEvent{Code: KeyEnd}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[8;10~"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModMeta, Code: KeyEnd}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;4u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyEscape}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[127;4u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyBackspace}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[57358;4u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyCapsLock}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[9;2u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift, Code: KeyTab}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;u"),
|
||||
[]Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[20320;2u"),
|
||||
[]Event{KeyPressEvent{Text: "你", Mod: ModShift, Code: '你'}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;:1u"),
|
||||
[]Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:3u"),
|
||||
[]Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:2u"),
|
||||
[]Event{KeyPressEvent{Code: 'Ã', Text: "Ã", IsRepeat: true, Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:1u"),
|
||||
[]Event{KeyPressEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:3u"),
|
||||
[]Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[97;2;65u"),
|
||||
[]Event{KeyPressEvent{Code: 'a', Text: "A", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[97;;229u"),
|
||||
[]Event{KeyPressEvent{Code: 'a', Text: "å"}},
|
||||
},
|
||||
|
||||
// focus/blur
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', 'I'},
|
||||
[]Event{
|
||||
FocusEvent{},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', 'O'},
|
||||
[]Event{
|
||||
BlurEvent{},
|
||||
},
|
||||
},
|
||||
// Mouse event.
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
|
||||
[]Event{
|
||||
MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
},
|
||||
// SGR Mouse event.
|
||||
seqTest{
|
||||
[]byte("\x1b[<0;33;17M"),
|
||||
[]Event{
|
||||
MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
},
|
||||
// Runes.
|
||||
seqTest{
|
||||
[]byte{'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'a', 'a', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
// Multi-byte rune.
|
||||
seqTest{
|
||||
[]byte("☃"),
|
||||
[]Event{
|
||||
KeyPressEvent{Code: '☃', Text: "☃"},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b☃"),
|
||||
[]Event{
|
||||
KeyPressEvent{Code: '☃', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
// Standalone control characters.
|
||||
seqTest{
|
||||
[]byte{'\x1b'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyEscape},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{ansi.SOH},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', ansi.SOH},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{ansi.NUL},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', ansi.NUL},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
// C1 control characters.
|
||||
seqTest{
|
||||
[]byte{'\x80'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: rune(0x80 - '@'), Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
// Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows.
|
||||
// This is incorrect, but it makes our test fail if we try it out.
|
||||
td = append(td, seqTest{
|
||||
[]byte{'\xfe'},
|
||||
[]Event{
|
||||
UnknownEvent(rune(0xfe)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var p Parser
|
||||
for _, tc := range td {
|
||||
t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
|
||||
var events []Event
|
||||
buf := tc.seq
|
||||
for len(buf) > 0 {
|
||||
width, Event := p.parseSequence(buf)
|
||||
switch Event := Event.(type) {
|
||||
case MultiEvent:
|
||||
events = append(events, Event...)
|
||||
default:
|
||||
events = append(events, Event)
|
||||
}
|
||||
buf = buf[width:]
|
||||
}
|
||||
if !reflect.DeepEqual(tc.Events, events) {
|
||||
t.Errorf("\nexpected event for %q:\n %#v\ngot:\n %#v", tc.seq, tc.Events, events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLongInput(t *testing.T) {
|
||||
expect := make([]Event, 1000)
|
||||
for i := range 1000 {
|
||||
expect[i] = KeyPressEvent{Code: 'a', Text: "a"}
|
||||
}
|
||||
input := strings.Repeat("a", 1000)
|
||||
drv, err := NewReader(strings.NewReader(input), "dumb", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected input driver error: %v", err)
|
||||
}
|
||||
|
||||
var Events []Event
|
||||
for {
|
||||
events, err := drv.ReadEvents()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected input error: %v", err)
|
||||
}
|
||||
Events = append(Events, events...)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(expect, Events) {
|
||||
t.Errorf("unexpected messages, expected:\n %+v\ngot:\n %+v", expect, Events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadInput(t *testing.T) {
|
||||
type test struct {
|
||||
keyname string
|
||||
in []byte
|
||||
out []Event
|
||||
}
|
||||
testData := []test{
|
||||
{
|
||||
"a",
|
||||
[]byte{'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"space",
|
||||
[]byte{' '},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Text: " "},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a alt+a",
|
||||
[]byte{'a', '\x1b', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a alt+a a",
|
||||
[]byte{'a', '\x1b', 'a', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ctrl+a",
|
||||
[]byte{byte(ansi.SOH)},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ctrl+a ctrl+b",
|
||||
[]byte{byte(ansi.SOH), byte(ansi.STX)},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl},
|
||||
KeyPressEvent{Code: 'b', Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
{
|
||||
"alt+a",
|
||||
[]byte{byte(0x1b), 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a b c d",
|
||||
[]byte{'a', 'b', 'c', 'd'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'b', Text: "b"},
|
||||
KeyPressEvent{Code: 'c', Text: "c"},
|
||||
KeyPressEvent{Code: 'd', Text: "d"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"up",
|
||||
[]byte("\x1b[A"),
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyUp},
|
||||
},
|
||||
},
|
||||
{
|
||||
"wheel up",
|
||||
[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
|
||||
[]Event{
|
||||
MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
},
|
||||
{
|
||||
"left motion release",
|
||||
[]byte{
|
||||
'\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33),
|
||||
'\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33),
|
||||
},
|
||||
[]Event{
|
||||
MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
MouseReleaseEvent{X: 64, Y: 32, Button: MouseNone},
|
||||
},
|
||||
},
|
||||
{
|
||||
"shift+tab",
|
||||
[]byte{'\x1b', '[', 'Z'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyTab, Mod: ModShift},
|
||||
},
|
||||
},
|
||||
{
|
||||
"enter",
|
||||
[]byte{'\r'},
|
||||
[]Event{KeyPressEvent{Code: KeyEnter}},
|
||||
},
|
||||
{
|
||||
"alt+enter",
|
||||
[]byte{'\x1b', '\r'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyEnter, Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"insert",
|
||||
[]byte{'\x1b', '[', '2', '~'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyInsert},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ctrl+alt+a",
|
||||
[]byte{'\x1b', byte(ansi.SOH)},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"CSI?----X?",
|
||||
[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
|
||||
[]Event{UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})},
|
||||
},
|
||||
// Powershell sequences.
|
||||
{
|
||||
"up",
|
||||
[]byte{'\x1b', 'O', 'A'},
|
||||
[]Event{KeyPressEvent{Code: KeyUp}},
|
||||
},
|
||||
{
|
||||
"down",
|
||||
[]byte{'\x1b', 'O', 'B'},
|
||||
[]Event{KeyPressEvent{Code: KeyDown}},
|
||||
},
|
||||
{
|
||||
"right",
|
||||
[]byte{'\x1b', 'O', 'C'},
|
||||
[]Event{KeyPressEvent{Code: KeyRight}},
|
||||
},
|
||||
{
|
||||
"left",
|
||||
[]byte{'\x1b', 'O', 'D'},
|
||||
[]Event{KeyPressEvent{Code: KeyLeft}},
|
||||
},
|
||||
{
|
||||
"alt+enter",
|
||||
[]byte{'\x1b', '\x0d'},
|
||||
[]Event{KeyPressEvent{Code: KeyEnter, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
"alt+backspace",
|
||||
[]byte{'\x1b', '\x7f'},
|
||||
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
"ctrl+space",
|
||||
[]byte{'\x00'},
|
||||
[]Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl}},
|
||||
},
|
||||
{
|
||||
"ctrl+alt+space",
|
||||
[]byte{'\x1b', '\x00'},
|
||||
[]Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt}},
|
||||
},
|
||||
{
|
||||
"esc",
|
||||
[]byte{'\x1b'},
|
||||
[]Event{KeyPressEvent{Code: KeyEscape}},
|
||||
},
|
||||
{
|
||||
"alt+esc",
|
||||
[]byte{'\x1b', '\x1b'},
|
||||
[]Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
"a b o",
|
||||
[]byte{
|
||||
'\x1b', '[', '2', '0', '0', '~',
|
||||
'a', ' ', 'b',
|
||||
'\x1b', '[', '2', '0', '1', '~',
|
||||
'o',
|
||||
},
|
||||
[]Event{
|
||||
PasteStartEvent{},
|
||||
PasteEvent("a b"),
|
||||
PasteEndEvent{},
|
||||
KeyPressEvent{Code: 'o', Text: "o"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a\x03\nb",
|
||||
[]byte{
|
||||
'\x1b', '[', '2', '0', '0', '~',
|
||||
'a', '\x03', '\n', 'b',
|
||||
'\x1b', '[', '2', '0', '1', '~',
|
||||
},
|
||||
[]Event{
|
||||
PasteStartEvent{},
|
||||
PasteEvent("a\x03\nb"),
|
||||
PasteEndEvent{},
|
||||
},
|
||||
},
|
||||
{
|
||||
"?0xfe?",
|
||||
[]byte{'\xfe'},
|
||||
[]Event{
|
||||
UnknownEvent(rune(0xfe)),
|
||||
},
|
||||
},
|
||||
{
|
||||
"a ?0xfe? b",
|
||||
[]byte{'a', '\xfe', ' ', 'b'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
UnknownEvent(rune(0xfe)),
|
||||
KeyPressEvent{Code: KeySpace, Text: " "},
|
||||
KeyPressEvent{Code: 'b', Text: "b"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, td := range testData {
|
||||
t.Run(fmt.Sprintf("%d: %s", i, td.keyname), func(t *testing.T) {
|
||||
Events := testReadInputs(t, bytes.NewReader(td.in))
|
||||
var buf strings.Builder
|
||||
for i, Event := range Events {
|
||||
if i > 0 {
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
if s, ok := Event.(fmt.Stringer); ok {
|
||||
buf.WriteString(s.String())
|
||||
} else {
|
||||
fmt.Fprintf(&buf, "%#v:%T", Event, Event)
|
||||
}
|
||||
}
|
||||
|
||||
if len(Events) != len(td.out) {
|
||||
t.Fatalf("unexpected message list length: got %d, expected %d\n got: %#v\n expected: %#v\n", len(Events), len(td.out), Events, td.out)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(td.out, Events) {
|
||||
t.Fatalf("expected:\n%#v\ngot:\n%#v", td.out, Events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testReadInputs(t *testing.T, input io.Reader) []Event {
|
||||
// We'll check that the input reader finishes at the end
|
||||
// without error.
|
||||
var wg sync.WaitGroup
|
||||
var inputErr error
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
cancel()
|
||||
wg.Wait()
|
||||
if inputErr != nil && !errors.Is(inputErr, io.EOF) {
|
||||
t.Fatalf("unexpected input error: %v", inputErr)
|
||||
}
|
||||
}()
|
||||
|
||||
dr, err := NewReader(input, "dumb", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected input driver error: %v", err)
|
||||
}
|
||||
|
||||
// The messages we're consuming.
|
||||
EventsC := make(chan Event)
|
||||
|
||||
// Start the reader in the background.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var events []Event
|
||||
events, inputErr = dr.ReadEvents()
|
||||
out:
|
||||
for _, ev := range events {
|
||||
select {
|
||||
case EventsC <- ev:
|
||||
case <-ctx.Done():
|
||||
break out
|
||||
}
|
||||
}
|
||||
EventsC <- nil
|
||||
}()
|
||||
|
||||
var Events []Event
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case Event := <-EventsC:
|
||||
if Event == nil {
|
||||
// end of input marker for the test.
|
||||
break loop
|
||||
}
|
||||
Events = append(Events, Event)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Errorf("timeout waiting for input event")
|
||||
break loop
|
||||
}
|
||||
}
|
||||
return Events
|
||||
}
|
||||
|
||||
// randTest defines the test input and expected output for a sequence
|
||||
// of interleaved control sequences and control characters.
|
||||
type randTest struct {
|
||||
data []byte
|
||||
lengths []int
|
||||
names []string
|
||||
}
|
||||
|
||||
// seed is the random seed to randomize the input. This helps check
|
||||
// that all the sequences get ultimately exercised.
|
||||
var seed = flag.Int64("seed", 0, "random seed (0 to autoselect)")
|
||||
|
||||
// genRandomData generates a randomized test, with a random seed unless
|
||||
// the seed flag was set.
|
||||
func genRandomData(logfn func(int64), length int) randTest {
|
||||
// We'll use a random source. However, we give the user the option
|
||||
// to override it to a specific value for reproduceability.
|
||||
s := *seed
|
||||
if s == 0 {
|
||||
s = time.Now().UnixNano()
|
||||
}
|
||||
// Inform the user so they know what to reuse to get the same data.
|
||||
logfn(s)
|
||||
return genRandomDataWithSeed(s, length)
|
||||
}
|
||||
|
||||
// genRandomDataWithSeed generates a randomized test with a fixed seed.
|
||||
func genRandomDataWithSeed(s int64, length int) randTest {
|
||||
src := rand.NewSource(s)
|
||||
r := rand.New(src)
|
||||
|
||||
// allseqs contains all the sequences, in sorted order. We sort
|
||||
// to make the test deterministic (when the seed is also fixed).
|
||||
type seqpair struct {
|
||||
seq string
|
||||
name string
|
||||
}
|
||||
var allseqs []seqpair
|
||||
for seq, key := range sequences {
|
||||
allseqs = append(allseqs, seqpair{seq, key.String()})
|
||||
}
|
||||
sort.Slice(allseqs, func(i, j int) bool { return allseqs[i].seq < allseqs[j].seq })
|
||||
|
||||
// res contains the computed test.
|
||||
var res randTest
|
||||
|
||||
for len(res.data) < length {
|
||||
alt := r.Intn(2)
|
||||
prefix := ""
|
||||
esclen := 0
|
||||
if alt == 1 {
|
||||
prefix = "alt+"
|
||||
esclen = 1
|
||||
}
|
||||
kind := r.Intn(3)
|
||||
switch kind {
|
||||
case 0:
|
||||
// A control character.
|
||||
if alt == 1 {
|
||||
res.data = append(res.data, '\x1b')
|
||||
}
|
||||
res.data = append(res.data, 1)
|
||||
res.names = append(res.names, "ctrl+"+prefix+"a")
|
||||
res.lengths = append(res.lengths, 1+esclen)
|
||||
|
||||
case 1, 2:
|
||||
// A sequence.
|
||||
seqi := r.Intn(len(allseqs))
|
||||
s := allseqs[seqi]
|
||||
if strings.Contains(s.name, "alt+") || strings.Contains(s.name, "meta+") {
|
||||
esclen = 0
|
||||
prefix = ""
|
||||
alt = 0
|
||||
}
|
||||
if alt == 1 {
|
||||
res.data = append(res.data, '\x1b')
|
||||
}
|
||||
res.data = append(res.data, s.seq...)
|
||||
if strings.HasPrefix(s.name, "ctrl+") {
|
||||
prefix = "ctrl+" + prefix
|
||||
}
|
||||
name := prefix + strings.TrimPrefix(s.name, "ctrl+")
|
||||
res.names = append(res.names, name)
|
||||
res.lengths = append(res.lengths, len(s.seq)+esclen)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func FuzzParseSequence(f *testing.F) {
|
||||
var p Parser
|
||||
for seq := range sequences {
|
||||
f.Add(seq)
|
||||
}
|
||||
f.Add("\x1b]52;?\x07") // OSC 52
|
||||
f.Add("\x1b]11;rgb:0000/0000/0000\x1b\\") // OSC 11
|
||||
f.Add("\x1bP>|charm terminal(0.1.2)\x1b\\") // DCS (XTVERSION)
|
||||
f.Add("\x1b_Gi=123\x1b\\") // APC
|
||||
f.Fuzz(func(t *testing.T, seq string) {
|
||||
n, _ := p.parseSequence([]byte(seq))
|
||||
if n == 0 && seq != "" {
|
||||
t.Errorf("expected a non-zero width for %q", seq)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkDetectSequenceMap benchmarks the map-based sequence
|
||||
// detector.
|
||||
func BenchmarkDetectSequenceMap(b *testing.B) {
|
||||
var p Parser
|
||||
td := genRandomDataWithSeed(123, 10000)
|
||||
for i := 0; i < b.N; i++ {
|
||||
for j, w := 0, 0; j < len(td.data); j += w {
|
||||
w, _ = p.parseSequence(td.data[j:])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/ansi/kitty"
|
||||
)
|
||||
|
||||
// KittyGraphicsEvent represents a Kitty Graphics response event.
|
||||
//
|
||||
// See https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
||||
type KittyGraphicsEvent struct {
|
||||
Options kitty.Options
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// KittyEnhancementsEvent represents a Kitty enhancements event.
|
||||
type KittyEnhancementsEvent int
|
||||
|
||||
// Kitty keyboard enhancement constants.
|
||||
// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
|
||||
const (
|
||||
KittyDisambiguateEscapeCodes KittyEnhancementsEvent = 1 << iota
|
||||
KittyReportEventTypes
|
||||
KittyReportAlternateKeys
|
||||
KittyReportAllKeysAsEscapeCodes
|
||||
KittyReportAssociatedText
|
||||
)
|
||||
|
||||
// Contains reports whether m contains the given enhancements.
|
||||
func (e KittyEnhancementsEvent) Contains(enhancements KittyEnhancementsEvent) bool {
|
||||
return e&enhancements == enhancements
|
||||
}
|
||||
|
||||
// Kitty Clipboard Control Sequences.
|
||||
var kittyKeyMap = map[int]Key{
|
||||
ansi.BS: {Code: KeyBackspace},
|
||||
ansi.HT: {Code: KeyTab},
|
||||
ansi.CR: {Code: KeyEnter},
|
||||
ansi.ESC: {Code: KeyEscape},
|
||||
ansi.DEL: {Code: KeyBackspace},
|
||||
|
||||
57344: {Code: KeyEscape},
|
||||
57345: {Code: KeyEnter},
|
||||
57346: {Code: KeyTab},
|
||||
57347: {Code: KeyBackspace},
|
||||
57348: {Code: KeyInsert},
|
||||
57349: {Code: KeyDelete},
|
||||
57350: {Code: KeyLeft},
|
||||
57351: {Code: KeyRight},
|
||||
57352: {Code: KeyUp},
|
||||
57353: {Code: KeyDown},
|
||||
57354: {Code: KeyPgUp},
|
||||
57355: {Code: KeyPgDown},
|
||||
57356: {Code: KeyHome},
|
||||
57357: {Code: KeyEnd},
|
||||
57358: {Code: KeyCapsLock},
|
||||
57359: {Code: KeyScrollLock},
|
||||
57360: {Code: KeyNumLock},
|
||||
57361: {Code: KeyPrintScreen},
|
||||
57362: {Code: KeyPause},
|
||||
57363: {Code: KeyMenu},
|
||||
57364: {Code: KeyF1},
|
||||
57365: {Code: KeyF2},
|
||||
57366: {Code: KeyF3},
|
||||
57367: {Code: KeyF4},
|
||||
57368: {Code: KeyF5},
|
||||
57369: {Code: KeyF6},
|
||||
57370: {Code: KeyF7},
|
||||
57371: {Code: KeyF8},
|
||||
57372: {Code: KeyF9},
|
||||
57373: {Code: KeyF10},
|
||||
57374: {Code: KeyF11},
|
||||
57375: {Code: KeyF12},
|
||||
57376: {Code: KeyF13},
|
||||
57377: {Code: KeyF14},
|
||||
57378: {Code: KeyF15},
|
||||
57379: {Code: KeyF16},
|
||||
57380: {Code: KeyF17},
|
||||
57381: {Code: KeyF18},
|
||||
57382: {Code: KeyF19},
|
||||
57383: {Code: KeyF20},
|
||||
57384: {Code: KeyF21},
|
||||
57385: {Code: KeyF22},
|
||||
57386: {Code: KeyF23},
|
||||
57387: {Code: KeyF24},
|
||||
57388: {Code: KeyF25},
|
||||
57389: {Code: KeyF26},
|
||||
57390: {Code: KeyF27},
|
||||
57391: {Code: KeyF28},
|
||||
57392: {Code: KeyF29},
|
||||
57393: {Code: KeyF30},
|
||||
57394: {Code: KeyF31},
|
||||
57395: {Code: KeyF32},
|
||||
57396: {Code: KeyF33},
|
||||
57397: {Code: KeyF34},
|
||||
57398: {Code: KeyF35},
|
||||
57399: {Code: KeyKp0},
|
||||
57400: {Code: KeyKp1},
|
||||
57401: {Code: KeyKp2},
|
||||
57402: {Code: KeyKp3},
|
||||
57403: {Code: KeyKp4},
|
||||
57404: {Code: KeyKp5},
|
||||
57405: {Code: KeyKp6},
|
||||
57406: {Code: KeyKp7},
|
||||
57407: {Code: KeyKp8},
|
||||
57408: {Code: KeyKp9},
|
||||
57409: {Code: KeyKpDecimal},
|
||||
57410: {Code: KeyKpDivide},
|
||||
57411: {Code: KeyKpMultiply},
|
||||
57412: {Code: KeyKpMinus},
|
||||
57413: {Code: KeyKpPlus},
|
||||
57414: {Code: KeyKpEnter},
|
||||
57415: {Code: KeyKpEqual},
|
||||
57416: {Code: KeyKpSep},
|
||||
57417: {Code: KeyKpLeft},
|
||||
57418: {Code: KeyKpRight},
|
||||
57419: {Code: KeyKpUp},
|
||||
57420: {Code: KeyKpDown},
|
||||
57421: {Code: KeyKpPgUp},
|
||||
57422: {Code: KeyKpPgDown},
|
||||
57423: {Code: KeyKpHome},
|
||||
57424: {Code: KeyKpEnd},
|
||||
57425: {Code: KeyKpInsert},
|
||||
57426: {Code: KeyKpDelete},
|
||||
57427: {Code: KeyKpBegin},
|
||||
57428: {Code: KeyMediaPlay},
|
||||
57429: {Code: KeyMediaPause},
|
||||
57430: {Code: KeyMediaPlayPause},
|
||||
57431: {Code: KeyMediaReverse},
|
||||
57432: {Code: KeyMediaStop},
|
||||
57433: {Code: KeyMediaFastForward},
|
||||
57434: {Code: KeyMediaRewind},
|
||||
57435: {Code: KeyMediaNext},
|
||||
57436: {Code: KeyMediaPrev},
|
||||
57437: {Code: KeyMediaRecord},
|
||||
57438: {Code: KeyLowerVol},
|
||||
57439: {Code: KeyRaiseVol},
|
||||
57440: {Code: KeyMute},
|
||||
57441: {Code: KeyLeftShift},
|
||||
57442: {Code: KeyLeftCtrl},
|
||||
57443: {Code: KeyLeftAlt},
|
||||
57444: {Code: KeyLeftSuper},
|
||||
57445: {Code: KeyLeftHyper},
|
||||
57446: {Code: KeyLeftMeta},
|
||||
57447: {Code: KeyRightShift},
|
||||
57448: {Code: KeyRightCtrl},
|
||||
57449: {Code: KeyRightAlt},
|
||||
57450: {Code: KeyRightSuper},
|
||||
57451: {Code: KeyRightHyper},
|
||||
57452: {Code: KeyRightMeta},
|
||||
57453: {Code: KeyIsoLevel3Shift},
|
||||
57454: {Code: KeyIsoLevel5Shift},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// These are some faulty C0 mappings some terminals such as WezTerm have
|
||||
// and doesn't follow the specs.
|
||||
kittyKeyMap[ansi.NUL] = Key{Code: KeySpace, Mod: ModCtrl}
|
||||
for i := ansi.SOH; i <= ansi.SUB; i++ {
|
||||
if _, ok := kittyKeyMap[i]; !ok {
|
||||
kittyKeyMap[i] = Key{Code: rune(i + 0x60), Mod: ModCtrl}
|
||||
}
|
||||
}
|
||||
for i := ansi.FS; i <= ansi.US; i++ {
|
||||
if _, ok := kittyKeyMap[i]; !ok {
|
||||
kittyKeyMap[i] = Key{Code: rune(i + 0x40), Mod: ModCtrl}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
kittyShift = 1 << iota
|
||||
kittyAlt
|
||||
kittyCtrl
|
||||
kittySuper
|
||||
kittyHyper
|
||||
kittyMeta
|
||||
kittyCapsLock
|
||||
kittyNumLock
|
||||
)
|
||||
|
||||
func fromKittyMod(mod int) KeyMod {
|
||||
var m KeyMod
|
||||
if mod&kittyShift != 0 {
|
||||
m |= ModShift
|
||||
}
|
||||
if mod&kittyAlt != 0 {
|
||||
m |= ModAlt
|
||||
}
|
||||
if mod&kittyCtrl != 0 {
|
||||
m |= ModCtrl
|
||||
}
|
||||
if mod&kittySuper != 0 {
|
||||
m |= ModSuper
|
||||
}
|
||||
if mod&kittyHyper != 0 {
|
||||
m |= ModHyper
|
||||
}
|
||||
if mod&kittyMeta != 0 {
|
||||
m |= ModMeta
|
||||
}
|
||||
if mod&kittyCapsLock != 0 {
|
||||
m |= ModCapsLock
|
||||
}
|
||||
if mod&kittyNumLock != 0 {
|
||||
m |= ModNumLock
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseKittyKeyboard parses a Kitty Keyboard Protocol sequence.
|
||||
//
|
||||
// In `CSI u`, this is parsed as:
|
||||
//
|
||||
// CSI codepoint ; modifiers u
|
||||
// codepoint: ASCII Dec value
|
||||
//
|
||||
// The Kitty Keyboard Protocol extends this with optional components that can be
|
||||
// enabled progressively. The full sequence is parsed as:
|
||||
//
|
||||
// CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u
|
||||
//
|
||||
// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
||||
func parseKittyKeyboard(params ansi.Params) (Event Event) {
|
||||
var isRelease bool
|
||||
var key Key
|
||||
|
||||
// The index of parameters separated by semicolons ';'. Sub parameters are
|
||||
// separated by colons ':'.
|
||||
var paramIdx int
|
||||
var sudIdx int // The sub parameter index
|
||||
for _, p := range params {
|
||||
// Kitty Keyboard Protocol has 3 optional components.
|
||||
switch paramIdx {
|
||||
case 0:
|
||||
switch sudIdx {
|
||||
case 0:
|
||||
var foundKey bool
|
||||
code := p.Param(1) // CSI u has a default value of 1
|
||||
key, foundKey = kittyKeyMap[code]
|
||||
if !foundKey {
|
||||
r := rune(code)
|
||||
if !utf8.ValidRune(r) {
|
||||
r = utf8.RuneError
|
||||
}
|
||||
|
||||
key.Code = r
|
||||
}
|
||||
|
||||
case 2:
|
||||
// shifted key + base key
|
||||
if b := rune(p.Param(1)); unicode.IsPrint(b) {
|
||||
// XXX: When alternate key reporting is enabled, the protocol
|
||||
// can return 3 things, the unicode codepoint of the key,
|
||||
// the shifted codepoint of the key, and the standard
|
||||
// PC-101 key layout codepoint.
|
||||
// This is useful to create an unambiguous mapping of keys
|
||||
// when using a different language layout.
|
||||
key.BaseCode = b
|
||||
}
|
||||
fallthrough
|
||||
|
||||
case 1:
|
||||
// shifted key
|
||||
if s := rune(p.Param(1)); unicode.IsPrint(s) {
|
||||
// XXX: We swap keys here because we want the shifted key
|
||||
// to be the Rune that is returned by the event.
|
||||
// For example, shift+a should produce "A" not "a".
|
||||
// In such a case, we set AltRune to the original key "a"
|
||||
// and Rune to "A".
|
||||
key.ShiftedCode = s
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
switch sudIdx {
|
||||
case 0:
|
||||
mod := p.Param(1)
|
||||
if mod > 1 {
|
||||
key.Mod = fromKittyMod(mod - 1)
|
||||
if key.Mod > ModShift {
|
||||
// XXX: We need to clear the text if we have a modifier key
|
||||
// other than a [ModShift] key.
|
||||
key.Text = ""
|
||||
}
|
||||
}
|
||||
|
||||
case 1:
|
||||
switch p.Param(1) {
|
||||
case 2:
|
||||
key.IsRepeat = true
|
||||
case 3:
|
||||
isRelease = true
|
||||
}
|
||||
case 2:
|
||||
}
|
||||
case 2:
|
||||
if code := p.Param(0); code != 0 {
|
||||
key.Text += string(rune(code))
|
||||
}
|
||||
}
|
||||
|
||||
sudIdx++
|
||||
if !p.HasMore() {
|
||||
paramIdx++
|
||||
sudIdx = 0
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:nestif
|
||||
if len(key.Text) == 0 && unicode.IsPrint(key.Code) &&
|
||||
(key.Mod <= ModShift || key.Mod == ModCapsLock || key.Mod == ModShift|ModCapsLock) {
|
||||
if key.Mod == 0 {
|
||||
key.Text = string(key.Code)
|
||||
} else {
|
||||
desiredCase := unicode.ToLower
|
||||
if key.Mod.Contains(ModShift) || key.Mod.Contains(ModCapsLock) {
|
||||
desiredCase = unicode.ToUpper
|
||||
}
|
||||
if key.ShiftedCode != 0 {
|
||||
key.Text = string(key.ShiftedCode)
|
||||
} else {
|
||||
key.Text = string(desiredCase(key.Code))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isRelease {
|
||||
return KeyReleaseEvent(key)
|
||||
}
|
||||
|
||||
return KeyPressEvent(key)
|
||||
}
|
||||
|
||||
// parseKittyKeyboardExt parses a Kitty Keyboard Protocol sequence extensions
|
||||
// for non CSI u sequences. This includes things like CSI A, SS3 A and others,
|
||||
// and CSI ~.
|
||||
func parseKittyKeyboardExt(params ansi.Params, k KeyPressEvent) Event {
|
||||
// Handle Kitty keyboard protocol
|
||||
if len(params) > 2 && // We have at least 3 parameters
|
||||
params[0].Param(1) == 1 && // The first parameter is 1 (defaults to 1)
|
||||
params[1].HasMore() { // The second parameter is a subparameter (separated by a ":")
|
||||
switch params[2].Param(1) { // The third parameter is the event type (defaults to 1)
|
||||
case 2:
|
||||
k.IsRepeat = true
|
||||
case 3:
|
||||
return KeyReleaseEvent(k)
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package input
|
||||
|
||||
// KeyMod represents modifier keys.
|
||||
type KeyMod int
|
||||
|
||||
// Modifier keys.
|
||||
const (
|
||||
ModShift KeyMod = 1 << iota
|
||||
ModAlt
|
||||
ModCtrl
|
||||
ModMeta
|
||||
|
||||
// These modifiers are used with the Kitty protocol.
|
||||
// XXX: Meta and Super are swapped in the Kitty protocol,
|
||||
// this is to preserve compatibility with XTerm modifiers.
|
||||
|
||||
ModHyper
|
||||
ModSuper // Windows/Command keys
|
||||
|
||||
// These are key lock states.
|
||||
|
||||
ModCapsLock
|
||||
ModNumLock
|
||||
ModScrollLock // Defined in Windows API only
|
||||
)
|
||||
|
||||
// Contains reports whether m contains the given modifiers.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// m := ModAlt | ModCtrl
|
||||
// m.Contains(ModCtrl) // true
|
||||
// m.Contains(ModAlt | ModCtrl) // true
|
||||
// m.Contains(ModAlt | ModCtrl | ModShift) // false
|
||||
func (m KeyMod) Contains(mods KeyMod) bool {
|
||||
return m&mods == mods
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package input
|
||||
|
||||
import "github.com/charmbracelet/x/ansi"
|
||||
|
||||
// ModeReportEvent is a message that represents a mode report event (DECRPM).
|
||||
//
|
||||
// See: https://vt100.net/docs/vt510-rm/DECRPM.html
|
||||
type ModeReportEvent struct {
|
||||
// Mode is the mode number.
|
||||
Mode ansi.Mode
|
||||
|
||||
// Value is the mode value.
|
||||
Value ansi.ModeSetting
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// MouseButton represents the button that was pressed during a mouse message.
|
||||
type MouseButton = ansi.MouseButton
|
||||
|
||||
// Mouse event buttons
|
||||
//
|
||||
// This is based on X11 mouse button codes.
|
||||
//
|
||||
// 1 = left button
|
||||
// 2 = middle button (pressing the scroll wheel)
|
||||
// 3 = right button
|
||||
// 4 = turn scroll wheel up
|
||||
// 5 = turn scroll wheel down
|
||||
// 6 = push scroll wheel left
|
||||
// 7 = push scroll wheel right
|
||||
// 8 = 4th button (aka browser backward button)
|
||||
// 9 = 5th button (aka browser forward button)
|
||||
// 10
|
||||
// 11
|
||||
//
|
||||
// Other buttons are not supported.
|
||||
const (
|
||||
MouseNone = ansi.MouseNone
|
||||
MouseLeft = ansi.MouseLeft
|
||||
MouseMiddle = ansi.MouseMiddle
|
||||
MouseRight = ansi.MouseRight
|
||||
MouseWheelUp = ansi.MouseWheelUp
|
||||
MouseWheelDown = ansi.MouseWheelDown
|
||||
MouseWheelLeft = ansi.MouseWheelLeft
|
||||
MouseWheelRight = ansi.MouseWheelRight
|
||||
MouseBackward = ansi.MouseBackward
|
||||
MouseForward = ansi.MouseForward
|
||||
MouseButton10 = ansi.MouseButton10
|
||||
MouseButton11 = ansi.MouseButton11
|
||||
)
|
||||
|
||||
// MouseEvent represents a mouse message. This is a generic mouse message that
|
||||
// can represent any kind of mouse event.
|
||||
type MouseEvent interface {
|
||||
fmt.Stringer
|
||||
|
||||
// Mouse returns the underlying mouse event.
|
||||
Mouse() Mouse
|
||||
}
|
||||
|
||||
// Mouse represents a Mouse message. Use [MouseEvent] to represent all mouse
|
||||
// messages.
|
||||
//
|
||||
// The X and Y coordinates are zero-based, with (0,0) being the upper left
|
||||
// corner of the terminal.
|
||||
//
|
||||
// // Catch all mouse events
|
||||
// switch Event := Event.(type) {
|
||||
// case MouseEvent:
|
||||
// m := Event.Mouse()
|
||||
// fmt.Println("Mouse event:", m.X, m.Y, m)
|
||||
// }
|
||||
//
|
||||
// // Only catch mouse click events
|
||||
// switch Event := Event.(type) {
|
||||
// case MouseClickEvent:
|
||||
// fmt.Println("Mouse click event:", Event.X, Event.Y, Event)
|
||||
// }
|
||||
type Mouse struct {
|
||||
X, Y int
|
||||
Button MouseButton
|
||||
Mod KeyMod
|
||||
}
|
||||
|
||||
// String returns a string representation of the mouse message.
|
||||
func (m Mouse) String() (s string) {
|
||||
if m.Mod.Contains(ModCtrl) {
|
||||
s += "ctrl+"
|
||||
}
|
||||
if m.Mod.Contains(ModAlt) {
|
||||
s += "alt+"
|
||||
}
|
||||
if m.Mod.Contains(ModShift) {
|
||||
s += "shift+"
|
||||
}
|
||||
|
||||
str := m.Button.String()
|
||||
if str == "" {
|
||||
s += "unknown"
|
||||
} else if str != "none" { // motion events don't have a button
|
||||
s += str
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// MouseClickEvent represents a mouse button click event.
|
||||
type MouseClickEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse click event.
|
||||
func (e MouseClickEvent) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseClickEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseReleaseEvent represents a mouse button release event.
|
||||
type MouseReleaseEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse release event.
|
||||
func (e MouseReleaseEvent) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseReleaseEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseWheelEvent represents a mouse wheel message event.
|
||||
type MouseWheelEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse wheel event.
|
||||
func (e MouseWheelEvent) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseWheelEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseMotionEvent represents a mouse motion event.
|
||||
type MouseMotionEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse motion event.
|
||||
func (e MouseMotionEvent) String() string {
|
||||
m := Mouse(e)
|
||||
if m.Button != 0 {
|
||||
return m.String() + "+motion"
|
||||
}
|
||||
return m.String() + "motion"
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseMotionEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events
|
||||
// look like:
|
||||
//
|
||||
// ESC [ < Cb ; Cx ; Cy (M or m)
|
||||
//
|
||||
// where:
|
||||
//
|
||||
// Cb is the encoded button code
|
||||
// Cx is the x-coordinate of the mouse
|
||||
// Cy is the y-coordinate of the mouse
|
||||
// M is for button press, m is for button release
|
||||
//
|
||||
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
|
||||
func parseSGRMouseEvent(cmd ansi.Cmd, params ansi.Params) Event {
|
||||
x, _, ok := params.Param(1, 1)
|
||||
if !ok {
|
||||
x = 1
|
||||
}
|
||||
y, _, ok := params.Param(2, 1)
|
||||
if !ok {
|
||||
y = 1
|
||||
}
|
||||
release := cmd.Final() == 'm'
|
||||
b, _, _ := params.Param(0, 0)
|
||||
mod, btn, _, isMotion := parseMouseButton(b)
|
||||
|
||||
// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
|
||||
x--
|
||||
y--
|
||||
|
||||
m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
|
||||
|
||||
// Wheel buttons don't have release events
|
||||
// Motion can be reported as a release event in some terminals (Windows Terminal)
|
||||
if isWheel(m.Button) {
|
||||
return MouseWheelEvent(m)
|
||||
} else if !isMotion && release {
|
||||
return MouseReleaseEvent(m)
|
||||
} else if isMotion {
|
||||
return MouseMotionEvent(m)
|
||||
}
|
||||
return MouseClickEvent(m)
|
||||
}
|
||||
|
||||
const x10MouseByteOffset = 32
|
||||
|
||||
// Parse X10-encoded mouse events; the simplest kind. The last release of X10
|
||||
// was December 1986, by the way. The original X10 mouse protocol limits the Cx
|
||||
// and Cy coordinates to 223 (=255-032).
|
||||
//
|
||||
// X10 mouse events look like:
|
||||
//
|
||||
// ESC [M Cb Cx Cy
|
||||
//
|
||||
// See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
|
||||
func parseX10MouseEvent(buf []byte) Event {
|
||||
v := buf[3:6]
|
||||
b := int(v[0])
|
||||
if b >= x10MouseByteOffset {
|
||||
// XXX: b < 32 should be impossible, but we're being defensive.
|
||||
b -= x10MouseByteOffset
|
||||
}
|
||||
|
||||
mod, btn, isRelease, isMotion := parseMouseButton(b)
|
||||
|
||||
// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
|
||||
x := int(v[1]) - x10MouseByteOffset - 1
|
||||
y := int(v[2]) - x10MouseByteOffset - 1
|
||||
|
||||
m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
|
||||
if isWheel(m.Button) {
|
||||
return MouseWheelEvent(m)
|
||||
} else if isMotion {
|
||||
return MouseMotionEvent(m)
|
||||
} else if isRelease {
|
||||
return MouseReleaseEvent(m)
|
||||
}
|
||||
return MouseClickEvent(m)
|
||||
}
|
||||
|
||||
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
|
||||
func parseMouseButton(b int) (mod KeyMod, btn MouseButton, isRelease bool, isMotion bool) {
|
||||
// mouse bit shifts
|
||||
const (
|
||||
bitShift = 0b0000_0100
|
||||
bitAlt = 0b0000_1000
|
||||
bitCtrl = 0b0001_0000
|
||||
bitMotion = 0b0010_0000
|
||||
bitWheel = 0b0100_0000
|
||||
bitAdd = 0b1000_0000 // additional buttons 8-11
|
||||
|
||||
bitsMask = 0b0000_0011
|
||||
)
|
||||
|
||||
// Modifiers
|
||||
if b&bitAlt != 0 {
|
||||
mod |= ModAlt
|
||||
}
|
||||
if b&bitCtrl != 0 {
|
||||
mod |= ModCtrl
|
||||
}
|
||||
if b&bitShift != 0 {
|
||||
mod |= ModShift
|
||||
}
|
||||
|
||||
if b&bitAdd != 0 {
|
||||
btn = MouseBackward + MouseButton(b&bitsMask)
|
||||
} else if b&bitWheel != 0 {
|
||||
btn = MouseWheelUp + MouseButton(b&bitsMask)
|
||||
} else {
|
||||
btn = MouseLeft + MouseButton(b&bitsMask)
|
||||
// X10 reports a button release as 0b0000_0011 (3)
|
||||
if b&bitsMask == bitsMask {
|
||||
btn = MouseNone
|
||||
isRelease = true
|
||||
}
|
||||
}
|
||||
|
||||
// Motion bit doesn't get reported for wheel events.
|
||||
if b&bitMotion != 0 && !isWheel(btn) {
|
||||
isMotion = true
|
||||
}
|
||||
|
||||
return //nolint:nakedret
|
||||
}
|
||||
|
||||
// isWheel returns true if the mouse event is a wheel event.
|
||||
func isWheel(btn MouseButton) bool {
|
||||
return btn >= MouseWheelUp && btn <= MouseWheelRight
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/ansi/parser"
|
||||
)
|
||||
|
||||
func TestMouseEvent_String(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
event Event
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "unknown",
|
||||
event: MouseClickEvent{Button: MouseButton(0xff)},
|
||||
expected: "unknown",
|
||||
},
|
||||
{
|
||||
name: "left",
|
||||
event: MouseClickEvent{Button: MouseLeft},
|
||||
expected: "left",
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
event: MouseClickEvent{Button: MouseRight},
|
||||
expected: "right",
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
event: MouseClickEvent{Button: MouseMiddle},
|
||||
expected: "middle",
|
||||
},
|
||||
{
|
||||
name: "release",
|
||||
event: MouseReleaseEvent{Button: MouseNone},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "wheelup",
|
||||
event: MouseWheelEvent{Button: MouseWheelUp},
|
||||
expected: "wheelup",
|
||||
},
|
||||
{
|
||||
name: "wheeldown",
|
||||
event: MouseWheelEvent{Button: MouseWheelDown},
|
||||
expected: "wheeldown",
|
||||
},
|
||||
{
|
||||
name: "wheelleft",
|
||||
event: MouseWheelEvent{Button: MouseWheelLeft},
|
||||
expected: "wheelleft",
|
||||
},
|
||||
{
|
||||
name: "wheelright",
|
||||
event: MouseWheelEvent{Button: MouseWheelRight},
|
||||
expected: "wheelright",
|
||||
},
|
||||
{
|
||||
name: "motion",
|
||||
event: MouseMotionEvent{Button: MouseNone},
|
||||
expected: "motion",
|
||||
},
|
||||
{
|
||||
name: "shift+left",
|
||||
event: MouseReleaseEvent{Button: MouseLeft, Mod: ModShift},
|
||||
expected: "shift+left",
|
||||
},
|
||||
{
|
||||
name: "shift+left", event: MouseClickEvent{Button: MouseLeft, Mod: ModShift},
|
||||
expected: "shift+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+shift+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl | ModShift},
|
||||
expected: "ctrl+shift+left",
|
||||
},
|
||||
{
|
||||
name: "alt+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt},
|
||||
expected: "alt+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl},
|
||||
expected: "ctrl+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl},
|
||||
expected: "ctrl+alt+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+shift+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl | ModShift},
|
||||
expected: "ctrl+alt+shift+left",
|
||||
},
|
||||
{
|
||||
name: "ignore coordinates",
|
||||
event: MouseClickEvent{X: 100, Y: 200, Button: MouseLeft},
|
||||
expected: "left",
|
||||
},
|
||||
{
|
||||
name: "broken type",
|
||||
event: MouseClickEvent{Button: MouseButton(120)},
|
||||
expected: "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
for i := range tt {
|
||||
tc := tt[i]
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := fmt.Sprint(tc.event)
|
||||
|
||||
if tc.expected != actual {
|
||||
t.Fatalf("expected %q but got %q",
|
||||
tc.expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseX10MouseDownEvent(t *testing.T) {
|
||||
encode := func(b byte, x, y int) []byte {
|
||||
return []byte{
|
||||
'\x1b',
|
||||
'[',
|
||||
'M',
|
||||
byte(32) + b,
|
||||
byte(x + 32 + 1),
|
||||
byte(y + 32 + 1),
|
||||
}
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
buf []byte
|
||||
expected Event
|
||||
}{
|
||||
// Position.
|
||||
{
|
||||
name: "zero position",
|
||||
buf: encode(0b0000_0000, 0, 0),
|
||||
expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "max position",
|
||||
buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1.
|
||||
expected: MouseClickEvent{X: 222, Y: 222, Button: MouseLeft},
|
||||
},
|
||||
// Simple.
|
||||
{
|
||||
name: "left",
|
||||
buf: encode(0b0000_0000, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "left in motion",
|
||||
buf: encode(0b0010_0000, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
buf: encode(0b0000_0001, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "middle in motion",
|
||||
buf: encode(0b0010_0001, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
buf: encode(0b0000_0010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "right in motion",
|
||||
buf: encode(0b0010_0010, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "motion",
|
||||
buf: encode(0b0010_0011, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
|
||||
},
|
||||
{
|
||||
name: "wheel up",
|
||||
buf: encode(0b0100_0000, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
{
|
||||
name: "wheel down",
|
||||
buf: encode(0b0100_0001, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "wheel left",
|
||||
buf: encode(0b0100_0010, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
|
||||
},
|
||||
{
|
||||
name: "wheel right",
|
||||
buf: encode(0b0100_0011, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
|
||||
},
|
||||
{
|
||||
name: "release",
|
||||
buf: encode(0b0000_0011, 32, 16),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseNone},
|
||||
},
|
||||
{
|
||||
name: "backward",
|
||||
buf: encode(0b1000_0000, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
|
||||
},
|
||||
{
|
||||
name: "forward",
|
||||
buf: encode(0b1000_0001, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
|
||||
},
|
||||
{
|
||||
name: "button 10",
|
||||
buf: encode(0b1000_0010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton10},
|
||||
},
|
||||
{
|
||||
name: "button 11",
|
||||
buf: encode(0b1000_0011, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton11},
|
||||
},
|
||||
// Combinations.
|
||||
{
|
||||
name: "alt+right",
|
||||
buf: encode(0b0000_1010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+right",
|
||||
buf: encode(0b0001_0010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "left in motion",
|
||||
buf: encode(0b0010_0000, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "alt+right in motion",
|
||||
buf: encode(0b0010_1010, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+right in motion",
|
||||
buf: encode(0b0011_0010, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+right",
|
||||
buf: encode(0b0001_1010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+wheel up",
|
||||
buf: encode(0b0101_0000, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelUp},
|
||||
},
|
||||
{
|
||||
name: "alt+wheel down",
|
||||
buf: encode(0b0100_1001, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+wheel down",
|
||||
buf: encode(0b0101_1001, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
// Overflow position.
|
||||
{
|
||||
name: "overflow position",
|
||||
buf: encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1.
|
||||
expected: MouseMotionEvent{X: -6, Y: -33, Button: MouseLeft},
|
||||
},
|
||||
}
|
||||
|
||||
for i := range tt {
|
||||
tc := tt[i]
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := parseX10MouseEvent(tc.buf)
|
||||
|
||||
if tc.expected != actual {
|
||||
t.Fatalf("expected %#v but got %#v",
|
||||
tc.expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSGRMouseEvent(t *testing.T) {
|
||||
type csiSequence struct {
|
||||
params []ansi.Param
|
||||
cmd ansi.Cmd
|
||||
}
|
||||
encode := func(b, x, y int, r bool) *csiSequence {
|
||||
re := 'M'
|
||||
if r {
|
||||
re = 'm'
|
||||
}
|
||||
return &csiSequence{
|
||||
params: []ansi.Param{
|
||||
ansi.Param(b),
|
||||
ansi.Param(x + 1),
|
||||
ansi.Param(y + 1),
|
||||
},
|
||||
cmd: ansi.Cmd(re) | ('<' << parser.PrefixShift),
|
||||
}
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
buf *csiSequence
|
||||
expected Event
|
||||
}{
|
||||
// Position.
|
||||
{
|
||||
name: "zero position",
|
||||
buf: encode(0, 0, 0, false),
|
||||
expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "225 position",
|
||||
buf: encode(0, 225, 225, false),
|
||||
expected: MouseClickEvent{X: 225, Y: 225, Button: MouseLeft},
|
||||
},
|
||||
// Simple.
|
||||
{
|
||||
name: "left",
|
||||
buf: encode(0, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "left in motion",
|
||||
buf: encode(32, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "left",
|
||||
buf: encode(0, 32, 16, true),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
buf: encode(1, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "middle in motion",
|
||||
buf: encode(33, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
buf: encode(1, 32, 16, true),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
buf: encode(2, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
buf: encode(2, 32, 16, true),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "motion",
|
||||
buf: encode(35, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
|
||||
},
|
||||
{
|
||||
name: "wheel up",
|
||||
buf: encode(64, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
{
|
||||
name: "wheel down",
|
||||
buf: encode(65, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "wheel left",
|
||||
buf: encode(66, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
|
||||
},
|
||||
{
|
||||
name: "wheel right",
|
||||
buf: encode(67, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
|
||||
},
|
||||
{
|
||||
name: "backward",
|
||||
buf: encode(128, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
|
||||
},
|
||||
{
|
||||
name: "backward in motion",
|
||||
buf: encode(160, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseBackward},
|
||||
},
|
||||
{
|
||||
name: "forward",
|
||||
buf: encode(129, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
|
||||
},
|
||||
{
|
||||
name: "forward in motion",
|
||||
buf: encode(161, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseForward},
|
||||
},
|
||||
// Combinations.
|
||||
{
|
||||
name: "alt+right",
|
||||
buf: encode(10, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+right",
|
||||
buf: encode(18, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+right",
|
||||
buf: encode(26, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "alt+wheel",
|
||||
buf: encode(73, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+wheel",
|
||||
buf: encode(81, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+wheel",
|
||||
buf: encode(89, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+shift+wheel",
|
||||
buf: encode(93, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModShift | ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
}
|
||||
|
||||
for i := range tt {
|
||||
tc := tt[i]
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := parseSGRMouseEvent(tc.buf.cmd, tc.buf.params)
|
||||
if tc.expected != actual {
|
||||
t.Fatalf("expected %#v but got %#v",
|
||||
tc.expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func TestParseSequence_Events(t *testing.T) {
|
||||
input := []byte("\x1b\x1b[Ztest\x00\x1b]10;rgb:1234/1234/1234\x07\x1b[27;2;27~\x1b[?1049;2$y\x1b[4;1$y")
|
||||
want := []Event{
|
||||
KeyPressEvent{Code: KeyTab, Mod: ModShift | ModAlt},
|
||||
KeyPressEvent{Code: 't', Text: "t"},
|
||||
KeyPressEvent{Code: 'e', Text: "e"},
|
||||
KeyPressEvent{Code: 's', Text: "s"},
|
||||
KeyPressEvent{Code: 't', Text: "t"},
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
|
||||
ForegroundColorEvent{color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}},
|
||||
KeyPressEvent{Code: KeyEscape, Mod: ModShift},
|
||||
ModeReportEvent{Mode: ansi.AltScreenSaveCursorMode, Value: ansi.ModeReset},
|
||||
ModeReportEvent{Mode: ansi.InsertReplaceMode, Value: ansi.ModeSet},
|
||||
}
|
||||
|
||||
var p Parser
|
||||
for i := 0; len(input) != 0; i++ {
|
||||
if i >= len(want) {
|
||||
t.Fatalf("reached end of want events")
|
||||
}
|
||||
n, got := p.parseSequence(input)
|
||||
if !reflect.DeepEqual(got, want[i]) {
|
||||
t.Errorf("got %#v (%T), want %#v (%T)", got, got, want[i], want[i])
|
||||
}
|
||||
input = input[n:]
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParseSequence(b *testing.B) {
|
||||
var p Parser
|
||||
input := []byte("\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~")
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
p.parseSequence(input)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package input
|
||||
|
||||
// PasteEvent is an message that is emitted when a terminal receives pasted text
|
||||
// using bracketed-paste.
|
||||
type PasteEvent string
|
||||
|
||||
// PasteStartEvent is an message that is emitted when the terminal starts the
|
||||
// bracketed-paste text.
|
||||
type PasteStartEvent struct{}
|
||||
|
||||
// PasteEndEvent is an message that is emitted when the terminal ends the
|
||||
// bracketed-paste text.
|
||||
type PasteEndEvent struct{}
|
||||
@@ -0,0 +1,389 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// buildKeysTable builds a table of key sequences and their corresponding key
|
||||
// events based on the VT100/VT200, XTerm, and Urxvt terminal specs.
|
||||
func buildKeysTable(flags int, term string) map[string]Key {
|
||||
nul := Key{Code: KeySpace, Mod: ModCtrl} // ctrl+@ or ctrl+space
|
||||
if flags&FlagCtrlAt != 0 {
|
||||
nul = Key{Code: '@', Mod: ModCtrl}
|
||||
}
|
||||
|
||||
tab := Key{Code: KeyTab} // ctrl+i or tab
|
||||
if flags&FlagCtrlI != 0 {
|
||||
tab = Key{Code: 'i', Mod: ModCtrl}
|
||||
}
|
||||
|
||||
enter := Key{Code: KeyEnter} // ctrl+m or enter
|
||||
if flags&FlagCtrlM != 0 {
|
||||
enter = Key{Code: 'm', Mod: ModCtrl}
|
||||
}
|
||||
|
||||
esc := Key{Code: KeyEscape} // ctrl+[ or escape
|
||||
if flags&FlagCtrlOpenBracket != 0 {
|
||||
esc = Key{Code: '[', Mod: ModCtrl} // ctrl+[ or escape
|
||||
}
|
||||
|
||||
del := Key{Code: KeyBackspace}
|
||||
if flags&FlagBackspace != 0 {
|
||||
del.Code = KeyDelete
|
||||
}
|
||||
|
||||
find := Key{Code: KeyHome}
|
||||
if flags&FlagFind != 0 {
|
||||
find.Code = KeyFind
|
||||
}
|
||||
|
||||
sel := Key{Code: KeyEnd}
|
||||
if flags&FlagSelect != 0 {
|
||||
sel.Code = KeySelect
|
||||
}
|
||||
|
||||
// The following is a table of key sequences and their corresponding key
|
||||
// events based on the VT100/VT200 terminal specs.
|
||||
//
|
||||
// See: https://vt100.net/docs/vt100-ug/chapter3.html#S3.2
|
||||
// See: https://vt100.net/docs/vt220-rm/chapter3.html
|
||||
//
|
||||
// XXX: These keys may be overwritten by other options like XTerm or
|
||||
// Terminfo.
|
||||
table := map[string]Key{
|
||||
// C0 control characters
|
||||
string(byte(ansi.NUL)): nul,
|
||||
string(byte(ansi.SOH)): {Code: 'a', Mod: ModCtrl},
|
||||
string(byte(ansi.STX)): {Code: 'b', Mod: ModCtrl},
|
||||
string(byte(ansi.ETX)): {Code: 'c', Mod: ModCtrl},
|
||||
string(byte(ansi.EOT)): {Code: 'd', Mod: ModCtrl},
|
||||
string(byte(ansi.ENQ)): {Code: 'e', Mod: ModCtrl},
|
||||
string(byte(ansi.ACK)): {Code: 'f', Mod: ModCtrl},
|
||||
string(byte(ansi.BEL)): {Code: 'g', Mod: ModCtrl},
|
||||
string(byte(ansi.BS)): {Code: 'h', Mod: ModCtrl},
|
||||
string(byte(ansi.HT)): tab,
|
||||
string(byte(ansi.LF)): {Code: 'j', Mod: ModCtrl},
|
||||
string(byte(ansi.VT)): {Code: 'k', Mod: ModCtrl},
|
||||
string(byte(ansi.FF)): {Code: 'l', Mod: ModCtrl},
|
||||
string(byte(ansi.CR)): enter,
|
||||
string(byte(ansi.SO)): {Code: 'n', Mod: ModCtrl},
|
||||
string(byte(ansi.SI)): {Code: 'o', Mod: ModCtrl},
|
||||
string(byte(ansi.DLE)): {Code: 'p', Mod: ModCtrl},
|
||||
string(byte(ansi.DC1)): {Code: 'q', Mod: ModCtrl},
|
||||
string(byte(ansi.DC2)): {Code: 'r', Mod: ModCtrl},
|
||||
string(byte(ansi.DC3)): {Code: 's', Mod: ModCtrl},
|
||||
string(byte(ansi.DC4)): {Code: 't', Mod: ModCtrl},
|
||||
string(byte(ansi.NAK)): {Code: 'u', Mod: ModCtrl},
|
||||
string(byte(ansi.SYN)): {Code: 'v', Mod: ModCtrl},
|
||||
string(byte(ansi.ETB)): {Code: 'w', Mod: ModCtrl},
|
||||
string(byte(ansi.CAN)): {Code: 'x', Mod: ModCtrl},
|
||||
string(byte(ansi.EM)): {Code: 'y', Mod: ModCtrl},
|
||||
string(byte(ansi.SUB)): {Code: 'z', Mod: ModCtrl},
|
||||
string(byte(ansi.ESC)): esc,
|
||||
string(byte(ansi.FS)): {Code: '\\', Mod: ModCtrl},
|
||||
string(byte(ansi.GS)): {Code: ']', Mod: ModCtrl},
|
||||
string(byte(ansi.RS)): {Code: '^', Mod: ModCtrl},
|
||||
string(byte(ansi.US)): {Code: '_', Mod: ModCtrl},
|
||||
|
||||
// Special keys in G0
|
||||
string(byte(ansi.SP)): {Code: KeySpace, Text: " "},
|
||||
string(byte(ansi.DEL)): del,
|
||||
|
||||
// Special keys
|
||||
|
||||
"\x1b[Z": {Code: KeyTab, Mod: ModShift},
|
||||
|
||||
"\x1b[1~": find,
|
||||
"\x1b[2~": {Code: KeyInsert},
|
||||
"\x1b[3~": {Code: KeyDelete},
|
||||
"\x1b[4~": sel,
|
||||
"\x1b[5~": {Code: KeyPgUp},
|
||||
"\x1b[6~": {Code: KeyPgDown},
|
||||
"\x1b[7~": {Code: KeyHome},
|
||||
"\x1b[8~": {Code: KeyEnd},
|
||||
|
||||
// Normal mode
|
||||
"\x1b[A": {Code: KeyUp},
|
||||
"\x1b[B": {Code: KeyDown},
|
||||
"\x1b[C": {Code: KeyRight},
|
||||
"\x1b[D": {Code: KeyLeft},
|
||||
"\x1b[E": {Code: KeyBegin},
|
||||
"\x1b[F": {Code: KeyEnd},
|
||||
"\x1b[H": {Code: KeyHome},
|
||||
"\x1b[P": {Code: KeyF1},
|
||||
"\x1b[Q": {Code: KeyF2},
|
||||
"\x1b[R": {Code: KeyF3},
|
||||
"\x1b[S": {Code: KeyF4},
|
||||
|
||||
// Application Cursor Key Mode (DECCKM)
|
||||
"\x1bOA": {Code: KeyUp},
|
||||
"\x1bOB": {Code: KeyDown},
|
||||
"\x1bOC": {Code: KeyRight},
|
||||
"\x1bOD": {Code: KeyLeft},
|
||||
"\x1bOE": {Code: KeyBegin},
|
||||
"\x1bOF": {Code: KeyEnd},
|
||||
"\x1bOH": {Code: KeyHome},
|
||||
"\x1bOP": {Code: KeyF1},
|
||||
"\x1bOQ": {Code: KeyF2},
|
||||
"\x1bOR": {Code: KeyF3},
|
||||
"\x1bOS": {Code: KeyF4},
|
||||
|
||||
// Keypad Application Mode (DECKPAM)
|
||||
|
||||
"\x1bOM": {Code: KeyKpEnter},
|
||||
"\x1bOX": {Code: KeyKpEqual},
|
||||
"\x1bOj": {Code: KeyKpMultiply},
|
||||
"\x1bOk": {Code: KeyKpPlus},
|
||||
"\x1bOl": {Code: KeyKpComma},
|
||||
"\x1bOm": {Code: KeyKpMinus},
|
||||
"\x1bOn": {Code: KeyKpDecimal},
|
||||
"\x1bOo": {Code: KeyKpDivide},
|
||||
"\x1bOp": {Code: KeyKp0},
|
||||
"\x1bOq": {Code: KeyKp1},
|
||||
"\x1bOr": {Code: KeyKp2},
|
||||
"\x1bOs": {Code: KeyKp3},
|
||||
"\x1bOt": {Code: KeyKp4},
|
||||
"\x1bOu": {Code: KeyKp5},
|
||||
"\x1bOv": {Code: KeyKp6},
|
||||
"\x1bOw": {Code: KeyKp7},
|
||||
"\x1bOx": {Code: KeyKp8},
|
||||
"\x1bOy": {Code: KeyKp9},
|
||||
|
||||
// Function keys
|
||||
|
||||
"\x1b[11~": {Code: KeyF1},
|
||||
"\x1b[12~": {Code: KeyF2},
|
||||
"\x1b[13~": {Code: KeyF3},
|
||||
"\x1b[14~": {Code: KeyF4},
|
||||
"\x1b[15~": {Code: KeyF5},
|
||||
"\x1b[17~": {Code: KeyF6},
|
||||
"\x1b[18~": {Code: KeyF7},
|
||||
"\x1b[19~": {Code: KeyF8},
|
||||
"\x1b[20~": {Code: KeyF9},
|
||||
"\x1b[21~": {Code: KeyF10},
|
||||
"\x1b[23~": {Code: KeyF11},
|
||||
"\x1b[24~": {Code: KeyF12},
|
||||
"\x1b[25~": {Code: KeyF13},
|
||||
"\x1b[26~": {Code: KeyF14},
|
||||
"\x1b[28~": {Code: KeyF15},
|
||||
"\x1b[29~": {Code: KeyF16},
|
||||
"\x1b[31~": {Code: KeyF17},
|
||||
"\x1b[32~": {Code: KeyF18},
|
||||
"\x1b[33~": {Code: KeyF19},
|
||||
"\x1b[34~": {Code: KeyF20},
|
||||
}
|
||||
|
||||
// CSI ~ sequence keys
|
||||
csiTildeKeys := map[string]Key{
|
||||
"1": find, "2": {Code: KeyInsert},
|
||||
"3": {Code: KeyDelete}, "4": sel,
|
||||
"5": {Code: KeyPgUp}, "6": {Code: KeyPgDown},
|
||||
"7": {Code: KeyHome}, "8": {Code: KeyEnd},
|
||||
// There are no 9 and 10 keys
|
||||
"11": {Code: KeyF1}, "12": {Code: KeyF2},
|
||||
"13": {Code: KeyF3}, "14": {Code: KeyF4},
|
||||
"15": {Code: KeyF5}, "17": {Code: KeyF6},
|
||||
"18": {Code: KeyF7}, "19": {Code: KeyF8},
|
||||
"20": {Code: KeyF9}, "21": {Code: KeyF10},
|
||||
"23": {Code: KeyF11}, "24": {Code: KeyF12},
|
||||
"25": {Code: KeyF13}, "26": {Code: KeyF14},
|
||||
"28": {Code: KeyF15}, "29": {Code: KeyF16},
|
||||
"31": {Code: KeyF17}, "32": {Code: KeyF18},
|
||||
"33": {Code: KeyF19}, "34": {Code: KeyF20},
|
||||
}
|
||||
|
||||
// URxvt keys
|
||||
// See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
|
||||
table["\x1b[a"] = Key{Code: KeyUp, Mod: ModShift}
|
||||
table["\x1b[b"] = Key{Code: KeyDown, Mod: ModShift}
|
||||
table["\x1b[c"] = Key{Code: KeyRight, Mod: ModShift}
|
||||
table["\x1b[d"] = Key{Code: KeyLeft, Mod: ModShift}
|
||||
table["\x1bOa"] = Key{Code: KeyUp, Mod: ModCtrl}
|
||||
table["\x1bOb"] = Key{Code: KeyDown, Mod: ModCtrl}
|
||||
table["\x1bOc"] = Key{Code: KeyRight, Mod: ModCtrl}
|
||||
table["\x1bOd"] = Key{Code: KeyLeft, Mod: ModCtrl}
|
||||
//nolint:godox
|
||||
// TODO: invistigate if shift-ctrl arrow keys collide with DECCKM keys i.e.
|
||||
// "\x1bOA", "\x1bOB", "\x1bOC", "\x1bOD"
|
||||
|
||||
// URxvt modifier CSI ~ keys
|
||||
for k, v := range csiTildeKeys {
|
||||
key := v
|
||||
// Normal (no modifier) already defined part of VT100/VT200
|
||||
// Shift modifier
|
||||
key.Mod = ModShift
|
||||
table["\x1b["+k+"$"] = key
|
||||
// Ctrl modifier
|
||||
key.Mod = ModCtrl
|
||||
table["\x1b["+k+"^"] = key
|
||||
// Shift-Ctrl modifier
|
||||
key.Mod = ModShift | ModCtrl
|
||||
table["\x1b["+k+"@"] = key
|
||||
}
|
||||
|
||||
// URxvt F keys
|
||||
// Note: Shift + F1-F10 generates F11-F20.
|
||||
// This means Shift + F1 and Shift + F2 will generate F11 and F12, the same
|
||||
// applies to Ctrl + Shift F1 & F2.
|
||||
//
|
||||
// P.S. Don't like this? Blame URxvt, configure your terminal to use
|
||||
// different escapes like XTerm, or switch to a better terminal ¯\_(ツ)_/¯
|
||||
//
|
||||
// See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
|
||||
table["\x1b[23$"] = Key{Code: KeyF11, Mod: ModShift}
|
||||
table["\x1b[24$"] = Key{Code: KeyF12, Mod: ModShift}
|
||||
table["\x1b[25$"] = Key{Code: KeyF13, Mod: ModShift}
|
||||
table["\x1b[26$"] = Key{Code: KeyF14, Mod: ModShift}
|
||||
table["\x1b[28$"] = Key{Code: KeyF15, Mod: ModShift}
|
||||
table["\x1b[29$"] = Key{Code: KeyF16, Mod: ModShift}
|
||||
table["\x1b[31$"] = Key{Code: KeyF17, Mod: ModShift}
|
||||
table["\x1b[32$"] = Key{Code: KeyF18, Mod: ModShift}
|
||||
table["\x1b[33$"] = Key{Code: KeyF19, Mod: ModShift}
|
||||
table["\x1b[34$"] = Key{Code: KeyF20, Mod: ModShift}
|
||||
table["\x1b[11^"] = Key{Code: KeyF1, Mod: ModCtrl}
|
||||
table["\x1b[12^"] = Key{Code: KeyF2, Mod: ModCtrl}
|
||||
table["\x1b[13^"] = Key{Code: KeyF3, Mod: ModCtrl}
|
||||
table["\x1b[14^"] = Key{Code: KeyF4, Mod: ModCtrl}
|
||||
table["\x1b[15^"] = Key{Code: KeyF5, Mod: ModCtrl}
|
||||
table["\x1b[17^"] = Key{Code: KeyF6, Mod: ModCtrl}
|
||||
table["\x1b[18^"] = Key{Code: KeyF7, Mod: ModCtrl}
|
||||
table["\x1b[19^"] = Key{Code: KeyF8, Mod: ModCtrl}
|
||||
table["\x1b[20^"] = Key{Code: KeyF9, Mod: ModCtrl}
|
||||
table["\x1b[21^"] = Key{Code: KeyF10, Mod: ModCtrl}
|
||||
table["\x1b[23^"] = Key{Code: KeyF11, Mod: ModCtrl}
|
||||
table["\x1b[24^"] = Key{Code: KeyF12, Mod: ModCtrl}
|
||||
table["\x1b[25^"] = Key{Code: KeyF13, Mod: ModCtrl}
|
||||
table["\x1b[26^"] = Key{Code: KeyF14, Mod: ModCtrl}
|
||||
table["\x1b[28^"] = Key{Code: KeyF15, Mod: ModCtrl}
|
||||
table["\x1b[29^"] = Key{Code: KeyF16, Mod: ModCtrl}
|
||||
table["\x1b[31^"] = Key{Code: KeyF17, Mod: ModCtrl}
|
||||
table["\x1b[32^"] = Key{Code: KeyF18, Mod: ModCtrl}
|
||||
table["\x1b[33^"] = Key{Code: KeyF19, Mod: ModCtrl}
|
||||
table["\x1b[34^"] = Key{Code: KeyF20, Mod: ModCtrl}
|
||||
table["\x1b[23@"] = Key{Code: KeyF11, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[24@"] = Key{Code: KeyF12, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[25@"] = Key{Code: KeyF13, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[26@"] = Key{Code: KeyF14, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[28@"] = Key{Code: KeyF15, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[29@"] = Key{Code: KeyF16, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[31@"] = Key{Code: KeyF17, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[32@"] = Key{Code: KeyF18, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[33@"] = Key{Code: KeyF19, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[34@"] = Key{Code: KeyF20, Mod: ModShift | ModCtrl}
|
||||
|
||||
// Register Alt + <key> combinations
|
||||
// XXX: this must come after URxvt but before XTerm keys to register URxvt
|
||||
// keys with alt modifier
|
||||
tmap := map[string]Key{}
|
||||
for seq, key := range table {
|
||||
key := key
|
||||
key.Mod |= ModAlt
|
||||
key.Text = "" // Clear runes
|
||||
tmap["\x1b"+seq] = key
|
||||
}
|
||||
maps.Copy(table, tmap)
|
||||
|
||||
// XTerm modifiers
|
||||
// These are offset by 1 to be compatible with our Mod type.
|
||||
// See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-PC-Style-Function-Keys
|
||||
modifiers := []KeyMod{
|
||||
ModShift, // 1
|
||||
ModAlt, // 2
|
||||
ModShift | ModAlt, // 3
|
||||
ModCtrl, // 4
|
||||
ModShift | ModCtrl, // 5
|
||||
ModAlt | ModCtrl, // 6
|
||||
ModShift | ModAlt | ModCtrl, // 7
|
||||
ModMeta, // 8
|
||||
ModMeta | ModShift, // 9
|
||||
ModMeta | ModAlt, // 10
|
||||
ModMeta | ModShift | ModAlt, // 11
|
||||
ModMeta | ModCtrl, // 12
|
||||
ModMeta | ModShift | ModCtrl, // 13
|
||||
ModMeta | ModAlt | ModCtrl, // 14
|
||||
ModMeta | ModShift | ModAlt | ModCtrl, // 15
|
||||
}
|
||||
|
||||
// SS3 keypad function keys
|
||||
ss3FuncKeys := map[string]Key{
|
||||
// These are defined in XTerm
|
||||
// Taken from Foot keymap.h and XTerm modifyOtherKeys
|
||||
// https://codeberg.org/dnkl/foot/src/branch/master/keymap.h
|
||||
"M": {Code: KeyKpEnter}, "X": {Code: KeyKpEqual},
|
||||
"j": {Code: KeyKpMultiply}, "k": {Code: KeyKpPlus},
|
||||
"l": {Code: KeyKpComma}, "m": {Code: KeyKpMinus},
|
||||
"n": {Code: KeyKpDecimal}, "o": {Code: KeyKpDivide},
|
||||
"p": {Code: KeyKp0}, "q": {Code: KeyKp1},
|
||||
"r": {Code: KeyKp2}, "s": {Code: KeyKp3},
|
||||
"t": {Code: KeyKp4}, "u": {Code: KeyKp5},
|
||||
"v": {Code: KeyKp6}, "w": {Code: KeyKp7},
|
||||
"x": {Code: KeyKp8}, "y": {Code: KeyKp9},
|
||||
}
|
||||
|
||||
// XTerm keys
|
||||
csiFuncKeys := map[string]Key{
|
||||
"A": {Code: KeyUp}, "B": {Code: KeyDown},
|
||||
"C": {Code: KeyRight}, "D": {Code: KeyLeft},
|
||||
"E": {Code: KeyBegin}, "F": {Code: KeyEnd},
|
||||
"H": {Code: KeyHome}, "P": {Code: KeyF1},
|
||||
"Q": {Code: KeyF2}, "R": {Code: KeyF3},
|
||||
"S": {Code: KeyF4},
|
||||
}
|
||||
|
||||
// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
|
||||
modifyOtherKeys := map[int]Key{
|
||||
ansi.BS: {Code: KeyBackspace},
|
||||
ansi.HT: {Code: KeyTab},
|
||||
ansi.CR: {Code: KeyEnter},
|
||||
ansi.ESC: {Code: KeyEscape},
|
||||
ansi.DEL: {Code: KeyBackspace},
|
||||
}
|
||||
|
||||
for _, m := range modifiers {
|
||||
// XTerm modifier offset +1
|
||||
xtermMod := strconv.Itoa(int(m) + 1)
|
||||
|
||||
// CSI 1 ; <modifier> <func>
|
||||
for k, v := range csiFuncKeys {
|
||||
// Functions always have a leading 1 param
|
||||
seq := "\x1b[1;" + xtermMod + k
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
// SS3 <modifier> <func>
|
||||
for k, v := range ss3FuncKeys {
|
||||
seq := "\x1bO" + xtermMod + k
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
// CSI <number> ; <modifier> ~
|
||||
for k, v := range csiTildeKeys {
|
||||
seq := "\x1b[" + k + ";" + xtermMod + "~"
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
// CSI 27 ; <modifier> ; <code> ~
|
||||
for k, v := range modifyOtherKeys {
|
||||
code := strconv.Itoa(k)
|
||||
seq := "\x1b[27;" + xtermMod + ";" + code + "~"
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
}
|
||||
|
||||
// Register terminfo keys
|
||||
// XXX: this might override keys already registered in table
|
||||
if flags&FlagTerminfo != 0 {
|
||||
titable := buildTerminfoKeys(flags, term)
|
||||
maps.Copy(table, titable)
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CapabilityEvent represents a Termcap/Terminfo response event. Termcap
|
||||
// responses are generated by the terminal in response to RequestTermcap
|
||||
// (XTGETTCAP) requests.
|
||||
//
|
||||
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
|
||||
type CapabilityEvent string
|
||||
|
||||
func parseTermcap(data []byte) CapabilityEvent {
|
||||
// XTGETTCAP
|
||||
if len(data) == 0 {
|
||||
return CapabilityEvent("")
|
||||
}
|
||||
|
||||
var tc strings.Builder
|
||||
split := bytes.Split(data, []byte{';'})
|
||||
for _, s := range split {
|
||||
parts := bytes.SplitN(s, []byte{'='}, 2)
|
||||
if len(parts) == 0 {
|
||||
return CapabilityEvent("")
|
||||
}
|
||||
|
||||
name, err := hex.DecodeString(string(parts[0]))
|
||||
if err != nil || len(name) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var value []byte
|
||||
if len(parts) > 1 {
|
||||
value, err = hex.DecodeString(string(parts[1]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if tc.Len() > 0 {
|
||||
tc.WriteByte(';')
|
||||
}
|
||||
tc.WriteString(string(name))
|
||||
if len(value) > 0 {
|
||||
tc.WriteByte('=')
|
||||
tc.WriteString(string(value))
|
||||
}
|
||||
}
|
||||
|
||||
return CapabilityEvent(tc.String())
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/xo/terminfo"
|
||||
)
|
||||
|
||||
func buildTerminfoKeys(flags int, term string) map[string]Key {
|
||||
table := make(map[string]Key)
|
||||
ti, _ := terminfo.Load(term)
|
||||
if ti == nil {
|
||||
return table
|
||||
}
|
||||
|
||||
tiTable := defaultTerminfoKeys(flags)
|
||||
|
||||
// Default keys
|
||||
for name, seq := range ti.StringCapsShort() {
|
||||
if !strings.HasPrefix(name, "k") || len(seq) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if k, ok := tiTable[name]; ok {
|
||||
table[string(seq)] = k
|
||||
}
|
||||
}
|
||||
|
||||
// Extended keys
|
||||
for name, seq := range ti.ExtStringCapsShort() {
|
||||
if !strings.HasPrefix(name, "k") || len(seq) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if k, ok := tiTable[name]; ok {
|
||||
table[string(seq)] = k
|
||||
}
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
// This returns a map of terminfo keys to key events. It's a mix of ncurses
|
||||
// terminfo default and user-defined key capabilities.
|
||||
// Upper-case caps that are defined in the default terminfo database are
|
||||
// - kNXT
|
||||
// - kPRV
|
||||
// - kHOM
|
||||
// - kEND
|
||||
// - kDC
|
||||
// - kIC
|
||||
// - kLFT
|
||||
// - kRIT
|
||||
//
|
||||
// See https://man7.org/linux/man-pages/man5/terminfo.5.html
|
||||
// See https://github.com/mirror/ncurses/blob/master/include/Caps-ncurses
|
||||
func defaultTerminfoKeys(flags int) map[string]Key {
|
||||
keys := map[string]Key{
|
||||
"kcuu1": {Code: KeyUp},
|
||||
"kUP": {Code: KeyUp, Mod: ModShift},
|
||||
"kUP3": {Code: KeyUp, Mod: ModAlt},
|
||||
"kUP4": {Code: KeyUp, Mod: ModShift | ModAlt},
|
||||
"kUP5": {Code: KeyUp, Mod: ModCtrl},
|
||||
"kUP6": {Code: KeyUp, Mod: ModShift | ModCtrl},
|
||||
"kUP7": {Code: KeyUp, Mod: ModAlt | ModCtrl},
|
||||
"kUP8": {Code: KeyUp, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kcud1": {Code: KeyDown},
|
||||
"kDN": {Code: KeyDown, Mod: ModShift},
|
||||
"kDN3": {Code: KeyDown, Mod: ModAlt},
|
||||
"kDN4": {Code: KeyDown, Mod: ModShift | ModAlt},
|
||||
"kDN5": {Code: KeyDown, Mod: ModCtrl},
|
||||
"kDN7": {Code: KeyDown, Mod: ModAlt | ModCtrl},
|
||||
"kDN6": {Code: KeyDown, Mod: ModShift | ModCtrl},
|
||||
"kDN8": {Code: KeyDown, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kcub1": {Code: KeyLeft},
|
||||
"kLFT": {Code: KeyLeft, Mod: ModShift},
|
||||
"kLFT3": {Code: KeyLeft, Mod: ModAlt},
|
||||
"kLFT4": {Code: KeyLeft, Mod: ModShift | ModAlt},
|
||||
"kLFT5": {Code: KeyLeft, Mod: ModCtrl},
|
||||
"kLFT6": {Code: KeyLeft, Mod: ModShift | ModCtrl},
|
||||
"kLFT7": {Code: KeyLeft, Mod: ModAlt | ModCtrl},
|
||||
"kLFT8": {Code: KeyLeft, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kcuf1": {Code: KeyRight},
|
||||
"kRIT": {Code: KeyRight, Mod: ModShift},
|
||||
"kRIT3": {Code: KeyRight, Mod: ModAlt},
|
||||
"kRIT4": {Code: KeyRight, Mod: ModShift | ModAlt},
|
||||
"kRIT5": {Code: KeyRight, Mod: ModCtrl},
|
||||
"kRIT6": {Code: KeyRight, Mod: ModShift | ModCtrl},
|
||||
"kRIT7": {Code: KeyRight, Mod: ModAlt | ModCtrl},
|
||||
"kRIT8": {Code: KeyRight, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kich1": {Code: KeyInsert},
|
||||
"kIC": {Code: KeyInsert, Mod: ModShift},
|
||||
"kIC3": {Code: KeyInsert, Mod: ModAlt},
|
||||
"kIC4": {Code: KeyInsert, Mod: ModShift | ModAlt},
|
||||
"kIC5": {Code: KeyInsert, Mod: ModCtrl},
|
||||
"kIC6": {Code: KeyInsert, Mod: ModShift | ModCtrl},
|
||||
"kIC7": {Code: KeyInsert, Mod: ModAlt | ModCtrl},
|
||||
"kIC8": {Code: KeyInsert, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kdch1": {Code: KeyDelete},
|
||||
"kDC": {Code: KeyDelete, Mod: ModShift},
|
||||
"kDC3": {Code: KeyDelete, Mod: ModAlt},
|
||||
"kDC4": {Code: KeyDelete, Mod: ModShift | ModAlt},
|
||||
"kDC5": {Code: KeyDelete, Mod: ModCtrl},
|
||||
"kDC6": {Code: KeyDelete, Mod: ModShift | ModCtrl},
|
||||
"kDC7": {Code: KeyDelete, Mod: ModAlt | ModCtrl},
|
||||
"kDC8": {Code: KeyDelete, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"khome": {Code: KeyHome},
|
||||
"kHOM": {Code: KeyHome, Mod: ModShift},
|
||||
"kHOM3": {Code: KeyHome, Mod: ModAlt},
|
||||
"kHOM4": {Code: KeyHome, Mod: ModShift | ModAlt},
|
||||
"kHOM5": {Code: KeyHome, Mod: ModCtrl},
|
||||
"kHOM6": {Code: KeyHome, Mod: ModShift | ModCtrl},
|
||||
"kHOM7": {Code: KeyHome, Mod: ModAlt | ModCtrl},
|
||||
"kHOM8": {Code: KeyHome, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kend": {Code: KeyEnd},
|
||||
"kEND": {Code: KeyEnd, Mod: ModShift},
|
||||
"kEND3": {Code: KeyEnd, Mod: ModAlt},
|
||||
"kEND4": {Code: KeyEnd, Mod: ModShift | ModAlt},
|
||||
"kEND5": {Code: KeyEnd, Mod: ModCtrl},
|
||||
"kEND6": {Code: KeyEnd, Mod: ModShift | ModCtrl},
|
||||
"kEND7": {Code: KeyEnd, Mod: ModAlt | ModCtrl},
|
||||
"kEND8": {Code: KeyEnd, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kpp": {Code: KeyPgUp},
|
||||
"kprv": {Code: KeyPgUp},
|
||||
"kPRV": {Code: KeyPgUp, Mod: ModShift},
|
||||
"kPRV3": {Code: KeyPgUp, Mod: ModAlt},
|
||||
"kPRV4": {Code: KeyPgUp, Mod: ModShift | ModAlt},
|
||||
"kPRV5": {Code: KeyPgUp, Mod: ModCtrl},
|
||||
"kPRV6": {Code: KeyPgUp, Mod: ModShift | ModCtrl},
|
||||
"kPRV7": {Code: KeyPgUp, Mod: ModAlt | ModCtrl},
|
||||
"kPRV8": {Code: KeyPgUp, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"knp": {Code: KeyPgDown},
|
||||
"knxt": {Code: KeyPgDown},
|
||||
"kNXT": {Code: KeyPgDown, Mod: ModShift},
|
||||
"kNXT3": {Code: KeyPgDown, Mod: ModAlt},
|
||||
"kNXT4": {Code: KeyPgDown, Mod: ModShift | ModAlt},
|
||||
"kNXT5": {Code: KeyPgDown, Mod: ModCtrl},
|
||||
"kNXT6": {Code: KeyPgDown, Mod: ModShift | ModCtrl},
|
||||
"kNXT7": {Code: KeyPgDown, Mod: ModAlt | ModCtrl},
|
||||
"kNXT8": {Code: KeyPgDown, Mod: ModShift | ModAlt | ModCtrl},
|
||||
|
||||
"kbs": {Code: KeyBackspace},
|
||||
"kcbt": {Code: KeyTab, Mod: ModShift},
|
||||
|
||||
// Function keys
|
||||
// This only includes the first 12 function keys. The rest are treated
|
||||
// as modifiers of the first 12.
|
||||
// Take a look at XTerm modifyFunctionKeys
|
||||
//
|
||||
// XXX: To use unambiguous function keys, use fixterms or kitty clipboard.
|
||||
//
|
||||
// See https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyFunctionKeys
|
||||
// See https://invisible-island.net/xterm/terminfo.html
|
||||
|
||||
"kf1": {Code: KeyF1},
|
||||
"kf2": {Code: KeyF2},
|
||||
"kf3": {Code: KeyF3},
|
||||
"kf4": {Code: KeyF4},
|
||||
"kf5": {Code: KeyF5},
|
||||
"kf6": {Code: KeyF6},
|
||||
"kf7": {Code: KeyF7},
|
||||
"kf8": {Code: KeyF8},
|
||||
"kf9": {Code: KeyF9},
|
||||
"kf10": {Code: KeyF10},
|
||||
"kf11": {Code: KeyF11},
|
||||
"kf12": {Code: KeyF12},
|
||||
"kf13": {Code: KeyF1, Mod: ModShift},
|
||||
"kf14": {Code: KeyF2, Mod: ModShift},
|
||||
"kf15": {Code: KeyF3, Mod: ModShift},
|
||||
"kf16": {Code: KeyF4, Mod: ModShift},
|
||||
"kf17": {Code: KeyF5, Mod: ModShift},
|
||||
"kf18": {Code: KeyF6, Mod: ModShift},
|
||||
"kf19": {Code: KeyF7, Mod: ModShift},
|
||||
"kf20": {Code: KeyF8, Mod: ModShift},
|
||||
"kf21": {Code: KeyF9, Mod: ModShift},
|
||||
"kf22": {Code: KeyF10, Mod: ModShift},
|
||||
"kf23": {Code: KeyF11, Mod: ModShift},
|
||||
"kf24": {Code: KeyF12, Mod: ModShift},
|
||||
"kf25": {Code: KeyF1, Mod: ModCtrl},
|
||||
"kf26": {Code: KeyF2, Mod: ModCtrl},
|
||||
"kf27": {Code: KeyF3, Mod: ModCtrl},
|
||||
"kf28": {Code: KeyF4, Mod: ModCtrl},
|
||||
"kf29": {Code: KeyF5, Mod: ModCtrl},
|
||||
"kf30": {Code: KeyF6, Mod: ModCtrl},
|
||||
"kf31": {Code: KeyF7, Mod: ModCtrl},
|
||||
"kf32": {Code: KeyF8, Mod: ModCtrl},
|
||||
"kf33": {Code: KeyF9, Mod: ModCtrl},
|
||||
"kf34": {Code: KeyF10, Mod: ModCtrl},
|
||||
"kf35": {Code: KeyF11, Mod: ModCtrl},
|
||||
"kf36": {Code: KeyF12, Mod: ModCtrl},
|
||||
"kf37": {Code: KeyF1, Mod: ModShift | ModCtrl},
|
||||
"kf38": {Code: KeyF2, Mod: ModShift | ModCtrl},
|
||||
"kf39": {Code: KeyF3, Mod: ModShift | ModCtrl},
|
||||
"kf40": {Code: KeyF4, Mod: ModShift | ModCtrl},
|
||||
"kf41": {Code: KeyF5, Mod: ModShift | ModCtrl},
|
||||
"kf42": {Code: KeyF6, Mod: ModShift | ModCtrl},
|
||||
"kf43": {Code: KeyF7, Mod: ModShift | ModCtrl},
|
||||
"kf44": {Code: KeyF8, Mod: ModShift | ModCtrl},
|
||||
"kf45": {Code: KeyF9, Mod: ModShift | ModCtrl},
|
||||
"kf46": {Code: KeyF10, Mod: ModShift | ModCtrl},
|
||||
"kf47": {Code: KeyF11, Mod: ModShift | ModCtrl},
|
||||
"kf48": {Code: KeyF12, Mod: ModShift | ModCtrl},
|
||||
"kf49": {Code: KeyF1, Mod: ModAlt},
|
||||
"kf50": {Code: KeyF2, Mod: ModAlt},
|
||||
"kf51": {Code: KeyF3, Mod: ModAlt},
|
||||
"kf52": {Code: KeyF4, Mod: ModAlt},
|
||||
"kf53": {Code: KeyF5, Mod: ModAlt},
|
||||
"kf54": {Code: KeyF6, Mod: ModAlt},
|
||||
"kf55": {Code: KeyF7, Mod: ModAlt},
|
||||
"kf56": {Code: KeyF8, Mod: ModAlt},
|
||||
"kf57": {Code: KeyF9, Mod: ModAlt},
|
||||
"kf58": {Code: KeyF10, Mod: ModAlt},
|
||||
"kf59": {Code: KeyF11, Mod: ModAlt},
|
||||
"kf60": {Code: KeyF12, Mod: ModAlt},
|
||||
"kf61": {Code: KeyF1, Mod: ModShift | ModAlt},
|
||||
"kf62": {Code: KeyF2, Mod: ModShift | ModAlt},
|
||||
"kf63": {Code: KeyF3, Mod: ModShift | ModAlt},
|
||||
}
|
||||
|
||||
// Preserve F keys from F13 to F63 instead of using them for F-keys
|
||||
// modifiers.
|
||||
if flags&FlagFKeys != 0 {
|
||||
keys["kf13"] = Key{Code: KeyF13}
|
||||
keys["kf14"] = Key{Code: KeyF14}
|
||||
keys["kf15"] = Key{Code: KeyF15}
|
||||
keys["kf16"] = Key{Code: KeyF16}
|
||||
keys["kf17"] = Key{Code: KeyF17}
|
||||
keys["kf18"] = Key{Code: KeyF18}
|
||||
keys["kf19"] = Key{Code: KeyF19}
|
||||
keys["kf20"] = Key{Code: KeyF20}
|
||||
keys["kf21"] = Key{Code: KeyF21}
|
||||
keys["kf22"] = Key{Code: KeyF22}
|
||||
keys["kf23"] = Key{Code: KeyF23}
|
||||
keys["kf24"] = Key{Code: KeyF24}
|
||||
keys["kf25"] = Key{Code: KeyF25}
|
||||
keys["kf26"] = Key{Code: KeyF26}
|
||||
keys["kf27"] = Key{Code: KeyF27}
|
||||
keys["kf28"] = Key{Code: KeyF28}
|
||||
keys["kf29"] = Key{Code: KeyF29}
|
||||
keys["kf30"] = Key{Code: KeyF30}
|
||||
keys["kf31"] = Key{Code: KeyF31}
|
||||
keys["kf32"] = Key{Code: KeyF32}
|
||||
keys["kf33"] = Key{Code: KeyF33}
|
||||
keys["kf34"] = Key{Code: KeyF34}
|
||||
keys["kf35"] = Key{Code: KeyF35}
|
||||
keys["kf36"] = Key{Code: KeyF36}
|
||||
keys["kf37"] = Key{Code: KeyF37}
|
||||
keys["kf38"] = Key{Code: KeyF38}
|
||||
keys["kf39"] = Key{Code: KeyF39}
|
||||
keys["kf40"] = Key{Code: KeyF40}
|
||||
keys["kf41"] = Key{Code: KeyF41}
|
||||
keys["kf42"] = Key{Code: KeyF42}
|
||||
keys["kf43"] = Key{Code: KeyF43}
|
||||
keys["kf44"] = Key{Code: KeyF44}
|
||||
keys["kf45"] = Key{Code: KeyF45}
|
||||
keys["kf46"] = Key{Code: KeyF46}
|
||||
keys["kf47"] = Key{Code: KeyF47}
|
||||
keys["kf48"] = Key{Code: KeyF48}
|
||||
keys["kf49"] = Key{Code: KeyF49}
|
||||
keys["kf50"] = Key{Code: KeyF50}
|
||||
keys["kf51"] = Key{Code: KeyF51}
|
||||
keys["kf52"] = Key{Code: KeyF52}
|
||||
keys["kf53"] = Key{Code: KeyF53}
|
||||
keys["kf54"] = Key{Code: KeyF54}
|
||||
keys["kf55"] = Key{Code: KeyF55}
|
||||
keys["kf56"] = Key{Code: KeyF56}
|
||||
keys["kf57"] = Key{Code: KeyF57}
|
||||
keys["kf58"] = Key{Code: KeyF58}
|
||||
keys["kf59"] = Key{Code: KeyF59}
|
||||
keys["kf60"] = Key{Code: KeyF60}
|
||||
keys["kf61"] = Key{Code: KeyF61}
|
||||
keys["kf62"] = Key{Code: KeyF62}
|
||||
keys["kf63"] = Key{Code: KeyF63}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func parseXTermModifyOtherKeys(params ansi.Params) Event {
|
||||
// XTerm modify other keys starts with ESC [ 27 ; <modifier> ; <code> ~
|
||||
xmod, _, _ := params.Param(1, 1)
|
||||
xrune, _, _ := params.Param(2, 1)
|
||||
mod := KeyMod(xmod - 1)
|
||||
r := rune(xrune)
|
||||
|
||||
switch r {
|
||||
case ansi.BS:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyBackspace}
|
||||
case ansi.HT:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyTab}
|
||||
case ansi.CR:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyEnter}
|
||||
case ansi.ESC:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyEscape}
|
||||
case ansi.DEL:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyBackspace}
|
||||
}
|
||||
|
||||
// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
|
||||
k := KeyPressEvent{Code: r, Mod: mod}
|
||||
if k.Mod <= ModShift {
|
||||
k.Text = string(r)
|
||||
}
|
||||
|
||||
return k
|
||||
}
|
||||
|
||||
// TerminalVersionEvent is a message that represents the terminal version.
|
||||
type TerminalVersionEvent string
|
||||
|
||||
// ModifyOtherKeysEvent represents a modifyOtherKeys event.
|
||||
//
|
||||
// 0: disable
|
||||
// 1: enable mode 1
|
||||
// 2: enable mode 2
|
||||
//
|
||||
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_
|
||||
// See: https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyOtherKeys
|
||||
type ModifyOtherKeysEvent uint8
|
||||
@@ -16,26 +16,38 @@ import (
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/id"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Info opencode.MessageUnion
|
||||
Parts []opencode.PartUnion
|
||||
}
|
||||
|
||||
type App struct {
|
||||
Info opencode.App
|
||||
Version string
|
||||
StatePath string
|
||||
Config *opencode.Config
|
||||
Client *opencode.Client
|
||||
State *config.State
|
||||
Provider *opencode.Provider
|
||||
Model *opencode.Model
|
||||
Session *opencode.Session
|
||||
Messages []opencode.MessageUnion
|
||||
Commands commands.CommandRegistry
|
||||
InitialModel *string
|
||||
InitialPrompt *string
|
||||
compactCancel context.CancelFunc
|
||||
Info opencode.App
|
||||
Modes []opencode.Mode
|
||||
Providers []opencode.Provider
|
||||
Version string
|
||||
StatePath string
|
||||
Config *opencode.Config
|
||||
Client *opencode.Client
|
||||
State *config.State
|
||||
ModeIndex int
|
||||
Mode *opencode.Mode
|
||||
Provider *opencode.Provider
|
||||
Model *opencode.Model
|
||||
Session *opencode.Session
|
||||
Messages []Message
|
||||
Commands commands.CommandRegistry
|
||||
InitialModel *string
|
||||
InitialPrompt *string
|
||||
IntitialMode *string
|
||||
compactCancel context.CancelFunc
|
||||
IsLeaderSequence bool
|
||||
}
|
||||
|
||||
type SessionSelectedMsg = *opencode.Session
|
||||
@@ -64,9 +76,11 @@ func New(
|
||||
ctx context.Context,
|
||||
version string,
|
||||
appInfo opencode.App,
|
||||
modes []opencode.Mode,
|
||||
httpClient *opencode.Client,
|
||||
model *string,
|
||||
prompt *string,
|
||||
initialModel *string,
|
||||
initialPrompt *string,
|
||||
initialMode *string,
|
||||
) (*App, error) {
|
||||
util.RootPath = appInfo.Path.Root
|
||||
util.CwdPath = appInfo.Path.Cwd
|
||||
@@ -87,14 +101,36 @@ func New(
|
||||
config.SaveState(appStatePath, appState)
|
||||
}
|
||||
|
||||
if appState.ModeModel == nil {
|
||||
appState.ModeModel = make(map[string]config.ModeModel)
|
||||
}
|
||||
|
||||
if configInfo.Theme != "" {
|
||||
appState.Theme = configInfo.Theme
|
||||
}
|
||||
|
||||
if configInfo.Model != "" {
|
||||
splits := strings.Split(configInfo.Model, "/")
|
||||
appState.Provider = splits[0]
|
||||
appState.Model = strings.Join(splits[1:], "/")
|
||||
var modeIndex int
|
||||
var mode *opencode.Mode
|
||||
modeName := "build"
|
||||
if appState.Mode != "" {
|
||||
modeName = appState.Mode
|
||||
}
|
||||
if initialMode != nil && *initialMode != "" {
|
||||
modeName = *initialMode
|
||||
}
|
||||
for i, m := range modes {
|
||||
if m.Name == modeName {
|
||||
modeIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
mode = &modes[modeIndex]
|
||||
|
||||
if mode.Model.ModelID != "" {
|
||||
appState.ModeModel[mode.Name] = config.ModeModel{
|
||||
ProviderID: mode.Model.ProviderID,
|
||||
ModelID: mode.Model.ModelID,
|
||||
}
|
||||
}
|
||||
|
||||
if err := theme.LoadThemesFromDirectories(
|
||||
@@ -119,16 +155,20 @@ func New(
|
||||
|
||||
app := &App{
|
||||
Info: appInfo,
|
||||
Modes: modes,
|
||||
Version: version,
|
||||
StatePath: appStatePath,
|
||||
Config: configInfo,
|
||||
State: appState,
|
||||
Client: httpClient,
|
||||
ModeIndex: modeIndex,
|
||||
Mode: mode,
|
||||
Session: &opencode.Session{},
|
||||
Messages: []opencode.MessageUnion{},
|
||||
Messages: []Message{},
|
||||
Commands: commands.LoadFromConfig(configInfo),
|
||||
InitialModel: model,
|
||||
InitialPrompt: prompt,
|
||||
InitialModel: initialModel,
|
||||
InitialPrompt: initialPrompt,
|
||||
IntitialMode: initialMode,
|
||||
}
|
||||
|
||||
return app, nil
|
||||
@@ -162,6 +202,45 @@ func (a *App) SetClipboard(text string) tea.Cmd {
|
||||
return tea.Sequence(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) SwitchMode() (*App, tea.Cmd) {
|
||||
a.ModeIndex++
|
||||
if a.ModeIndex >= len(a.Modes) {
|
||||
a.ModeIndex = 0
|
||||
}
|
||||
a.Mode = &a.Modes[a.ModeIndex]
|
||||
|
||||
modelID := a.Mode.Model.ModelID
|
||||
providerID := a.Mode.Model.ProviderID
|
||||
if modelID == "" {
|
||||
if model, ok := a.State.ModeModel[a.Mode.Name]; ok {
|
||||
modelID = model.ModelID
|
||||
providerID = model.ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
if modelID != "" {
|
||||
for _, provider := range a.Providers {
|
||||
if provider.ID == providerID {
|
||||
a.Provider = &provider
|
||||
for _, model := range provider.Models {
|
||||
if model.ID == modelID {
|
||||
a.Model = &model
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.State.Mode = a.Mode.Name
|
||||
|
||||
return a, func() tea.Msg {
|
||||
a.SaveState()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) InitializeProvider() tea.Cmd {
|
||||
providersResponse, err := a.Client.Config.Providers(context.Background())
|
||||
if err != nil {
|
||||
@@ -198,6 +277,14 @@ func (a *App) InitializeProvider() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.Providers = providers
|
||||
|
||||
// retains backwards compatibility with old state format
|
||||
if model, ok := a.State.ModeModel[a.State.Mode]; ok {
|
||||
a.State.Provider = model.ProviderID
|
||||
a.State.Model = model.ModelID
|
||||
}
|
||||
|
||||
var currentProvider *opencode.Provider
|
||||
var currentModel *opencode.Model
|
||||
for _, provider := range providers {
|
||||
@@ -270,7 +357,7 @@ func (a *App) IsBusy() bool {
|
||||
}
|
||||
|
||||
lastMessage := a.Messages[len(a.Messages)-1]
|
||||
if casted, ok := lastMessage.(opencode.AssistantMessage); ok {
|
||||
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
|
||||
return casted.Time.Completed == 0
|
||||
}
|
||||
return false
|
||||
@@ -297,6 +384,7 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
||||
|
||||
go func() {
|
||||
_, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
|
||||
MessageID: opencode.F(id.Ascending(id.Message)),
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
})
|
||||
@@ -322,10 +410,14 @@ func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
||||
a.compactCancel = nil
|
||||
}()
|
||||
|
||||
_, err := a.Client.Session.Summarize(compactCtx, a.Session.ID, opencode.SessionSummarizeParams{
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
})
|
||||
_, err := a.Client.Session.Summarize(
|
||||
compactCtx,
|
||||
a.Session.ID,
|
||||
opencode.SessionSummarizeParams{
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if compactCtx.Err() != context.Canceled {
|
||||
slog.Error("Failed to compact session", "error", err)
|
||||
@@ -367,56 +459,70 @@ func (a *App) SendChatMessage(
|
||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||
}
|
||||
|
||||
optimisticParts := []opencode.UserMessagePart{{
|
||||
Type: opencode.UserMessagePartTypeText,
|
||||
Text: text,
|
||||
message := opencode.UserMessage{
|
||||
ID: id.Ascending(id.Message),
|
||||
SessionID: a.Session.ID,
|
||||
Role: opencode.UserMessageRoleUser,
|
||||
Time: opencode.UserMessageTime{
|
||||
Created: float64(time.Now().UnixMilli()),
|
||||
},
|
||||
}
|
||||
|
||||
parts := []opencode.PartUnion{opencode.TextPart{
|
||||
ID: id.Ascending(id.Part),
|
||||
MessageID: message.ID,
|
||||
SessionID: a.Session.ID,
|
||||
Type: opencode.TextPartTypeText,
|
||||
Text: text,
|
||||
}}
|
||||
if len(attachments) > 0 {
|
||||
for _, attachment := range attachments {
|
||||
optimisticParts = append(optimisticParts, opencode.UserMessagePart{
|
||||
Type: opencode.UserMessagePartTypeFile,
|
||||
Filename: attachment.Filename.Value,
|
||||
Mime: attachment.Mime.Value,
|
||||
URL: attachment.URL.Value,
|
||||
parts = append(parts, opencode.FilePart{
|
||||
ID: id.Ascending(id.Part),
|
||||
MessageID: message.ID,
|
||||
SessionID: a.Session.ID,
|
||||
Type: opencode.FilePartTypeFile,
|
||||
Filename: attachment.Filename.Value,
|
||||
Mime: attachment.Mime.Value,
|
||||
URL: attachment.URL.Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
optimisticMessage := opencode.UserMessage{
|
||||
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
|
||||
Role: opencode.UserMessageRoleUser,
|
||||
Parts: optimisticParts,
|
||||
SessionID: a.Session.ID,
|
||||
Time: opencode.UserMessageTime{
|
||||
Created: float64(time.Now().Unix()),
|
||||
},
|
||||
}
|
||||
|
||||
a.Messages = append(a.Messages, optimisticMessage)
|
||||
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
|
||||
a.Messages = append(a.Messages, Message{Info: message, Parts: parts})
|
||||
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: message}))
|
||||
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
parts := []opencode.UserMessagePartUnionParam{
|
||||
opencode.TextPartParam{
|
||||
Type: opencode.F(opencode.TextPartTypeText),
|
||||
Text: opencode.F(text),
|
||||
},
|
||||
}
|
||||
if len(attachments) > 0 {
|
||||
for _, attachment := range attachments {
|
||||
parts = append(parts, opencode.FilePartParam{
|
||||
Mime: attachment.Mime,
|
||||
Type: attachment.Type,
|
||||
URL: attachment.URL,
|
||||
Filename: attachment.Filename,
|
||||
partsParam := []opencode.SessionChatParamsPartUnion{}
|
||||
for _, part := range parts {
|
||||
switch casted := part.(type) {
|
||||
case opencode.TextPart:
|
||||
partsParam = append(partsParam, opencode.TextPartParam{
|
||||
ID: opencode.F(casted.ID),
|
||||
MessageID: opencode.F(casted.MessageID),
|
||||
SessionID: opencode.F(casted.SessionID),
|
||||
Type: opencode.F(casted.Type),
|
||||
Text: opencode.F(casted.Text),
|
||||
})
|
||||
case opencode.FilePart:
|
||||
partsParam = append(partsParam, opencode.FilePartParam{
|
||||
ID: opencode.F(casted.ID),
|
||||
Mime: opencode.F(casted.Mime),
|
||||
MessageID: opencode.F(casted.MessageID),
|
||||
SessionID: opencode.F(casted.SessionID),
|
||||
Type: opencode.F(casted.Type),
|
||||
URL: opencode.F(casted.URL),
|
||||
Filename: opencode.F(casted.Filename),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
|
||||
Parts: opencode.F(parts),
|
||||
Parts: opencode.F(partsParam),
|
||||
MessageID: opencode.F(message.ID),
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
Mode: opencode.F(a.Mode.Name),
|
||||
})
|
||||
if err != nil {
|
||||
errormsg := fmt.Sprintf("failed to send message: %v", err)
|
||||
@@ -471,15 +577,25 @@ func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]opencode.Message, error) {
|
||||
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
|
||||
response, err := a.Client.Session.Messages(ctx, sessionId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response == nil {
|
||||
return []opencode.Message{}, nil
|
||||
return []Message{}, nil
|
||||
}
|
||||
messages := []Message{}
|
||||
for _, message := range *response {
|
||||
msg := Message{
|
||||
Info: message.Info.AsUnion(),
|
||||
Parts: []opencode.PartUnion{},
|
||||
}
|
||||
for _, part := range message.Parts {
|
||||
msg.Parts = append(msg.Parts, part.AsUnion())
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
messages := *response
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -44,7 +45,7 @@ var (
|
||||
writeImg: []string{"xsel", "--clipboard", "--input"},
|
||||
},
|
||||
{
|
||||
name: "wl-clipboard",
|
||||
name: "wl-copy",
|
||||
readCmd: []string{"wl-paste", "-n"},
|
||||
writeCmd: []string{"wl-copy"},
|
||||
readImg: []string{"wl-paste", "-t", "image/png", "-n"},
|
||||
@@ -66,20 +67,31 @@ func initialize() error {
|
||||
return nil // Already initialized
|
||||
}
|
||||
|
||||
// Check which clipboard tool is available
|
||||
for i, tool := range clipboardTools {
|
||||
cmd := exec.Command("which", tool.name)
|
||||
if err := cmd.Run(); err == nil {
|
||||
clipboardTools[i].available = true
|
||||
if selectedTool < 0 {
|
||||
selectedTool = i
|
||||
slog.Debug("Clipboard tool found", "tool", tool.name)
|
||||
order := []string{"xclip", "xsel", "wl-copy"}
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
order = []string{"wl-copy", "xclip", "xsel"}
|
||||
}
|
||||
|
||||
for _, name := range order {
|
||||
for i, tool := range clipboardTools {
|
||||
if tool.name == name {
|
||||
cmd := exec.Command("which", tool.name)
|
||||
if err := cmd.Run(); err == nil {
|
||||
clipboardTools[i].available = true
|
||||
if selectedTool < 0 {
|
||||
selectedTool = i
|
||||
slog.Debug("Clipboard tool found", "tool", tool.name)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selectedTool < 0 {
|
||||
slog.Warn("No clipboard utility found on system. Copy/paste functionality will be disabled.")
|
||||
slog.Warn(
|
||||
"No clipboard utility found on system. Copy/paste functionality will be disabled. See https://opencode.ai/docs/troubleshooting/ for more information.",
|
||||
)
|
||||
return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
|
||||
|
||||
For X11 systems:
|
||||
@@ -138,7 +150,8 @@ func readText(tool struct {
|
||||
// xclip returns error when clipboard doesn't contain requested type
|
||||
checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
|
||||
targets, _ := checkCmd.Output()
|
||||
if bytes.Contains(targets, []byte("image/png")) && !bytes.Contains(targets, []byte("UTF8_STRING")) {
|
||||
if bytes.Contains(targets, []byte("image/png")) &&
|
||||
!bytes.Contains(targets, []byte("UTF8_STRING")) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
}
|
||||
@@ -168,7 +181,8 @@ func readImage(tool struct {
|
||||
}
|
||||
|
||||
// Verify it's PNG data
|
||||
if len(out) < 8 || !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
|
||||
if len(out) < 8 ||
|
||||
!bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
|
||||
|
||||
const (
|
||||
AppHelpCommand CommandName = "app_help"
|
||||
SwitchModeCommand CommandName = "switch_mode"
|
||||
EditorOpenCommand CommandName = "editor_open"
|
||||
SessionNewCommand CommandName = "session_new"
|
||||
SessionListCommand CommandName = "session_list"
|
||||
@@ -152,6 +153,11 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
Keybindings: parseBindings("<leader>h"),
|
||||
Trigger: []string{"help"},
|
||||
},
|
||||
{
|
||||
Name: SwitchModeCommand,
|
||||
Description: "switch mode",
|
||||
Keybindings: parseBindings("tab"),
|
||||
},
|
||||
{
|
||||
Name: EditorOpenCommand,
|
||||
Description: "open editor",
|
||||
|
||||
@@ -43,7 +43,7 @@ func (c *CommandCompletionProvider) getCommandCompletionItem(
|
||||
Title: title,
|
||||
Value: value,
|
||||
ProviderID: c.GetId(),
|
||||
})
|
||||
}, dialog.WithBackgroundColor(t.BackgroundElement()))
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetChildEntries(
|
||||
@@ -91,16 +91,21 @@ func (c *CommandCompletionProvider) GetChildEntries(
|
||||
}
|
||||
|
||||
// Find fuzzy matches
|
||||
matches := fuzzy.RankFind(query, commandNames)
|
||||
matches := fuzzy.RankFindFold(query, commandNames)
|
||||
|
||||
// Sort by score (best matches first)
|
||||
sort.Sort(matches)
|
||||
|
||||
// Convert matches to completion items
|
||||
// Convert matches to completion items, deduplicating by command name
|
||||
items := []dialog.CompletionItemI{}
|
||||
seen := make(map[string]bool)
|
||||
for _, match := range matches {
|
||||
if item, ok := commandMap[match.Target]; ok {
|
||||
items = append(items, item)
|
||||
// Use the command's value (name) as the deduplication key
|
||||
if !seen[item.GetValue()] {
|
||||
seen[item.GetValue()] = true
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
|
||||
@@ -54,7 +54,9 @@ func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
|
||||
Value: file.Path,
|
||||
ProviderID: cg.GetId(),
|
||||
Raw: file,
|
||||
})
|
||||
},
|
||||
dialog.WithBackgroundColor(t.BackgroundElement()),
|
||||
)
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +102,9 @@ func (cg *filesContextGroup) GetChildEntries(
|
||||
Value: file,
|
||||
ProviderID: cg.GetId(),
|
||||
Raw: file,
|
||||
})
|
||||
},
|
||||
dialog.WithBackgroundColor(theme.CurrentTheme().BackgroundElement()),
|
||||
)
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -180,7 +181,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
attachment := &textarea.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Display: "@" + filePath,
|
||||
URL: fmt.Sprintf("file://./%s", filePath),
|
||||
URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
|
||||
Filename: filePath,
|
||||
MediaType: mediaType,
|
||||
}
|
||||
@@ -241,13 +242,17 @@ func (m *editorComponent) Content(width int) string {
|
||||
prompt,
|
||||
m.textarea.View(),
|
||||
)
|
||||
borderForeground := t.Border()
|
||||
if m.app.IsLeaderSequence {
|
||||
borderForeground = t.Accent()
|
||||
}
|
||||
textarea = styles.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderForeground(t.Border()).
|
||||
BorderForeground(borderForeground).
|
||||
BorderBackground(t.Background()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
|
||||
@@ -305,10 +305,8 @@ func renderToolDetails(
|
||||
return ""
|
||||
}
|
||||
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusPending ||
|
||||
toolCall.State.Status == opencode.ToolPartStateStatusRunning {
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
|
||||
title := renderToolTitle(toolCall, width)
|
||||
title = styles.NewStyle().Width(width - 6).Render(title)
|
||||
return renderContentBlock(app, title, highlight, width)
|
||||
}
|
||||
|
||||
@@ -339,126 +337,124 @@ func renderToolDetails(
|
||||
borderColor = t.BorderActive()
|
||||
}
|
||||
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
|
||||
metadata := toolCall.State.Metadata.(map[string]any)
|
||||
switch toolCall.Tool {
|
||||
case "read":
|
||||
preview := metadata["preview"]
|
||||
if preview != nil && toolInputMap["filePath"] != nil {
|
||||
filename := toolInputMap["filePath"].(string)
|
||||
body = preview.(string)
|
||||
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
|
||||
}
|
||||
case "edit":
|
||||
if filename, ok := toolInputMap["filePath"].(string); ok {
|
||||
diffField := metadata["diff"]
|
||||
if diffField != nil {
|
||||
patch := diffField.(string)
|
||||
var formattedDiff string
|
||||
formattedDiff, _ = diff.FormatUnifiedDiff(
|
||||
filename,
|
||||
patch,
|
||||
diff.WithWidth(width-2),
|
||||
)
|
||||
body = strings.TrimSpace(formattedDiff)
|
||||
style := styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
Foreground(t.TextMuted()).
|
||||
Padding(1, 2).
|
||||
Width(width - 4)
|
||||
if highlight {
|
||||
style = style.Foreground(t.Text()).Bold(true)
|
||||
}
|
||||
metadata := toolCall.State.Metadata.(map[string]any)
|
||||
switch toolCall.Tool {
|
||||
case "read":
|
||||
preview := metadata["preview"]
|
||||
if preview != nil && toolInputMap["filePath"] != nil {
|
||||
filename := toolInputMap["filePath"].(string)
|
||||
body = preview.(string)
|
||||
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
|
||||
}
|
||||
case "edit":
|
||||
if filename, ok := toolInputMap["filePath"].(string); ok {
|
||||
diffField := metadata["diff"]
|
||||
if diffField != nil {
|
||||
patch := diffField.(string)
|
||||
var formattedDiff string
|
||||
formattedDiff, _ = diff.FormatUnifiedDiff(
|
||||
filename,
|
||||
patch,
|
||||
diff.WithWidth(width-2),
|
||||
)
|
||||
body = strings.TrimSpace(formattedDiff)
|
||||
style := styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
Foreground(t.TextMuted()).
|
||||
Padding(1, 2).
|
||||
Width(width - 4)
|
||||
if highlight {
|
||||
style = style.Foreground(t.Text()).Bold(true)
|
||||
}
|
||||
|
||||
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
|
||||
diagnostics = style.Render(diagnostics)
|
||||
body += "\n" + diagnostics
|
||||
}
|
||||
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
|
||||
diagnostics = style.Render(diagnostics)
|
||||
body += "\n" + diagnostics
|
||||
}
|
||||
|
||||
title := renderToolTitle(toolCall, width)
|
||||
title = style.Render(title)
|
||||
content := title + "\n" + body
|
||||
content = renderContentBlock(
|
||||
app,
|
||||
content,
|
||||
highlight,
|
||||
width,
|
||||
WithPadding(0),
|
||||
WithBorderColor(borderColor),
|
||||
)
|
||||
return content
|
||||
title := renderToolTitle(toolCall, width)
|
||||
title = style.Render(title)
|
||||
content := title + "\n" + body
|
||||
content = renderContentBlock(
|
||||
app,
|
||||
content,
|
||||
highlight,
|
||||
width,
|
||||
WithPadding(0),
|
||||
WithBorderColor(borderColor),
|
||||
)
|
||||
return content
|
||||
}
|
||||
}
|
||||
case "write":
|
||||
if filename, ok := toolInputMap["filePath"].(string); ok {
|
||||
if content, ok := toolInputMap["content"].(string); ok {
|
||||
body = util.RenderFile(filename, content, width)
|
||||
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
|
||||
body += "\n\n" + diagnostics
|
||||
}
|
||||
}
|
||||
case "write":
|
||||
if filename, ok := toolInputMap["filePath"].(string); ok {
|
||||
if content, ok := toolInputMap["content"].(string); ok {
|
||||
body = util.RenderFile(filename, content, width)
|
||||
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
|
||||
body += "\n\n" + diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
case "bash":
|
||||
stdout := metadata["stdout"]
|
||||
if stdout != nil {
|
||||
command := toolInputMap["command"].(string)
|
||||
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
}
|
||||
case "webfetch":
|
||||
if format, ok := toolInputMap["format"].(string); ok && result != nil {
|
||||
body = *result
|
||||
body = util.TruncateHeight(body, 10)
|
||||
if format == "html" || format == "markdown" {
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
}
|
||||
}
|
||||
case "todowrite":
|
||||
todos := metadata["todos"]
|
||||
if todos != nil {
|
||||
for _, item := range todos.([]any) {
|
||||
todo := item.(map[string]any)
|
||||
content := todo["content"].(string)
|
||||
switch todo["status"] {
|
||||
case "completed":
|
||||
body += fmt.Sprintf("- [x] %s\n", content)
|
||||
case "cancelled":
|
||||
body += fmt.Sprintf("- [~] %s\n", content)
|
||||
// case "in-progress":
|
||||
// body += fmt.Sprintf("- [ ] %s\n", content)
|
||||
default:
|
||||
body += fmt.Sprintf("- [ ] %s\n", content)
|
||||
}
|
||||
}
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
}
|
||||
case "task":
|
||||
summary := metadata["summary"]
|
||||
if summary != nil {
|
||||
toolcalls := summary.([]any)
|
||||
steps := []string{}
|
||||
for _, toolcall := range toolcalls {
|
||||
call := toolcall.(map[string]any)
|
||||
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
|
||||
data, _ := json.Marshal(toolInvocation)
|
||||
var toolCall opencode.ToolPart
|
||||
_ = json.Unmarshal(data, &toolCall)
|
||||
step := renderToolTitle(toolCall, width)
|
||||
step = "∟ " + step
|
||||
steps = append(steps, step)
|
||||
}
|
||||
}
|
||||
body = strings.Join(steps, "\n")
|
||||
}
|
||||
default:
|
||||
if result == nil {
|
||||
empty := ""
|
||||
result = &empty
|
||||
}
|
||||
}
|
||||
case "bash":
|
||||
stdout := metadata["stdout"]
|
||||
if stdout != nil {
|
||||
command := toolInputMap["command"].(string)
|
||||
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
}
|
||||
case "webfetch":
|
||||
if format, ok := toolInputMap["format"].(string); ok && result != nil {
|
||||
body = *result
|
||||
body = util.TruncateHeight(body, 10)
|
||||
body = styles.NewStyle().Width(width - 6).Render(body)
|
||||
if format == "html" || format == "markdown" {
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
}
|
||||
}
|
||||
case "todowrite":
|
||||
todos := metadata["todos"]
|
||||
if todos != nil {
|
||||
for _, item := range todos.([]any) {
|
||||
todo := item.(map[string]any)
|
||||
content := todo["content"].(string)
|
||||
switch todo["status"] {
|
||||
case "completed":
|
||||
body += fmt.Sprintf("- [x] %s\n", content)
|
||||
case "cancelled":
|
||||
// strike through cancelled todo
|
||||
body += fmt.Sprintf("- [~] ~~%s~~\n", content)
|
||||
case "in_progress":
|
||||
// highlight in progress todo
|
||||
body += fmt.Sprintf("- [ ] `%s`\n", content)
|
||||
default:
|
||||
body += fmt.Sprintf("- [ ] %s\n", content)
|
||||
}
|
||||
}
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
}
|
||||
case "task":
|
||||
summary := metadata["summary"]
|
||||
if summary != nil {
|
||||
toolcalls := summary.([]any)
|
||||
steps := []string{}
|
||||
for _, item := range toolcalls {
|
||||
data, _ := json.Marshal(item)
|
||||
var toolCall opencode.ToolPart
|
||||
_ = json.Unmarshal(data, &toolCall)
|
||||
step := renderToolTitle(toolCall, width)
|
||||
step = "∟ " + step
|
||||
steps = append(steps, step)
|
||||
}
|
||||
body = strings.Join(steps, "\n")
|
||||
}
|
||||
body = styles.NewStyle().Width(width - 6).Render(body)
|
||||
default:
|
||||
if result == nil {
|
||||
empty := ""
|
||||
result = &empty
|
||||
}
|
||||
body = *result
|
||||
body = util.TruncateHeight(body, 10)
|
||||
body = styles.NewStyle().Width(width - 6).Render(body)
|
||||
}
|
||||
|
||||
error := ""
|
||||
@@ -489,8 +485,6 @@ func renderToolName(name string) string {
|
||||
switch name {
|
||||
case "webfetch":
|
||||
return "Fetch"
|
||||
case "todowrite", "todoread":
|
||||
return "Plan"
|
||||
default:
|
||||
normalizedName := name
|
||||
if after, ok := strings.CutPrefix(name, "opencode_"); ok {
|
||||
@@ -500,14 +494,48 @@ func renderToolName(name string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func getTodoPhase(metadata map[string]any) string {
|
||||
todos, ok := metadata["todos"].([]any)
|
||||
if !ok || len(todos) == 0 {
|
||||
return "Plan"
|
||||
}
|
||||
|
||||
counts := map[string]int{"pending": 0, "completed": 0}
|
||||
for _, item := range todos {
|
||||
if todo, ok := item.(map[string]any); ok {
|
||||
if status, ok := todo["status"].(string); ok {
|
||||
counts[status]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total := len(todos)
|
||||
switch {
|
||||
case counts["pending"] == total:
|
||||
return "Creating plan"
|
||||
case counts["completed"] == total:
|
||||
return "Completing plan"
|
||||
default:
|
||||
return "Updating plan"
|
||||
}
|
||||
}
|
||||
|
||||
func getTodoTitle(toolCall opencode.ToolPart) string {
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
|
||||
if metadata, ok := toolCall.State.Metadata.(map[string]any); ok {
|
||||
return getTodoPhase(metadata)
|
||||
}
|
||||
}
|
||||
return "Plan"
|
||||
}
|
||||
|
||||
func renderToolTitle(
|
||||
toolCall opencode.ToolPart,
|
||||
width int,
|
||||
) string {
|
||||
// TODO: handle truncate to width
|
||||
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
|
||||
return renderToolAction(toolCall.Tool)
|
||||
title := renderToolAction(toolCall.Tool)
|
||||
return styles.NewStyle().Width(width - 6).Render(title)
|
||||
}
|
||||
|
||||
toolArgs := ""
|
||||
@@ -547,8 +575,10 @@ func renderToolTitle(
|
||||
case "webfetch":
|
||||
toolArgs = renderArgs(&toolArgsMap, "url")
|
||||
title = fmt.Sprintf("%s %s", title, toolArgs)
|
||||
case "todowrite", "todoread":
|
||||
// title is just the tool name
|
||||
case "todowrite":
|
||||
title = getTodoTitle(toolCall)
|
||||
case "todoread":
|
||||
return "Plan"
|
||||
default:
|
||||
toolName := renderToolName(toolCall.Tool)
|
||||
title = fmt.Sprintf("%s %s", toolName, toolArgs)
|
||||
@@ -559,7 +589,7 @@ func renderToolTitle(
|
||||
func renderToolAction(name string) string {
|
||||
switch name {
|
||||
case "task":
|
||||
return "Searching..."
|
||||
return "Planning..."
|
||||
case "bash":
|
||||
return "Writing command..."
|
||||
case "edit":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
@@ -105,6 +106,13 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
case opencode.EventListResponseEventMessagePartUpdated:
|
||||
if msg.Properties.Part.SessionID == m.app.Session.ID {
|
||||
m.renderView(m.width)
|
||||
if m.tail {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewport, cmd := m.viewport.Update(msg)
|
||||
@@ -130,16 +138,16 @@ func (m *messagesComponent) renderView(width int) {
|
||||
var content string
|
||||
var cached bool
|
||||
|
||||
switch casted := message.(type) {
|
||||
switch casted := message.Info.(type) {
|
||||
case opencode.UserMessage:
|
||||
userLoop:
|
||||
for partIndex, part := range casted.Parts {
|
||||
switch part := part.AsUnion().(type) {
|
||||
for partIndex, part := range message.Parts {
|
||||
switch part := part.(type) {
|
||||
case opencode.TextPart:
|
||||
remainingParts := casted.Parts[partIndex+1:]
|
||||
remainingParts := message.Parts[partIndex+1:]
|
||||
fileParts := make([]opencode.FilePart, 0)
|
||||
for _, part := range remainingParts {
|
||||
switch part := part.AsUnion().(type) {
|
||||
switch part := part.(type) {
|
||||
case opencode.FilePart:
|
||||
fileParts = append(fileParts, part)
|
||||
}
|
||||
@@ -180,9 +188,9 @@ func (m *messagesComponent) renderView(width int) {
|
||||
if !cached {
|
||||
content = renderText(
|
||||
m.app,
|
||||
message,
|
||||
message.Info,
|
||||
part.Text,
|
||||
m.app.Info.User,
|
||||
m.app.Config.Username,
|
||||
m.showToolDetails,
|
||||
m.partCount == m.selectedPart,
|
||||
width,
|
||||
@@ -201,12 +209,12 @@ func (m *messagesComponent) renderView(width int) {
|
||||
|
||||
case opencode.AssistantMessage:
|
||||
hasTextPart := false
|
||||
for partIndex, p := range casted.Parts {
|
||||
switch part := p.AsUnion().(type) {
|
||||
for partIndex, p := range message.Parts {
|
||||
switch part := p.(type) {
|
||||
case opencode.TextPart:
|
||||
hasTextPart = true
|
||||
finished := casted.Time.Completed > 0
|
||||
remainingParts := casted.Parts[partIndex+1:]
|
||||
remainingParts := message.Parts[partIndex+1:]
|
||||
toolCallParts := make([]opencode.ToolPart, 0)
|
||||
|
||||
// sometimes tool calls happen without an assistant message
|
||||
@@ -221,7 +229,7 @@ func (m *messagesComponent) renderView(width int) {
|
||||
if !remaining {
|
||||
break
|
||||
}
|
||||
switch part := part.AsUnion().(type) {
|
||||
switch part := part.(type) {
|
||||
case opencode.TextPart:
|
||||
// we only want tool calls associated with the current text part.
|
||||
// if we hit another text part, we're done.
|
||||
@@ -237,13 +245,13 @@ func (m *messagesComponent) renderView(width int) {
|
||||
}
|
||||
|
||||
if finished {
|
||||
key := m.cache.GenerateKey(casted.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount)
|
||||
key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, m.selectedPart == m.partCount)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderText(
|
||||
m.app,
|
||||
message,
|
||||
p.Text,
|
||||
message.Info,
|
||||
part.Text,
|
||||
casted.ModelID,
|
||||
m.showToolDetails,
|
||||
m.partCount == m.selectedPart,
|
||||
@@ -256,8 +264,8 @@ func (m *messagesComponent) renderView(width int) {
|
||||
} else {
|
||||
content = renderText(
|
||||
m.app,
|
||||
message,
|
||||
p.Text,
|
||||
message.Info,
|
||||
part.Text,
|
||||
casted.ModelID,
|
||||
m.showToolDetails,
|
||||
m.partCount == m.selectedPart,
|
||||
@@ -267,7 +275,7 @@ func (m *messagesComponent) renderView(width int) {
|
||||
)
|
||||
}
|
||||
if content != "" {
|
||||
m = m.updateSelected(content, p.Text)
|
||||
m = m.updateSelected(content, part.Text)
|
||||
blocks = append(blocks, content)
|
||||
}
|
||||
case opencode.ToolPart:
|
||||
@@ -313,7 +321,7 @@ func (m *messagesComponent) renderView(width int) {
|
||||
}
|
||||
|
||||
error := ""
|
||||
if assistant, ok := message.(opencode.AssistantMessage); ok {
|
||||
if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
|
||||
switch err := assistant.Error.AsUnion().(type) {
|
||||
case nil:
|
||||
case opencode.AssistantMessageErrorMessageOutputLengthError:
|
||||
@@ -371,11 +379,65 @@ func (m *messagesComponent) header(width int) string {
|
||||
headerLines,
|
||||
util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()),
|
||||
)
|
||||
|
||||
share := ""
|
||||
if m.app.Session.Share.URL != "" {
|
||||
headerLines = append(headerLines, muted(m.app.Session.Share.URL+" /unshare"))
|
||||
share = muted(m.app.Session.Share.URL + " /unshare")
|
||||
} else {
|
||||
headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
|
||||
share = base("/share") + muted(" to create a shareable link")
|
||||
}
|
||||
|
||||
sessionInfo := ""
|
||||
tokens := float64(0)
|
||||
cost := float64(0)
|
||||
contextWindow := m.app.Model.Limit.Context
|
||||
|
||||
for _, message := range m.app.Messages {
|
||||
if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
|
||||
cost += assistant.Cost
|
||||
usage := assistant.Tokens
|
||||
if usage.Output > 0 {
|
||||
if assistant.Summary {
|
||||
tokens = usage.Output
|
||||
continue
|
||||
}
|
||||
tokens = (usage.Input +
|
||||
usage.Cache.Write +
|
||||
usage.Cache.Read +
|
||||
usage.Output +
|
||||
usage.Reasoning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current model is a subscription model (cost is 0 for both input and output)
|
||||
isSubscriptionModel := m.app.Model != nil &&
|
||||
m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
|
||||
|
||||
sessionInfo = styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.Background()).
|
||||
Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
|
||||
|
||||
background := t.Background()
|
||||
share = layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &background,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Align: layout.AlignStretch,
|
||||
Width: width - 6,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: share,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: sessionInfo,
|
||||
},
|
||||
)
|
||||
|
||||
headerLines = append(headerLines, share)
|
||||
|
||||
header := strings.Join(headerLines, "\n")
|
||||
|
||||
header = styles.NewStyle().
|
||||
@@ -393,6 +455,50 @@ func (m *messagesComponent) header(width int) string {
|
||||
return "\n" + header + "\n"
|
||||
}
|
||||
|
||||
func formatTokensAndCost(
|
||||
tokens float64,
|
||||
contextWindow float64,
|
||||
cost float64,
|
||||
isSubscriptionModel bool,
|
||||
) string {
|
||||
// Format tokens in human-readable format (e.g., 110K, 1.2M)
|
||||
var formattedTokens string
|
||||
switch {
|
||||
case tokens >= 1_000_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
|
||||
case tokens >= 1_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
|
||||
default:
|
||||
formattedTokens = fmt.Sprintf("%d", int(tokens))
|
||||
}
|
||||
|
||||
// Remove .0 suffix if present
|
||||
if strings.HasSuffix(formattedTokens, ".0K") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
|
||||
}
|
||||
if strings.HasSuffix(formattedTokens, ".0M") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
|
||||
}
|
||||
|
||||
percentage := (float64(tokens) / float64(contextWindow)) * 100
|
||||
|
||||
if isSubscriptionModel {
|
||||
return fmt.Sprintf(
|
||||
"%s/%d%%",
|
||||
formattedTokens,
|
||||
int(percentage),
|
||||
)
|
||||
}
|
||||
|
||||
formattedCost := fmt.Sprintf("$%.2f", cost)
|
||||
return fmt.Sprintf(
|
||||
"%s/%d%% (%s)",
|
||||
formattedTokens,
|
||||
int(percentage),
|
||||
formattedCost,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesComponent) View(width, height int) string {
|
||||
t := theme.CurrentTheme()
|
||||
if m.rendering {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/charmbracelet/bubbles/v2/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
@@ -18,10 +19,11 @@ import (
|
||||
)
|
||||
|
||||
type CompletionItem struct {
|
||||
Title string
|
||||
Value string
|
||||
ProviderID string
|
||||
Raw any
|
||||
Title string
|
||||
Value string
|
||||
ProviderID string
|
||||
Raw any
|
||||
backgroundColor *compat.AdaptiveColor
|
||||
}
|
||||
|
||||
type CompletionItemI interface {
|
||||
@@ -32,14 +34,19 @@ type CompletionItemI interface {
|
||||
GetRaw() any
|
||||
}
|
||||
|
||||
func (ci *CompletionItem) Render(selected bool, width int) string {
|
||||
func (ci *CompletionItem) Render(selected bool, width int, isFirstInViewport bool) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||
|
||||
truncatedStr := truncate.String(string(ci.DisplayValue()), uint(width-4))
|
||||
|
||||
backgroundColor := t.BackgroundPanel()
|
||||
if ci.backgroundColor != nil {
|
||||
backgroundColor = *ci.backgroundColor
|
||||
}
|
||||
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundElement()).
|
||||
Background(backgroundColor).
|
||||
Padding(0, 1)
|
||||
|
||||
if selected {
|
||||
@@ -66,7 +73,25 @@ func (ci *CompletionItem) GetRaw() any {
|
||||
return ci.Raw
|
||||
}
|
||||
|
||||
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
|
||||
func (ci *CompletionItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type CompletionItemOption func(*CompletionItem)
|
||||
|
||||
func WithBackgroundColor(color compat.AdaptiveColor) CompletionItemOption {
|
||||
return func(ci *CompletionItem) {
|
||||
ci.backgroundColor = &color
|
||||
}
|
||||
}
|
||||
|
||||
func NewCompletionItem(
|
||||
completionItem CompletionItem,
|
||||
opts ...CompletionItemOption,
|
||||
) CompletionItemI {
|
||||
for _, opt := range opts {
|
||||
opt(&completionItem)
|
||||
}
|
||||
return &completionItem
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,10 @@ package dialog
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
@@ -30,116 +26,61 @@ type FindDialog interface {
|
||||
}
|
||||
|
||||
type findDialogComponent struct {
|
||||
query string
|
||||
completionProvider CompletionProvider
|
||||
width, height int
|
||||
modal *modal.Modal
|
||||
textInput textinput.Model
|
||||
list list.List[CompletionItemI]
|
||||
}
|
||||
|
||||
type findDialogKeyMap struct {
|
||||
Select key.Binding
|
||||
Cancel key.Binding
|
||||
}
|
||||
|
||||
var findDialogKeys = findDialogKeyMap{
|
||||
Select: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
),
|
||||
Cancel: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
),
|
||||
searchDialog *SearchDialog
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
return f.searchDialog.Init()
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case []CompletionItemI:
|
||||
f.list.SetItems(msg)
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
if f.textInput.Value() == "" {
|
||||
return f, nil
|
||||
}
|
||||
f.textInput.SetValue("")
|
||||
return f.update(msg)
|
||||
// Convert CompletionItemI to list.ListItem
|
||||
items := make([]list.ListItem, len(msg))
|
||||
for i, item := range msg {
|
||||
items[i] = item
|
||||
}
|
||||
f.searchDialog.SetItems(items)
|
||||
return f, nil
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, findDialogKeys.Select):
|
||||
item, i := f.list.GetSelectedItem()
|
||||
if i == -1 {
|
||||
return f, nil
|
||||
}
|
||||
case SearchSelectionMsg:
|
||||
// Handle selection from search dialog
|
||||
if item, ok := msg.Item.(CompletionItemI); ok {
|
||||
return f, f.selectFile(item)
|
||||
case key.Matches(msg, findDialogKeys.Cancel):
|
||||
return f, f.Close()
|
||||
default:
|
||||
f.textInput, cmd = f.textInput.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
f, cmd = f.update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
|
||||
return f, tea.Batch(cmds...)
|
||||
}
|
||||
case SearchCancelledMsg:
|
||||
return f, f.Close()
|
||||
|
||||
func (f *findDialogComponent) update(msg tea.Msg) (*findDialogComponent, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
var cmds []tea.Cmd
|
||||
|
||||
query := f.textInput.Value()
|
||||
if query != f.query {
|
||||
f.query = query
|
||||
cmd = func() tea.Msg {
|
||||
items, err := f.completionProvider.GetChildEntries(query)
|
||||
case SearchQueryChangedMsg:
|
||||
// Update completion items based on search query
|
||||
return f, func() tea.Msg {
|
||||
items, err := f.completionProvider.GetChildEntries(msg.Query)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
u, cmd := f.list.Update(msg)
|
||||
f.list = u.(list.List[CompletionItemI])
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return f, tea.Batch(cmds...)
|
||||
// Forward all other messages to the search dialog
|
||||
updatedDialog, cmd := f.searchDialog.Update(msg)
|
||||
f.searchDialog = updatedDialog.(*SearchDialog)
|
||||
return f, cmd
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
f.textInput.SetWidth(f.width - 8)
|
||||
f.list.SetMaxWidth(f.width - 4)
|
||||
inputView := f.textInput.View()
|
||||
inputView = styles.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Height(1).
|
||||
Width(f.width-4).
|
||||
Padding(0, 0).
|
||||
Render(inputView)
|
||||
|
||||
listView := f.list.View()
|
||||
return styles.NewStyle().Height(12).Render(inputView + "\n" + listView)
|
||||
return f.searchDialog.View()
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) SetWidth(width int) {
|
||||
f.width = width
|
||||
if width > 4 {
|
||||
f.textInput.SetWidth(width - 4)
|
||||
f.list.SetMaxWidth(width - 4)
|
||||
}
|
||||
f.searchDialog.SetWidth(width - 4)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) SetHeight(height int) {
|
||||
@@ -147,7 +88,7 @@ func (f *findDialogComponent) SetHeight(height int) {
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) IsEmpty() bool {
|
||||
return f.list.IsEmpty()
|
||||
return f.searchDialog.GetQuery() == ""
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd {
|
||||
@@ -164,67 +105,32 @@ func (f *findDialogComponent) Render(background string) string {
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Close() tea.Cmd {
|
||||
f.textInput.Reset()
|
||||
f.textInput.Blur()
|
||||
f.searchDialog.SetQuery("")
|
||||
f.searchDialog.Blur()
|
||||
return util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
|
||||
func createTextInput(existing *textinput.Model) textinput.Model {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
ti := textinput.New()
|
||||
|
||||
ti.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ti.Styles.Focused.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ti.Styles.Cursor.Color = t.Primary()
|
||||
ti.VirtualCursor = true
|
||||
|
||||
ti.Prompt = " "
|
||||
ti.CharLimit = -1
|
||||
ti.Focus()
|
||||
|
||||
if existing != nil {
|
||||
ti.SetValue(existing.Value())
|
||||
ti.SetWidth(existing.Width())
|
||||
}
|
||||
|
||||
return ti
|
||||
}
|
||||
|
||||
func NewFindDialog(completionProvider CompletionProvider) FindDialog {
|
||||
ti := createTextInput(nil)
|
||||
|
||||
li := list.NewListComponent(
|
||||
[]CompletionItemI{},
|
||||
10, // max visible items
|
||||
completionProvider.GetEmptyMessage(),
|
||||
false,
|
||||
)
|
||||
searchDialog := NewSearchDialog("Search files...", 10)
|
||||
|
||||
// Initialize with empty query to get initial items
|
||||
go func() {
|
||||
items, err := completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
return
|
||||
}
|
||||
li.SetItems(items)
|
||||
// Convert CompletionItemI to list.ListItem
|
||||
listItems := make([]list.ListItem, len(items))
|
||||
for i, item := range items {
|
||||
listItems[i] = item
|
||||
}
|
||||
searchDialog.SetItems(listItems)
|
||||
}()
|
||||
|
||||
return &findDialogComponent{
|
||||
query: "",
|
||||
completionProvider: completionProvider,
|
||||
textInput: ti,
|
||||
list: li,
|
||||
searchDialog: searchDialog,
|
||||
modal: modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(80),
|
||||
|
||||
@@ -30,9 +30,10 @@ func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
h.width = msg.Width
|
||||
h.height = msg.Height
|
||||
// Set viewport size with some padding for the modal
|
||||
h.viewport = viewport.New(viewport.WithWidth(msg.Width-4), viewport.WithHeight(msg.Height-6))
|
||||
h.commandsComponent.SetSize(msg.Width-4, msg.Height-6)
|
||||
// Set viewport size with some padding for the modal, but cap at reasonable width
|
||||
maxWidth := min(80, msg.Width-8)
|
||||
h.viewport = viewport.New(viewport.WithWidth(maxWidth-4), viewport.WithHeight(msg.Height-6))
|
||||
h.commandsComponent.SetSize(maxWidth-4, msg.Height-6)
|
||||
}
|
||||
|
||||
// Update viewport content
|
||||
@@ -48,7 +49,7 @@ func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (h *helpDialog) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
h.commandsComponent.SetBackgroundColor(t.BackgroundElement())
|
||||
h.commandsComponent.SetBackgroundColor(t.BackgroundPanel())
|
||||
return h.viewport.View()
|
||||
}
|
||||
|
||||
@@ -69,11 +70,11 @@ func NewHelpDialog(app *app.App) HelpDialog {
|
||||
return &helpDialog{
|
||||
app: app,
|
||||
commandsComponent: commandsComponent.New(app,
|
||||
commandsComponent.WithBackground(theme.CurrentTheme().BackgroundElement()),
|
||||
commandsComponent.WithBackground(theme.CurrentTheme().BackgroundPanel()),
|
||||
commandsComponent.WithShowAll(true),
|
||||
commandsComponent.WithKeybinds(true),
|
||||
),
|
||||
modal: modal.New(modal.WithTitle("Help")),
|
||||
modal: modal.New(modal.WithTitle("Help"), modal.WithMaxWidth(80)),
|
||||
viewport: vp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package dialog
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
@@ -30,13 +32,13 @@ type ModelDialog interface {
|
||||
}
|
||||
|
||||
type modelDialog struct {
|
||||
app *app.App
|
||||
allModels []ModelWithProvider
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
modelList list.List[ModelItem]
|
||||
dialogWidth int
|
||||
app *app.App
|
||||
allModels []ModelWithProvider
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
searchDialog *SearchDialog
|
||||
dialogWidth int
|
||||
}
|
||||
|
||||
type ModelWithProvider struct {
|
||||
@@ -49,7 +51,7 @@ type ModelItem struct {
|
||||
ProviderName string
|
||||
}
|
||||
|
||||
func (m ModelItem) Render(selected bool, width int) string {
|
||||
func (m *ModelItem) Render(selected bool, width int, isFirstInViewport bool) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
if selected {
|
||||
@@ -79,6 +81,10 @@ func (m ModelItem) Render(selected bool, width int) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ModelItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type modelKeyMap struct {
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
@@ -97,43 +103,53 @@ var modelKeys = modelKeyMap{
|
||||
|
||||
func (m *modelDialog) Init() tea.Cmd {
|
||||
m.setupAllModels()
|
||||
return nil
|
||||
return m.searchDialog.Init()
|
||||
}
|
||||
|
||||
func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, modelKeys.Enter):
|
||||
_, selectedIndex := m.modelList.GetSelectedItem()
|
||||
if selectedIndex >= 0 && selectedIndex < len(m.allModels) {
|
||||
selectedModel := m.allModels[selectedIndex]
|
||||
return m, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(
|
||||
app.ModelSelectedMsg{
|
||||
Provider: selectedModel.Provider,
|
||||
Model: selectedModel.Model,
|
||||
}),
|
||||
)
|
||||
case SearchSelectionMsg:
|
||||
// Handle selection from search dialog
|
||||
if modelItem, ok := msg.Item.(*ModelItem); ok {
|
||||
// Find the corresponding ModelWithProvider
|
||||
for _, model := range m.allModels {
|
||||
if model.Model.Name == modelItem.ModelName && model.Provider.Name == modelItem.ProviderName {
|
||||
return m, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(
|
||||
app.ModelSelectedMsg{
|
||||
Provider: model.Provider,
|
||||
Model: model.Model,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
case key.Matches(msg, modelKeys.Escape):
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
|
||||
case SearchCancelledMsg:
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
|
||||
case SearchQueryChangedMsg:
|
||||
// Update the list based on search query
|
||||
items := m.buildDisplayList(msg.Query)
|
||||
m.searchDialog.SetItems(items)
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.searchDialog.SetWidth(m.dialogWidth)
|
||||
m.searchDialog.SetHeight(msg.Height)
|
||||
}
|
||||
|
||||
// Update the list component
|
||||
updatedList, cmd := m.modelList.Update(msg)
|
||||
m.modelList = updatedList.(list.List[ModelItem])
|
||||
updatedDialog, cmd := m.searchDialog.Update(msg)
|
||||
m.searchDialog = updatedDialog.(*SearchDialog)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *modelDialog) View() string {
|
||||
return m.modelList.View()
|
||||
return m.searchDialog.View()
|
||||
}
|
||||
|
||||
func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
|
||||
@@ -170,6 +186,7 @@ func (m *modelDialog) setupAllModels() {
|
||||
|
||||
m.sortModels()
|
||||
|
||||
// Calculate optimal width based on all models
|
||||
modelItems := make([]ModelItem, len(m.allModels))
|
||||
for i, modelWithProvider := range m.allModels {
|
||||
modelItems[i] = ModelItem{
|
||||
@@ -177,15 +194,15 @@ func (m *modelDialog) setupAllModels() {
|
||||
ProviderName: modelWithProvider.Provider.Name,
|
||||
}
|
||||
}
|
||||
|
||||
m.dialogWidth = m.calculateOptimalWidth(modelItems)
|
||||
|
||||
m.modelList = list.NewListComponent(modelItems, numVisibleModels, "No models available", true)
|
||||
m.modelList.SetMaxWidth(m.dialogWidth)
|
||||
// Initialize search dialog
|
||||
m.searchDialog = NewSearchDialog("Search models...", numVisibleModels)
|
||||
m.searchDialog.SetWidth(m.dialogWidth)
|
||||
|
||||
if len(m.allModels) > 0 {
|
||||
m.modelList.SetSelectedIndex(0)
|
||||
}
|
||||
// Build initial display list (empty query shows grouped view)
|
||||
items := m.buildDisplayList("")
|
||||
m.searchDialog.SetItems(items)
|
||||
}
|
||||
|
||||
func (m *modelDialog) sortModels() {
|
||||
@@ -248,6 +265,163 @@ func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// buildDisplayList creates the list items based on search query
|
||||
func (m *modelDialog) buildDisplayList(query string) []list.ListItem {
|
||||
if query != "" {
|
||||
// Search mode: use fuzzy matching
|
||||
return m.buildSearchResults(query)
|
||||
} else {
|
||||
// Grouped mode: show Recent section and provider groups
|
||||
return m.buildGroupedResults()
|
||||
}
|
||||
}
|
||||
|
||||
// buildSearchResults creates a flat list of search results using fuzzy matching
|
||||
func (m *modelDialog) buildSearchResults(query string) []list.ListItem {
|
||||
type modelMatch struct {
|
||||
model ModelWithProvider
|
||||
score int
|
||||
}
|
||||
|
||||
modelNames := []string{}
|
||||
modelMap := make(map[string]ModelWithProvider)
|
||||
|
||||
// Create search strings and perform fuzzy matching
|
||||
for _, model := range m.allModels {
|
||||
searchStr := fmt.Sprintf("%s %s", model.Model.Name, model.Provider.Name)
|
||||
modelNames = append(modelNames, searchStr)
|
||||
modelMap[searchStr] = model
|
||||
|
||||
searchStr = fmt.Sprintf("%s %s", model.Provider.Name, model.Model.Name)
|
||||
modelNames = append(modelNames, searchStr)
|
||||
modelMap[searchStr] = model
|
||||
}
|
||||
|
||||
matches := fuzzy.RankFindFold(query, modelNames)
|
||||
sort.Sort(matches)
|
||||
|
||||
items := []list.ListItem{}
|
||||
for _, match := range matches {
|
||||
model := modelMap[match.Target]
|
||||
existingItem := slices.IndexFunc(items, func(item list.ListItem) bool {
|
||||
castedItem := item.(*ModelItem)
|
||||
return castedItem.ModelName == model.Model.Name &&
|
||||
castedItem.ProviderName == model.Provider.Name
|
||||
})
|
||||
if existingItem != -1 {
|
||||
continue
|
||||
}
|
||||
items = append(items, &ModelItem{
|
||||
ModelName: model.Model.Name,
|
||||
ProviderName: model.Provider.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// buildGroupedResults creates a grouped list with Recent section and provider groups
|
||||
func (m *modelDialog) buildGroupedResults() []list.ListItem {
|
||||
var items []list.ListItem
|
||||
|
||||
// Add Recent section
|
||||
recentModels := m.getRecentModels(5)
|
||||
if len(recentModels) > 0 {
|
||||
items = append(items, list.HeaderItem("Recent"))
|
||||
for _, model := range recentModels {
|
||||
items = append(items, &ModelItem{
|
||||
ModelName: model.Model.Name,
|
||||
ProviderName: model.Provider.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Group models by provider
|
||||
providerGroups := make(map[string][]ModelWithProvider)
|
||||
for _, model := range m.allModels {
|
||||
providerName := model.Provider.Name
|
||||
providerGroups[providerName] = append(providerGroups[providerName], model)
|
||||
}
|
||||
|
||||
// Get sorted provider names for consistent order
|
||||
var providerNames []string
|
||||
for name := range providerGroups {
|
||||
providerNames = append(providerNames, name)
|
||||
}
|
||||
sort.Strings(providerNames)
|
||||
|
||||
// Add provider groups
|
||||
for _, providerName := range providerNames {
|
||||
models := providerGroups[providerName]
|
||||
|
||||
// Sort models within provider group
|
||||
sort.Slice(models, func(i, j int) bool {
|
||||
modelA := models[i]
|
||||
modelB := models[j]
|
||||
|
||||
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
|
||||
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
|
||||
|
||||
// Sort by usage time first, then by release date, then alphabetically
|
||||
if !usageA.IsZero() && !usageB.IsZero() {
|
||||
return usageA.After(usageB)
|
||||
}
|
||||
if !usageA.IsZero() && usageB.IsZero() {
|
||||
return true
|
||||
}
|
||||
if usageA.IsZero() && !usageB.IsZero() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sort by release date if available
|
||||
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
|
||||
dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
|
||||
dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
|
||||
if !dateA.IsZero() && !dateB.IsZero() {
|
||||
return dateA.After(dateB)
|
||||
}
|
||||
}
|
||||
|
||||
return modelA.Model.Name < modelB.Model.Name
|
||||
})
|
||||
|
||||
// Add provider header
|
||||
items = append(items, list.HeaderItem(providerName))
|
||||
|
||||
// Add models in this provider group
|
||||
for _, model := range models {
|
||||
items = append(items, &ModelItem{
|
||||
ModelName: model.Model.Name,
|
||||
ProviderName: model.Provider.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// getRecentModels returns the most recently used models
|
||||
func (m *modelDialog) getRecentModels(limit int) []ModelWithProvider {
|
||||
var recentModels []ModelWithProvider
|
||||
|
||||
// Get recent models from app state
|
||||
for _, usage := range m.app.State.RecentlyUsedModels {
|
||||
if len(recentModels) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
// Find the corresponding model
|
||||
for _, model := range m.allModels {
|
||||
if model.Provider.ID == usage.ProviderID && model.Model.ID == usage.ModelID {
|
||||
recentModels = append(recentModels, model)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return recentModels
|
||||
}
|
||||
|
||||
func (m *modelDialog) Render(background string) string {
|
||||
return m.modal.Render(m.View(), background)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
// SearchQueryChangedMsg is emitted when the search query changes
|
||||
type SearchQueryChangedMsg struct {
|
||||
Query string
|
||||
}
|
||||
|
||||
// SearchSelectionMsg is emitted when an item is selected
|
||||
type SearchSelectionMsg struct {
|
||||
Item interface{}
|
||||
Index int
|
||||
}
|
||||
|
||||
// SearchCancelledMsg is emitted when the search is cancelled
|
||||
type SearchCancelledMsg struct{}
|
||||
|
||||
// SearchDialog is a reusable component that combines a text input with a list
|
||||
type SearchDialog struct {
|
||||
textInput textinput.Model
|
||||
list list.List[list.ListItem]
|
||||
width int
|
||||
height int
|
||||
focused bool
|
||||
}
|
||||
|
||||
type searchKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
}
|
||||
|
||||
var searchKeys = searchKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "ctrl+p"),
|
||||
key.WithHelp("↑", "previous item"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "ctrl+n"),
|
||||
key.WithHelp("↓", "next item"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "cancel"),
|
||||
),
|
||||
}
|
||||
|
||||
// NewSearchDialog creates a new SearchDialog
|
||||
func NewSearchDialog(placeholder string, maxVisibleItems int) *SearchDialog {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = placeholder
|
||||
ti.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Blurred.Text = styles.NewStyle().
|
||||
Foreground(textColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Text = styles.NewStyle().
|
||||
Foreground(textColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Prompt = styles.NewStyle().
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Cursor.Color = t.Primary()
|
||||
ti.VirtualCursor = true
|
||||
|
||||
ti.Prompt = " "
|
||||
ti.CharLimit = -1
|
||||
ti.Focus()
|
||||
|
||||
emptyList := list.NewListComponent(
|
||||
[]list.ListItem{},
|
||||
maxVisibleItems,
|
||||
" No items",
|
||||
false,
|
||||
)
|
||||
|
||||
return &SearchDialog{
|
||||
textInput: ti,
|
||||
list: emptyList,
|
||||
focused: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchDialog) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
value := s.textInput.Value()
|
||||
if value == "" {
|
||||
return s, nil
|
||||
}
|
||||
s.textInput.Reset()
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
return SearchQueryChangedMsg{Query: ""}
|
||||
})
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, searchKeys.Escape):
|
||||
return s, func() tea.Msg { return SearchCancelledMsg{} }
|
||||
|
||||
case key.Matches(msg, searchKeys.Enter):
|
||||
if selectedItem, idx := s.list.GetSelectedItem(); selectedItem != nil {
|
||||
return s, func() tea.Msg {
|
||||
return SearchSelectionMsg{Item: selectedItem, Index: idx}
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, searchKeys.Up):
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[list.ListItem])
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case key.Matches(msg, searchKeys.Down):
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[list.ListItem])
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
default:
|
||||
oldValue := s.textInput.Value()
|
||||
var cmd tea.Cmd
|
||||
s.textInput, cmd = s.textInput.Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
if newValue := s.textInput.Value(); newValue != oldValue {
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
return SearchQueryChangedMsg{Query: newValue}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (s *SearchDialog) View() string {
|
||||
s.list.SetMaxWidth(s.width)
|
||||
listView := s.list.View()
|
||||
listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleItems(), lipgloss.Top, listView)
|
||||
textinput := s.textInput.View()
|
||||
return textinput + "\n\n" + listView
|
||||
}
|
||||
|
||||
// SetWidth sets the width of the search dialog
|
||||
func (s *SearchDialog) SetWidth(width int) {
|
||||
s.width = width
|
||||
s.textInput.SetWidth(width - 2) // Account for padding and borders
|
||||
}
|
||||
|
||||
// SetHeight sets the height of the search dialog
|
||||
func (s *SearchDialog) SetHeight(height int) {
|
||||
s.height = height
|
||||
}
|
||||
|
||||
// SetItems updates the list items
|
||||
func (s *SearchDialog) SetItems(items []list.ListItem) {
|
||||
s.list.SetItems(items)
|
||||
}
|
||||
|
||||
// GetQuery returns the current search query
|
||||
func (s *SearchDialog) GetQuery() string {
|
||||
return s.textInput.Value()
|
||||
}
|
||||
|
||||
// SetQuery sets the search query
|
||||
func (s *SearchDialog) SetQuery(query string) {
|
||||
s.textInput.SetValue(query)
|
||||
}
|
||||
|
||||
// Focus focuses the search dialog
|
||||
func (s *SearchDialog) Focus() {
|
||||
s.focused = true
|
||||
s.textInput.Focus()
|
||||
}
|
||||
|
||||
// Blur removes focus from the search dialog
|
||||
func (s *SearchDialog) Blur() {
|
||||
s.focused = false
|
||||
s.textInput.Blur()
|
||||
}
|
||||
@@ -30,7 +30,7 @@ type sessionItem struct {
|
||||
isDeleteConfirming bool
|
||||
}
|
||||
|
||||
func (s sessionItem) Render(selected bool, width int) string {
|
||||
func (s sessionItem) Render(selected bool, width int, isFirstInViewport bool) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle()
|
||||
|
||||
@@ -75,6 +75,10 @@ func (s sessionItem) Render(selected bool, width int) string {
|
||||
return itemStyle.Render(truncatedStr)
|
||||
}
|
||||
|
||||
func (s sessionItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type sessionDialog struct {
|
||||
width int
|
||||
height int
|
||||
@@ -110,6 +114,13 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
|
||||
)
|
||||
}
|
||||
case "n":
|
||||
s.app.Session = &opencode.Session{}
|
||||
s.app.Messages = []app.Message{}
|
||||
return s, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(app.SessionClearedMsg{}),
|
||||
)
|
||||
case "x", "delete", "backspace":
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||
if s.deleteConfirmation == idx {
|
||||
@@ -150,10 +161,21 @@ func (s *sessionDialog) Render(background string) string {
|
||||
listView := s.list.View()
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
helpStyle := styles.NewStyle().PaddingLeft(1).PaddingTop(1)
|
||||
helpText := styles.NewStyle().Foreground(t.Text()).Render("x/del")
|
||||
helpText = helpText + styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render(" delete session")
|
||||
helpText = helpStyle.Render(helpText)
|
||||
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
|
||||
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
|
||||
|
||||
leftHelp := keyStyle("n") + mutedStyle(" new session")
|
||||
rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
|
||||
|
||||
bgColor := t.BackgroundPanel()
|
||||
helpText := layout.Render(layout.FlexOptions{
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Width: layout.Current.Container.Width - 14,
|
||||
Background: &bgColor,
|
||||
}, layout.FlexItem{View: leftHelp}, layout.FlexItem{View: rightHelp})
|
||||
|
||||
helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
|
||||
|
||||
content := strings.Join([]string{listView, helpText}, "\n")
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
)
|
||||
|
||||
type ListItem interface {
|
||||
Render(selected bool, width int) string
|
||||
Render(selected bool, width int, isFirstInViewport bool) string
|
||||
Selectable() bool
|
||||
}
|
||||
|
||||
type List[T ListItem] interface {
|
||||
@@ -24,6 +25,8 @@ type List[T ListItem] interface {
|
||||
SetSelectedIndex(idx int)
|
||||
SetEmptyMessage(msg string)
|
||||
IsEmpty() bool
|
||||
GetMaxVisibleItems() int
|
||||
GetActualHeight() int
|
||||
}
|
||||
|
||||
type listComponent[T ListItem] struct {
|
||||
@@ -46,11 +49,11 @@ type listKeyMap struct {
|
||||
|
||||
var simpleListKeys = listKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithKeys("up", "ctrl+p"),
|
||||
key.WithHelp("↑", "previous list item"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithKeys("down", "ctrl+n"),
|
||||
key.WithHelp("↓", "next list item"),
|
||||
),
|
||||
UpAlpha: key.NewBinding(
|
||||
@@ -72,14 +75,10 @@ func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
|
||||
if c.selectedIdx > 0 {
|
||||
c.selectedIdx--
|
||||
}
|
||||
c.moveUp()
|
||||
return c, nil
|
||||
case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
|
||||
if c.selectedIdx < len(c.items)-1 {
|
||||
c.selectedIdx++
|
||||
}
|
||||
c.moveDown()
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
@@ -87,8 +86,50 @@ func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// moveUp moves the selection up, skipping non-selectable items
|
||||
func (c *listComponent[T]) moveUp() {
|
||||
if len(c.items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the previous selectable item
|
||||
for i := c.selectedIdx - 1; i >= 0; i-- {
|
||||
if c.items[i].Selectable() {
|
||||
c.selectedIdx = i
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no selectable item found above, stay at current position
|
||||
}
|
||||
|
||||
// moveDown moves the selection down, skipping non-selectable items
|
||||
func (c *listComponent[T]) moveDown() {
|
||||
if len(c.items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
originalIdx := c.selectedIdx
|
||||
for {
|
||||
if c.selectedIdx < len(c.items)-1 {
|
||||
c.selectedIdx++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if c.items[c.selectedIdx].Selectable() {
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent infinite loop
|
||||
if c.selectedIdx == originalIdx {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) GetSelectedItem() (T, int) {
|
||||
if len(c.items) > 0 {
|
||||
if len(c.items) > 0 && c.items[c.selectedIdx].Selectable() {
|
||||
return c.items[c.selectedIdx], c.selectedIdx
|
||||
}
|
||||
|
||||
@@ -97,8 +138,13 @@ func (c *listComponent[T]) GetSelectedItem() (T, int) {
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetItems(items []T) {
|
||||
c.selectedIdx = 0
|
||||
c.items = items
|
||||
c.selectedIdx = 0
|
||||
|
||||
// Ensure initial selection is on a selectable item
|
||||
if len(items) > 0 && !items[0].Selectable() {
|
||||
c.moveDown()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) GetItems() []T {
|
||||
@@ -123,19 +169,19 @@ func (c *listComponent[T]) SetSelectedIndex(idx int) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) View() string {
|
||||
func (c *listComponent[T]) GetMaxVisibleItems() int {
|
||||
return c.maxVisibleItems
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) GetActualHeight() int {
|
||||
items := c.items
|
||||
maxWidth := c.maxWidth
|
||||
if maxWidth == 0 {
|
||||
maxWidth = 80 // Default width if not set
|
||||
if len(items) == 0 {
|
||||
return 1 // For empty message
|
||||
}
|
||||
|
||||
maxVisibleItems := min(c.maxVisibleItems, len(items))
|
||||
startIdx := 0
|
||||
|
||||
if len(items) <= 0 {
|
||||
return c.fallbackMsg
|
||||
}
|
||||
|
||||
if len(items) > maxVisibleItems {
|
||||
halfVisible := maxVisibleItems / 2
|
||||
if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
|
||||
@@ -147,17 +193,142 @@ func (c *listComponent[T]) View() string {
|
||||
|
||||
endIdx := min(startIdx+maxVisibleItems, len(items))
|
||||
|
||||
listItems := make([]string, 0, maxVisibleItems)
|
||||
height := 0
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
item := items[i]
|
||||
isFirstInViewport := (i == startIdx)
|
||||
|
||||
// Check if this is a HeaderItem and calculate its height
|
||||
if _, ok := any(item).(HeaderItem); ok {
|
||||
if isFirstInViewport {
|
||||
height += 1 // No top margin
|
||||
} else {
|
||||
height += 2 // With top margin
|
||||
}
|
||||
} else {
|
||||
height += 1 // Regular items take 1 line
|
||||
}
|
||||
}
|
||||
|
||||
return height
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) View() string {
|
||||
items := c.items
|
||||
maxWidth := c.maxWidth
|
||||
if maxWidth == 0 {
|
||||
maxWidth = 80 // Default width if not set
|
||||
}
|
||||
|
||||
if len(items) <= 0 {
|
||||
return c.fallbackMsg
|
||||
}
|
||||
|
||||
// Calculate viewport based on actual heights, not item counts
|
||||
startIdx, endIdx := c.calculateViewport()
|
||||
|
||||
listItems := make([]string, 0, endIdx-startIdx)
|
||||
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
item := items[i]
|
||||
title := item.Render(i == c.selectedIdx, maxWidth)
|
||||
isFirstInViewport := (i == startIdx)
|
||||
title := item.Render(i == c.selectedIdx, maxWidth, isFirstInViewport)
|
||||
listItems = append(listItems, title)
|
||||
}
|
||||
|
||||
return strings.Join(listItems, "\n")
|
||||
}
|
||||
|
||||
// calculateViewport determines which items to show based on available height
|
||||
func (c *listComponent[T]) calculateViewport() (startIdx, endIdx int) {
|
||||
items := c.items
|
||||
if len(items) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Helper function to calculate height of an item at given position
|
||||
getItemHeight := func(idx int, isFirst bool) int {
|
||||
if _, ok := any(items[idx]).(HeaderItem); ok {
|
||||
if isFirst {
|
||||
return 1 // No top margin
|
||||
} else {
|
||||
return 2 // With top margin
|
||||
}
|
||||
}
|
||||
return 1 // Regular items
|
||||
}
|
||||
|
||||
// If we have fewer items than max, show all
|
||||
if len(items) <= c.maxVisibleItems {
|
||||
return 0, len(items)
|
||||
}
|
||||
|
||||
// Try to center the selected item in the viewport
|
||||
// Start by trying to put selected item in the middle
|
||||
targetStart := c.selectedIdx - c.maxVisibleItems/2
|
||||
if targetStart < 0 {
|
||||
targetStart = 0
|
||||
}
|
||||
|
||||
// Find the actual start and end indices that fit within our height budget
|
||||
bestStart := 0
|
||||
bestEnd := 0
|
||||
bestHeight := 0
|
||||
|
||||
// Try different starting positions around our target
|
||||
for start := max(0, targetStart-2); start <= min(len(items)-1, targetStart+2); start++ {
|
||||
currentHeight := 0
|
||||
end := start
|
||||
|
||||
for end < len(items) && currentHeight < c.maxVisibleItems {
|
||||
itemHeight := getItemHeight(end, end == start)
|
||||
if currentHeight+itemHeight > c.maxVisibleItems {
|
||||
break
|
||||
}
|
||||
currentHeight += itemHeight
|
||||
end++
|
||||
}
|
||||
|
||||
// Check if this viewport contains the selected item and is better than current best
|
||||
if start <= c.selectedIdx && c.selectedIdx < end {
|
||||
if currentHeight > bestHeight || (currentHeight == bestHeight && abs(start+end-2*c.selectedIdx) < abs(bestStart+bestEnd-2*c.selectedIdx)) {
|
||||
bestStart = start
|
||||
bestEnd = end
|
||||
bestHeight = currentHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no good viewport found that contains selected item, just show from selected item
|
||||
if bestEnd == 0 {
|
||||
bestStart = c.selectedIdx
|
||||
currentHeight := 0
|
||||
for bestEnd = bestStart; bestEnd < len(items) && currentHeight < c.maxVisibleItems; bestEnd++ {
|
||||
itemHeight := getItemHeight(bestEnd, bestEnd == bestStart)
|
||||
if currentHeight+itemHeight > c.maxVisibleItems {
|
||||
break
|
||||
}
|
||||
currentHeight += itemHeight
|
||||
}
|
||||
}
|
||||
|
||||
return bestStart, bestEnd
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func NewListComponent[T ListItem](
|
||||
items []T,
|
||||
maxVisibleItems int,
|
||||
@@ -176,7 +347,7 @@ func NewListComponent[T ListItem](
|
||||
// StringItem is a simple implementation of ListItem for string values
|
||||
type StringItem string
|
||||
|
||||
func (s StringItem) Render(selected bool, width int) string {
|
||||
func (s StringItem) Render(selected bool, width int, isFirstInViewport bool) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle()
|
||||
|
||||
@@ -198,6 +369,37 @@ func (s StringItem) Render(selected bool, width int) string {
|
||||
return itemStyle.Render(truncatedStr)
|
||||
}
|
||||
|
||||
func (s StringItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// HeaderItem is a non-selectable header item for grouping
|
||||
type HeaderItem string
|
||||
|
||||
func (h HeaderItem) Render(selected bool, width int, isFirstInViewport bool) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle()
|
||||
|
||||
truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
|
||||
|
||||
headerStyle := baseStyle.
|
||||
Foreground(t.Accent()).
|
||||
Bold(true).
|
||||
MarginBottom(0).
|
||||
PaddingLeft(1)
|
||||
|
||||
// Only add top margin if this is not the first item in the viewport
|
||||
if !isFirstInViewport {
|
||||
headerStyle = headerStyle.MarginTop(1)
|
||||
}
|
||||
|
||||
return headerStyle.Render(truncatedStr)
|
||||
}
|
||||
|
||||
func (h HeaderItem) Selectable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// NewStringList creates a new list component with string items
|
||||
func NewStringList(
|
||||
items []string,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
)
|
||||
|
||||
// testItem is a simple test implementation of ListItem
|
||||
type testItem struct {
|
||||
value string
|
||||
}
|
||||
|
||||
func (t testItem) Render(selected bool, width int) string {
|
||||
return t.value
|
||||
}
|
||||
|
||||
// createTestList creates a list with test items for testing
|
||||
func createTestList() *listComponent[testItem] {
|
||||
items := []testItem{
|
||||
{value: "item1"},
|
||||
{value: "item2"},
|
||||
{value: "item3"},
|
||||
}
|
||||
list := NewListComponent(items, 5, "empty", false)
|
||||
return list.(*listComponent[testItem])
|
||||
}
|
||||
|
||||
func TestArrowKeyNavigation(t *testing.T) {
|
||||
list := createTestList()
|
||||
|
||||
// Test down arrow navigation
|
||||
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
updatedModel, _ := list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 1 {
|
||||
t.Errorf("Expected selected index 1 after down arrow, got %d", idx)
|
||||
}
|
||||
|
||||
// Test up arrow navigation
|
||||
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
|
||||
updatedModel, _ = list.Update(upKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected selected index 0 after up arrow, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJKKeyNavigation(t *testing.T) {
|
||||
items := []testItem{
|
||||
{value: "item1"},
|
||||
{value: "item2"},
|
||||
{value: "item3"},
|
||||
}
|
||||
// Create list with alpha keys enabled
|
||||
list := NewListComponent(items, 5, "empty", true).(*listComponent[testItem])
|
||||
|
||||
// Test j key (down)
|
||||
jKey := tea.KeyPressMsg{Code: 'j', Text: "j"}
|
||||
updatedModel, _ := list.Update(jKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 1 {
|
||||
t.Errorf("Expected selected index 1 after 'j' key, got %d", idx)
|
||||
}
|
||||
|
||||
// Test k key (up)
|
||||
kKey := tea.KeyPressMsg{Code: 'k', Text: "k"}
|
||||
updatedModel, _ = list.Update(kKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected selected index 0 after 'k' key, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlNavigation(t *testing.T) {
|
||||
list := createTestList()
|
||||
|
||||
// Test Ctrl-N (down)
|
||||
ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
|
||||
updatedModel, _ := list.Update(ctrlN)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 1 {
|
||||
t.Errorf("Expected selected index 1 after Ctrl-N, got %d", idx)
|
||||
}
|
||||
|
||||
// Test Ctrl-P (up)
|
||||
ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
|
||||
updatedModel, _ = list.Update(ctrlP)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected selected index 0 after Ctrl-P, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationBoundaries(t *testing.T) {
|
||||
list := createTestList()
|
||||
|
||||
// Test up arrow at first item (should stay at 0)
|
||||
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
|
||||
updatedModel, _ := list.Update(upKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected to stay at index 0 when pressing up at first item, got %d", idx)
|
||||
}
|
||||
|
||||
// Move to last item
|
||||
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
updatedModel, _ = list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 2 {
|
||||
t.Errorf("Expected to be at index 2, got %d", idx)
|
||||
}
|
||||
|
||||
// Test down arrow at last item (should stay at 2)
|
||||
updatedModel, _ = list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 2 {
|
||||
t.Errorf("Expected to stay at index 2 when pressing down at last item, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyList(t *testing.T) {
|
||||
emptyList := NewListComponent([]testItem{}, 5, "empty", false).(*listComponent[testItem])
|
||||
|
||||
// Test navigation on empty list (should not crash)
|
||||
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
|
||||
ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
|
||||
ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
|
||||
|
||||
updatedModel, _ := emptyList.Update(downKey)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = emptyList.Update(upKey)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = emptyList.Update(ctrlN)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = emptyList.Update(ctrlP)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
|
||||
// Verify empty list behavior
|
||||
_, idx := emptyList.GetSelectedItem()
|
||||
if idx != -1 {
|
||||
t.Errorf("Expected index -1 for empty list, got %d", idx)
|
||||
}
|
||||
|
||||
if !emptyList.IsEmpty() {
|
||||
t.Error("Expected IsEmpty() to return true for empty list")
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func (m *Modal) Render(contentView string, background string) string {
|
||||
var finalContent string
|
||||
if m.title != "" {
|
||||
titleStyle := baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
Foreground(t.Text()).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
@@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string {
|
||||
modalView,
|
||||
background,
|
||||
layout.WithOverlayBorder(),
|
||||
layout.WithOverlayBorderColor(t.Primary()),
|
||||
layout.WithOverlayBorderColor(t.BorderActive()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
@@ -55,45 +55,6 @@ func (m statusComponent) logo() string {
|
||||
Render(open + code + version)
|
||||
}
|
||||
|
||||
func formatTokensAndCost(tokens float64, contextWindow float64, cost float64, isSubscriptionModel bool) string {
|
||||
// Format tokens in human-readable format (e.g., 110K, 1.2M)
|
||||
var formattedTokens string
|
||||
switch {
|
||||
case tokens >= 1_000_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
|
||||
case tokens >= 1_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
|
||||
default:
|
||||
formattedTokens = fmt.Sprintf("%d", int(tokens))
|
||||
}
|
||||
|
||||
// Remove .0 suffix if present
|
||||
if strings.HasSuffix(formattedTokens, ".0K") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
|
||||
}
|
||||
if strings.HasSuffix(formattedTokens, ".0M") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
|
||||
}
|
||||
|
||||
percentage := (float64(tokens) / float64(contextWindow)) * 100
|
||||
|
||||
if isSubscriptionModel {
|
||||
return fmt.Sprintf(
|
||||
"Context: %s (%d%%)",
|
||||
formattedTokens,
|
||||
int(percentage),
|
||||
)
|
||||
}
|
||||
|
||||
formattedCost := fmt.Sprintf("$%.2f", cost)
|
||||
return fmt.Sprintf(
|
||||
"Context: %s (%d%%), Cost: %s",
|
||||
formattedTokens,
|
||||
int(percentage),
|
||||
formattedCost,
|
||||
)
|
||||
}
|
||||
|
||||
func (m statusComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
logo := m.logo()
|
||||
@@ -104,50 +65,68 @@ func (m statusComponent) View() string {
|
||||
Padding(0, 1).
|
||||
Render(m.cwd)
|
||||
|
||||
sessionInfo := ""
|
||||
if m.app.Session.ID != "" {
|
||||
tokens := float64(0)
|
||||
cost := float64(0)
|
||||
contextWindow := m.app.Model.Limit.Context
|
||||
|
||||
for _, message := range m.app.Messages {
|
||||
if assistant, ok := message.(opencode.AssistantMessage); ok {
|
||||
cost += assistant.Cost
|
||||
usage := assistant.Tokens
|
||||
if usage.Output > 0 {
|
||||
if assistant.Summary {
|
||||
tokens = usage.Output
|
||||
continue
|
||||
}
|
||||
tokens = (usage.Input +
|
||||
usage.Cache.Write +
|
||||
usage.Cache.Read +
|
||||
usage.Output +
|
||||
usage.Reasoning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current model is a subscription model (cost is 0 for both input and output)
|
||||
isSubscriptionModel := m.app.Model != nil &&
|
||||
m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
|
||||
|
||||
sessionInfo = styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundElement()).
|
||||
Padding(0, 1).
|
||||
Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
|
||||
var modeBackground compat.AdaptiveColor
|
||||
var modeForeground compat.AdaptiveColor
|
||||
switch m.app.ModeIndex {
|
||||
case 0:
|
||||
modeBackground = t.BackgroundElement()
|
||||
modeForeground = t.TextMuted()
|
||||
case 1:
|
||||
modeBackground = t.Secondary()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 2:
|
||||
modeBackground = t.Accent()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 3:
|
||||
modeBackground = t.Success()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 4:
|
||||
modeBackground = t.Warning()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 5:
|
||||
modeBackground = t.Primary()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 6:
|
||||
modeBackground = t.Error()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
default:
|
||||
modeBackground = t.Secondary()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
}
|
||||
|
||||
// diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
|
||||
command := m.app.Commands[commands.SwitchModeCommand]
|
||||
kb := command.Keybindings[0]
|
||||
key := kb.Key
|
||||
if kb.RequiresLeader {
|
||||
key = m.app.Config.Keybinds.Leader + " " + kb.Key
|
||||
}
|
||||
|
||||
modeStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
|
||||
modeNameStyle := modeStyle.Bold(true).Render
|
||||
modeDescStyle := modeStyle.Render
|
||||
mode := modeNameStyle(strings.ToUpper(m.app.Mode.Name)) + modeDescStyle(" MODE")
|
||||
mode = modeStyle.
|
||||
Padding(0, 1).
|
||||
BorderLeft(true).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderForeground(modeBackground).
|
||||
BorderBackground(t.BackgroundPanel()).
|
||||
Render(mode)
|
||||
|
||||
mode = styles.NewStyle().
|
||||
Faint(true).
|
||||
Background(t.BackgroundPanel()).
|
||||
Foreground(t.TextMuted()).
|
||||
Render(key+" ") +
|
||||
mode
|
||||
|
||||
space := max(
|
||||
0,
|
||||
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
|
||||
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(mode),
|
||||
)
|
||||
spacer := styles.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
|
||||
|
||||
status := logo + cwd + spacer + sessionInfo
|
||||
status := logo + cwd + spacer + mode
|
||||
|
||||
blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
|
||||
return blank + "\n" + status
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user