Compare commits

...

49 Commits

Author SHA1 Message Date
Dax Raad 1729c310d9 switch global config to ~/.config/opencode/opencode.json 2025-07-11 20:51:23 -04:00
Dax Raad 0130190bbd docs: add model docs 2025-07-11 20:33:06 -04:00
Aiden Cline 97a31ddffc tweak: plan interactions should match web (TUI) (#895) 2025-07-11 18:03:22 -04:00
zWing 3249420ad1 fix: avoid overwriting the provider.option.baseURL (#880) 2025-07-11 18:01:28 -04:00
Dax Raad 4bb8536d34 introduce cache version concept for auto cleanup when breaking cache changes happen 2025-07-11 17:50:49 -04:00
Jay c73d4a137e docs: Update troubleshooting.mdx 2025-07-11 17:50:25 -04:00
Dax Raad 57ac8f2741 wip: stats 2025-07-11 17:37:41 -04:00
Jay V 2f1acee5a1 docs: share page add time footer back 2025-07-11 14:24:20 -04:00
Jay V 9ca54020ac docs: share page mobile bugs 2025-07-11 14:24:20 -04:00
Jay V f7d44b178b docs: share fix mobile diffs 2025-07-11 14:24:20 -04:00
Sergii Kozak b4950a157c fix(session): add fallback for undefined output token limit (#860)
Co-authored-by: opencode <noreply@opencode.ai>
2025-07-11 10:55:13 -04:00
alexz dfbef066c7 fix: ENAMETOOLONG: name too long when adding custom mode (#881) 2025-07-11 10:54:52 -04:00
GitHub Action 26fd76fbee ignore: update download stats 2025-07-11 2025-07-11 12:04:08 +00:00
adamdotdevin 04769d8a26 fix(tui): help commands bg color 2025-07-11 06:03:21 -05:00
adamdotdevin 34b576d9b5 fix(tui): don't include /mode trigger 2025-07-11 06:01:51 -05:00
adamdotdevin 22b244f847 fix(tui): actually fix mouse ansi codes leaking 2025-07-11 06:00:20 -05:00
Aiden Cline 7e1fc275e7 fix: avoid worker exception, graceful 404 (#869) 2025-07-11 04:55:56 -05:00
Frank 3b9b391320 wip: github actions 2025-07-11 06:55:13 +08:00
Frank 766bfd025c wip: github actions 2025-07-11 05:23:24 +08:00
Jay V c7f30e1065 docs: share page fix terminal part 2025-07-10 17:21:21 -04:00
Frank 1c4fd7f28f Api: add endpoint for getting github app token 2025-07-11 05:01:27 +08:00
adamdotdevin 85805d2c38 fix(tui): handle SIGTERM, closes #319 2025-07-10 15:59:03 -05:00
Timo Clasen 982cb3e71a fix(tui): center help dilaog (#853) 2025-07-10 15:56:19 -05:00
adamdotdevin 294d0e7ee3 fix(tui): mouse wheel ansi codes leaking into editor 2025-07-10 15:49:58 -05:00
Jay V 8be1ca836c docs: fix diag styles 2025-07-10 16:38:51 -04:00
Jay V 2e5f96fa41 docs: share page attachment 2025-07-10 16:38:51 -04:00
Dax Raad c056b0add9 add step finish part 2025-07-10 16:25:38 -04:00
Dax Raad b00bb3c083 run: properly close session.list 2025-07-10 16:13:01 -04:00
Dax Raad d9befd3aa6 disable filewatcher, fixes file descriptor leak 2025-07-10 15:58:45 -04:00
Dax Raad 49de703ba1 config: escape file: string content 2025-07-10 15:38:58 -04:00
Dax Raad 22988894c8 ci: slow down stats 2025-07-10 15:31:06 -04:00
adamdotdevin 34b1754f25 docs: clipboard requirements on linux 2025-07-10 13:12:37 -05:00
adamdotdevin 54fe3504ba feat(tui): accent editor border on leader key 2025-07-10 12:57:22 -05:00
Jay V d2c862e32d docs: edit local models 2025-07-10 13:49:24 -04:00
Jay V afc53afb35 docs: edit mode 2025-07-10 13:29:37 -04:00
Gabriel Garrett b56e49c5dc Adds real example in docs of how to configure custom provider (#840) 2025-07-10 13:29:30 -04:00
Aiden Cline 8b2a909e1f fix: encode & decode file paths (#843) 2025-07-10 11:19:54 -05:00
Jay V e9c954d45e docs: add modes to sidebar 2025-07-10 12:07:44 -04:00
Jay V 6f449d13af docs: add modes to sidebar 2025-07-10 12:07:18 -04:00
Dax Raad 6e375bef0d docs: modes 2025-07-10 11:53:28 -04:00
Dax Raad 67106a6967 docs: add config variable docs 2025-07-10 11:48:55 -04:00
Dax Raad b5d690620d support env and file pointers in config 2025-07-10 11:45:31 -04:00
Dax Raad 9db3ce1d0b opencode run respects mode 2025-07-10 11:28:28 -04:00
Dax Raad 1cc55b68ef wip: scrap 2025-07-10 11:25:37 -04:00
Dax Raad 469f667774 set max output token limit to 32_000 2025-07-10 11:25:37 -04:00
adamdottv 6603d9a9f0 feat: --mode flag passed to tui 2025-07-10 10:19:25 -05:00
adamdottv 5dc1920a4c feat: mode flag in cli run command 2025-07-10 10:13:15 -05:00
adamdottv d3e5f3f3a8 feat(tui): add token and cost info to session header 2025-07-10 10:06:51 -05:00
adamdottv ce4cb820f7 feat(tui): modes 2025-07-10 10:06:51 -05:00
97 changed files with 8139 additions and 565 deletions
+1
View File
@@ -12,3 +12,4 @@
| 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) |
+54 -1
View File
@@ -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:",
@@ -337,6 +342,42 @@
"@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=="],
"@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=="],
"@opencode/function": ["@opencode/function@workspace:packages/function"],
@@ -575,6 +616,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=="],
@@ -807,6 +850,8 @@
"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=="],
@@ -993,7 +1038,7 @@
"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=="],
@@ -1527,6 +1572,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,6 +1644,10 @@
"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=="],
@@ -1815,6 +1866,8 @@
"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=="],
+3 -1
View File
@@ -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
+5
View File
@@ -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"
}
}
+60 -1
View File
@@ -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>
@@ -104,7 +108,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]
@@ -218,5 +222,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 })
},
}
+16 -8
View File
@@ -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 {}
+6 -4
View File
@@ -65,10 +65,12 @@ export namespace BunProc {
}))
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", "--exact", "--cwd", Global.Path.cache, "--registry=https://registry.npmjs.org", pkg + "@" + version],
{
cwd: Global.Path.cache,
},
).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
@@ -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() {},
})
+17 -3
View File
@@ -8,6 +8,7 @@ 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"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -52,6 +53,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 +66,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
}
@@ -139,10 +146,17 @@ export const RunCommand = cmd({
UI.error(err)
})
const mode = args.mode ? await Mode.get(args.mode) : await Mode.list().then((x) => x[0])
const result = await Session.chat({
sessionID: session.id,
providerID,
modelID,
...(mode.model
? mode.model
: {
providerID,
modelID,
}),
mode: mode.name,
parts: [
{
type: "text",
+179
View File
@@ -0,0 +1,179 @@
import { Storage } from "../../storage/storage"
import { MessageV2 } from "../../session/message-v2"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
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",
describe: "analyze and display statistics from message-v2 format",
handler: async () => {
await bootstrap({ cwd: process.cwd() }, async () => {
const stats: SessionStats = {
totalSessions: 0,
totalMessages: 0,
totalCost: 0,
totalTokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
toolUsage: {},
dateRange: {
earliest: Date.now(),
latest: 0,
},
days: 0,
costPerDay: 0,
}
const sessionMap = new Map<string, number>()
try {
for await (const messagePath of Storage.list("session/message")) {
try {
const message = await Storage.readJSON<MessageV2.Info>(messagePath)
if (!message.parts.find((part) => part.type === "step-finish")) continue
stats.totalMessages++
const sessionId = message.sessionID
sessionMap.set(sessionId, (sessionMap.get(sessionId) || 0) + 1)
if (message.time.created < stats.dateRange.earliest) {
stats.dateRange.earliest = message.time.created
}
if (message.time.created > stats.dateRange.latest) {
stats.dateRange.latest = message.time.created
}
if (message.role === "assistant") {
stats.totalCost += message.cost
stats.totalTokens.input += message.tokens.input
stats.totalTokens.output += message.tokens.output
stats.totalTokens.reasoning += message.tokens.reasoning
stats.totalTokens.cache.read += message.tokens.cache.read
stats.totalTokens.cache.write += message.tokens.cache.write
for (const part of message.parts) {
if (part.type === "tool") {
stats.toolUsage[part.tool] = (stats.toolUsage[part.tool] || 0) + 1
}
}
}
} catch (e) {
continue
}
}
} catch (e) {
console.error("Failed to read storage:", e)
return
}
stats.totalSessions = sessionMap.size
if (stats.dateRange.latest > 0) {
const daysDiff = (stats.dateRange.latest - stats.dateRange.earliest) / (1000 * 60 * 60 * 24)
stats.days = Math.max(1, Math.ceil(daysDiff))
stats.costPerDay = stats.totalCost / stats.days
}
displayStats(stats)
})
},
})
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()
}
+7
View File
@@ -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()
+43 -10
View File
@@ -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"
@@ -70,6 +70,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"),
@@ -174,7 +175,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 +199,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({
+1 -1
View File
@@ -46,7 +46,7 @@ export namespace FileWatcher {
)
export function init() {
if (Flag.OPENCODE_DISABLE_WATCHER) return
if (Flag.OPENCODE_DISABLE_WATCHER || true) return
state()
}
}
+11 -1
View File
@@ -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 = "1"
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)
}
+2
View File
@@ -14,6 +14,7 @@ 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"
const cancel = new AbortController()
@@ -72,6 +73,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")
+1 -1
View File
@@ -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,
+1
View File
@@ -448,6 +448,7 @@ export namespace Server {
z.object({
providerID: z.string(),
modelID: z.string(),
mode: z.string(),
parts: MessageV2.UserPart.array(),
}),
),
+14 -7
View File
@@ -42,6 +42,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"),
@@ -319,15 +321,13 @@ export namespace Session {
}
const previous = msgs.at(-1) as MessageV2.Assistant
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
// auto summarize if too long
if (previous) {
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,
@@ -350,7 +350,9 @@ export namespace Session {
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") {
@@ -414,7 +416,7 @@ export namespace Session {
return [
{
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,
},
{
@@ -580,7 +582,7 @@ export namespace Session {
const result = 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,
@@ -719,6 +721,11 @@ export namespace Session {
const usage = getUsage(model.info, value.usage, value.providerMetadata)
next.cost += usage.cost
next.tokens = usage.tokens
next.parts.push({
type: "step-finish",
tokens: usage.tokens,
cost: usage.cost,
})
break
case "text-start":
+25 -4
View File
@@ -116,6 +116,25 @@ export namespace MessageV2 {
})
export type StepStartPart = z.infer<typeof StepStartPart>
export const StepFinishPart = z
.object({
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(),
@@ -137,9 +156,11 @@ export namespace MessageV2 {
})
export type User = z.infer<typeof User>
export const AssistantPart = z.discriminatedUnion("type", [TextPart, ToolPart, StepStartPart]).openapi({
ref: "AssistantMessagePart",
})
export const AssistantPart = z
.discriminatedUnion("type", [TextPart, ToolPart, StepStartPart, StepFinishPart])
.openapi({
ref: "AssistantMessagePart",
})
export type AssistantPart = z.infer<typeof AssistantPart>
export const Assistant = Base.extend({
@@ -164,8 +185,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(),
+2 -1
View File
@@ -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 -1
View File
@@ -6,4 +6,4 @@
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
export {}
+25 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+14
View File
@@ -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
}
+143
View File
@@ -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
}
+25
View File
@@ -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
}
+136
View File
@@ -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
}
+7
View File
@@ -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
+18
View File
@@ -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
}
+6
View File
@@ -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
+196
View File
@@ -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
}
+17
View File
@@ -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
}
+25
View File
@@ -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)
}
}
}
+620
View File
@@ -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()
}
+271
View File
@@ -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,
}),
}
}
+9
View File
@@ -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{}
+27
View File
@@ -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")
}
}
+18
View File
@@ -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
)
+19
View File
@@ -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=
+45
View File
@@ -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
}
+574
View File
@@ -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",
}
+880
View File
@@ -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:])
}
}
}
+353
View File
@@ -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
}
+37
View File
@@ -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
}
+14
View File
@@ -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
}
+292
View File
@@ -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
}
+481
View File
@@ -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
+47
View File
@@ -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)
}
}
+13
View File
@@ -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{}
+389
View File
@@ -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
}
+54
View File
@@ -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())
}
+277
View File
@@ -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
}
+47
View File
@@ -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
+112 -26
View File
@@ -22,20 +22,26 @@ import (
)
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 []opencode.MessageUnion
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
IntitialMode *string
compactCancel context.CancelFunc
IsLeaderSequence bool
}
type SessionSelectedMsg = *opencode.Session
@@ -64,9 +70,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 +95,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 +149,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{},
Commands: commands.LoadFromConfig(configInfo),
InitialModel: model,
InitialPrompt: prompt,
InitialModel: initialModel,
InitialPrompt: initialPrompt,
IntitialMode: initialMode,
}
return app, nil
@@ -162,6 +196,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 +271,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 {
@@ -322,10 +403,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)
@@ -417,6 +502,7 @@ func (a *App) SendChatMessage(
Parts: opencode.F(parts),
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)
@@ -79,7 +79,9 @@ func initialize() error {
}
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 +140,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 +171,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",
@@ -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).
@@ -423,9 +423,11 @@ func renderToolDetails(
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)
// 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)
}
@@ -489,8 +491,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,6 +500,41 @@ 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,
@@ -547,8 +582,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)
@@ -1,6 +1,7 @@
package chat
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/v2/viewport"
@@ -371,11 +372,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.(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 +448,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 {
@@ -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,
}
}
@@ -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
+15 -6
View File
@@ -16,18 +16,27 @@ type ModelUsage struct {
LastUsed time.Time `toml:"last_used"`
}
type ModeModel struct {
ProviderID string `toml:"provider_id"`
ModelID string `toml:"model_id"`
}
type State struct {
Theme string `toml:"theme"`
Provider string `toml:"provider"`
Model string `toml:"model"`
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
Theme string `toml:"theme"`
ModeModel map[string]ModeModel `toml:"mode_model"`
Provider string `toml:"provider"`
Model string `toml:"model"`
Mode string `toml:"mode"`
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
}
func NewState() *State {
return &State{
Theme: "opencode",
Mode: "build",
ModeModel: make(map[string]ModeModel),
RecentlyUsedModels: make([]ModelUsage, 0),
}
}
+29 -67
View File
@@ -23,6 +23,7 @@ import (
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -69,17 +70,16 @@ type appModel struct {
showCompletionDialog bool
fileCompletionActive bool
leaderBinding *key.Binding
isLeaderSequence bool
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
exitKeyState ExitKeyState
lastScroll time.Time
messagesRight bool
fileViewer fileviewer.Model
lastMouse tea.Mouse
fileViewerStart int
fileViewerEnd int
fileViewerHit bool
// isLeaderSequence bool
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
exitKeyState ExitKeyState
messagesRight bool
fileViewer fileviewer.Model
lastMouse tea.Mouse
fileViewerStart int
fileViewerEnd int
fileViewerHit bool
}
func (a appModel) Init() tea.Cmd {
@@ -106,44 +106,6 @@ func (a appModel) Init() tea.Cmd {
return tea.Batch(cmds...)
}
var BUGGED_SCROLL_KEYS = map[string]bool{
"0": true,
"1": true,
"2": true,
"3": true,
"4": true,
"5": true,
"6": true,
"7": true,
"8": true,
"9": true,
"M": true,
"m": true,
"[": true,
";": true,
"<": true,
}
func isScrollRelatedInput(keyString string) bool {
if len(keyString) == 0 {
return false
}
for _, char := range keyString {
charStr := string(char)
if !BUGGED_SCROLL_KEYS[charStr] {
return false
}
}
if len(keyString) > 3 &&
(keyString[len(keyString)-1] == 'M' || keyString[len(keyString)-1] == 'm') {
return true
}
return len(keyString) > 1
}
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
@@ -152,10 +114,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
keyString := msg.String()
if time.Since(a.lastScroll) < time.Millisecond*100 && (BUGGED_SCROLL_KEYS[keyString] || isScrollRelatedInput(keyString)) {
return a, nil
}
// 1. Handle active modal
if a.modal != nil {
switch keyString {
@@ -183,9 +141,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// 2. Check for commands that require leader
if a.isLeaderSequence {
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
a.isLeaderSequence = false
if a.app.IsLeaderSequence {
matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
a.app.IsLeaderSequence = false
if len(matches) > 0 {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}
@@ -260,21 +218,21 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 5. Check for leader key activation
if a.leaderBinding != nil &&
!a.isLeaderSequence &&
!a.app.IsLeaderSequence &&
key.Matches(msg, *a.leaderBinding) {
a.isLeaderSequence = true
a.app.IsLeaderSequence = true
return a, nil
}
// 6 Handle input clear command
inputClearCommand := a.app.Commands[commands.InputClearCommand]
if inputClearCommand.Matches(msg, a.isLeaderSequence) && a.editor.Length() > 0 {
if inputClearCommand.Matches(msg, a.app.IsLeaderSequence) && a.editor.Length() > 0 {
return a, util.CmdHandler(commands.ExecuteCommandMsg(inputClearCommand))
}
// 7. Handle interrupt key debounce for session interrupt
interruptCommand := a.app.Commands[commands.SessionInterruptCommand]
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() {
if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() {
switch a.interruptKeyState {
case InterruptKeyIdle:
// First interrupt key press - start debounce timer
@@ -293,7 +251,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 8. Handle exit key debounce for app exit when using non-leader command
exitCommand := a.app.Commands[commands.AppExitCommand]
if exitCommand.Matches(msg, a.isLeaderSequence) {
if exitCommand.Matches(msg, a.app.IsLeaderSequence) {
switch a.exitKeyState {
case ExitKeyIdle:
// First exit key press - start debounce timer
@@ -311,10 +269,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// 9. Check again for commands that don't require leader (excluding interrupt when busy and exit when in debounce)
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
if len(matches) > 0 {
// Skip interrupt key if we're in debounce mode and app is busy
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
return a, nil
}
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
@@ -325,7 +283,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editor = updatedEditor.(chat.EditorComponent)
return a, cmd
case tea.MouseWheelMsg:
a.lastScroll = time.Now()
if a.modal != nil {
return a, nil
}
@@ -524,8 +481,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
a.app.State.Provider = msg.Provider.ID
a.app.State.Model = msg.Model.ID
a.app.State.ModeModel[a.app.Mode.Name] = config.ModeModel{
ProviderID: msg.Provider.ID,
ModelID: msg.Model.ID,
}
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
a.app.SaveState()
case dialog.ThemeSelectedMsg:
@@ -823,6 +782,10 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
case commands.AppHelpCommand:
helpDialog := dialog.NewHelpDialog(a.app)
a.modal = helpDialog
case commands.SwitchModeCommand:
updated, cmd := a.app.SwitchMode()
a.app = updated
cmds = append(cmds, cmd)
case commands.EditorOpenCommand:
if a.app.IsBusy() {
// status.Warn("Agent is working, please wait...")
@@ -1060,7 +1023,6 @@ func NewModel(app *app.App) tea.Model {
fileProvider: fileProvider,
symbolsProvider: symbolsProvider,
leaderBinding: leaderBinding,
isLeaderSequence: false,
showCompletionDialog: false,
fileCompletionActive: false,
toastManager: toast.NewToastManager(),
+4 -4
View File
@@ -1,4 +1,4 @@
configured_endpoints: 21
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-879570c29c56e0a73a0624a84662b7f7c319a3c790c78ec6ac4cf62a7b1a5bd0.yml
openapi_spec_hash: 2432e2dfed22193a0c6b3dfe0f82ec7d
config_hash: 53e3aeb355f3b2e0d10985d6d7635a7e
configured_endpoints: 22
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-eb25bb3673f94d0e98a7036e2a2b0ed7ad63d1598665f2d5e091ec0835273798.yml
openapi_spec_hash: 62f6a8a06aaa4f4ae13e85d56652724f
config_hash: 589ec6a935a43a3c49a325ece86cbda2
+2
View File
@@ -20,12 +20,14 @@ Response Types:
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#App">App</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#LogLevel">LogLevel</a>
- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Mode">Mode</a>
Methods:
- <code title="get /app">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Get">Get</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#App">App</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /app/init">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Init">Init</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="post /log">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Log">Log</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppLogParams">AppLogParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
- <code title="get /mode">client.App.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AppService.Modes">Modes</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Mode">Mode</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
# Find
+56
View File
@@ -55,6 +55,14 @@ func (r *AppService) Log(ctx context.Context, body AppLogParams, opts ...option.
return
}
// List all modes
func (r *AppService) Modes(ctx context.Context, opts ...option.RequestOption) (res *[]Mode, err error) {
opts = append(r.Options[:], opts...)
path := "mode"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
return
}
type App struct {
Git bool `json:"git,required"`
Hostname string `json:"hostname,required"`
@@ -149,6 +157,54 @@ func (r LogLevel) IsKnown() bool {
return false
}
type Mode struct {
Name string `json:"name,required"`
Tools map[string]bool `json:"tools,required"`
Model ModeModel `json:"model"`
Prompt string `json:"prompt"`
JSON modeJSON `json:"-"`
}
// modeJSON contains the JSON metadata for the struct [Mode]
type modeJSON struct {
Name apijson.Field
Tools apijson.Field
Model apijson.Field
Prompt apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *Mode) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r modeJSON) RawJSON() string {
return r.raw
}
type ModeModel struct {
ModelID string `json:"modelID,required"`
ProviderID string `json:"providerID,required"`
JSON modeModelJSON `json:"-"`
}
// modeModelJSON contains the JSON metadata for the struct [ModeModel]
type modeModelJSON struct {
ModelID apijson.Field
ProviderID apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ModeModel) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r modeModelJSON) RawJSON() string {
return r.raw
}
type AppLogParams struct {
// Log level
Level param.Field[AppLogParamsLevel] `json:"level,required"`
+22
View File
@@ -85,3 +85,25 @@ func TestAppLogWithOptionalParams(t *testing.T) {
t.Fatalf("err should be nil: %s", err.Error())
}
}
func TestAppModes(t *testing.T) {
t.Skip("skipped: tests are disabled for the time being")
baseURL := "http://localhost:4010"
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
baseURL = envURL
}
if !testutil.CheckTestServer(t, baseURL) {
return
}
client := opencode.NewClient(
option.WithBaseURL(baseURL),
)
_, err := client.App.Modes(context.TODO())
if err != nil {
var apierr *opencode.Error
if errors.As(err, &apierr) {
t.Log(string(apierr.DumpRequest(true)))
}
t.Fatalf("err should be nil: %s", err.Error())
}
}
+77 -1
View File
@@ -65,7 +65,8 @@ type Config struct {
// Minimum log level to write to log files
LogLevel LogLevel `json:"log_level"`
// MCP (Model Context Protocol) server configurations
Mcp map[string]ConfigMcp `json:"mcp"`
Mcp map[string]ConfigMcp `json:"mcp"`
Mode ConfigMode `json:"mode"`
// Model to use in the format of provider/model, eg anthropic/claude-2
Model string `json:"model"`
// Custom provider configurations and model overrides
@@ -86,6 +87,7 @@ type configJSON struct {
Keybinds apijson.Field
LogLevel apijson.Field
Mcp apijson.Field
Mode apijson.Field
Model apijson.Field
Provider apijson.Field
Theme apijson.Field
@@ -276,6 +278,77 @@ func (r ConfigMcpType) IsKnown() bool {
return false
}
type ConfigMode struct {
Build ConfigModeBuild `json:"build"`
Plan ConfigModePlan `json:"plan"`
ExtraFields map[string]ConfigMode `json:"-,extras"`
JSON configModeJSON `json:"-"`
}
// configModeJSON contains the JSON metadata for the struct [ConfigMode]
type configModeJSON struct {
Build apijson.Field
Plan apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigMode) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configModeJSON) RawJSON() string {
return r.raw
}
type ConfigModeBuild struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Tools map[string]bool `json:"tools"`
JSON configModeBuildJSON `json:"-"`
}
// configModeBuildJSON contains the JSON metadata for the struct [ConfigModeBuild]
type configModeBuildJSON struct {
Model apijson.Field
Prompt apijson.Field
Tools apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigModeBuild) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configModeBuildJSON) RawJSON() string {
return r.raw
}
type ConfigModePlan struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Tools map[string]bool `json:"tools"`
JSON configModePlanJSON `json:"-"`
}
// configModePlanJSON contains the JSON metadata for the struct [ConfigModePlan]
type configModePlanJSON struct {
Model apijson.Field
Prompt apijson.Field
Tools apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigModePlan) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configModePlanJSON) RawJSON() string {
return r.raw
}
type ConfigProvider struct {
Models map[string]ConfigProviderModel `json:"models,required"`
ID string `json:"id"`
@@ -460,6 +533,8 @@ type Keybinds struct {
SessionShare string `json:"session_share,required"`
// Unshare current session
SessionUnshare string `json:"session_unshare,required"`
// Switch mode
SwitchMode string `json:"switch_mode,required"`
// List available themes
ThemeList string `json:"theme_list,required"`
// Toggle tool details
@@ -500,6 +575,7 @@ type keybindsJSON struct {
SessionNew apijson.Field
SessionShare apijson.Field
SessionUnshare apijson.Field
SwitchMode apijson.Field
ThemeList apijson.Field
ToolDetails apijson.Field
raw string
+142 -28
View File
@@ -438,12 +438,17 @@ func (r AssistantMessageErrorName) IsKnown() bool {
type AssistantMessagePart struct {
Type AssistantMessagePartType `json:"type,required"`
ID string `json:"id"`
Cost float64 `json:"cost"`
// This field can have the runtime type of [ToolPartState].
State interface{} `json:"state"`
Text string `json:"text"`
Tool string `json:"tool"`
JSON assistantMessagePartJSON `json:"-"`
union AssistantMessagePartUnion
State interface{} `json:"state"`
Synthetic bool `json:"synthetic"`
Text string `json:"text"`
// This field can have the runtime type of
// [AssistantMessagePartStepFinishPartTokens].
Tokens interface{} `json:"tokens"`
Tool string `json:"tool"`
JSON assistantMessagePartJSON `json:"-"`
union AssistantMessagePartUnion
}
// assistantMessagePartJSON contains the JSON metadata for the struct
@@ -451,8 +456,11 @@ type AssistantMessagePart struct {
type assistantMessagePartJSON struct {
Type apijson.Field
ID apijson.Field
Cost apijson.Field
State apijson.Field
Synthetic apijson.Field
Text apijson.Field
Tokens apijson.Field
Tool apijson.Field
raw string
ExtraFields map[string]apijson.Field
@@ -474,12 +482,14 @@ func (r *AssistantMessagePart) UnmarshalJSON(data []byte) (err error) {
// AsUnion returns a [AssistantMessagePartUnion] interface which you can cast to
// the specific types for more type safety.
//
// Possible runtime types of the union are [TextPart], [ToolPart], [StepStartPart].
// Possible runtime types of the union are [TextPart], [ToolPart], [StepStartPart],
// [AssistantMessagePartStepFinishPart].
func (r AssistantMessagePart) AsUnion() AssistantMessagePartUnion {
return r.union
}
// Union satisfied by [TextPart], [ToolPart] or [StepStartPart].
// Union satisfied by [TextPart], [ToolPart], [StepStartPart] or
// [AssistantMessagePartStepFinishPart].
type AssistantMessagePartUnion interface {
implementsAssistantMessagePart()
}
@@ -503,20 +513,117 @@ func init() {
Type: reflect.TypeOf(StepStartPart{}),
DiscriminatorValue: "step-start",
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(AssistantMessagePartStepFinishPart{}),
DiscriminatorValue: "step-finish",
},
)
}
type AssistantMessagePartStepFinishPart struct {
Cost float64 `json:"cost,required"`
Tokens AssistantMessagePartStepFinishPartTokens `json:"tokens,required"`
Type AssistantMessagePartStepFinishPartType `json:"type,required"`
JSON assistantMessagePartStepFinishPartJSON `json:"-"`
}
// assistantMessagePartStepFinishPartJSON contains the JSON metadata for the struct
// [AssistantMessagePartStepFinishPart]
type assistantMessagePartStepFinishPartJSON struct {
Cost apijson.Field
Tokens apijson.Field
Type apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *AssistantMessagePartStepFinishPart) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r assistantMessagePartStepFinishPartJSON) RawJSON() string {
return r.raw
}
func (r AssistantMessagePartStepFinishPart) implementsAssistantMessagePart() {}
type AssistantMessagePartStepFinishPartTokens struct {
Cache AssistantMessagePartStepFinishPartTokensCache `json:"cache,required"`
Input float64 `json:"input,required"`
Output float64 `json:"output,required"`
Reasoning float64 `json:"reasoning,required"`
JSON assistantMessagePartStepFinishPartTokensJSON `json:"-"`
}
// assistantMessagePartStepFinishPartTokensJSON contains the JSON metadata for the
// struct [AssistantMessagePartStepFinishPartTokens]
type assistantMessagePartStepFinishPartTokensJSON struct {
Cache apijson.Field
Input apijson.Field
Output apijson.Field
Reasoning apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *AssistantMessagePartStepFinishPartTokens) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r assistantMessagePartStepFinishPartTokensJSON) RawJSON() string {
return r.raw
}
type AssistantMessagePartStepFinishPartTokensCache struct {
Read float64 `json:"read,required"`
Write float64 `json:"write,required"`
JSON assistantMessagePartStepFinishPartTokensCacheJSON `json:"-"`
}
// assistantMessagePartStepFinishPartTokensCacheJSON contains the JSON metadata for
// the struct [AssistantMessagePartStepFinishPartTokensCache]
type assistantMessagePartStepFinishPartTokensCacheJSON struct {
Read apijson.Field
Write apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *AssistantMessagePartStepFinishPartTokensCache) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r assistantMessagePartStepFinishPartTokensCacheJSON) RawJSON() string {
return r.raw
}
type AssistantMessagePartStepFinishPartType string
const (
AssistantMessagePartStepFinishPartTypeStepFinish AssistantMessagePartStepFinishPartType = "step-finish"
)
func (r AssistantMessagePartStepFinishPartType) IsKnown() bool {
switch r {
case AssistantMessagePartStepFinishPartTypeStepFinish:
return true
}
return false
}
type AssistantMessagePartType string
const (
AssistantMessagePartTypeText AssistantMessagePartType = "text"
AssistantMessagePartTypeTool AssistantMessagePartType = "tool"
AssistantMessagePartTypeStepStart AssistantMessagePartType = "step-start"
AssistantMessagePartTypeText AssistantMessagePartType = "text"
AssistantMessagePartTypeTool AssistantMessagePartType = "tool"
AssistantMessagePartTypeStepStart AssistantMessagePartType = "step-start"
AssistantMessagePartTypeStepFinish AssistantMessagePartType = "step-finish"
)
func (r AssistantMessagePartType) IsKnown() bool {
switch r {
case AssistantMessagePartTypeText, AssistantMessagePartTypeTool, AssistantMessagePartTypeStepStart:
case AssistantMessagePartTypeText, AssistantMessagePartTypeTool, AssistantMessagePartTypeStepStart, AssistantMessagePartTypeStepFinish:
return true
}
return false
@@ -815,15 +922,17 @@ func (r StepStartPartType) IsKnown() bool {
}
type TextPart struct {
Text string `json:"text,required"`
Type TextPartType `json:"type,required"`
JSON textPartJSON `json:"-"`
Text string `json:"text,required"`
Type TextPartType `json:"type,required"`
Synthetic bool `json:"synthetic"`
JSON textPartJSON `json:"-"`
}
// textPartJSON contains the JSON metadata for the struct [TextPart]
type textPartJSON struct {
Text apijson.Field
Type apijson.Field
Synthetic apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
@@ -855,8 +964,9 @@ func (r TextPartType) IsKnown() bool {
}
type TextPartParam struct {
Text param.Field[string] `json:"text,required"`
Type param.Field[TextPartType] `json:"type,required"`
Text param.Field[string] `json:"text,required"`
Type param.Field[TextPartType] `json:"type,required"`
Synthetic param.Field[bool] `json:"synthetic"`
}
func (r TextPartParam) MarshalJSON() (data []byte, err error) {
@@ -1311,13 +1421,14 @@ func (r userMessageTimeJSON) RawJSON() string {
}
type UserMessagePart struct {
Type UserMessagePartType `json:"type,required"`
Filename string `json:"filename"`
Mime string `json:"mime"`
Text string `json:"text"`
URL string `json:"url"`
JSON userMessagePartJSON `json:"-"`
union UserMessagePartUnion
Type UserMessagePartType `json:"type,required"`
Filename string `json:"filename"`
Mime string `json:"mime"`
Synthetic bool `json:"synthetic"`
Text string `json:"text"`
URL string `json:"url"`
JSON userMessagePartJSON `json:"-"`
union UserMessagePartUnion
}
// userMessagePartJSON contains the JSON metadata for the struct [UserMessagePart]
@@ -1325,6 +1436,7 @@ type userMessagePartJSON struct {
Type apijson.Field
Filename apijson.Field
Mime apijson.Field
Synthetic apijson.Field
Text apijson.Field
URL apijson.Field
raw string
@@ -1390,11 +1502,12 @@ func (r UserMessagePartType) IsKnown() bool {
}
type UserMessagePartParam struct {
Type param.Field[UserMessagePartType] `json:"type,required"`
Filename param.Field[string] `json:"filename"`
Mime param.Field[string] `json:"mime"`
Text param.Field[string] `json:"text"`
URL param.Field[string] `json:"url"`
Type param.Field[UserMessagePartType] `json:"type,required"`
Filename param.Field[string] `json:"filename"`
Mime param.Field[string] `json:"mime"`
Synthetic param.Field[bool] `json:"synthetic"`
Text param.Field[string] `json:"text"`
URL param.Field[string] `json:"url"`
}
func (r UserMessagePartParam) MarshalJSON() (data []byte, err error) {
@@ -1409,6 +1522,7 @@ type UserMessagePartUnionParam interface {
}
type SessionChatParams struct {
Mode param.Field[string] `json:"mode,required"`
ModelID param.Field[string] `json:"modelID,required"`
Parts param.Field[[]UserMessagePartUnionParam] `json:"parts,required"`
ProviderID param.Field[string] `json:"providerID,required"`
+4 -2
View File
@@ -117,10 +117,12 @@ func TestSessionChat(t *testing.T) {
context.TODO(),
"id",
opencode.SessionChatParams{
Mode: opencode.F("mode"),
ModelID: opencode.F("modelID"),
Parts: opencode.F([]opencode.UserMessagePartUnionParam{opencode.TextPartParam{
Text: opencode.F("text"),
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F("text"),
Type: opencode.F(opencode.TextPartTypeText),
Synthetic: opencode.F(true),
}}),
ProviderID: opencode.F("providerID"),
},
+2 -1
View File
@@ -62,8 +62,9 @@ export default defineConfig({
sidebar: [
"docs",
"docs/cli",
"docs/rules",
"docs/share",
"docs/modes",
"docs/rules",
"docs/config",
"docs/models",
"docs/themes",
+1 -1
View File
@@ -321,6 +321,7 @@ export default function Share(props: {
const filteredParts = createMemo(() =>
msg.parts.filter((x, index) => {
if (x.type === "step-start" && index > 0) return false
if (x.type === "text" && x.synthetic === true) return false
if (x.type === "tool" && x.tool === "todoread") return false
if (x.type === "text" && !x.text) return false
if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running"))
@@ -363,7 +364,6 @@ export default function Share(props: {
<div data-section="part" data-part-type="summary">
<div data-section="decoration">
<span data-status={connectionStatus()[0]}></span>
<div></div>
</div>
<div data-section="content">
<p data-section="copy">{getStatusText(connectionStatus())}</p>
@@ -58,3 +58,20 @@ export function createOverflow() {
},
}
}
export function formatDuration(ms: number): string {
const ONE_SECOND = 1000
const ONE_MINUTE = 60 * ONE_SECOND
if (ms >= ONE_MINUTE) {
const minutes = Math.floor(ms / ONE_MINUTE)
return minutes === 1 ? `1min` : `${minutes}mins`
}
if (ms >= ONE_SECOND) {
const seconds = Math.floor(ms / ONE_SECOND)
return `${seconds}s`
}
return `${ms}ms`
}
@@ -0,0 +1,85 @@
.root {
display: contents;
[data-slot="expand-button"] {
flex: 0 0 auto;
padding: 2px 0;
font-size: 0.75rem;
}
[data-slot="body"] {
border: 1px solid var(--sl-color-divider);
border-radius: 0.25rem;
overflow: hidden;
width: 100%;
}
[data-slot="header"] {
position: relative;
border-bottom: 1px solid var(--sl-color-divider);
width: 100%;
height: 1.625rem;
text-align: center;
padding: 0 3.25rem;
> span {
max-width: min(100%, 140ch);
display: inline-block;
white-space: nowrap;
overflow: hidden;
line-height: 1.625rem;
font-size: 0.75rem;
text-overflow: ellipsis;
color: var(--sl-color-text-dimmed);
}
&::before {
content: "";
position: absolute;
pointer-events: none;
top: 8px;
left: 10px;
width: 2rem;
height: 0.5rem;
line-height: 0;
background-color: var(--sl-color-hairline);
mask-image: var(--term-icon);
mask-repeat: no-repeat;
}
}
[data-slot="content"] {
display: flex;
flex-direction: column;
padding: 0.5rem calc(0.5rem + 3px);
pre {
--shiki-dark-bg: var(--sl-color-bg) !important;
background-color: var(--sl-color-bg) !important;
line-height: 1.6;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
span {
white-space: break-spaces;
}
}
}
[data-slot="output"] {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 10;
line-clamp: 10;
overflow: hidden;
}
&[data-expanded] [data-slot="output"] {
display: block;
-webkit-line-clamp: none;
line-clamp: none;
overflow: visible;
}
}
@@ -0,0 +1,67 @@
import style from "./content-bash.module.css"
import { createResource, createSignal } from "solid-js"
import { createOverflow } from "./common"
import { codeToHtml } from "shiki"
interface Props {
command: string
output: string
description?: string
expand?: boolean
}
export function ContentBash(props: Props) {
const [commandHtml] = createResource(
() => props.command,
async (command) => {
return codeToHtml(command || "", {
lang: "bash",
themes: {
light: "github-light",
dark: "github-dark",
},
})
},
)
const [outputHtml] = createResource(
() => props.output,
async (output) => {
return codeToHtml(output || "", {
lang: "console",
themes: {
light: "github-light",
dark: "github-dark",
},
})
},
)
const [expanded, setExpanded] = createSignal(false)
const overflow = createOverflow()
return (
<div class={style.root} data-expanded={expanded() || props.expand === true ? true : undefined}>
<div data-slot="body">
<div data-slot="header">
<span>{props.description}</span>
</div>
<div data-slot="content">
<div innerHTML={commandHtml()} />
<div data-slot="output" ref={overflow.ref} innerHTML={outputHtml()} />
</div>
</div>
{!props.expand && overflow.status && (
<button
type="button"
data-component="text-button"
data-slot="expand-button"
onClick={() => setExpanded((e) => !e)}
>
{expanded() ? "Show less" : "Show more"}
</button>
)}
</div>
)
}
@@ -1,5 +1,4 @@
.root {
max-width: var(--md-tool-width);
border: 1px solid var(--sl-color-divider);
background-color: var(--sl-color-bg-surface);
border-radius: 0.25rem;
@@ -9,6 +8,7 @@
border: none;
background-color: transparent;
padding: 0;
border-radius: 0;
}
pre {
@@ -31,7 +31,7 @@
overflow-x: visible;
min-width: 0;
align-items: stretch;
padding: 0 1rem;
padding: 0 1rem 0 2.2ch;
&[data-diff-type="removed"] {
background-color: var(--sl-color-red-low);
@@ -44,7 +44,7 @@
&::before {
content: "-";
position: absolute;
left: 0.5ch;
left: 0.6ch;
top: 1px;
user-select: none;
color: var(--sl-color-red-high);
@@ -64,7 +64,7 @@
position: absolute;
user-select: none;
color: var(--sl-color-green-high);
left: 0.5ch;
left: 0.6ch;
top: 1px;
}
}
@@ -75,50 +75,95 @@
}
}
.diff > .row:first-child [data-section="cell"]:first-child {
padding-top: 0.5rem;
}
/* .diff > .row:first-child [data-section="cell"]:first-child { */
/* padding-top: 0.5rem; */
/* } */
/**/
/* .diff > .row:last-child [data-section="cell"]:last-child { */
/* padding-bottom: 0.5rem; */
/* } */
/**/
/* [data-section="cell"] { */
/* position: relative; */
/* flex: 1; */
/* display: flex; */
/* flex-direction: column; */
/**/
/* width: 100%; */
/* padding: 0.1875rem 0.5rem 0.1875rem 2.2ch; */
/* margin: 0; */
/**/
/* &[data-display-mobile="true"] { */
/* display: none; */
/* } */
/**/
/* pre { */
/* --shiki-dark-bg: var(--sl-color-bg-surface) !important; */
/* background-color: var(--sl-color-bg-surface) !important; */
/**/
/* white-space: pre-wrap; */
/* word-break: break-word; */
/**/
/* code > span:empty::before { */
/* content: "\00a0"; */
/* white-space: pre; */
/* display: inline-block; */
/* width: 0; */
/* } */
/* } */
/* } */
.diff > .row:last-child [data-section="cell"]:last-child {
padding-bottom: 0.5rem;
}
[data-component="mobile"] {
[data-section="cell"] {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
& > [data-component="diff-block"] > div {
padding: 0 1rem 0 2.2ch;
width: 100%;
padding: 0.1875rem 0.5rem 0.1875rem 2.2ch;
margin: 0;
&[data-diff-type="removed"] {
position: relative;
background-color: var(--sl-color-red-low);
&[data-display-mobile="true"] {
display: none;
}
pre {
--shiki-dark-bg: var(--sl-color-red-low) !important;
background-color: var(--sl-color-red-low) !important;
}
pre {
--shiki-dark-bg: var(--sl-color-bg-surface) !important;
background-color: var(--sl-color-bg-surface) !important;
&::before {
content: "-";
position: absolute;
left: 0.6ch;
top: 1px;
user-select: none;
color: var(--sl-color-red-high);
}
}
white-space: pre-wrap;
word-break: break-word;
&[data-diff-type="added"] {
position: relative;
background-color: var(--sl-color-green-low);
code > span:empty::before {
content: "\00a0";
white-space: pre;
display: inline-block;
width: 0;
pre {
--shiki-dark-bg: var(--sl-color-green-low) !important;
background-color: var(--sl-color-green-low) !important;
}
&::before {
content: "+";
position: absolute;
left: 0.6ch;
top: 1px;
user-select: none;
color: var(--sl-color-green-high);
}
}
}
}
@media (max-width: 40rem) {
[data-slot="desktop"] {
[data-component="desktop"] {
display: none;
}
[data-slot="mobile"] {
[data-component="mobile"] {
display: block;
}
}
@@ -173,12 +173,9 @@ export function ContentDiff(props: Props) {
{mobileRows().map((block) => (
<div data-component="diff-block" data-type={block.type}>
{block.lines.map((line) => (
<ContentCode
code={line}
lang={props.lang}
data-section="cell"
data-diff-type={block.type === "removed" ? "removed" : block.type === "added" ? "added" : ""}
/>
<div data-diff-type={block.type === "removed" ? "removed" : block.type === "added" ? "added" : ""}>
<ContentCode code={line} lang={props.lang} flush />
</div>
))}
</div>
))}
@@ -7,7 +7,6 @@
align-items: flex-start;
gap: 1rem;
align-self: flex-start;
max-width: var(--md-tool-width);
[data-section="content"] {
pre {
@@ -7,7 +7,6 @@
align-items: flex-start;
gap: 1rem;
align-self: flex-start;
max-width: var(--md-tool-width);
&[data-highlight="true"] {
background-color: var(--sl-color-blue-low);
@@ -8,7 +8,6 @@
align-items: flex-start;
gap: 1rem;
align-self: flex-start;
max-width: var(--md-tool-width);
font-size: 0.875rem;
&[data-compact] {
@@ -101,10 +101,12 @@
}
[data-component="content"] {
flex: 1 1 auto;
min-width: 0;
padding: 0 0 0.375rem;
display: flex;
flex-direction: column;
gap: 1rem;
flex-grow: 1;
}
[data-component="spacer"] {
@@ -117,6 +119,24 @@
color: var(--sl-color-text-dimmed);
}
[data-component="user-text"] {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
flex-grow: 1;
max-width: var(--md-tool-width);
}
[data-component="assistant-text"] {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
flex-grow: 1;
max-width: var(--md-tool-width);
}
[data-component="step-start"] {
display: flex;
flex-direction: column;
@@ -137,6 +157,29 @@
}
}
[data-component="attachment"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
padding-bottom: 1rem;
[data-slot="copy"] {
line-height: 18px;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: -0.5px;
color: var(--sl-color-text-secondary);
}
[data-slot="filename"] {
line-height: 1.5;
font-size: 0.875rem;
font-weight: 500;
max-width: var(--md-tool-width);
}
}
[data-component="button-text"] {
cursor: pointer;
appearance: none;
@@ -171,7 +214,31 @@
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
padding-bottom: 1rem;
&[data-tool="bash"] {
max-width: var(--sm-tool-width);
}
&[data-tool="error"] {
max-width: var(--md-tool-width);
}
&[data-tool="read"],
&[data-tool="edit"],
&[data-tool="list"],
&[data-tool="glob"],
&[data-tool="grep"],
&[data-tool="write"],
&[data-tool="webfetch"] {
[data-component="tool-result"] {
max-width: var(--sm-tool-width);
}
}
&[data-tool="edit"] {
[data-component="tool-result"] {
max-width: var(--lg-tool-width);
}
}
}
[data-component="tool-title"] {
@@ -284,68 +351,6 @@
}
}
[data-component="terminal"] {
width: 100%;
max-width: var(--sm-tool-width);
[data-slot="body"] {
display: flex;
flex-direction: column;
border: 1px solid var(--sl-color-divider);
border-radius: 0.25rem;
overflow: hidden;
}
[data-slot="header"] {
position: relative;
border-bottom: 1px solid var(--sl-color-divider);
width: 100%;
height: 1.625rem;
text-align: center;
padding: 0 3.25rem;
> span {
max-width: min(100%, 140ch);
display: inline-block;
white-space: nowrap;
overflow: hidden;
line-height: 1.625rem;
font-size: 0.75rem;
text-overflow: ellipsis;
color: var(--sl-color-text-dimmed);
}
&::before {
content: "";
position: absolute;
pointer-events: none;
top: 8px;
left: 10px;
width: 2rem;
height: 0.5rem;
line-height: 0;
background-color: var(--sl-color-hairline);
mask-image: var(--term-icon);
mask-repeat: no-repeat;
}
}
[data-slot="content"] {
display: flex;
flex-direction: column;
padding: 0.5rem calc(0.5rem + 3px);
pre {
--shiki-dark-bg: var(--sl-color-bg) !important;
background-color: var(--sl-color-bg) !important;
line-height: 1.6;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
}
}
}
[data-component="tool-args"] {
display: inline-grid;
align-items: center;
+133 -129
View File
@@ -1,20 +1,12 @@
import map from "lang-map"
import { DateTime } from "luxon"
import {
For,
Show,
Match,
Switch,
type JSX,
createMemo,
createSignal,
type ParentProps
} from "solid-js"
import { For, Show, Match, Switch, type JSX, createMemo, createSignal, type ParentProps } from "solid-js"
import {
IconHashtag,
IconSparkles,
IconGlobeAlt,
IconDocument,
IconPaperClip,
IconQueueList,
IconUserCircle,
IconCommandLine,
@@ -28,16 +20,20 @@ import {
IconDocumentMagnifyingGlass,
} from "../icons"
import { IconMeta, IconOpenAI, IconGemini, IconAnthropic } from "../icons/custom"
import { formatDuration } from "../share/common"
import { ContentCode } from "./content-code"
import { ContentDiff } from "./content-diff"
import { ContentText } from "./content-text"
import { ContentError } from "./content-error"
import { ContentMarkdown } from "./content-markdown"
import { ContentBash } from "./content-bash"
import type { MessageV2 } from "opencode/session/message-v2"
import type { Diagnostic } from "vscode-languageserver-types"
import styles from "./part.module.css"
const MIN_DURATION = 2
export interface PartProps {
index: number
message: MessageV2.Info
@@ -80,10 +76,12 @@ export function Part(props: PartProps) {
<IconUserCircle width={18} height={18} />
</Match>
<Match when={props.message.role === "user" && props.part.type === "file"}>
<IconDocument width={18} height={18} />
<IconPaperClip width={18} height={18} />
</Match>
<Match when={props.part.type === "step-start" && props.message.role === "assistant" && props.message.modelID}>
{model => <ProviderIcon model={model()} size={18} />}
<Match
when={props.part.type === "step-start" && props.message.role === "assistant" && props.message.modelID}
>
{(model) => <ProviderIcon model={model()} size={18} />}
</Match>
<Match when={props.part.type === "tool" && props.part.tool === "todowrite"}>
<IconQueueList width={18} height={18} />
@@ -131,13 +129,13 @@ export function Part(props: PartProps) {
</div>
<div data-component="content">
{props.message.role === "user" && props.part.type === "text" && (
<>
<div data-component="user-text">
<ContentText text={props.part.text} expand={props.last} />
<Spacer />
</>
</div>
)}
{props.message.role === "assistant" && props.part.type === "text" && (
<>
<div data-component="assistant-text">
<ContentMarkdown expand={props.last} text={props.part.text} />
{props.last && props.message.role === "assistant" && props.message.time.completed && (
<Footer
@@ -149,14 +147,12 @@ export function Part(props: PartProps) {
</Footer>
)}
<Spacer />
</>
</div>
)}
{props.message.role === "user" && props.part.type === "file" && (
<div data-component="tool-title">
<span data-slot="name">Read</span>
<span data-slot="target" title={props.part.filename}>
{props.part.filename}
</span>
<div data-component="attachment">
<div data-slot="copy">Attachment</div>
<div data-slot="filename">{props.part.filename}</div>
</div>
)}
{props.part.type === "step-start" && props.message.role === "assistant" && (
@@ -165,101 +161,107 @@ export function Part(props: PartProps) {
<div data-slot="model">{props.message.modelID}</div>
</div>
)}
{props.part.type === "tool" &&
props.part.state.status === "error" && (
<div data-component="tool">
<ContentError>
{formatErrorString(props.part.state.error)}
</ContentError>
</div>
)}
{props.part.type === "tool" && props.part.state.status === "error" && (
<div data-component="tool" data-tool="error">
<ContentError>{formatErrorString(props.part.state.error)}</ContentError>
<Spacer />
</div>
)}
{props.part.type === "tool" &&
props.part.state.status === "completed" &&
props.message.role === "assistant" && (
<div data-component="tool" data-tool={props.part.tool}>
<Switch>
<Match when={props.part.tool === "grep"}>
<GrepTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "glob"}>
<GlobTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "list"}>
<ListTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "read"}>
<ReadTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "write"}>
<WriteTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "edit"}>
<EditTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "bash"}>
<BashTool
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
message={props.message}
/>
</Match>
<Match when={props.part.tool === "todowrite"}>
<TodoWriteTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "webfetch"}>
<WebFetchTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={true}>
<FallbackTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
</Switch>
</div>
<>
<div data-component="tool" data-tool={props.part.tool}>
<Switch>
<Match when={props.part.tool === "grep"}>
<GrepTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "glob"}>
<GlobTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "list"}>
<ListTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "read"}>
<ReadTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "write"}>
<WriteTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "edit"}>
<EditTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "bash"}>
<BashTool
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
message={props.message}
/>
</Match>
<Match when={props.part.tool === "todowrite"}>
<TodoWriteTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={props.part.tool === "webfetch"}>
<WebFetchTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
<Match when={true}>
<FallbackTool
message={props.message}
id={props.part.id}
tool={props.part.tool}
state={props.part.state}
/>
</Match>
</Switch>
</div>
<ToolFooter
time={
DateTime.fromMillis(props.message.time.completed || 0)
.diff(DateTime.fromMillis(props.message.time.created || 0))
.toMillis()
} />
</>
)}
</div>
</div>
@@ -515,7 +517,7 @@ export function WriteTool(props: ToolProps) {
</span>
</div>
<Show when={diagnostics().length > 0}>
<div data-component="error">{diagnostics()}</div>
<ContentError>{diagnostics()}</ContentError>
</Show>
<div data-component="tool-result">
<Switch>
@@ -558,7 +560,7 @@ export function EditTool(props: ToolProps) {
</Switch>
</div>
<Show when={diagnostics().length > 0}>
<div data-component="error">{diagnostics()}</div>
<ContentError>{diagnostics()}</ContentError>
</Show>
</>
)
@@ -566,19 +568,11 @@ export function EditTool(props: ToolProps) {
export function BashTool(props: ToolProps) {
return (
<>
<div data-component="terminal" data-size="sm">
<div data-slot="body">
<div data-slot="header">
<span>{props.state.metadata.description}</span>
</div>
<div data-slot="content">
<ContentCode flush lang="bash" code={props.state.input.command} />
<ContentCode flush lang="console" code={props.state.metadata?.stdout || ""} />
</div>
</div>
</div>
</>
<ContentBash
command={props.state.input.command}
output={props.state.metadata?.stdout || ""}
description={props.state.metadata.description}
/>
)
}
@@ -641,6 +635,16 @@ function Footer(props: ParentProps<{ title: string }>) {
)
}
function ToolFooter(props: { time: number }) {
return props.time > MIN_DURATION ? (
<Footer title={`${props.time}ms`}>
{formatDuration(props.time)}
</Footer>
) : (
<Spacer />
)
}
export function FallbackTool(props: ToolProps) {
return (
<>
@@ -129,3 +129,4 @@ The opencode CLI takes the following flags.
| `--print-logs` | | Print logs to stderr |
| `--prompt` | `-p` | Prompt to use |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--mode` | | Mode to use |
+97 -12
View File
@@ -3,12 +3,9 @@ title: Config
description: Using the opencode JSON config.
---
You can configure opencode using a JSON config file that can be placed in:
You can configure opencode using a JSON config file.
- Globally under `~/.config/opencode/config.json`.
- Your project root under `opencode.json`. This is safe to be checked into Git and uses the same schema as the global one.
```json
```json title="opencode config"
{
"$schema": "https://opencode.ai/config.json",
"theme": "opencode",
@@ -18,10 +15,24 @@ You can configure opencode using a JSON config file that can be placed in:
}
```
In most cases, you'll want to use the global config for things like themes, providers, or keybinds. Having a config per project is useful if you are using different providers for your company.
This can be used to configure opencode globally or for a specific project.
---
### Global
Place your global opencode config in `~/.config/opencode/opencode.json`. You'll want to use the global config for things like themes, providers, or keybinds.
---
### Per project
You can also add a `opencode.json` in your project. This is useful for configuring providers or modes specific to your project.
When opencode starts up, it looks for a config file in the current directory or traverse up to the nearest Git directory.
This is also safe to be checked into Git and uses the same schema as the global one.
---
## Schema
@@ -32,6 +43,25 @@ Your editor should be able to validate and autocomplete based on the schema.
---
### Modes
opencode comes with two built-in modes: _build_, the default with all tools enabled. And _plan_, restricted mode with file modification tools disabled. You can override these built-in modes or define your own custom modes with the `mode` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"mode": {
"build": { },
"plan": { },
"my-custom-mode": { }
}
}
```
[Learn more here](/docs/modes).
---
### Models
You can configure the providers and models you want to use in your opencode config through the `provider` and `model` options.
@@ -44,7 +74,7 @@ You can configure the providers and models you want to use in your opencode conf
}
```
[Learn more here](/docs/models).
You can also configure [local models](/docs/models#local). [Learn more](/docs/models).
---
@@ -81,12 +111,12 @@ You can configure the minimum log level through the `log_level` option.
With the following options:
| Level | Description |
| ----- | ----------- |
| Level | Description |
| ------- | ---------------------------------------- |
| `DEBUG` | All messages including debug information |
| `INFO` | Informational messages and above |
| `WARN` | Warnings and errors only |
| `ERROR` | Errors only |
| `INFO` | Informational messages and above |
| `WARN` | Warnings and errors only |
| `ERROR` | Errors only |
The **default** log level is `INFO`. If you are running opencode locally in
development mode it's set to `DEBUG`.
@@ -139,3 +169,58 @@ The `disabled_providers` option accepts an array of provider IDs. When a provide
- It won't be loaded even if environment variables are set
- It won't be loaded even if API keys are configured through `opencode auth login`
- The provider's models won't appear in the model selection list
---
## Variables
You can use variable substitution in your config files to reference environment variables and file contents.
---
### Env vars
Use `{env:VARIABLE_NAME}` to substitute environment variables:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"model": "{env:OPENCODE_MODEL}",
"provider": {
"anthropic": {
"api_key": "{env:ANTHROPIC_API_KEY}"
}
}
}
```
If the environment variable is not set, it will be replaced with an empty string.
---
### Files
Use `{file:path/to/file}` to substitute the contents of a file:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["{file:./custom-instructions.md}"],
"provider": {
"openai": {
"api_key": "{file:~/.secrets/openai-key}"
}
}
}
```
File paths can be:
- Relative to the config file directory
- Or absolute paths starting with `/` or `~`
These are useful for:
- Keeping sensitive data like API keys in separate files.
- Including large instruction files without cluttering your config.
- Sharing common configuration snippets across multiple config files.
+53 -4
View File
@@ -11,10 +11,14 @@ opencode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.
You can configure providers in your opencode config under the `provider` section.
---
### Defaults
Most popular providers are preloaded by default. If you've added the credentials for a provider through `opencode auth login`, they'll be available when you start opencode.
---
### Custom
You can add custom providers by specifying the npm package for the provider and the models you want to use.
@@ -24,11 +28,9 @@ You can add custom providers by specifying the npm package for the provider and
"$schema": "https://opencode.ai/config.json",
"provider": {
"openrouter": {
"npm": "@openrouter/ai-sdk-provider",
"name": "OpenRouter",
"options": {},
"models": {
"anthropic/claude-3.5-sonnet": {
"weirdo/some-weird-model": {
"name": "Claude 3.5 Sonnet"
}
}
@@ -37,9 +39,44 @@ You can add custom providers by specifying the npm package for the provider and
}
```
---
### Local
To configure a local model, specify the npm package to use and the `baseURL`.
You can configure local model like ones served through LM Studio or Ollama. To
do so, you'll need to specify a couple of things.
Here's an example of configuring a local model from LM Studio:
```json title="opencode.json" {4-15}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"lmstudio": {
"npm": "@ai-sdk/openai-compatible",
"name": "LM Studio (local)",
"options": {
"baseURL": "http://127.0.0.1:1234/v1"
},
"models": {
"google/gemma-3n-e4b": {
"name": "Gemma 3n-e4b (local)"
}
}
}
}
}
```
In this example:
- `lmstudio` is the custom provider ID. We'll use this later.
- `npm` specifies the package to use for this provider. Here, `@ai-sdk/openai-compatible` is used for any OpenAI-compatible API.
- `name` is the display name for the provider in the UI.
- `options.baseURL` is the endpoint for the local server.
- `models` is a map of model IDs to their configurations. The model name will be displayed in the model selection list.
Similarly, to configure a local model from Ollama:
```json title="opencode.json" {5,7}
{
@@ -58,6 +95,18 @@ To configure a local model, specify the npm package to use and the `baseURL`.
}
```
To set one of these as the default model, you can set the `model` key at the
root.
```json title="opencode.json" {3}
{
"$schema": "https://opencode.ai/config.json",
"model": "lmstudio/google/gemma-3n-e4b"
}
```
Here the full ID is `provider_id/model_id`, where `provider_id` is the key in the `provider` list we set above and `model_id` is the key from the `provider.models` list.
---
## Select a model
@@ -0,0 +1,199 @@
---
title: Modes
description: Different modes for different use cases.
---
Modes in opencode allow you to customize the behavior, tools, and prompts for different use cases.
It comes with two built-in modes: **build** and **plan**. You can customize
these or configure your own through the opencode config.
:::tip
Use the plan mode to analyze code and review suggestions without making any code
changes.
:::
You can switch between modes during a session or configure them in your config file.
---
## Built-in
opencode comes with two built-in modes.
---
### Build
Build is the **default** mode with all tools enabled. This is the standard mode for development work where you need full access to file operations and system commands.
---
### Plan
A restricted mode designed for planning and analysis. In plan mode, the following tools are disabled by default:
- `write` - Cannot create new files
- `edit` - Cannot modify existing files
- `patch` - Cannot apply patches
- `bash` - Cannot execute shell commands
This mode is useful when you want the AI to analyze code, suggest changes, or create plans without making any actual modifications to your codebase.
---
## Switching
You can switch between modes during a session using the _Tab_ key. Or your configured `switch_mode` keybind.
---
## Configure
You can customize the built-in modes or create your own in the opencode [config](/docs/config).
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"mode": {
"build": {
"model": "anthropic/claude-sonnet-4-20250514",
"prompt": "{file:./prompts/build.txt}",
"tools": {
"write": true,
"edit": true,
"bash": true
}
},
"plan": {
"model": "anthropic/claude-haiku-4-20250514",
"tools": {
"write": false,
"edit": false,
"bash": false
}
}
}
}
```
Let's look at these options in detail.
---
### Model
Use the `model` config to override the default model for this mode. Useful for using different models optimized for different tasks. For example, a faster model for planning, a more capable model for implementation.
```json title="opencode.json"
{
"mode": {
"plan": {
"model": "anthropic/claude-haiku-4-20250514"
}
}
}
```
---
### Prompt
Specify a custom system prompt file for this mode with the `prompt` config. The prompt file should contain instructions specific to the mode's purpose.
```json title="opencode.json"
{
"mode": {
"review": {
"prompt": "{file:./prompts/code-review.txt}"
}
}
}
```
This path is relative to where the config file is located. So this works for
both the global opencode config and the project specific config.
---
### Tools
Control which tools are available in this mode with the `tools` config. You can enable or disable specific tools by setting them to `true` or `false`.
```json
{
"mode": {
"readonly": {
"tools": {
"write": false,
"edit": false,
"bash": false,
"read": true,
"grep": true,
"glob": true
}
}
}
}
```
If no tools are specified, all tools are enabled by default.
---
#### Available tools
Here are all the tools can be controlled through the mode config.
| Tool | Description |
| ----------- | ----------------------- |
| `bash` | Execute shell commands |
| `edit` | Modify existing files |
| `write` | Create new files |
| `read` | Read file contents |
| `grep` | Search file contents |
| `glob` | Find files by pattern |
| `list` | List directory contents |
| `patch` | Apply patches to files |
| `todowrite` | Manage todo lists |
| `todoread` | Read todo lists |
| `webfetch` | Fetch web content |
---
## Custom modes
You can create your own custom modes by adding them to the `mode` configuration. For example, a documentation mode that focuses on reading and analysis.
```json title="opencode.json" {4-14}
{
"$schema": "https://opencode.ai/config.json",
"mode": {
"docs": {
"prompt": "{file:./prompts/documentation.txt}",
"tools": {
"write": true,
"edit": true,
"bash": false,
"read": true,
"grep": true,
"glob": true
}
}
}
}
```
---
### Use cases
Here are some common use cases for different modes.
- **Build mode**: Full development work with all tools enabled
- **Plan mode**: Analysis and planning without making changes
- **Review mode**: Code review with read-only access plus documentation tools
- **Debug mode**: Focused on investigation with bash and read tools enabled
- **Docs mode**: Documentation writing with file operations but no system commands
You might also find different models are good for different use cases.
+1 -1
View File
@@ -81,7 +81,7 @@ If you have both global and project-specific rules, opencode will combine them t
## Custom Instructions
You can specify custom instruction files in your `opencode.json` or the global `~/.config/opencode/config.json`. This allows you and your team to reuse existing rules rather than having to duplicate them to AGENTS.md.
You can specify custom instruction files in your `opencode.json` or the global `~/.config/opencode/opencode.json`. This allows you and your team to reuse existing rules rather than having to duplicate them to AGENTS.md.
Example:
@@ -26,7 +26,7 @@ You can configure the log level in your [config file](/docs/config#logging) to g
opencode stores session data and other application data on disk at:
- **macOS/Linux**: `~/.local/share/opencode/`
- **Windows**: `%APPDATA%\opencode\`
- **Windows**: `%USERPROFILE%\.local\share\opencode`
This directory contains:
@@ -43,17 +43,17 @@ This directory contains:
If you're experiencing issues with opencode:
1. **Report issues on GitHub**
The best way to report bugs or request features is through our GitHub repository:
[**github.com/sst/opencode/issues**](https://github.com/sst/opencode/issues)
Before creating a new issue, search existing issues to see if your problem has already been reported.
2. **Join our Discord**
For real-time help and community discussion, join our Discord server:
[**opencode.ai/discord**](https://opencode.ai/discord)
---
@@ -85,3 +85,34 @@ Here are some common issues and how to resolve them.
1. Check that you've authenticated with the provider
2. Verify the model name in your config is correct
3. Some models may require specific access or subscriptions
---
### Copy/paste not working on Linux
Linux users need to have one of the following clipboard utilities installed for copy/paste functionality to work:
**For X11 systems:**
```bash
apt install -y xclip
# or
apt install -y xsel
```
**For Wayland systems:**
```bash
apt install -y wl-clipboard
```
**For headless environments:**
```bash
apt install -y xvfb
# and run:
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
export DISPLAY=:99.0
```
opencode will automatically detect and use the first available clipboard tool in order of preference: `xclip`, `xsel`, then `wl-clipboard`.
+1 -1
View File
@@ -6,4 +6,4 @@
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
export {}
+2 -2
View File
@@ -9,8 +9,8 @@ SERVER_PID=$!
echo "Waiting for server to start..."
sleep 3
echo "Fetching OpenAPI spec from http://localhost:4096/doc..."
curl -s http://localhost:4096/doc > openapi.json
echo "Fetching OpenAPI spec from http://127.0.0.1:4096/doc..."
curl -s http://127.0.0.1:4096/doc > openapi.json
echo "Stopping server..."
kill $SERVER_PID
+1
View File
@@ -60,6 +60,7 @@ async function fetchReleases(): Promise<Release[]> {
if (batch.length < per) break
page++
await new Promise((resolve) => setTimeout(resolve, 1000))
}
return releases
+17 -9
View File
@@ -5,20 +5,28 @@
declare module "sst" {
export interface Resource {
Api: {
type: "sst.cloudflare.Worker"
url: string
"Api": {
"type": "sst.cloudflare.Worker"
"url": string
}
Bucket: {
type: "sst.cloudflare.Bucket"
"Bucket": {
"type": "sst.cloudflare.Bucket"
}
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
}
}
}
/// <reference path="sst-env.d.ts" />
import "sst"
export {}
export {}
+2
View File
@@ -49,10 +49,12 @@ resources:
models:
app: App
logLevel: LogLevel
mode: Mode
methods:
get: get /app
init: post /app/init
log: post /log
modes: get /mode
find:
models: