Compare commits

..

51 Commits

Author SHA1 Message Date
bicabone
c5dcccfd7e .gitignore 2025-11-13 16:00:33 -05:00
bicabone
1f7086fe03 feat: Add sprint documents for feature-bench testing
- Added uninstaller feature sprint (Issue #3900)
- Added maxSteps feature sprint (Issue #3631)
- These sprints will be used by feature-bench test suite
2025-11-12 22:24:50 -05:00
GitHub Action
5f1417f1a1 ignore: update download stats 2025-11-12 2025-11-12 12:01:58 +00:00
GitHub Action
740f9dadef ignore: update download stats 2025-11-11 2025-11-11 12:01:53 +00:00
GitHub Action
4b66048e2b ignore: update download stats 2025-11-10 2025-11-10 12:01:54 +00:00
GitHub Action
e019b097ff ignore: update download stats 2025-11-09 2025-11-09 12:01:42 +00:00
GitHub Action
7409236838 ignore: update download stats 2025-11-08 2025-11-08 12:01:48 +00:00
GitHub Action
7caf149e46 ignore: update download stats 2025-11-07 2025-11-07 12:01:41 +00:00
GitHub Action
f1db3a2d29 ignore: update download stats 2025-11-06 2025-11-06 12:01:52 +00:00
GitHub Action
c454918144 ignore: update download stats 2025-11-05 2025-11-05 12:01:44 +00:00
GitHub Action
c5153772c6 ignore: update download stats 2025-11-04 2025-11-04 12:02:03 +00:00
Haris Gušić
573ffe186b fix(tui): Show correct keybind in session delete confirmation message (#3805) 2025-11-03 09:22:05 -05:00
Alex Knight
0f7ff3fcb1 Log share link immediately after session creation (#3811) 2025-11-03 09:21:43 -05:00
frankdierolf
2c3aa330b9 fix: correct clipboard image encoding and binary handling (#3817) 2025-11-03 09:21:13 -05:00
Pranshu Raj
47b2fb79dc docs: add session_child_cycle and session_child_cycle_reverse keybinds (#3807) 2025-11-03 09:20:35 -05:00
Sebastian Herrlinger
6deaf54bb3 use new opentui getTextRange method and Bun.stringWidth instead of value.length to mitigate issues like #3734 2025-11-03 15:15:55 +01:00
GitHub Action
d549cd3213 ignore: update download stats 2025-11-03 2025-11-03 12:04:38 +00:00
Ivan Starkov
93e52f7ecf feat: Enhance task display with [subagent type] (#3772) 2025-11-03 01:09:31 -06:00
Aiden Cline
88f12b0822 core: prevent TypeError when error handling encounters non-object errors
When API errors like token limit exceeded errors are passed as strings to error checking methods, the 'in' operator would throw a TypeError. This fix adds a type guard to check that the input is an object before attempting to access its properties, allowing proper error classification even when encountering unexpected error formats from providers.
2025-11-02 23:38:56 -06:00
Zeldris
54af7f9e18 docs: use brew official formula (#3733) 2025-11-02 21:00:23 -06:00
Dax Raad
be685e95a3 docs 2025-11-03 01:57:36 +00:00
Dax Raad
dc2ab75fca ci: eventualy consistency 2025-11-03 01:57:36 +00:00
opencode
f1324e886f release: v1.0.15 2025-11-03 01:57:36 +00:00
opencode
c47fde2ca4 release: v1.0.14 2025-11-03 00:12:08 +00:00
Dax Raad
f42e1c6375 tui: fix focus management and dialog interactions 2025-11-02 19:07:22 -05:00
Dax Raad
f68374ad22 DELETE GO BUBBLETEA CRAP HOORAY 2025-11-02 18:43:33 -05:00
opencode
5e86c9b791 release: v1.0.13 2025-11-02 23:31:25 +00:00
Dax Raad
94658c31c5 add back child session cycle 2025-11-02 18:26:38 -05:00
Dax Raad
9fd672a1cb undo 2025-11-02 16:31:32 -05:00
Dax Raad
10523c4372 move dialog select keybind to input 2025-11-02 15:47:04 -05:00
Dax Raad
d1cd7d0344 ci: centralize Bun version to package.json to ensure consistent builds across CI and local development 2025-11-02 15:42:15 -05:00
Dax Raad
06ac1be226 upgrade to bun 1.3.1 2025-11-02 14:00:50 -05:00
Dax Raad
05489bc843 tui: fix file path handling when pasting images with spaces in filename
- Fixes issue where files with spaces in their names couldn't be pasted as images
- Prevents default paste behavior to avoid conflicts with image insertion
- Improves error handling for file reading operations
2025-11-02 13:45:44 -05:00
Dax Raad
3f02eecf22 tui: add /timeline command to quickly navigate to specific messages in session history 2025-11-02 18:27:42 +00:00
opencode
f5ca78ed7b release: v1.0.12 2025-11-02 18:27:42 +00:00
Dax Raad
894cbaa51e fix duplicate plugin subscriptions 2025-11-02 13:22:58 -05:00
John Eismeier
8b70b89fde fix: typos (#3757)
Signed-off-by: John E <jeis4wpi@outlook.com>
2025-11-02 09:56:40 -06:00
Aditya Mathur
f9dbc586dc chore: update hono-openapi to version 1.1.1 (#3738) 2025-11-02 09:21:55 -06:00
GitHub Action
ffeef63ca1 ignore: update download stats 2025-11-02 2025-11-02 12:04:05 +00:00
kcrommett
4da58294d9 add nightowl theme back after opentui release (#3732) 2025-11-02 04:29:14 -05:00
opencode
fa2e88f49b release: v1.0.11 2025-11-02 08:18:59 +00:00
Dax Raad
28e765ef0a fix dialog 2025-11-02 02:53:55 -05:00
Dax Raad
bfbcb5f200 tui: prevent default Enter key behavior when selecting dialog options to avoid conflicts 2025-11-02 01:19:30 -05:00
Aiden Cline
89492b3002 ci: fix regex 2025-11-01 20:23:10 -05:00
opencode-agent[bot]
2663415d47 github action: truncate PR titles to 256 chars to avoid GH api errors (#3727)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-01 20:09:35 -05:00
Aiden Cline
51be67cc14 ci: stop auto assigning 2025-11-01 19:57:09 -05:00
Sebastian Herrlinger
92a1943771 upgrade to opentui 0.1.32, activates kitty keyboard 2025-11-02 01:45:38 +01:00
opencode
1e15fc273a release: v1.0.10 2025-11-01 18:06:28 +00:00
Dax
104a895a71 Light mode (#3709) 2025-11-01 13:54:01 -04:00
Dax Raad
f98e730405 docs update 2025-11-01 13:23:03 -04:00
Dax Raad
b12bef05d3 docs: update keybinds documentation with current defaults and remove deprecated bindings 2025-11-01 12:32:22 -04:00
179 changed files with 1213 additions and 29512 deletions

View File

@@ -5,6 +5,8 @@ runs:
steps:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- name: Cache ~/.bun
id: cache-bun

View File

@@ -21,7 +21,7 @@ jobs:
const description = issue.body || '';
// Check for version patterns like v1.0.x or 1.0.x
const versionPattern = /\b[v]?1\.0\.[x\d]\b/i;
const versionPattern = /[v]?1\.0\./i;
if (versionPattern.test(title) || versionPattern.test(description)) {
await github.rest.issues.addLabels({
@@ -30,11 +30,4 @@ jobs:
issue_number: issue.number,
labels: ['opentui']
});
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: ['thdxr']
});
}

7
.gitignore vendored
View File

@@ -5,8 +5,15 @@ node_modules
.env
.idea
.vscode
*~
openapi.json
playground
tmp
dist
.turbo
# Test suite artifacts
opencode/.bun/
opencode/.local/
opencode/.cache/
opencode/node_modules

View File

@@ -17,7 +17,7 @@
## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environnement:
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environment:
json
{

View File

@@ -28,7 +28,7 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install sst/tap/opencode # macOS and Linux
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
```

View File

@@ -127,3 +127,14 @@
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
| 2025-11-04 | 663,907 (+10,777) | 608,056 (+10,917) | 1,271,963 (+21,694) |
| 2025-11-05 | 675,059 (+11,152) | 619,690 (+11,634) | 1,294,749 (+22,786) |
| 2025-11-06 | 686,249 (+11,190) | 630,885 (+11,195) | 1,317,134 (+22,385) |
| 2025-11-07 | 696,626 (+10,377) | 642,146 (+11,261) | 1,338,772 (+21,638) |
| 2025-11-08 | 706,032 (+9,406) | 653,489 (+11,343) | 1,359,521 (+20,749) |
| 2025-11-09 | 713,462 (+7,430) | 660,459 (+6,970) | 1,373,921 (+14,400) |
| 2025-11-10 | 722,280 (+8,818) | 668,225 (+7,766) | 1,390,505 (+16,584) |
| 2025-11-11 | 729,769 (+7,489) | 677,501 (+9,276) | 1,407,270 (+16,765) |
| 2025-11-12 | 740,168 (+10,399) | 686,454 (+8,953) | 1,426,622 (+19,352) |

View File

@@ -39,7 +39,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.9",
"version": "1.0.15",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -66,7 +66,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.9",
"version": "1.0.15",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -90,7 +90,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.9",
"version": "1.0.15",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -111,7 +111,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.9",
"version": "1.0.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -150,7 +150,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.9",
"version": "1.0.15",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -166,7 +166,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.9",
"version": "1.0.15",
"bin": {
"opencode": "./bin/opencode",
},
@@ -184,8 +184,8 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.0.0-20251031-fc297165",
"@opentui/solid": "0.0.0-20251031-fc297165",
"@opentui/core": "0.1.33",
"@opentui/solid": "0.1.33",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -199,7 +199,7 @@
"fuzzysort": "3.1.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "1.0.7",
"hono-openapi": "1.1.1",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
@@ -243,7 +243,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.9",
"version": "1.0.15",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -263,7 +263,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.9",
"version": "1.0.15",
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
@@ -274,7 +274,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.9",
"version": "1.0.15",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -287,7 +287,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.9",
"version": "1.0.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -317,7 +317,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.9",
"version": "1.0.15",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -961,21 +961,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.0.0-20251031-fc297165", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.0.0-20251031-fc297165", "@opentui/core-darwin-x64": "0.0.0-20251031-fc297165", "@opentui/core-linux-arm64": "0.0.0-20251031-fc297165", "@opentui/core-linux-x64": "0.0.0-20251031-fc297165", "@opentui/core-win32-arm64": "0.0.0-20251031-fc297165", "@opentui/core-win32-x64": "0.0.0-20251031-fc297165", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-xtUF/uJF04d1wl4f7vsRNsDN8P9uK9Mcx1SAcm79wAN90VPNB4j2G0s7qlt8SD4zB0iWPjXICqJidjRzrQ3QVg=="],
"@opentui/core": ["@opentui/core@0.1.33", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.33", "@opentui/core-darwin-x64": "0.1.33", "@opentui/core-linux-arm64": "0.1.33", "@opentui/core-linux-x64": "0.1.33", "@opentui/core-win32-arm64": "0.1.33", "@opentui/core-win32-x64": "0.1.33", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vwHdrPIqnsY6YnG2JTNhenHSsx+HUPYrQTBZdmEfCj9ROGVzKgUKbSDH1xGK2OtSNRb2KVBg4XaMpq0bie6afQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20251031-fc297165", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SD5AiofTfOT+JBx7tcBcd6BdD9sc+RPkHbhIJeqkw5V/GJ4OjyUW3m2kyR9iTs1nLMbKD5o9gyVXpLig4KmFiQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.33", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JBvzcP2V7fT9KxFAMenHRd/t72qPP5IL5kzge2uok1T7t2nw3Wa+CWI5s6FYP42p2b1W9qZkv5Fno5gA7OAYuQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20251031-fc297165", "", { "os": "darwin", "cpu": "x64" }, "sha512-uhzxSvmfeK7vv8uNdhl8Mn2yMnjOVqdjZTOIV2aI8H9SCp8cmnzuLA8FXFO+BW6kgxsg6LbVdp4d4jDCgwtKLQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.33", "", { "os": "darwin", "cpu": "x64" }, "sha512-x7DY6VCkAky10z/2o4UkkuNW/nIvoX7uAh3dJOHWZCLbiKywSFvFk3QZVVcH5BMk4tOOophYTzika4s4HpaeMg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20251031-fc297165", "", { "os": "linux", "cpu": "arm64" }, "sha512-qGjjk/QTrAyqwzPC+6NhqiQZ31k3GxufbtccF8Yqan0GLuA6GrKcU72IcPwVA5t/6VIXaLkJZyFfub7CoO1D/g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBc1EdkVxsLBtqGjXM2BYpBJLa57ogcrSADSZbc5cQkPu0muSGzUwBbVnVZJUjWEfk6n5jcd4dDmLezVoQga0A=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20251031-fc297165", "", { "os": "linux", "cpu": "x64" }, "sha512-gre61Sxc9yX8lrqGNXz5fyE7xJHfkgDi8smGPE2OVP8HmXh0Rn1tXMzFywweEs9MELP3kdQ0VhimYJWkp8FyWg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.33", "", { "os": "linux", "cpu": "x64" }, "sha512-3oVL5mrLlKLUc1lc4v7xS3BJ9N7PnnimbGwAvlnVpfaAygotAs1XkPcjsUe6ItMnSJyi0FWiDHUE2+GiDtM5Nw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20251031-fc297165", "", { "os": "win32", "cpu": "arm64" }, "sha512-44jsq/Ea+jIjZDXyt0w23/DkvwniQFPRB1tocGp6VrOHyHKa0IPHAQ+iuM0felbnmdMUFYyTyh1iOfAcuZyaaA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.33", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q68v7wssE+r0OG1KIGfi7m3fnu8KOK4ZNg9ML6EwE47VF9/bqgUe+6fPiXh5mmHzTwof7nAOdXCf052av5/upQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20251031-fc297165", "", { "os": "win32", "cpu": "x64" }, "sha512-L20tCPrLFMCuX4lC2JTcixiCGFNM5RTHQwKLRcxcsSdKBr6a/7ztOG2a/2RNWkrrlbwTrUREVXH4Ivk3EOuStw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.33", "", { "os": "win32", "cpu": "x64" }, "sha512-PvuchmUnbMCUXXMzfle/WTzhNGIdJ6RGCCoclx3YVUyNUVuUicPf42OEV+td2m81/Hr3CgcLn98HYX1TLIzPrw=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20251031-fc297165", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20251031-fc297165", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-9u7ULKztDG1SvvU/wNCTFL7JYNPkG+pevcEU3JA7M2uUTIWrvKf/rD13lxtfVe7/yfxcY69SMRlaJGWpfxud5w=="],
"@opentui/solid": ["@opentui/solid@0.1.33", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.33", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-bWSALdGJ2j51zwZ2gK1ZIBxFgauHq+V1ejEnyd4XamYMdWfpAKU+AUWDVLbpx1T9XG1oAnycJZfYX7BsZdVOOg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2221,7 +2221,7 @@
"hono": ["hono@4.7.10", "", {}, "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="],
"hono-openapi": ["hono-openapi@1.0.7", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.1", "@standard-community/standard-openapi": "^0.2.4", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-rMn+nn4/HMisyi549L3zT7WCmVvmpiKsyt790GcGfqvJf9mJfhq6txw09l0IhSBxpJpA0pXVKxFijcsnGfshUA=="],
"hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],

View File

@@ -152,6 +152,9 @@ try {
return session.id.slice(-8)
})()
console.log("opencode session", session.id)
if (shareId) {
console.log("Share link:", `${useShareUrl()}/s/${shareId}`)
}
// Handle 3 cases
// 1. Issue
@@ -168,7 +171,9 @@ try {
const summary = await summarize(response)
await pushToLocalBranch(summary)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
const hasShared = prData.comments.nodes.some((c) =>
c.body.includes(`${useShareUrl()}/s/${shareId}`),
)
await updateComment(`${response}${footer({ image: !hasShared })}`)
}
// Fork PR
@@ -180,7 +185,9 @@ try {
const summary = await summarize(response)
await pushToForkBranch(summary, prData)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
const hasShared = prData.comments.nodes.some((c) =>
c.body.includes(`${useShareUrl()}/s/${shareId}`),
)
await updateComment(`${response}${footer({ image: !hasShared })}`)
}
}
@@ -361,7 +368,9 @@ async function getAccessToken() {
if (!response.ok) {
const responseJson = (await response.json()) as { error?: string }
throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`)
throw new Error(
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`,
)
}
const responseJson = (await response.json()) as { token: string }
@@ -402,8 +411,12 @@ async function getUserPrompt() {
// ie. <img alt="Image" src="https://github.com/user-attachments/assets/xxxx" />
// ie. [api.json](https://github.com/user-attachments/files/21433810/api.json)
// ie. ![Image](https://github.com/user-attachments/assets/xxxx)
const mdMatches = prompt.matchAll(/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi)
const tagMatches = prompt.matchAll(/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi)
const mdMatches = prompt.matchAll(
/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi,
)
const tagMatches = prompt.matchAll(
/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi,
)
const matches = [...mdMatches, ...tagMatches].sort((a, b) => a.index - b.index)
console.log("Images", JSON.stringify(matches, null, 2))
@@ -430,7 +443,8 @@ async function getUserPrompt() {
// Replace img tag with file path, ie. @image.png
const replacement = `@${filename}`
prompt = prompt.slice(0, start + offset) + replacement + prompt.slice(start + offset + tag.length)
prompt =
prompt.slice(0, start + offset) + replacement + prompt.slice(start + offset + tag.length)
offset += replacement.length - tag.length
const contentType = res.headers.get("content-type")
@@ -498,7 +512,12 @@ async function subscribeSessionEvents() {
? JSON.stringify(part.state.input)
: "Unknown"
console.log()
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
console.log(
color + `|`,
"\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`,
"",
"\x1b[0m" + title,
)
}
if (part.type === "text") {
@@ -710,7 +729,8 @@ async function assertPermissions() {
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
}
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
if (!["admin", "write"].includes(permission))
throw new Error(`User ${actor} does not have write permissions`)
}
async function updateComment(body: string) {
@@ -730,12 +750,13 @@ async function updateComment(body: string) {
async function createPR(base: string, branch: string, title: string, body: string) {
console.log("Creating pull request...")
const { repo } = useContext()
const truncatedTitle = title.length > 256 ? title.slice(0, 253) + "..." : title
const pr = await octoRest.rest.pulls.create({
owner: repo.owner,
repo: repo.repo,
head: branch,
base,
title,
title: truncatedTitle,
body,
})
return pr.data.number
@@ -753,7 +774,9 @@ function footer(opts?: { image?: boolean }) {
return `<a href="${useShareUrl()}/s/${shareId}"><img width="200" alt="${titleAlt}" src="https://social-cards.sst.dev/opencode-share/${title64}.png?model=${providerID}/${modelID}&version=${session.version}&id=${shareId}" /></a>\n`
})()
const shareUrl = shareId ? `[opencode session](${useShareUrl()}/s/${shareId})&nbsp;&nbsp;|&nbsp;&nbsp;` : ""
const shareUrl = shareId
? `[opencode session](${useShareUrl()}/s/${shareId})&nbsp;&nbsp;|&nbsp;&nbsp;`
: ""
return `\n\n${image}${shareUrl}[github run](${useEnvRunUrl()})`
}
@@ -936,9 +959,13 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
})
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
const files = (pr.files.nodes || []).map((f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`)
const files = (pr.files.nodes || []).map(
(f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`,
)
const reviewData = (pr.reviews.nodes || []).map((r) => {
const comments = (r.comments.nodes || []).map((c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`)
const comments = (r.comments.nodes || []).map(
(c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`,
)
return [
`- ${r.author.login} at ${r.submittedAt}:`,
` - Review body: ${r.body}`,
@@ -960,9 +987,15 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
`Deletions: ${pr.deletions}`,
`Total Commits: ${pr.commits.totalCount}`,
`Changed Files: ${pr.files.nodes.length} files`,
...(comments.length > 0 ? ["<pull_request_comments>", ...comments, "</pull_request_comments>"] : []),
...(files.length > 0 ? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"] : []),
...(reviewData.length > 0 ? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"] : []),
...(comments.length > 0
? ["<pull_request_comments>", ...comments, "</pull_request_comments>"]
: []),
...(files.length > 0
? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"]
: []),
...(reviewData.length > 0
? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"]
: []),
"</pull_request>",
].join("\n")
}

View File

@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.0",
"packageManager": "bun@1.3.1",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",

View File

@@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "1.0.9"
"version": "1.0.15"
},
"dependencies": {
"@ibm/plex": "6.4.1",

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.9",
"version": "1.0.15",
"private": true,
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.9",
"version": "1.0.15",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.9",
"version": "1.0.15",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.9",
"version": "1.0.15",
"description": "",
"type": "module",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.9",
"version": "1.0.15",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.9",
"version": "1.0.15",
"name": "opencode",
"type": "module",
"private": true,
@@ -54,8 +54,8 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.0.0-20251031-fc297165",
"@opentui/solid": "0.0.0-20251031-fc297165",
"@opentui/core": "0.1.33",
"@opentui/solid": "0.1.33",
"@parcel/watcher": "2.5.1",
"@solid-primitives/event-bus": "1.1.2",
"@pierre/precision-diffs": "catalog:",
@@ -69,7 +69,7 @@
"fuzzysort": "3.1.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "1.0.7",
"hono-openapi": "1.1.1",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",

View File

@@ -125,10 +125,8 @@ if (!Script.preview) {
"build() {",
` cd "opencode-\${pkgver}"`,
` bun install`,
" cd packages/tui",
` CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=\${pkgver}" -o tui cmd/opencode/main.go`,
" cd ../opencode",
` bun build --define OPENCODE_TUI_PATH="'$(realpath ../tui/tui)'" --define OPENCODE_VERSION="'\${pkgver}'" --compile --target=bun-linux-x64 --outfile=opencode ./src/index.ts`,
" cd ./packages/opencode",
` OPENCODE_CHANNEL=latest OPENCODE_VERSION=${pkgver} bun run ./script/build.ts --single`,
"}",
"",
"package() {",

View File

@@ -29,6 +29,63 @@ import { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const str = data.toString()
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (match) {
cleanup()
const color = match[1]
// Parse RGB values from color string
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
let r = 0,
g = 0,
b = 0
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
} else if (color.startsWith("#")) {
r = parseInt(color.substring(1, 3), 16)
g = parseInt(color.substring(3, 5), 16)
b = parseInt(color.substring(5, 7), 16)
} else if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
r = parseInt(parts[0])
g = parseInt(parts[1])
b = parseInt(parts[2])
}
// Calculate luminance using relative luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
// Determine if dark or light based on luminance threshold
resolve(luminance > 0.5 ? "light" : "dark")
}
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}
export function tui(input: {
url: string
sessionID?: string
@@ -38,7 +95,9 @@ export function tui(input: {
onExit?: () => Promise<void>
}) {
// promise to prevent immediate exit
return new Promise<void>((resolve) => {
return new Promise<void>(async (resolve) => {
const mode = await getTerminalBackgroundColor()
const routeData: Route | undefined = input.sessionID
? {
type: "session",
@@ -65,8 +124,12 @@ export function tui(input: {
<RouteProvider data={routeData}>
<SDKProvider url={input.url}>
<SyncProvider>
<ThemeProvider>
<LocalProvider initialModel={input.model} initialAgent={input.agent} initialPrompt={input.prompt}>
<ThemeProvider mode={mode}>
<LocalProvider
initialModel={input.model}
initialAgent={input.agent}
initialPrompt={input.prompt}
>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
@@ -91,6 +154,7 @@ export function tui(input: {
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: true,
},
)
})
@@ -109,7 +173,7 @@ function App() {
const sync = useSync()
const toast = useToast()
const [sessionExists, setSessionExists] = createSignal(false)
const { theme } = useTheme()
const { theme, mode, setMode } = useTheme()
const exit = useExit()
useKeyboard(async (evt) => {
@@ -238,6 +302,14 @@ function App() {
},
category: "System",
},
{
title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
value: "theme.switch_mode",
onSelect: () => {
setMode(mode() === "dark" ? "light" : "dark")
},
category: "System",
},
{
title: "Help",
value: "help.show",
@@ -251,7 +323,7 @@ function App() {
value: "app.exit",
onSelect: exit,
category: "System",
}
},
])
createEffect(() => {
@@ -335,7 +407,9 @@ function App() {
paddingRight={1}
>
<text fg={theme.textMuted}>open</text>
<text attributes={TextAttributes.BOLD}>code </text>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
code{" "}
</text>
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>

View File

@@ -18,6 +18,8 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>()
const deleteKeybind = "ctrl+d"
const options = createMemo(() => {
const today = new Date().toDateString()
return sync.data.session
@@ -30,7 +32,7 @@ export function DialogSessionList() {
}
const isDeleting = toDelete() === x.id
return {
title: isDeleting ? "Press delete again to confirm" : x.title,
title: isDeleting ? `Press ${deleteKeybind} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
@@ -60,7 +62,7 @@ export function DialogSessionList() {
}}
keybind={[
{
keybind: Keybind.parse("ctrl+d")[0],
keybind: Keybind.parse(deleteKeybind)[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {

View File

@@ -14,12 +14,14 @@ export function DialogStatus() {
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>Status</text>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Status
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text>No MCP Servers</text>}>
<box>
<text>{Object.keys(sync.data.mcp).length} MCP Servers</text>
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
@@ -35,7 +37,7 @@ export function DialogStatus() {
>
</text>
<text wrapMode="word">
<text fg={theme.text} wrapMode="word">
<b>{key}</b>{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
@@ -52,7 +54,7 @@ export function DialogStatus() {
</Show>
{sync.data.lsp.length > 0 && (
<box>
<text>{sync.data.lsp.length} LSP Servers</text>
<text fg={theme.text}>{sync.data.lsp.length} LSP Servers</text>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
@@ -67,7 +69,7 @@ export function DialogStatus() {
>
</text>
<text wrapMode="word">
<text fg={theme.text} wrapMode="word">
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
</text>
</box>
@@ -75,9 +77,12 @@ export function DialogStatus() {
</For>
</box>
)}
<Show when={enabledFormatters().length > 0} fallback={<text>No Formatters</text>}>
<Show
when={enabledFormatters().length > 0}
fallback={<text fg={theme.text}>No Formatters</text>}
>
<box>
<text>{enabledFormatters().length} Formatters</text>
<text fg={theme.text}>{enabledFormatters().length} Formatters</text>
<For each={enabledFormatters()}>
{(item) => (
<box flexDirection="row" gap={1}>
@@ -89,7 +94,7 @@ export function DialogStatus() {
>
</text>
<text wrapMode="word">
<text wrapMode="word" fg={theme.text}>
<b>{item.name}</b>
</text>
</box>

View File

@@ -49,7 +49,12 @@ export function Autocomplete(props: {
})
const filter = createMemo(() => {
if (!store.visible) return
return props.value.substring(store.index + 1).split(" ")[0]
// Track props.value to make memo reactive to text changes
props.value // <- there surely is a better way to do this, like making .input() reactive
const val = props.input().getTextRange(store.index + 1, props.input().visualCursor.offset + 1)
return val
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
@@ -70,7 +75,7 @@ export function Autocomplete(props: {
const virtualText = "@" + text
const extmarkStart = store.index
const extmarkEnd = extmarkStart + virtualText.length
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
const styleId =
part.type === "file"
@@ -229,6 +234,11 @@ export function Autocomplete(props: {
description: "rename session",
onSelect: () => command.trigger("session.rename"),
},
{
display: "/timeline",
description: "jump to message",
onSelect: () => command.trigger("session.timeline"),
},
)
}
results.push(
@@ -359,7 +369,7 @@ export function Autocomplete(props: {
return store.visible
},
onInput(value: string) {
if (store.visible && value.length <= store.index) hide()
if (store.visible && Bun.stringWidth(value) <= store.index) hide()
},
onKeyDown(e: KeyEvent) {
if (store.visible) {
@@ -373,7 +383,10 @@ export function Autocomplete(props: {
if (e.name === "@") {
const cursorOffset = props.input().visualCursor.offset
const charBeforeCursor =
cursorOffset === 0 ? undefined : props.value.at(cursorOffset - 1)
cursorOffset === 0
? undefined
: props.input().getTextRange(cursorOffset - 1, cursorOffset)
if (
charBeforeCursor === " " ||
charBeforeCursor === "\n" ||

View File

@@ -11,7 +11,7 @@ import {
} from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX, onMount, batch } from "solid-js"
import { useLocal } from "@tui/context/local"
import { SyntaxTheme, useTheme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
@@ -60,7 +60,7 @@ export function Prompt(props: PromptProps) {
const history = usePromptHistory()
const command = useCommandDialog()
const renderer = useRenderer()
const { theme } = useTheme()
const { theme, syntax } = useTheme()
const textareaKeybindings = createMemo(() => {
const newlineBindings = keybind.all.input_newline || []
@@ -86,9 +86,9 @@ export function Prompt(props: PromptProps) {
]
})
const fileStyleId = SyntaxTheme.getStyleId("extmark.file")!
const agentStyleId = SyntaxTheme.getStyleId("extmark.agent")!
const pasteStyleId = SyntaxTheme.getStyleId("extmark.paste")!
const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId: number
command.register(() => {
@@ -134,6 +134,7 @@ export function Prompt(props: PromptProps) {
keybind: "input_submit",
category: "Prompt",
onSelect: (dialog) => {
if (!input.focused) return
submit()
dialog.clear()
},
@@ -315,9 +316,9 @@ export function Prompt(props: PromptProps) {
const sessionID = props.sessionID
? props.sessionID
: await (async () => {
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
return sessionID
})()
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
return sessionID
})()
const messageID = Identifier.ascending("message")
let inputText = store.prompt.input
@@ -616,14 +617,16 @@ export function Prompt(props: PromptProps) {
// trim ' from the beginning and end of the pasted content. just
// ' and nothing else
const filepath = pastedContent.replace(/^'+|'+$/g, "")
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
console.log(pastedContent, filepath)
try {
const file = Bun.file(filepath)
if (file.type.startsWith("image/")) {
event.preventDefault()
const content = await file
.arrayBuffer()
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
.catch(console.error)
if (content) {
await pasteImage({
filename: file.name,
@@ -680,7 +683,7 @@ export function Prompt(props: PromptProps) {
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.primary}
syntaxStyle={SyntaxTheme}
syntaxStyle={syntax()}
/>
</box>
<box
@@ -691,7 +694,7 @@ export function Prompt(props: PromptProps) {
></box>
</box>
<box flexDirection="row" justifyContent="space-between">
<text flexShrink={0} wrapMode="none">
<text flexShrink={0} wrapMode="none" fg={theme.text}>
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ bold: true }}>{local.model.parsed().model}</span>
</text>
@@ -701,14 +704,14 @@ export function Prompt(props: PromptProps) {
</Match>
<Match when={status() === "working"}>
<box flexDirection="row" gap={1}>
<text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
</text>
</box>
</Match>
<Match when={props.hint}>{props.hint!}</Match>
<Match when={true}>
<text>
<text fg={theme.text}>
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createMemo, Match, Show, Switch, type ParentProps } from "solid-js"
import { Prompt } from "@tui/component/prompt"
import { createMemo, Match, Show, Switch, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useKeybind } from "../context/keybind"
import type { KeybindsConfig } from "@opencode-ai/sdk"
@@ -7,27 +7,18 @@ import { Logo } from "../component/logo"
import { Locale } from "@/util/locale"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useDialog } from "../ui/dialog"
export function Home() {
const sync = useSync()
const { theme } = useTheme()
const dialog = useDialog()
const mcpError = createMemo(() => {
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
})
let promptRef: PromptRef | undefined = undefined
createEffect(() => {
dialog.allClosedEvent.listen(() => {
promptRef?.focus()
})
})
const Hint = (
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
<text>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}></span> mcp errors{" "}
@@ -64,7 +55,7 @@ export function Home() {
<HelpRow keybind="agent_cycle">Switch agent</HelpRow>
</box>
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
<Prompt hint={Hint} ref={(r) => (promptRef = r)} />
<Prompt hint={Hint} />
</box>
<Toast />
</box>
@@ -76,7 +67,7 @@ function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
const { theme } = useTheme()
return (
<box flexDirection="row" justifyContent="space-between" width="100%">
<text>{props.children}</text>
<text fg={theme.text}>{props.children}</text>
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
</box>
)

View File

@@ -51,7 +51,7 @@ export function Header() {
borderColor={theme.backgroundElement}
flexShrink={0}
>
<text>
<text fg={theme.text}>
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{session().title}</span>
</text>
@@ -64,7 +64,7 @@ export function Header() {
</text>
</Match>
<Match when={true}>
<text wrapMode="word">
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>

View File

@@ -12,10 +12,10 @@ import {
} from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "path"
import { useRouteData } from "@tui/context/route"
import { useRoute, useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
import { SyntaxTheme, useTheme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type {
@@ -82,6 +82,7 @@ function use() {
export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync()
const kv = useKV()
const { theme } = useTheme()
@@ -111,12 +112,6 @@ export function Session() {
let prompt: PromptRef
const keybind = useKeybind()
createEffect(() => {
dialog.allClosedEvent.listen(() => {
prompt.focus()
})
})
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
@@ -156,6 +151,23 @@ export function Session() {
const local = useLocal()
function moveChild(direction: number) {
const parentID = session()?.parentID ?? session()?.id
let children = sync.data.session
.filter((x) => x.parentID === parentID || x.id === parentID)
.toSorted((b, a) => a.id.localeCompare(b.id))
if (children.length === 1) return
let next = children.findIndex((x) => x.id === session()?.id) + direction
if (next >= children.length) next = 0
if (next < 0) next = children.length - 1
if (children[next]) {
navigate({
type: "session",
sessionID: children[next].id,
})
}
}
const command = useCommandDialog()
command.register(() => [
{
@@ -397,6 +409,28 @@ export function Session() {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
},
},
{
title: "Next child session",
value: "session.child.next",
keybind: "session_child_cycle",
category: "Session",
disabled: true,
onSelect: (dialog) => {
moveChild(1)
dialog.clear()
},
},
{
title: "Previous child session",
value: "session.child.previous",
keybind: "session_child_cycle_reverse",
category: "Session",
disabled: true,
onSelect: (dialog) => {
moveChild(-1)
dialog.clear()
},
},
])
const revert = createMemo(() => {
@@ -458,6 +492,34 @@ export function Session() {
>
<box flexGrow={1} gap={1}>
<Show when={session()}>
<Show when={session().parentID}>
<box
backgroundColor={theme.backgroundPanel}
justifyContent="space-between"
flexDirection="row"
paddingTop={1}
paddingBottom={1}
flexShrink={0}
paddingLeft={2}
paddingRight={2}
>
<text fg={theme.text}>
Previous{" "}
<span style={{ fg: theme.textMuted }}>
{keybind.print("session_child_cycle_reverse")}
</span>
</text>
<text fg={theme.text}>
<b>Viewing subagent session</b>
</text>
<text fg={theme.text}>
<span style={{ fg: theme.textMuted }}>
{keybind.print("session_child_cycle")}
</span>{" "}
Next
</text>
</box>
</Show>
<Show when={!sidebarVisible()}>
<Header />
</Show>
@@ -641,7 +703,7 @@ function UserMessage(props: {
borderColor={color()}
flexShrink={0}
>
<text>{text()?.text}</text>
<text fg={theme.text}>{text()?.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
@@ -652,7 +714,7 @@ function UserMessage(props: {
return theme.secondary
})
return (
<text>
<text fg={theme.text}>
<span style={{ bg: bg(), fg: theme.background }}>
{" "}
{MIME_BADGE[file.mime] ?? file.mime}{" "}
@@ -667,7 +729,7 @@ function UserMessage(props: {
</For>
</box>
</Show>
<text>
<text fg={theme.text}>
{sync.data.config.username ?? "You"}{" "}
<Show
when={queued()}
@@ -782,7 +844,7 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
>
<text>{props.part.text.trim()}</text>
<text fg={theme.text}>{props.part.text.trim()}</text>
</box>
</box>
</Show>
@@ -791,13 +853,14 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
function TextPart(props: { part: TextPart; message: AssistantMessage }) {
const ctx = use()
const { syntax } = useTheme()
return (
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<code
filetype="markdown"
drawUnstyledText={false}
syntaxStyle={SyntaxTheme}
syntaxStyle={syntax()}
content={props.part.text.trim()}
conceal={ctx.conceal()}
/>
@@ -920,16 +983,14 @@ function GenericTool(props: ToolProps<any>) {
)
}
type ToolRegistration<T extends Tool.Info = any> = {
name: string
container: "inline" | "block"
render?: Component<ToolProps<T>>
}
const ToolRegistry = (() => {
const state: Record<
string,
{ name: string; container: "inline" | "block"; render?: Component<ToolProps<any>> }
> = {}
function register<T extends Tool.Info>(input: {
name: string
container: "inline" | "block"
render?: Component<ToolProps<T>>
}) {
const state: Record<string, ToolRegistration> = {}
function register<T extends Tool.Info>(input: ToolRegistration<T>) {
state[input.name] = input
return input
}
@@ -997,7 +1058,7 @@ ToolRegistry.register<typeof WriteTool>({
name: "write",
container: "block",
render(props) {
const { theme } = useTheme()
const { theme, syntax } = useTheme()
const lines = createMemo(() => {
return props.input.content?.split("\n") ?? []
})
@@ -1028,7 +1089,7 @@ ToolRegistry.register<typeof WriteTool>({
<box paddingLeft={1} flexGrow={1}>
<code
filetype={filetype(props.input.filePath!)}
syntaxStyle={SyntaxTheme}
syntaxStyle={syntax()}
content={code()}
/>
</box>
@@ -1093,10 +1154,16 @@ ToolRegistry.register<typeof TaskTool>({
container: "block",
render(props) {
const { theme } = useTheme()
const keybind = useKeybind()
return (
<>
<ToolTitle icon="%" fallback="Delegating..." when={props.input.description}>
Task {props.input.description}
<ToolTitle
icon="%"
fallback="Delegating..."
when={props.input.subagent_type ?? props.input.description}
>
Task [{props.input.subagent_type ?? "unknown"}] {props.input.description}
</ToolTitle>
<Show when={props.metadata.summary?.length}>
<box>
@@ -1109,6 +1176,10 @@ ToolRegistry.register<typeof TaskTool>({
</For>
</box>
</Show>
<text fg={theme.text}>
{keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
<span style={{ fg: theme.textMuted }}> to navigate between subagent sessions</span>
</text>
</>
)
},
@@ -1131,6 +1202,7 @@ ToolRegistry.register<typeof EditTool>({
container: "block",
render(props) {
const ctx = use()
const { theme, syntax } = useTheme()
const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
@@ -1210,21 +1282,21 @@ ToolRegistry.register<typeof EditTool>({
</ToolTitle>
<Switch>
<Match when={props.permission["diff"]}>
<text>{props.permission["diff"]?.trim()}</text>
<text fg={theme.text}>{props.permission["diff"]?.trim()}</text>
</Match>
<Match when={diff() && style() === "split"}>
<box paddingLeft={1} flexDirection="row" gap={2}>
<box flexGrow={1} flexBasis={0}>
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.oldContent} />
<code filetype={ft()} syntaxStyle={syntax()} content={diff()!.oldContent} />
</box>
<box flexGrow={1} flexBasis={0}>
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.newContent} />
<code filetype={ft()} syntaxStyle={syntax()} content={diff()!.newContent} />
</box>
</box>
</Match>
<Match when={code()}>
<box paddingLeft={1}>
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={code()} />
<code filetype={ft()} syntaxStyle={syntax()} content={code()} />
</box>
</Match>
</Switch>
@@ -1237,6 +1309,7 @@ ToolRegistry.register<typeof PatchTool>({
name: "patch",
container: "block",
render(props) {
const { theme } = useTheme()
return (
<>
<ToolTitle icon="%" fallback="Preparing patch..." when={true}>
@@ -1244,7 +1317,7 @@ ToolRegistry.register<typeof PatchTool>({
</ToolTitle>
<Show when={props.output}>
<box>
<text>{props.output?.trim()}</text>
<text fg={theme.text}>{props.output?.trim()}</text>
</box>
</Show>
</>

View File

@@ -42,7 +42,7 @@ export function Sidebar(props: { sessionID: string }) {
<Show when={session()}>
<box flexShrink={0} gap={1} width={40}>
<box>
<text>
<text fg={theme.text}>
<b>{session().title}</b>
</text>
<Show when={session().share?.url}>
@@ -50,7 +50,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
</box>
<box>
<text>
<text fg={theme.text}>
<b>Context</b>
</text>
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
@@ -59,7 +59,7 @@ export function Sidebar(props: { sessionID: string }) {
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
<text>
<text fg={theme.text}>
<b>MCP</b>
</text>
<For each={Object.entries(sync.data.mcp)}>
@@ -77,7 +77,7 @@ export function Sidebar(props: { sessionID: string }) {
>
</text>
<text wrapMode="word">
<text fg={theme.text} wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
@@ -96,7 +96,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={sync.data.lsp.length > 0}>
<box>
<text>
<text fg={theme.text}>
<b>LSP</b>
</text>
<For each={sync.data.lsp}>
@@ -123,7 +123,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={session().summary?.diffs}>
<box>
<text>
<text fg={theme.text}>
<b>Modified Files</b>
</text>
<For each={session().summary?.diffs || []}>
@@ -155,7 +155,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={todo().length > 0}>
<box>
<text>
<text fg={theme.text}>
<b>Todo</b>
</text>
<For each={todo()}>

View File

@@ -130,6 +130,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (evt.name === "return") {
const option = selected()
if (option) {
// evt.preventDefault()
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
}
@@ -161,7 +162,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box gap={1}>
<box paddingLeft={3} paddingRight={2}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.title}
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingTop={1} paddingBottom={1}>
@@ -172,12 +175,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.onFilter?.(e)
})
}}
onKeyDown={(e) => {}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.focus()
setTimeout(() => input.focus(), 1)
}}
placeholder="Enter search term"
/>

View File

@@ -1,23 +1,9 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { createEventBus } from "@solid-primitives/event-bus"
const Border = {
topLeft: "┃",
topRight: "┃",
bottomLeft: "┃",
bottomRight: "┃",
horizontal: "",
vertical: "┃",
topT: "+",
bottomT: "+",
leftT: "+",
rightT: "+",
cross: "+",
}
export function Dialog(
props: ParentProps<{
size?: "medium" | "large"
@@ -45,11 +31,9 @@ export function Dialog(
onMouseUp={async (e) => {
e.stopPropagation()
}}
customBorderChars={Border}
width={props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel}
borderColor={theme.border}
paddingTop={1}
>
{props.children}
@@ -66,7 +50,6 @@ function init() {
}[],
size: "medium" as "medium" | "large",
})
const allClosedEvent = createEventBus<void>()
useKeyboard((evt) => {
if (evt.name === "escape" && store.stack.length > 0) {
@@ -97,12 +80,6 @@ function init() {
}, 1)
}
createEffect(() => {
if (store.stack.length === 0) {
allClosedEvent.emit()
}
})
return {
clear() {
for (const item of store.stack) {
@@ -115,7 +92,9 @@ function init() {
refocus()
},
replace(input: any, onClose?: () => void) {
if (store.stack.length === 0) focus = renderer.currentFocusedRenderable
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
}
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
@@ -136,9 +115,6 @@ function init() {
setSize(size: "medium" | "large") {
setStore("size", size)
},
get allClosedEvent() {
return allClosedEvent
}
}
}

View File

@@ -30,13 +30,13 @@ export namespace Clipboard {
}
if (os === "linux") {
const wayland = await $`wl-paste -t image/png`.nothrow().text()
if (wayland) {
return { data: Buffer.from(wayland).toString("base64url"), mime: "image/png" }
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
if (wayland && wayland.byteLength > 0) {
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
}
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().text()
if (x11) {
return { data: Buffer.from(x11).toString("base64url"), mime: "image/png" }
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
if (x11 && x11.byteLength > 0) {
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
}
}
@@ -47,7 +47,7 @@ export namespace Clipboard {
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64url"), mime: "image/png" }
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}

View File

@@ -466,14 +466,24 @@ export namespace Config {
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_forward_delete: z.string().optional().default("ctrl+d").describe("Forward delete"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("enter").describe("Submit input"),
input_submit: z.string().optional().default("return").describe("Submit input"),
input_newline: z
.string()
.optional()
.default("shift+enter,ctrl+j")
.default("shift+return,ctrl+j")
.describe("Insert newline in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Previous history item"),
session_child_cycle: z
.string()
.optional()
.default("ctrl+right")
.describe("Next child session"),
session_child_cycle_reverse: z
.string()
.optional()
.default("ctrl+left")
.describe("Previous child session"),
})
.strict()
.meta({

View File

@@ -12,7 +12,11 @@ const context = Context.create<Context>("instance")
const cache = new Map<string, Context>()
export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
async provide<R>(input: {
directory: string
init?: () => Promise<any>
fn: () => R
}): Promise<R> {
let existing = cache.get(input.directory)
if (!existing) {
const project = await Project.fromDirectory(input.directory)
@@ -24,8 +28,8 @@ export const Instance = {
}
return context.provide(existing, async () => {
if (!cache.has(input.directory)) {
await input.init?.()
cache.set(input.directory, existing)
await input.init?.()
}
return input.fn()
})

View File

@@ -26,7 +26,7 @@ Usage notes:
Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above):
<example_agent_descriptions>
"code-reviewer": use this agent after you are done writing a signficant piece of code
"code-reviewer": use this agent after you are done writing a significant piece of code
"greeting-responder": use this agent when to respond to user greetings with a friendly joke
</example_agent_description>
@@ -45,7 +45,7 @@ function isPrime(n) {
}
</code>
<commentary>
Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code
Since a significant piece of code was written and the task was completed, now use the code-reviewer agent to review the code
</commentary>
assistant: Now let me use the code-reviewer agent to review the code
assistant: Uses the Task tool to launch the code-reviewer agent

View File

@@ -27,7 +27,7 @@ export abstract class NamedError extends Error {
}
static isInstance(input: any): input is InstanceType<typeof result> {
return "name" in input && input.name === name
return typeof input === "object" && "name" in input && input.name === name
}
schema() {

View File

@@ -28,8 +28,14 @@ describe("Keybind.toString", () => {
})
test("should convert shift modifier to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: true, leader: false, name: "enter" }
expect(Keybind.toString(info)).toBe("shift+enter")
const info: Keybind.Info = {
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "return",
}
expect(Keybind.toString(info)).toBe("shift+return")
})
test("should convert function key to string", () => {
@@ -38,7 +44,13 @@ describe("Keybind.toString", () => {
})
test("should convert special key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "pgup" }
const info: Keybind.Info = {
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "pgup",
}
expect(Keybind.toString(info)).toBe("pgup")
})
@@ -220,15 +232,15 @@ describe("Keybind.parse", () => {
])
})
test("should parse shift+enter combination", () => {
const result = Keybind.parse("shift+enter")
test("should parse shift+return combination", () => {
const result = Keybind.parse("shift+return")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "enter",
name: "return",
},
])
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.9",
"version": "1.0.15",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",

View File

@@ -1,7 +1,18 @@
import { $ } from "bun"
import path from "path"
if (process.versions.bun !== "1.3.0") {
throw new Error("This script requires bun@1.3.0")
const rootPkgPath = path.resolve(import.meta.dir, "../../../package.json")
const rootPkg = await Bun.file(rootPkgPath).json()
const expectedBunVersion = rootPkg.packageManager?.split("@")[1]
if (!expectedBunVersion) {
throw new Error("packageManager field not found in root package.json")
}
if (process.versions.bun !== expectedBunVersion) {
throw new Error(
`This script requires bun@${expectedBunVersion}, but you are using bun@${process.versions.bun}`,
)
}
const CHANNEL =
@@ -9,6 +20,7 @@ const CHANNEL =
(await $`git branch --show-current`.text().then((x) => x.trim()))
const IS_PREVIEW = CHANNEL !== "latest"
const VERSION = await (async () => {
if (process.env["OPENCODE_VERSION"]) return process.env["OPENCODE_VERSION"]
if (IS_PREVIEW)
return `0.0.0-${CHANNEL}-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}`
const version = await fetch("https://registry.npmjs.org/opencode-ai/latest")

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.9",
"version": "1.0.15",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",

View File

@@ -166,6 +166,14 @@ export type KeybindsConfig = {
* Previous history item
*/
history_next?: string
/**
* Next child session
*/
session_child_cycle?: string
/**
* Previous child session
*/
session_child_cycle_reverse?: string
}
export type AgentConfig = {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.9",
"version": "1.0.15",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",

View File

@@ -1,4 +0,0 @@
opencode-test
cmd/opencode/opencode
opencode

View File

@@ -1,77 +0,0 @@
version: 2
project_name: opencode
before:
hooks:
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w -X github.com/sst/opencode/internal/version.Version={{.Version}}
main: ./main.go
archives:
- format: tar.gz
name_template: >-
opencode-
{{- if eq .Os "darwin" }}mac-
{{- else if eq .Os "windows" }}windows-
{{- else if eq .Os "linux" }}linux-{{end}}
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "#86" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
snapshot:
name_template: "0.0.0-{{ .Timestamp }}"
aurs:
- name: opencode
homepage: "https://github.com/sst/opencode"
description: "terminal based agent that can build anything"
maintainers:
- "dax"
- "adam"
license: "MIT"
private_key: "{{ .Env.AUR_KEY }}"
git_url: "ssh://aur@aur.archlinux.org/opencode-bin.git"
provides:
- opencode
conflicts:
- opencode
package: |-
install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"
brews:
- repository:
owner: sst
name: homebrew-tap
nfpms:
- maintainer: kujtimiihoxha
description: terminal based agent that can build anything
formats:
- deb
- rpm
file_name_template: >-
{{ .ProjectName }}-
{{- if eq .Os "darwin" }}mac
{{- else }}{{ .Os }}{{ end }}-{{ .Arch }}
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^doc:"
- "^test:"
- "^ci:"
- "^ignore:"
- "^example:"
- "^wip:"

View File

@@ -1,175 +0,0 @@
package main
import (
"context"
"io"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
tea "github.com/charmbracelet/bubbletea/v2"
flag "github.com/spf13/pflag"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode-sdk-go/option"
"github.com/sst/opencode-sdk-go/packages/ssestream"
"github.com/sst/opencode/internal/api"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/decoders"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/internal/util"
"golang.org/x/sync/errgroup"
)
var Version = "dev"
func main() {
version := Version
if version != "dev" && !strings.HasPrefix(Version, "v") {
version = "v" + Version
}
var model *string = flag.String("model", "", "model to begin with")
var prompt *string = flag.String("prompt", "", "prompt to begin with")
var agent *string = flag.String("agent", "", "agent to begin with")
var sessionID *string = flag.String("session", "", "session ID")
flag.Parse()
url := os.Getenv("OPENCODE_SERVER")
stat, err := os.Stdin.Stat()
if err != nil {
slog.Error("Failed to stat stdin", "error", err)
os.Exit(1)
}
// Check if there's data piped to stdin
if (stat.Mode() & os.ModeCharDevice) == 0 {
stdin, err := io.ReadAll(os.Stdin)
if err != nil {
slog.Error("Failed to read stdin", "error", err)
os.Exit(1)
}
stdinContent := strings.TrimSpace(string(stdin))
if stdinContent != "" {
if prompt == nil || *prompt == "" {
prompt = &stdinContent
} else {
combined := *prompt + "\n" + stdinContent
prompt = &combined
}
}
}
// Register custom SSE decoder to handle large events (>32MB)
// This is a workaround for the bufio.Scanner token size limit in the auto-generated SDK
// See: packages/tui/internal/decoders/decoder.go
ssestream.RegisterDecoder("text/event-stream", decoders.NewUnboundedDecoder)
httpClient := opencode.NewClient(
option.WithBaseURL(url),
)
var agents []opencode.Agent
var path *opencode.Path
var project *opencode.Project
batch := errgroup.Group{}
batch.Go(func() error {
result, err := httpClient.Project.Current(context.Background(), opencode.ProjectCurrentParams{})
if err != nil {
return err
}
project = result
return nil
})
batch.Go(func() error {
result, err := httpClient.Agent.List(context.Background(), opencode.AgentListParams{})
if err != nil {
return err
}
agents = *result
return nil
})
batch.Go(func() error {
result, err := httpClient.Path.Get(context.Background(), opencode.PathGetParams{})
if err != nil {
return err
}
path = result
return nil
})
err = batch.Wait()
if err != nil {
panic(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
apiHandler := util.NewAPILogHandler(ctx, httpClient, "tui", slog.LevelDebug)
logger := slog.New(apiHandler)
slog.SetDefault(logger)
slog.Debug("TUI launched")
go func() {
err = clipboard.Init()
if err != nil {
slog.Error("Failed to initialize clipboard", "error", err)
}
}()
// Create main context for the application
app_, err := app.New(ctx, version, project, path, agents, httpClient, model, prompt, agent, sessionID)
if err != nil {
panic(err)
}
tuiModel := tui.NewModel(app_).(*tui.Model)
program := tea.NewProgram(
tuiModel,
tea.WithAltScreen(),
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, opencode.EventListParams{})
for stream.Next() {
evt := stream.Current().AsUnion()
program.Send(evt)
}
if err := stream.Err(); err != nil {
slog.Error("Error streaming events", "error", err)
program.Send(err)
}
}()
go api.Start(ctx, program, httpClient)
// Handle signals in a separate goroutine
go func() {
sig := <-sigChan
slog.Info("Received signal, shutting down gracefully", "signal", sig)
tuiModel.Cleanup()
program.Quit()
}()
// Run the TUI
result, err := program.Run()
if err != nil {
slog.Error("TUI error", "error", err)
}
tuiModel.Cleanup()
slog.Info("TUI exited", "result", result)
}

View File

@@ -1,99 +0,0 @@
module github.com/sst/opencode
go 1.24.0
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.4
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
github.com/charmbracelet/x/ansi v0.9.3
github.com/fsnotify/fsnotify v1.8.0
github.com/google/uuid v1.6.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
golang.org/x/image v0.28.0
rsc.io/qr v0.2.0
)
replace (
github.com/charmbracelet/x/input => ./input
github.com/sst/opencode-sdk-go => ../sdk/go
)
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
require (
dario.cat/mergo v1.0.2 // indirect
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.7 // indirect
github.com/charmbracelet/x/windows v0.2.1 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/getkin/kin-openapi v0.127.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
github.com/sosodev/duration v1.3.1 // indirect
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/tools v0.34.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
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.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
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/pflag v1.0.6
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.26.0
gopkg.in/yaml.v3 v3.0.1 // indirect
)
tool (
github.com/atombender/go-jsonschema
github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
)

View File

@@ -1,313 +0,0 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
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.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.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/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=
github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY=
github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
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=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View File

@@ -1,14 +0,0 @@
//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
}

View File

@@ -1,143 +0,0 @@
//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
}

View File

@@ -1,25 +0,0 @@
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
}

View File

@@ -1,136 +0,0 @@
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
}

View File

@@ -1,7 +0,0 @@
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

View File

@@ -1,18 +0,0 @@
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
}

View File

@@ -1,6 +0,0 @@
// 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

View File

@@ -1,192 +0,0 @@
//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
// humanreadable events.
type Reader struct {
rd cancelreader.CancelReader
table map[string]Key // table is a lookup table for key sequences.
term string // $TERM
paste []byte // bracketed paste buffer; nil when disabled
buf [256]byte // read buffer
partialSeq []byte // holds incomplete escape sequences
keyState win32InputState
parser Parser
logger Logger
}
// NewReader returns a new input event reader.
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) }
// 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() }
func (d *Reader) readEvents() ([]Event, error) {
nb, err := d.rd.Read(d.buf[:])
if err != nil {
return nil, err
}
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
} else {
buf = d.buf[:nb]
}
// Fast path: direct lookup for simple escape sequences.
if bytes.HasPrefix(buf, []byte{0x1b}) {
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) {
consumed, ev := d.parser.parseSequence(buf[i:])
if d.logger != nil && consumed > 0 {
d.logger.Printf("input: %q", buf[i:i+consumed])
}
// Incomplete sequence store remainder and exit.
if consumed == 0 && ev == nil {
rem := len(buf) - i
if rem > 0 {
d.partialSeq = make([]byte, rem)
copy(d.partialSeq, buf[i:])
}
break
}
// Handle bracketed paste specially so we dont emit a paste event for
// every byte.
if d.paste != nil {
if _, ok := ev.(PasteEndEvent); !ok {
d.paste = append(d.paste, buf[i])
i++
continue
}
}
switch ev.(type) {
case PasteStartEvent:
d.paste = []byte{}
case PasteEndEvent:
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
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 += consumed
}
// Collapse bursts of wheel/motion events into a single event each.
events = coalesceMouseEvents(events)
return events, nil
}
// coalesceMouseEvents reduces the volume of MouseWheelEvent and MouseMotionEvent
// objects that arrive in rapid succession by keeping only the most recent
// event in each contiguous run.
func coalesceMouseEvents(in []Event) []Event {
if len(in) < 2 {
return in
}
out := make([]Event, 0, len(in))
for _, ev := range in {
switch ev.(type) {
case MouseWheelEvent:
if len(out) > 0 {
if _, ok := out[len(out)-1].(MouseWheelEvent); ok {
out[len(out)-1] = ev // replace previous wheel event
continue
}
}
case MouseMotionEvent:
if len(out) > 0 {
if _, ok := out[len(out)-1].(MouseMotionEvent); ok {
out[len(out)-1] = ev // replace previous motion event
continue
}
}
}
out = append(out, ev)
}
return out
}

View File

@@ -1,17 +0,0 @@
//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
}

View File

@@ -1,25 +0,0 @@
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)
}
}
}

View File

@@ -1,642 +0,0 @@
//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, vkc == xwindows.VK_OEM_PLUS, vkc == xwindows.VK_OEM_COMMA,
vkc == xwindows.VK_OEM_MINUS, vkc == xwindows.VK_OEM_PERIOD, vkc == xwindows.VK_OEM_2,
vkc == xwindows.VK_OEM_3, vkc == xwindows.VK_OEM_4, vkc == xwindows.VK_OEM_5,
vkc == xwindows.VK_OEM_6, vkc == xwindows.VK_OEM_7:
// Use the actual character provided by Windows for current keyboard layout
// instead of hardcoded US layout mappings
if !unicode.IsControl(r) && unicode.IsPrint(r) {
baseCode = r
} else {
// Fallback to original hardcoded mappings for non-printable cases
switch vkc {
case xwindows.VK_OEM_1:
baseCode = ';'
case xwindows.VK_OEM_PLUS:
baseCode = '+'
case xwindows.VK_OEM_COMMA:
baseCode = ','
case xwindows.VK_OEM_MINUS:
baseCode = '-'
case xwindows.VK_OEM_PERIOD:
baseCode = '.'
case xwindows.VK_OEM_2:
baseCode = '/'
case xwindows.VK_OEM_3:
baseCode = '`'
case xwindows.VK_OEM_4:
baseCode = '['
case xwindows.VK_OEM_5:
baseCode = '\\'
case xwindows.VK_OEM_6:
baseCode = ']'
case 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
// FIXED: Remove numlock and scroll lock states when checking for printable text
// These lock states shouldn't affect normal typing
cksForTextCheck := cks &^ (xwindows.NUMLOCK_ON | xwindows.SCROLLLOCK_ON)
var text string
keyCode := baseCode
if !unicode.IsControl(r) {
rw := utf8.EncodeRune(utf8Buf[:], r)
keyCode, _ = utf8.DecodeRune(utf8Buf[:rw])
if unicode.IsPrint(keyCode) && (cksForTextCheck == 0 ||
cksForTextCheck == xwindows.SHIFT_PRESSED ||
cksForTextCheck == 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)
}
}
// Special case: numeric keypad divide should produce "/" text on all layouts (fix french keyboard layout)
if baseCode == KeyKpDivide {
text = "/"
}
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()
}

View File

@@ -1,271 +0,0 @@
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,
}),
}
}

View File

@@ -1,9 +0,0 @@
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{}

View File

@@ -1,27 +0,0 @@
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")
}
}

View File

@@ -1,18 +0,0 @@
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
)

View File

@@ -1,19 +0,0 @@
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=

View File

@@ -1,45 +0,0 @@
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
}

View File

@@ -1,574 +0,0 @@
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",
}

View File

@@ -1,880 +0,0 @@
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 reproducibility.
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:])
}
}
}

View File

@@ -1,353 +0,0 @@
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
}

View File

@@ -1,37 +0,0 @@
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
}

View File

@@ -1,14 +0,0 @@
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
}

View File

@@ -1,292 +0,0 @@
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
}

View File

@@ -1,481 +0,0 @@
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

View File

@@ -1,47 +0,0 @@
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)
}
}

View File

@@ -1,13 +0,0 @@
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{}

View File

@@ -1,389 +0,0 @@
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: investigate 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
}

View File

@@ -1,54 +0,0 @@
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())
}

View File

@@ -1,277 +0,0 @@
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
}

View File

@@ -1,47 +0,0 @@
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

View File

@@ -1,41 +0,0 @@
package api
import (
"context"
"encoding/json"
"log"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
)
type Request struct {
Path string `json:"path"`
Body json.RawMessage `json:"body"`
}
func Start(ctx context.Context, program *tea.Program, client *opencode.Client) {
for {
select {
case <-ctx.Done():
return
default:
var req Request
if err := client.Get(ctx, "/tui/control/next", nil, &req); err != nil {
log.Printf("Error getting next request: %v", err)
continue
}
program.Send(req)
}
}
}
func Reply(ctx context.Context, client *opencode.Client, response interface{}) tea.Cmd {
return func() tea.Msg {
err := client.Post(ctx, "/tui/control/response", response, nil)
if err != nil {
return err
}
return nil
}
}

Some files were not shown because too many files have changed in this diff Show More