Compare commits

...
880 Commits
Author SHA1 Message Date
Dax Raad 400623f117 sync
publish / publish (push) Has been cancelled
2025-07-23 23:32:00 -04:00
Dax Raad 4b047d3dab sync 2025-07-23 22:57:44 -04:00
Dax Raad 550d2d3f99 sync 2025-07-23 22:57:21 -04:00
Dax Raad 149f133747 sync 2025-07-23 21:56:02 -04:00
Dax Raad ff23f67ad5 disable undo/redo for now 2025-07-23 21:02:13 -04:00
Rico Sta. CruzandGitHub 8f0644e35b fix: update max visible height in list tests (#1269) 2025-07-23 20:49:15 -04:00
Dax Raad 3fdd23df16 fix header width 2025-07-23 20:48:35 -04:00
Dax Raad 2c82ee592c wip: always force create snapshot 2025-07-23 20:46:43 -04:00
Dax Raad 1ad529db59 wip: fix redoing 2025-07-23 20:42:02 -04:00
96866e52ce basic undo feature (#1268)
Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Andrew Joslin <andrew@ajoslin.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Tobias Walle <9933601+tobias-walle@users.noreply.github.com>
2025-07-23 20:30:46 -04:00
Yihui KhuuandGitHub 507c975e92 feat: pass mode into task tool (#1248) 2025-07-23 20:29:59 -04:00
Aiden ClineandGitHub 3e69d5276b docs: remove deprecated 'log_level' reference in docs (#1258) 2025-07-23 18:53:58 -04:00
Aiden ClineandGitHub 289a4d9b18 tweak: handle pasted attachment references (#1257) 2025-07-23 15:41:17 -05:00
Tobias WalleandGitHub 12bf5f641d fix "working" spinner animation (#1054) (#1259) 2025-07-23 15:40:34 -05:00
Dax Raad 2051e85e96 remove providers path
publish / publish (push) Has been cancelled
2025-07-23 12:15:31 -04:00
Dax Raad 12b86829d9 add debug paths command 2025-07-23 12:14:54 -04:00
GitHub Action 6c9ec54129 ignore: update download stats 2025-07-23 2025-07-23 12:04:18 +00:00
Aiden ClineandGitHub b7b0cdbd7c tweak: ensure most recently interacted with session appears at the top (#1239) 2025-07-22 22:37:36 -05:00
Dax Raad fd98c3189a config: improve config schema
publish / publish (push) Has been cancelled
2025-07-22 20:35:40 -04:00
Jay V 1278353616 docs: edit ide 2025-07-22 19:02:30 -04:00
Andrew JoslinandGitHub 638ec7bc50 Allow multiline prompts for github agent (#1225) 2025-07-22 18:30:51 -04:00
Aiden ClineandGitHub 38ae7d60aa feat(tui): support pipe into tui (#1230) 2025-07-22 17:19:20 -05:00
Jay V 2d1f9fc321 docs: add tutorial closes #740 2025-07-22 17:54:53 -04:00
Frank ee0c8132db wip: vscode extension 2025-07-22 17:13:58 -04:00
Dax Raad c2208fa1f9 ci: error github api fail 2025-07-22 17:06:06 -04:00
Frank bf42d8b011 wip: vscode extension 2025-07-22 16:50:56 -04:00
Frank 0deb85fa45 wip: vscode extension
publish / publish (push) Has been cancelled
2025-07-22 16:46:44 -04:00
Frank da19b10703 wip: vscode extension 2025-07-22 16:46:44 -04:00
Frank 80b17dab44 wip: vscode extension 2025-07-22 16:46:44 -04:00
Dax Raad 6d2ffa82de ignore: lock changes 2025-07-22 15:49:36 -04:00
Dax Raad 7998c3b5ce wip: tui api 2025-07-22 15:49:24 -04:00
Frank 13def91e9a wip: vscode extension
publish-vscode / publish (push) Has been cancelled
2025-07-22 15:36:55 -04:00
Frank 26a40610dd wip: vscode extension 2025-07-22 15:28:09 -04:00
Frank db2fbed691 wip: vscode extension 2025-07-22 13:21:49 -04:00
Aiden ClineandGitHub 3d4c1425d9 tweak: cleanup cancelled markdown (#1222) 2025-07-22 12:08:03 -05:00
adamdotdevin 10c8b49590 chore: generate sdk into packages/sdk 2025-07-22 11:50:51 -05:00
Dax Raad 500cea5ce7 wip: append-prompt is better 2025-07-22 12:27:02 -04:00
Dax Raad 5aafab118f wip: tui api 2025-07-22 12:15:50 -04:00
Frank 01f8d3b05d wip: vscode extension 2025-07-22 11:21:29 -04:00
adamdotdevin 99d6a28249 fix(tui): more defensive attachment conversion
publish / publish (push) Has been cancelled
2025-07-22 09:28:13 -05:00
GitHub Action 5eaf7ab586 ignore: update download stats 2025-07-22 2025-07-22 12:04:22 +00:00
Aiden ClineandGitHub e4f754eee7 fix: mouse text selection bug (#1206) 2025-07-21 19:15:36 -05:00
Dax Raad f20ef61bc7 wip: api for tui 2025-07-21 19:53:58 -04:00
Frank 5611ef8b28 wip: vscode extension 2025-07-21 19:10:57 -04:00
Timo ClasenandGitHub bec796e3c3 feat(tui): add ctrl+p and ctrl-n to history navigation (#1199) 2025-07-21 15:10:50 -05:00
Frank 0bd8b2c72f wip: vscode extension
publish-vscode / publish (push) Has been cancelled
2025-07-21 15:48:46 -04:00
Dax Raad 5550ce47e1 ci: tweaks 2025-07-21 15:45:44 -04:00
Dax Raad 2d84dadc0c fix broken attachments
publish / publish (push) Has been cancelled
2025-07-21 15:38:41 -04:00
Dax Raad 45c0578b22 fix title generation bug
publish / publish (push) Has been cancelled
2025-07-21 15:23:47 -04:00
DaxandGitHub 1ded535175 message queuing (#1200)
publish / publish (push) Has been cancelled
2025-07-21 15:14:54 -04:00
adamdotdevin d957ab849b fix(tui): up/down arrow handling
publish / publish (push) Has been cancelled
2025-07-21 10:44:21 -05:00
4b2e52c834 feat(tui): paste minimizing (#784)
Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com>
2025-07-21 10:31:29 -05:00
Dax Raad 6867658c0f do not copy empty strings 2025-07-21 11:27:15 -04:00
Dax Raad b8620395cb include newline between messages when copying
publish / publish (push) Has been cancelled
2025-07-21 11:22:51 -04:00
Dax Raad 90d37c98f8 add toast for copy
publish / publish (push) Has been cancelled
2025-07-21 11:19:54 -04:00
adamelmore c9a40917c2 feat(tui): disable keybinds 2025-07-21 10:08:25 -05:00
adamelmore 0aa0e740cd docs: cleanup 2025-07-21 10:02:58 -05:00
adamelmore bb17d14665 feat(tui): theme override with OPENCODE_THEME 2025-07-21 10:02:57 -05:00
adamdotdevin cd0b2ae032 fix(tui): restore spinner ticks
publish / publish (push) Has been cancelled
2025-07-21 05:58:24 -05:00
adamdotdevin 8e8796507d feat(tui): message history select with up/down arrows
publish / publish (push) Has been cancelled
2025-07-21 05:52:11 -05:00
Aiden ClineandGitHub cef5c29583 fix: pasting issue (#1182) 2025-07-21 04:09:16 -05:00
Aiden ClineandGitHub acaed1f270 fix: export cmd (#1184) 2025-07-21 04:08:26 -05:00
DaxandGitHub cda0dbc195 Update STATS.md 2025-07-20 20:36:23 -04:00
Dax Raad 758425a8e4 trimmed selection ui
publish / publish (push) Has been cancelled
2025-07-20 19:36:56 -04:00
Dax Raad 93446df335 ignore: remove log 2025-07-20 19:08:19 -04:00
Dax Raad adc8b90e0f implement copy paste much wow can you believe we went this long without it so stupid i blame adam
publish / publish (push) Has been cancelled
2025-07-20 19:05:38 -04:00
Dax Raad 733c9903ec do not snapshot nongit projects for now
publish / publish (push) Has been cancelled
2025-07-20 13:59:30 -04:00
Frank 7306e20361 wip: vscode extension
publish-vscode / publish (push) Has been cancelled
2025-07-20 13:31:16 -04:00
Frank b4c7042c17 wip: vscode extension
publish-github-action / publish (push) Has been cancelled
publish-vscode / publish (push) Has been cancelled
2025-07-20 13:27:37 -04:00
Frank 6965787b33 wip: vscode extension 2025-07-20 13:17:51 -04:00
Frank ce064b8b0e wip: github action 2025-07-20 13:14:14 -04:00
Frank 0fc546fc6b wip: vscode extension 2025-07-20 13:13:18 -04:00
Frank 77ac9e5ec2 wip: github action 2025-07-20 13:13:00 -04:00
Frank af2c0b3695 wip: github action 2025-07-20 13:07:48 -04:00
Frank 811b22367d wip: github action 2025-07-20 12:41:02 -04:00
Frank 933d50e25a wip: github actions
publish-github-action / publish (push) Has been cancelled
2025-07-20 12:36:53 -04:00
Frank 800bee2722 wip: vscode extension 2025-07-20 12:00:09 -04:00
Dax Raad 5b4fb96c2e wip: make api logger sort correctly 2025-07-20 11:54:56 -04:00
Frank 1d20bf343d wip: vscode extension 2025-07-20 11:54:30 -04:00
Frank 79d9bf57f7 wip: vscode extension 2025-07-20 11:47:18 -04:00
Frank 7b63db6a13 wip: vscode extension 2025-07-20 11:45:35 -04:00
Frank 0e1565449e wip: vscode extension
publish-vscode / publish (push) Has been cancelled
publish / publish (push) Has been cancelled
2025-07-20 11:33:44 -04:00
GitHub Action f9a47fe5a3 ignore: update download stats 2025-07-20 2025-07-20 12:04:10 +00:00
adamdotdevin 2bf9d5d4ec wip: file part source in server/api (optional) 2025-07-20 05:39:18 -05:00
adamdotdevin c18f9ece69 chore: updated tui gitignore 2025-07-20 05:39:18 -05:00
adamdotdevin 4e3c73c4f5 chore: updated stainless script 2025-07-20 05:39:18 -05:00
b0tmtlandGitHub 8bf2eeccd0 fix(windows): resolve numlock and French keyboard input issues (#1165) 2025-07-20 05:28:15 -05:00
Dax Raad 6232e0fc58 fix bad layout on first render of chat history
publish / publish (push) Has been cancelled
2025-07-19 22:38:36 -04:00
Dax Raad a8b4aed446 fix bash tool rendering
publish / publish (push) Has been cancelled
2025-07-19 22:25:15 -04:00
Aiden ClineandGitHub 03de0c406d fix: title generation for certain providers (#1159) 2025-07-19 20:01:55 -05:00
Aiden ClineandGitHub faf8da8743 fix: adjust editor parsing to handle flags like --wait (#1160) 2025-07-19 20:01:25 -05:00
Dax Raad 3386908fd6 ci: ignore
publish / publish (push) Has been cancelled
2025-07-19 19:30:12 -04:00
Dax Raad 5a8847952a ci: ignore
publish / publish (push) Has been cancelled
2025-07-19 19:29:05 -04:00
Dax Raad 87d21ebf2b Revert "fix: prevent sparse spacing in hyphenated words (#1102)"
publish / publish (push) Has been cancelled
This reverts commit 2b44dbdbf1.
2025-07-19 19:25:15 -04:00
Timo ClasenandGitHub a524fc545c fix(hooks): prevent session_complete hook from firing on subagent sessions (#1149)
publish / publish (push) Has been cancelled
2025-07-19 18:20:07 -05:00
Dax Raad 4316edaf43 fix first run github copilot
publish / publish (push) Has been cancelled
2025-07-19 19:19:38 -04:00
Dax Raad d845924e8b ci: ignore
publish / publish (push) Has been cancelled
2025-07-19 19:00:17 -04:00
Dax Raad a29b322bdd ci: ignore
publish / publish (push) Has been cancelled
2025-07-19 18:54:46 -04:00
Dax Raad 9723ffa7a6 ignore: ci
publish / publish (push) Has been cancelled
2025-07-19 18:48:43 -04:00
Dax Raad f06cd88773 perf: more performance improvements
publish / publish (push) Has been cancelled
2025-07-19 18:41:21 -04:00
Dax Raad 9af92b6914 perf: scroll to bottom in thread
publish / publish (push) Has been cancelled
2025-07-19 17:55:01 -04:00
Dax Raad 8f64c4b312 disable todo tools when running as task
publish / publish (push) Has been cancelled
2025-07-19 15:54:11 -04:00
Dax Raad a32877e908 ignore: create memo abstraction
publish / publish (push) Has been cancelled
2025-07-19 15:26:26 -04:00
Dax Raad 6465c9c44a fix openrouter caching
publish / publish (push) Has been cancelled
2025-07-19 15:11:21 -04:00
Dax Raad 4699739814 shitty hack for terrible charm bubbletea performance
publish / publish (push) Has been cancelled
2025-07-19 15:00:11 -04:00
Dax Raad c1d87c32a2 remove log level from config 2025-07-19 13:37:02 -04:00
Aiden ClineandGitHub 9c5d9be33a fix: bullet display (#1148) 2025-07-19 12:36:50 -05:00
Aiden ClineandGitHub 97d9c851e6 fix: escape ansi sequences (#1139) 2025-07-19 12:02:24 -05:00
Dax Raad 76bd702992 docs: fix typo 2025-07-19 12:45:33 -04:00
Yihui KhuuandGitHub 50c453e577 feat(tui): collapse session header into single line when sharing is disabled (#1145) 2025-07-19 11:43:04 -05:00
Dax Raad 86d5b25d18 pass through model.options properly without having to nest it under provider name. you may have to update your configs see https://opencode.ai/docs/models/#openrouter for an example
publish / publish (push) Has been cancelled
2025-07-19 12:41:58 -04:00
TomandGitHub 2b44dbdbf1 fix: prevent sparse spacing in hyphenated words (#1102) 2025-07-19 09:28:40 -05:00
Dax Raad 4bbbbac5f6 vercel ai gateway
publish / publish (push) Has been cancelled
2025-07-19 10:08:36 -04:00
GitHub Action 3c3a997d2a ignore: update download stats 2025-07-19 2025-07-19 12:04:11 +00:00
CodinCatandGitHub 1676f8b5dd fix table heading rendering (#1138) 2025-07-18 20:17:22 -05:00
Dax Raad c87a7469a0 ci: rollback install script 2025-07-18 18:57:58 -04:00
Michael HansonandGitHub 132e26ddbf docs: Clarify MCP config instructions (#1026)
publish / publish (push) Has been cancelled
2025-07-18 16:04:29 -04:00
Rami ChowdhuryandGitHub f1da70b1de feat(provider): add Gemini tool schema sanitization (#1132) 2025-07-18 16:02:54 -04:00
Aiden ClineandGitHub 5c9d1910af fix: func called before definition (#1134) 2025-07-18 15:00:32 -05:00
Timo ClasenandGitHub 18abcab208 feat(config): make small model configurable (#1030)
publish / publish (push) Has been cancelled
2025-07-18 14:16:50 -04:00
opencode-agent[bot]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>thdxr
01e7dc2d02 Added install dir priority & user feedback (#1129)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2025-07-18 14:15:10 -04:00
adamdotdevin 611854e4b6 feat(tui): simpler layout, always stretched 2025-07-18 13:03:27 -05:00
DaxandGitHub d56dec4ba7 wip: optional IDs in api (#1128)
publish / publish (push) Has been cancelled
2025-07-18 13:42:50 -04:00
Dax Raad c952e9ae3d message rendering performance improvements 2025-07-18 13:40:07 -04:00
GitHub Action 6470243095 ignore: update download stats 2025-07-18 2025-07-18 12:04:28 +00:00
GitHub Action c8321cfbd9 ignore: update download stats 2025-07-18 2025-07-18 12:02:18 +00:00
Yihui KhuuandGitHub 46c246e01f fix: \{return} should be replaced with new line on all lines (#1119) 2025-07-18 06:22:36 -05:00
adamdotdevin 9964d8e6c0 fix: model cost overrides
publish / publish (push) Has been cancelled
2025-07-18 05:08:35 -05:00
Timo ClasenandGitHub df33143396 feat(tui): parse for file attachments when exiting EDITOR (#1117) 2025-07-18 04:47:20 -05:00
Aiden ClineandGitHub 571aeaaea2 tweak: remove needless resorting (#1116) 2025-07-18 04:42:43 -05:00
Aiden ClineandGitHub edfea03917 tweak: fix [object Object] in logging (#1114) 2025-07-18 04:41:23 -05:00
TomandGitHub 81c88cc742 fix(tui): ensure viewport scrolls to bottom on new messages (#1110) 2025-07-18 04:41:03 -05:00
Mike WallioandGitHub 99b9390d80 Update to a customized beast mode v3 for opencode. (#1109) 2025-07-17 20:10:06 -05:00
Dax Raad 23c30521d8 only enable ruff if it seems to be used
publish / publish (push) Has been cancelled
2025-07-17 18:07:06 -04:00
Wendell MisiedjanandGitHub e681d610de feat: support AWS_BEARER_TOKEN_BEDROCK for amazon bedrock provider autoloading (#1094)
publish / publish (push) Has been cancelled
2025-07-17 09:12:30 -05:00
Aiden ClineandGitHub a1fdeded3e tweak: allow mcp servers to include headers (#1096) 2025-07-17 09:11:48 -05:00
GitHub Action 2051312d12 ignore: update download stats 2025-07-17 2025-07-17 14:07:13 +00:00
Alexander DrottsgårdandGitHub 20cb7a76af feat(tui): highlight current session in sessions modal (#1093) 2025-07-17 07:40:15 -05:00
Timo ClasenandGitHub a493aec174 feat(tui): remove share commands from help if sharing is disabled (#1087) 2025-07-17 04:28:12 -05:00
Aiden ClineandGitHub 3ce3ac8e61 fix: message error centering (#1085) 2025-07-17 04:27:40 -05:00
Timo ClasenandGitHub 91ad64feda fix(tui): user defined ctrl+z should take precedence over suspending (#1088) 2025-07-17 04:27:02 -05:00
Timo ClasenandGitHub 60b55f9d92 feat(tui): remove sharing info from session header when sharing is disabled (#1076) 2025-07-16 17:36:48 -05:00
Timo ClasenandGitHub 3c6c2bf13b docs(share): add explicit manual share mode (#1074) 2025-07-16 16:08:25 -05:00
Aiden ClineandGitHub d4f9375548 fix: type 'reasoning' was provided without its required following item (#1072) 2025-07-16 15:59:40 -05:00
Jay V 28b39f547e docs: edit 2025-07-16 16:59:12 -04:00
Jay V 7520f5efa8 docs: update enterprise doc 2025-07-16 16:44:28 -04:00
Jay V eb4cdf4b20 docs: config doc 2025-07-16 16:27:44 -04:00
Jay V 9f6fc1c3c5 docs: edits 2025-07-16 16:20:09 -04:00
Mike WallioandGitHub dfede9ae6e Remove binary file opencode (#1069) 2025-07-16 15:10:40 -05:00
Daniel Saldarriaga LópezandGitHub fc45c0c944 docs: fix keybinds documentation to match actual config schema (#867)
publish / publish (push) Has been cancelled
2025-07-16 15:34:52 -04:00
adamdotdevin 9d869f784c fix(tui): expand edit calls 2025-07-16 14:33:57 -05:00
adamdotdevin bd244f73af fix(tui): slightly faster scroll speed 2025-07-16 14:26:46 -05:00
Dax Raad dd34556e9c only include severity 1 diagnostics from lsp in edit tool output
publish / publish (push) Has been cancelled
2025-07-16 15:25:37 -04:00
adamdotdevin f7dd48e60d feat(tui): more ways to quit 2025-07-16 14:20:28 -05:00
Dax Raad 93c779cf48 docs: better variable examples 2025-07-16 14:56:24 -04:00
adamdotdevin 360c04c542 docs: copying text 2025-07-16 13:26:26 -05:00
adamdotdevin 529fd57e75 fix: missing dependency
publish / publish (push) Has been cancelled
2025-07-16 12:58:29 -05:00
adamdotdevin faea3777e1 fix: missing dependency 2025-07-16 12:56:11 -05:00
Aiden ClineandGitHub a4664e2344 fix: generate title should use same options as model it uses to gen (#1064) 2025-07-16 12:46:52 -05:00
adamdotdevin cdc1d8a94d feat(tui): layout config to render full width
publish / publish (push) Has been cancelled
2025-07-16 12:43:02 -05:00
Jay V fdd6d6600f docs: rename workflow 2025-07-16 13:38:00 -04:00
Jay V 9f44cfd595 docs: discord releases 2025-07-16 13:17:04 -04:00
Aiden ClineandGitHub 70229b150c Fix: better title generation (needs to change due to small models) (#1059) 2025-07-16 11:47:56 -05:00
John Henry RuddenandGitHub 050ff943a6 Fix: Add escape sequence for @ symbols to prevent send blocking (#1029) 2025-07-16 11:18:48 -05:00
TomandGitHub 88b58fd6a0 fix: Prevent division by zero in context percentage calculation (#1055) 2025-07-16 09:35:20 -05:00
Jeremy MackandGitHub 5d67e13df5 fix: grep omitting text after a colon (#1053) 2025-07-16 09:09:05 -05:00
Adi YeroslavandGitHub 57d1a60efc feat(tui): shift+tab to cycle modes backward (#1049) 2025-07-16 07:43:48 -05:00
Nipuna PereraandGitHub add81b9739 Enhance private npm registry support (#998) 2025-07-16 08:31:38 -04:00
GitHub Action 81bdb8e269 ignore: update download stats 2025-07-16 2025-07-16 12:04:30 +00:00
adamdotdevin a563fdd287 fix(tui): diagnostics rendering 2025-07-16 06:55:14 -05:00
adamdotdevin 7c93bf5993 fix(tui): pending tool call width 2025-07-16 06:27:32 -05:00
adamdotdevin 6a5a4247c6 fix(gh): build 2025-07-16 06:13:43 -05:00
adamdotdevin a39136a2a0 fix(tui): render attachments in user messages in accent color 2025-07-16 06:09:27 -05:00
adamdotdevin 9f5b59f336 chore: messages cleanup 2025-07-16 06:09:27 -05:00
adamdotdevin 01c125b058 fix(tui): faster cache algo 2025-07-16 06:09:27 -05:00
adamdotdevin d41aa2bc72 chore(tui): simplify messages component, remove navigate, add copy last message 2025-07-16 06:09:26 -05:00
Robin MoserandGitHub f45deb37f0 fix: don't sign snapshot commits (#1046) 2025-07-16 04:46:32 -05:00
Matias InsaurraldeandGitHub e89972a396 perf: move ANSI regex compilations to package level (#1040)
Signed-off-by: Matías Insaurralde <matias@insaurral.de>
2025-07-16 04:20:25 -05:00
Frank c3c647a21a wip: github actions 2025-07-16 16:20:06 +08:00
Frank b79167ce66 sync 2025-07-16 16:12:31 +08:00
Frank 7ac0a2bc65 wip: github actions 2025-07-16 16:05:51 +08:00
FrankandGitHub cb032cff2b wip: github actions 2025-07-16 03:57:14 -04:00
FrankandGitHub 867a69a751 wip: github actions 2025-07-16 03:54:20 -04:00
Frank 20b8efcc50 wip: github actions 2025-07-16 15:36:23 +08:00
Frank a86d42149f wip: github actions 2025-07-16 14:59:53 +08:00
Frank 82a36acfe3 wip: github action 2025-07-16 14:59:53 +08:00
Dax Raad 0793c3f2a3 clean up export command
publish / publish (push) Has been cancelled
2025-07-15 21:50:43 -04:00
Dax Raad 5c860b0d69 fix share page v1 message 2025-07-15 21:35:32 -04:00
Dax Raad 05bb127a8e enable bash tool in plan mode 2025-07-15 21:28:03 -04:00
aronandGitHub 1bbd84008f move spoof prompt to support anthropic with custom modes (#1031)
publish / publish (push) Has been cancelled
2025-07-15 21:16:27 -04:00
fdfd4d69d3 add support for modified gemini-cli system prompt (#1033)
Co-authored-by: Dax Raad <d@ironbay.co>
2025-07-15 21:13:11 -04:00
JayandGitHub 7f659cce36 docs: Update README.md 2025-07-15 20:09:26 -04:00
Jay V 48fcaa83be docs: fix config 2025-07-15 19:54:51 -04:00
Jay V 70c16c4c95 docs: adding action to notify discord 2025-07-15 19:49:38 -04:00
Jay V c1e1ef6eb5 docs: readme 2025-07-15 18:32:04 -04:00
Jay V bb155db8b2 docs: share tweak copy button 2025-07-15 18:25:25 -04:00
7c91f668d1 docs: share add copy button to messages in web interface (#902)
Co-authored-by: Jay <air@live.ca>
2025-07-15 17:56:33 -04:00
Jay V 1af103d29e docs: share handle non bundled langs 2025-07-15 17:47:22 -04:00
Jay V 8a3e581edc docs: share fix diff bugs 2025-07-15 17:47:22 -04:00
Jay V 749e7838a4 docs: share page task tool 2025-07-15 17:47:22 -04:00
Dax Raad 73b46c2bf9 docs: document base URL 2025-07-15 14:57:50 -04:00
8bd250fb15 feat(tui): add /export command to export conversation to editor (#989)
Co-authored-by: opencode <noreply@opencode.ai>
2025-07-15 13:53:21 -05:00
Dax Raad b1ab641905 add small model for title generation
publish / publish (push) Has been cancelled
2025-07-15 14:00:52 -04:00
adamdotdevin 76e256ed64 fix(tui): wider max width
publish / publish (push) Has been cancelled
2025-07-15 12:44:41 -05:00
adamdotdevin 4f955f2127 fix(tui): mouse scroll ansi parsing and perf 2025-07-15 12:03:30 -05:00
Aiden ClineandGitHub bbeb579d3a tweak: (opencode run): adjust tool call rendering, reduce number of "Unknowns" (#1012) 2025-07-15 11:22:57 -05:00
Timo ClasenandGitHub f707fb3f8d feat(tui): add keymap to remove entries from recently used models (#1019) 2025-07-15 11:20:56 -05:00
adamdotdevin 6b98acb7be chore: update stainless defs 2025-07-15 10:03:11 -05:00
adamdotdevin 2487b18f62 chore: update stainless script to kick off prod build 2025-07-15 08:15:31 -05:00
adamdotdevin 533f64fe26 fix(tui): rework lists and search dialog 2025-07-15 08:07:26 -05:00
Dax Raad b5c85d3806 fix logic for suprpessing snapshots in big directories
publish / publish (push) Has been cancelled
2025-07-15 09:07:04 -04:00
Dax Raad bcf952bc8a upgrade ai sdk 2025-07-15 09:06:35 -04:00
GitHub Action a6dc75a44c ignore: update download stats 2025-07-15 2025-07-15 12:04:28 +00:00
Joohoon ChaandGitHub 416daca9c6 fix(tui): close completion dialog on ctrl+h (#1005) 2025-07-15 06:24:05 -05:00
Aiden ClineandGitHub 636fe0fb64 Fix: failed to open session (#999) 2025-07-15 05:40:29 -05:00
Frank 95e0957d64 wip: github actions 2025-07-15 17:45:16 +08:00
Dax Raad 2eefdae6a9 ignore: fix types 2025-07-15 00:56:03 -04:00
Dax Raad d62746ceb7 fix panic
publish / publish (push) Has been cancelled
2025-07-15 00:35:02 -04:00
Dax Raad 4b2ce14ff3 bring back task tool
publish / publish (push) Has been cancelled
2025-07-15 00:05:54 -04:00
294a11752e fix: --continue pull the latest session id consistently (#918)
Co-authored-by: Dax Raad <d@ironbay.co>
2025-07-14 20:32:00 -04:00
Dax Raad 1cf1d1f634 docs: fix agents.md 2025-07-14 20:23:05 -04:00
Ryan Roden-CorrentandGitHub 2ce694d41f Add support for job-control suspend (ctrl+z/SIGSTP). (#944) 2025-07-14 20:13:46 -04:00
CodinCatandGitHub d6eff3b3a3 improve error handling and logging for GitHub API failures in upgrade and install script (#972) 2025-07-14 20:13:12 -04:00
Dax Raad e63a6d45c1 docs: README 2025-07-14 20:10:43 -04:00
Dax Raad 93686519ba docs: README 2025-07-14 20:06:15 -04:00
Mike WallioandGitHub f593792fb5 Standardize parameter description references in Edit and MultiEdit tools (#984) 2025-07-14 20:03:59 -04:00
Dax Raad 2cdb37c32b support anthropic console login flow 2025-07-14 18:07:55 -04:00
Timo ClasenandGitHub 535d79b64c docs: fix typo (#982) 2025-07-14 16:40:16 -04:00
Dax Raad b4e4c3f662 wip: snapshot 2025-07-14 15:29:08 -04:00
adamdotdevin ba676e7ae0 fix(tui): support readline nav in new search component
publish / publish (push) Has been cancelled
2025-07-14 12:20:58 -05:00
adamdotdevin a1c8e5af45 chore: use new search component in find dialog 2025-07-14 12:15:47 -05:00
adamdotdevin f1e7e7c138 feat(tui): even better model selector 2025-07-14 12:15:46 -05:00
Dax Raad 80b77caec0 ignore: share page fix 2025-07-14 13:13:33 -04:00
Dorian KarterandGitHub 86a2ea44b5 feat(tui): add support for readline list nav (ctrl-p/ctrl-n) (#955) 2025-07-14 10:21:09 -05:00
Dax Raad a2002c88c6 wip: update sdk
publish / publish (push) Has been cancelled
2025-07-14 11:18:08 -04:00
opencode-agent[bot]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>thdxr
d8bcf4f4e7 Fix issue: Option to update username shown in conversations. (#975)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2025-07-14 11:03:04 -04:00
Dax Raad 31e0326f78 fix init command and escape to cancel
publish / publish (push) Has been cancelled
2025-07-14 10:48:17 -04:00
adamdotdevin a53d2ea356 fix(tui): build and bg color 2025-07-14 09:14:02 -05:00
adamdotdevin 229a280652 fix(tui): find dialog bg color 2025-07-14 09:09:55 -05:00
Nicholas HamiltonandGitHub 8d0350d923 feat: ability to create new session from session dialog (#920) 2025-07-14 09:04:43 -05:00
Almir SarajčićandGitHub 4192d7eacc Fix failing git hooks (#966) 2025-07-14 07:52:29 -05:00
Munawwar FirozandGitHub 7b8b4cf8c7 feat: ctrl+left arrow / ctrl+right arrow key support (#969) 2025-07-14 07:16:06 -05:00
Almir SarajčićandGitHub 1f4de75348 Explain usage of external references in AGENTS.md (#965) 2025-07-14 07:06:37 -05:00
GitHub Action 457755c690 ignore: update download stats 2025-07-14 2025-07-14 12:04:16 +00:00
Aiden ClineandGitHub 052a1e7514 fix: file command visual bug (#959) 2025-07-14 07:03:02 -05:00
139d6e2818 Fix clipboard on Wayland systems (#941)
Co-authored-by: Daniel Nouri <daniel@redhotcar>
2025-07-14 06:57:45 -05:00
Dax Raad 06554efdf4 get rid of cli markdown dep
publish / publish (push) Has been cancelled
2025-07-13 23:06:31 -04:00
Dax Raad 67e9bda94f ci
publish / publish (push) Has been cancelled
2025-07-13 22:58:33 -04:00
Dax Raad 53bb6b4c4f fix missing tokens
publish / publish (push) Has been cancelled
2025-07-13 22:56:29 -04:00
Dax Raad 73d54c7068 fix type error 2025-07-13 17:25:13 -04:00
DaxandGitHub 90d6c4ab41 Part data model (#950) 2025-07-13 17:22:11 -04:00
opencode-agent[bot]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>thdxr
736396fc70 Added sharing config with auto/disabled options (#951)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2025-07-13 16:43:58 -04:00
Dax Raad 177bfed93e ci: github action 2025-07-13 16:22:58 -04:00
Dax Raad 91f8477ef5 wip: mcp 2025-07-13 16:22:16 -04:00
John Henry RuddenandGitHub f04a5e50ee fix: deduplicate command suggestions (#934) 2025-07-13 14:47:26 -05:00
Aiden ClineandGitHub bb28b70700 Fix: title generation (#949) 2025-07-13 14:46:36 -05:00
Frank 7361a02ef3 wip: github actions 2025-07-13 23:59:25 +08:00
GitHub Action d465f150fc ignore: update download stats 2025-07-13 2025-07-13 12:04:11 +00:00
Dax Raad 17fa8c117b fix packages being reinstalled on every start
publish / publish (push) Has been cancelled
2025-07-12 12:41:12 -04:00
Muzammil KhanandGitHub 9aa0c40a00 feat: add more ignore patterns to the ls tool (#913) 2025-07-12 12:06:58 -04:00
GitHub Action fd4648da17 ignore: update download stats 2025-07-12 2025-07-12 12:03:59 +00:00
Dax Raad aadca5013a fix share page timestamps 2025-07-11 21:49:20 -04:00
Dax Raad 5c3d490e59 share page hide step-finish events 2025-07-11 21:45:56 -04:00
Dax Raad 1254f48135 fix issue preventing things from working when node_modules or package.json present in ~/
publish / publish (push) Has been cancelled
2025-07-11 21:09:39 -04:00
Dax Raad 1729c310d9 switch global config to ~/.config/opencode/opencode.json
publish / publish (push) Has been cancelled
2025-07-11 20:51:23 -04:00
Dax Raad 0130190bbd docs: add model docs 2025-07-11 20:33:06 -04:00
Aiden ClineandGitHub 97a31ddffc tweak: plan interactions should match web (TUI) (#895) 2025-07-11 18:03:22 -04:00
zWingandGitHub 3249420ad1 fix: avoid overwriting the provider.option.baseURL (#880) 2025-07-11 18:01:28 -04:00
Dax Raad 4bb8536d34 introduce cache version concept for auto cleanup when breaking cache changes happen
publish / publish (push) Has been cancelled
2025-07-11 17:50:49 -04:00
JayandGitHub c73d4a137e docs: Update troubleshooting.mdx 2025-07-11 17:50:25 -04:00
Dax Raad 57ac8f2741 wip: stats 2025-07-11 17:37:41 -04:00
Jay V 2f1acee5a1 docs: share page add time footer back
publish / publish (push) Has been cancelled
2025-07-11 14:24:20 -04:00
Jay V 9ca54020ac docs: share page mobile bugs 2025-07-11 14:24:20 -04:00
Jay V f7d44b178b docs: share fix mobile diffs 2025-07-11 14:24:20 -04:00
b4950a157c fix(session): add fallback for undefined output token limit (#860)
Co-authored-by: opencode <noreply@opencode.ai>
2025-07-11 10:55:13 -04:00
alexzandGitHub dfbef066c7 fix: ENAMETOOLONG: name too long when adding custom mode (#881) 2025-07-11 10:54:52 -04:00
GitHub Action 26fd76fbee ignore: update download stats 2025-07-11 2025-07-11 12:04:08 +00:00
adamdotdevin 04769d8a26 fix(tui): help commands bg color 2025-07-11 06:03:21 -05:00
adamdotdevin 34b576d9b5 fix(tui): don't include /mode trigger 2025-07-11 06:01:51 -05:00
adamdotdevin 22b244f847 fix(tui): actually fix mouse ansi codes leaking 2025-07-11 06:00:20 -05:00
Aiden ClineandGitHub 7e1fc275e7 fix: avoid worker exception, graceful 404 (#869) 2025-07-11 04:55:56 -05:00
Frank 3b9b391320 wip: github actions 2025-07-11 06:55:13 +08:00
Frank 766bfd025c wip: github actions 2025-07-11 05:23:24 +08:00
Jay V c7f30e1065 docs: share page fix terminal part 2025-07-10 17:21:21 -04:00
Frank 1c4fd7f28f Api: add endpoint for getting github app token 2025-07-11 05:01:27 +08:00
adamdotdevin 85805d2c38 fix(tui): handle SIGTERM, closes #319
publish / publish (push) Has been cancelled
2025-07-10 15:59:03 -05:00
Timo ClasenandGitHub 982cb3e71a fix(tui): center help dilaog (#853) 2025-07-10 15:56:19 -05:00
adamdotdevin 294d0e7ee3 fix(tui): mouse wheel ansi codes leaking into editor 2025-07-10 15:49:58 -05:00
Jay V 8be1ca836c docs: fix diag styles 2025-07-10 16:38:51 -04:00
Jay V 2e5f96fa41 docs: share page attachment 2025-07-10 16:38:51 -04:00
Dax Raad c056b0add9 add step finish part
publish / publish (push) Has been cancelled
2025-07-10 16:25:38 -04:00
Dax Raad b00bb3c083 run: properly close session.list 2025-07-10 16:13:01 -04:00
Dax Raad d9befd3aa6 disable filewatcher, fixes file descriptor leak
publish / publish (push) Has been cancelled
2025-07-10 15:58:45 -04:00
Dax Raad 49de703ba1 config: escape file: string content
publish / publish (push) Has been cancelled
2025-07-10 15:38:58 -04:00
Dax Raad 22988894c8 ci: slow down stats 2025-07-10 15:31:06 -04:00
adamdotdevin 34b1754f25 docs: clipboard requirements on linux 2025-07-10 13:12:37 -05:00
adamdotdevin 54fe3504ba feat(tui): accent editor border on leader key 2025-07-10 12:57:22 -05:00
Jay V d2c862e32d docs: edit local models 2025-07-10 13:49:24 -04:00
Jay V afc53afb35 docs: edit mode 2025-07-10 13:29:37 -04:00
Gabriel GarrettandGitHub b56e49c5dc Adds real example in docs of how to configure custom provider (#840) 2025-07-10 13:29:30 -04:00
Aiden ClineandGitHub 8b2a909e1f fix: encode & decode file paths (#843) 2025-07-10 11:19:54 -05:00
Jay V e9c954d45e docs: add modes to sidebar 2025-07-10 12:07:44 -04:00
Jay V 6f449d13af docs: add modes to sidebar 2025-07-10 12:07:18 -04:00
Dax Raad 6e375bef0d docs: modes
publish / publish (push) Has been cancelled
2025-07-10 11:53:28 -04:00
Dax Raad 67106a6967 docs: add config variable docs 2025-07-10 11:48:55 -04:00
Dax Raad b5d690620d support env and file pointers in config 2025-07-10 11:45:31 -04:00
Dax Raad 9db3ce1d0b opencode run respects mode 2025-07-10 11:28:28 -04:00
Dax Raad 1cc55b68ef wip: scrap 2025-07-10 11:25:37 -04:00
Dax Raad 469f667774 set max output token limit to 32_000 2025-07-10 11:25:37 -04:00
adamdottv 6603d9a9f0 feat: --mode flag passed to tui 2025-07-10 10:19:25 -05:00
adamdottv 5dc1920a4c feat: mode flag in cli run command 2025-07-10 10:13:15 -05:00
adamdottv d3e5f3f3a8 feat(tui): add token and cost info to session header 2025-07-10 10:06:51 -05:00
adamdottv ce4cb820f7 feat(tui): modes 2025-07-10 10:06:51 -05:00
Dax Raad ba5be6b625 make LSP lazy again
publish / publish (push) Has been cancelled
2025-07-10 09:37:40 -04:00
adamdottv f95c3f4177 fix(tui): fouc in textarea on app load 2025-07-10 08:20:17 -05:00
adamdottv d2b1307bff fix(tui): textarea cursor sync issues with attachments 2025-07-10 07:49:36 -05:00
adamdottv b40ba32adc fix(tui): textarea issues 2025-07-10 07:38:57 -05:00
GitHub Action ce0cebb7d7 ignore: update download stats 2025-07-10 2025-07-10 12:04:15 +00:00
Dax Raad f478f89a68 temporary grok 4 patch
publish / publish (push) Has been cancelled
2025-07-10 07:57:55 -04:00
Dax Raad 85d95f0f2b disable lsp on non-git folders 2025-07-10 07:39:02 -04:00
Dax Raad 1515efc77c fix session is busy error
publish / publish (push) Has been cancelled
2025-07-10 07:27:03 -04:00
Josh MedeskiandGitHub 6d393759e1 feat(tui): subsitute cwd home path on status bar (#808) 2025-07-10 06:12:19 -05:00
Adi YeroslavandGitHub a1701678cd feat(tui): /editor - change the auto-send behavior to put content in input box instead (#827) 2025-07-10 05:57:52 -05:00
Timo ClasenandGitHub c411a26d6f feat(tui): hide cost if using subscription model (#828) 2025-07-10 05:56:36 -05:00
adamdottv 85dbfeb314 feat(tui): @symbol attachments 2025-07-10 05:53:00 -05:00
Dax Raad 085c0e4e2b respect go.work when spawning LSP
publish / publish (push) Has been cancelled
2025-07-09 22:54:47 -04:00
Dax Raad 8404a97c3e better detection of prettier formatter
publish / publish (push) Has been cancelled
2025-07-09 22:37:31 -04:00
Dax Raad 0ee3b1ede2 do not wait for LSP to be fully ready
publish / publish (push) Has been cancelled
2025-07-09 21:59:38 -04:00
Dax Raad a826936702 modes concept 2025-07-09 21:59:38 -04:00
Jay V fd4a5d5a63 docs: share doc edit 2025-07-09 20:26:31 -04:00
Jay V 69cf1d7b7e docs: share doc 2025-07-09 20:24:09 -04:00
Jay V 8e0a1d1167 docs: edit troubleshooting 2025-07-09 19:55:14 -04:00
Timo ClasenandGitHub f22021187d feat(tui): treat pasted text file paths as file references (#809) 2025-07-09 18:37:39 -05:00
Jay V febecc348a docs: enterprise doc 2025-07-09 15:46:57 -04:00
Jay V c5ccfc3e94 docs: share page last part fix 2025-07-09 15:46:57 -04:00
1f6efc6b94 Add gpt-4.1 beast prompt (#778)
publish / publish (push) Has been cancelled
Co-authored-by: Dax Raad <d@ironbay.co>
2025-07-09 12:11:54 -04:00
Frank DenisandGitHub 727fe6f942 LSP: fix SimpleRoots to actually search in the root directory (#795) 2025-07-09 10:35:06 -05:00
Dax Raad a91e79382e ci: remove checked in config.schema.json 2025-07-09 11:30:42 -04:00
Dax Raad 5c626e0a2f ci: generate config schema as part of build 2025-07-09 11:25:58 -04:00
adamdottv 8e9e383219 chore: troubleshooting docs 2025-07-09 10:12:36 -05:00
Dax Raad f383008cc1 lsp: spawn only a single tsserver in project root
publish / publish (push) Has been cancelled
2025-07-09 11:06:44 -04:00
adamdottv 303ade25ed feat: discord redirect 2025-07-09 10:01:42 -05:00
adamdottv 53f8e7850e feat: configurable log levels 2025-07-09 10:00:03 -05:00
adamdottv ca8ce88354 feat(tui): move logging to server logs 2025-07-09 08:16:10 -05:00
adamdottv 37a86439c4 fix(tui): don't panic on missing linux clipboard tool 2025-07-09 06:51:58 -05:00
adamdottv 269b43f4de fix(tui): markdown wrapping off sometimes 2025-07-09 06:41:53 -05:00
adamdottv 3f25e5bf86 chore: internal clipboard package 2025-07-09 04:55:24 -05:00
Aiden ClineandGitHub 67765fa47c tweak: keep completion options open when trigger is still present (#789) 2025-07-09 04:42:31 -05:00
adamdottv 58b1c58bc5 fix(tui): clear command priority
publish / publish (push) Has been cancelled
2025-07-08 19:26:50 -05:00
Dax Raad d80badc50f ci: ignore chore commits 2025-07-08 20:05:33 -04:00
Dax Raad 75279e5ccf wip: symbols endpoint 2025-07-08 20:05:33 -04:00
Yihui KhuuandGitHub 7893b84614 Add debounce before exit when using non-leader exit command (#759) 2025-07-08 18:53:38 -05:00
Dax Raad cfc715bd48 wip: remove excess import
publish / publish (push) Has been cancelled
2025-07-08 19:51:09 -04:00
adamdottv 39bcba85a9 chore: vendor clipboard into go package 2025-07-08 18:48:40 -05:00
adamdottv da3df51316 chore: remove clipboard temp 2025-07-08 18:47:59 -05:00
adamdottv 12190e4efc chore: vendor clipboard into go package 2025-07-08 18:46:42 -05:00
Aiden ClineandGitHub d2a9b2f64a fix: documentation typo (#781) 2025-07-08 18:30:46 -05:00
adamdottv aacadd8a8a fix(tui): panic when reading/writing clipboard on linux 2025-07-08 18:29:45 -05:00
Jay V 969154a473 docs: share page image 2025-07-08 19:24:21 -04:00
Jay V 4d6ca3fab1 docs: share page many model case 2025-07-08 19:08:33 -04:00
Dax Raad 00ea5082e7 add typescript lsp timeout if it fails to start
publish / publish (push) Has been cancelled
2025-07-08 18:33:12 -04:00
Dax Raad 4a878b88c0 properly load typescript lsp in subpaths
publish / publish (push) Has been cancelled
2025-07-08 18:18:45 -04:00
Dax Raad 6de955847c big rework of LSP system 2025-07-08 18:14:49 -04:00
Jay V 3ba5d528b4 docs: share bugs 2025-07-08 18:14:36 -04:00
Jay V f99e2b3429 docs: share error part 2025-07-08 18:00:08 -04:00
Jay V 7e4e6f6e51 docs: share page bugs 2025-07-08 17:18:38 -04:00
Jay V 0514f3f43b docs: share image model 2025-07-08 17:18:38 -04:00
1e07384364 fix: make compact command interruptible (#691)
publish / publish (push) Has been cancelled
Co-authored-by: GitHub Action <action@github.com>
2025-07-08 15:37:25 -05:00
4c4739c422 fix(tool): fix ripgrep invocation on Windows (#700)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-07-08 15:36:26 -05:00
Rami ChowdhuryandGitHub 2d8b90a6ff feat(storage): ensure storage directory exists and handle paths correctly (#771) 2025-07-08 15:34:11 -05:00
Robb CurrallandGitHub a2fa7ffa42 fix: support cancelled task state (#775) 2025-07-08 15:33:39 -05:00
Frank DenisandGitHub f7d6175283 Add support for the Zig Language Server (ZLS) (#756) 2025-07-08 15:31:11 -05:00
TommyandGitHub 9ed187ee52 docs: add terminal requirements (#708) 2025-07-08 15:30:05 -05:00
Gal SchlezingerandGitHub 14d81e574b [config json schema] declare default values and examples for in-ide documentation (#754) 2025-07-08 15:29:07 -05:00
adamdottv 6efe8cc8df fix: env has to be string
publish / publish (push) Has been cancelled
2025-07-08 14:59:03 -05:00
adamdottv daa5fc916a fix(tui): pasting causes panic on macos 2025-07-08 14:57:17 -05:00
adamdottv c659496b96 fix(tui): model/provider arg parsing 2025-07-08 14:11:57 -05:00
Timo ClasenandGitHub 21fbf21cb6 fix(copilot): add vision request header (#773) 2025-07-08 14:01:54 -05:00
adamdottv f31cbf2744 fix: image reading
publish / publish (push) Has been cancelled
2025-07-08 13:02:13 -05:00
Aiden ClineandGitHub 8322f18e03 fix: display errors when using opencode run ... (#751) 2025-07-08 10:38:11 -05:00
adamdottv 562bdb95e2 fix: include symlinks in ripgrep searches 2025-07-08 10:02:19 -05:00
DaxandGitHub a57ce8365d Update STATS.md 2025-07-08 10:30:02 -04:00
adamdottv 0da83ae67e feat(tui): command aliases 2025-07-08 08:20:55 -05:00
adamdottv 662d022a48 feat(tui): paste images and pdfs 2025-07-08 08:09:01 -05:00
GitHub Action 9efef03919 ignore: update download stats 2025-07-08 2025-07-08 12:04:27 +00:00
GitHub Action 7a9fb3fa92 ignore: update download stats 2025-07-08 2025-07-08 10:51:06 +00:00
adamdottv ea96ead346 feat(tui): handle --model and --prompt flags 2025-07-08 05:50:18 -05:00
Dax Raad 6100a77b85 start file watcher only for tui 2025-07-07 21:05:04 -04:00
Dax Raad c7a59ee2b1 better handling of aborting sessions
publish / publish (push) Has been cancelled
2025-07-07 20:59:00 -04:00
Jay V a272b58fe9 docs: intro 2025-07-07 17:41:46 -04:00
Dax Raad 9948fcf1b6 fix crash when running on new project
publish / publish (push) Has been cancelled
2025-07-07 17:39:52 -04:00
Dax Raad 0d50c867ff fix mcp tools corrupting session
publish / publish (push) Has been cancelled
2025-07-07 17:05:16 -04:00
Dax Raad 27f7e02f12 run: truncate prompt 2025-07-07 16:41:42 -04:00
Jay V 0f93ecd564 docs: canonical url 2025-07-07 16:37:00 -04:00
Dax Raad da909d9684 append piped stdin to prompt
publish / publish (push) Has been cancelled
2025-07-07 16:33:21 -04:00
Jay V facd851b11 docs: dynamic domain 2025-07-07 16:31:15 -04:00
Dax Raadandopencode c51de945a5 Add stdin support to run command
Allow piping content to opencode run when no message arguments are provided, enabling standard Unix pipe patterns for better CLI integration.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-07-07 16:29:13 -04:00
Jay V 9253a3ca9e docs: debug 2025-07-07 16:26:23 -04:00
Dax Raad 7cfa297a78 wip: model and prompt flags for tui 2025-07-07 16:24:37 -04:00
Jay V 661b74def6 docs: debug info 2025-07-07 16:13:26 -04:00
Dax Raad b478e5655c fix interrupt
publish / publish (push) Has been cancelled
2025-07-07 16:12:47 -04:00
f884766445 v2 message format and upgrade to ai sdk v5 (#743)
publish / publish (push) Has been cancelled
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Liang-Shih Lin <liangshihlin@proton.me>
Co-authored-by: Dominik Engelhardt <dominikengelhardt@ymail.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-07-07 15:53:43 -04:00
Jay V 76b2e4539c docs: discord
publish / publish (push) Has been cancelled
2025-07-07 14:44:37 -04:00
Dominik EngelhardtandGitHub d87922c0eb Fix Elixir LSP startup (#726) 2025-07-06 23:37:46 -04:00
Liang-Shih LinandGitHub 2446483df5 fix: Skip opencode upgrade if same version (#720) 2025-07-06 23:36:59 -04:00
GitHub Action f4c453155d Update download stats 2025-07-06 2025-07-06 12:03:56 +00:00
Dax Raad 969ad80ed2 fix openrouter caching with anthropic, should be a lot cheaper 2025-07-05 11:39:54 -04:00
GitHub Action af064b41d7 Update download stats 2025-07-05 2025-07-05 12:03:56 +00:00
Dax Raad ea6bfef21a use full filepath
publish / publish (push) Has been cancelled
2025-07-04 17:58:03 -04:00
Jay V 107363b1d9 docs: fix show more in share page 2025-07-04 17:57:12 -04:00
Dax Raad 85214d7c59 fix input bar not rendering capital letters
publish / publish (push) Has been cancelled
2025-07-04 17:21:51 -04:00
Timo ClasenandGitHub 997cb2d945 fix(tui): optimistic rendering (#692) 2025-07-04 16:06:57 -05:00
Dax Raad 45b139390c make file attachments work good like
publish / publish (push) Has been cancelled
2025-07-04 16:21:26 -04:00
Jay V 994368de15 docs: share fix scrolling again 2025-07-04 13:53:25 -04:00
Jay V 143fd8e076 docs: share improve markdown rendering of ai responses 2025-07-04 13:53:25 -04:00
Dax Raad 06dba28bd6 wip: fix media type 2025-07-04 12:50:52 -04:00
adamdottv b8d276a049 fix(tui): full paths for attachments 2025-07-04 11:42:22 -05:00
Dax Raad ee01f01271 file attachments 2025-07-04 12:24:01 -04:00
adamdottv 32d5db4f0a fix(tui): markdown wrapping off sometimes 2025-07-04 11:16:38 -05:00
adamdottv f6108b7be8 fix(tui): handle pdf and image @ files 2025-07-04 11:13:09 -05:00
adamdottv 94ef341c9d feat(tui): render attachments 2025-07-04 10:55:02 -05:00
adamdottv f9abc7c84f feat(tui): file attachments 2025-07-04 10:55:02 -05:00
adamdottv 891ed6ebc0 fix(tui): slower startup due to file.status 2025-07-04 10:55:01 -05:00
Dax Raad 163e23a68b removed banned command concept 2025-07-04 11:32:12 -04:00
VladimirandGitHub f13b0af491 docs: Fix invalid json in the mcp example config (#645) 2025-07-04 11:24:13 -04:00
Aiden ClineandGitHub 4a0be45d3d chore: document instructions configuration option (#670) 2025-07-04 11:22:45 -04:00
Dax Raad 23788674c8 disable snapshots temporarily
publish / publish (push) Has been cancelled
2025-07-04 08:45:18 -04:00
GitHub Action 121eb24e73 Update download stats 2025-07-04 2025-07-04 12:26:16 +00:00
Dax Raad 571d60182a improve snapshotting speed further
publish / publish (push) Has been cancelled
2025-07-03 21:36:09 -04:00
Jay V 167a9dcaf3 docs: share fix scroll to anchor 2025-07-03 20:30:21 -04:00
Dax Raad 37327259cb ci: ignore 2025-07-03 20:30:02 -04:00
Dax Raad cdb25656d5 improve snapshot speed
publish / publish (push) Has been cancelled
2025-07-03 20:16:25 -04:00
Jay V 25c876caa2 docs: share fix last message not expandable 2025-07-03 19:33:55 -04:00
Dax Raad cf83e31f23 add elixir lsp support
publish / publish (push) Has been cancelled
2025-07-03 19:29:51 -04:00
Dax Raad 3bc238b58b wip: logs 2025-07-03 19:29:51 -04:00
Jay V b8de69dced docs: fix share page scroll performance 2025-07-03 19:15:38 -04:00
Jay V e7fcb692a4 docs: tweak page title 2025-07-03 16:23:08 -04:00
Timo ClasenandGitHub dae38574ab chore: add dev script (#666) 2025-07-03 14:43:25 -05:00
Dax Raad ed4f862b49 fix /unshare
publish / publish (push) Has been cancelled
2025-07-03 15:34:04 -04:00
adamdottv fce59db94a chore: simplify completions 2025-07-03 12:48:22 -05:00
Jay V 3e2a0c7281 docs: share handle slow loading pages 2025-07-03 13:15:21 -04:00
adamdottv 5a0910ea79 chore: better local dev with stainless script 2025-07-03 11:49:15 -05:00
adamdottv 1dffabcfda fix(tui): panic on completions failure
publish / publish (push) Has been cancelled
2025-07-03 10:53:43 -05:00
adamdottv c389e0ed43 fix(tui): redundant tool calls in each message in collapsed mode
publish / publish (push) Has been cancelled
2025-07-03 10:42:27 -05:00
Dax Raad 204801052a flag for disabling file watcher 2025-07-03 10:37:08 -04:00
Dax Raad 2528d8cb88 increase max retries to 10
publish / publish (push) Has been cancelled
2025-07-03 10:32:55 -04:00
adamdottv 6b73ffd1c1 fix(tui): include orphaned tool calls 2025-07-03 09:32:44 -05:00
adamdottv 0eadc50a33 fix(tui): selected message visuals 2025-07-03 09:03:04 -05:00
Dax Raad aeea84a877 fix webdomain 2025-07-03 09:58:25 -04:00
GitHub Action a54c5c6298 Update download stats 2025-07-03 2025-07-03 12:26:51 +00:00
adamdottv 8825cd3811 feat(tui): unshare command
publish / publish (push) Has been cancelled
2025-07-03 07:09:09 -05:00
adamdottv 3d9a5d9970 fix(tui): always show status bar 2025-07-03 06:53:05 -05:00
adamdottv 1f9e195fa6 fix(tui): better highlight visuals 2025-07-03 06:49:37 -05:00
Craig AndrewsandGitHub 73c012c76c fix: simplify parallel map using channels (#582) 2025-07-03 05:43:10 -05:00
LevandGitHub 2ace57404b fix: properly handle utf-8 in diff highlighting (#585) 2025-07-03 05:42:40 -05:00
Dax Raad 8c4b5e088b do not install gopls if go is not installed
publish / publish (push) Has been cancelled
2025-07-02 23:59:08 -04:00
Jacob HandsandGitHub 69920a73d7 fix: use correct opencode bin path when running in development mode (#483)
publish / publish (push) Has been cancelled
2025-07-02 23:37:48 -04:00
Timo ClasenandGitHub ae76a3467a fix: typescript error (#618) 2025-07-02 23:27:43 -04:00
Adi YeroslavandGitHub 701107cda4 Update prompt reference from CLAUDE.md to AGENTS.md (#623) 2025-07-02 23:27:22 -04:00
Aiden ClineandGitHub b99565959b feat: configurable instructions (#624) 2025-07-02 23:27:04 -04:00
andrewxtandGitHub 67aa7ce04d fix mouse scroll events being interpreted as keyboard input (#628) 2025-07-02 23:26:09 -04:00
Dax Raad c663fbc3ee remove need for glibc
publish / publish (push) Has been cancelled
2025-07-02 22:53:04 -04:00
Dax Raadandopencode 2090bab537 fix(tui): change messages layout toggle keybinding from <leader>m to <leader>p
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-07-02 20:06:30 -04:00
Aiden ClineandGitHub 64d5fff9a3 fix: unawaited promise causes opencode to use unenabled formatter (#625)
publish / publish (push) Has been cancelled
2025-07-02 19:19:31 -04:00
Jay V 925f695503 docs: tweak styles 2025-07-02 18:44:05 -04:00
adamdottv f1c925795d fix: typescript error 2025-07-02 16:08:41 -05:00
adamdottv c82a060eca feat(tui): file viewer, select messages 2025-07-02 16:08:11 -05:00
63e783ef79 Changed handling of OpenRouter requests to add some custom headers so that it can see the app (#613)
Co-authored-by: Dax Raad <d@ironbay.co>
2025-07-02 14:43:59 -04:00
Dax Raad 35d6273fb3 wip: session revert/unrevert 2025-07-02 13:10:36 -04:00
Mark HugginsandGitHub b89d4a16fd fix: Copilot Premium Requests (#595) 2025-07-02 12:04:53 -04:00
Prashant ChoudharyGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2799a96032 fix: Ensure shared file previews use truncated content (#607)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-07-02 12:04:10 -04:00
Timo ClasenandGitHub 8f4b79227c fix(formatting): check for enabled formatters (#611) 2025-07-02 12:03:42 -04:00
Dax Raad c810b6d206 wip: symbols for lsp 2025-07-02 11:35:25 -04:00
Dax Raad fa35407572 fix lazy loading 2025-07-02 11:18:25 -04:00
Dax Raad 8bbbc07aff fix filewatcher not closing cleanly
publish / publish (push) Has been cancelled
2025-07-02 11:15:12 -04:00
GitHub Action 75a21ba3ce Update download stats 2025-07-02
publish / publish (push) Has been cancelled
2025-07-02 12:26:24 +00:00
Timo ClasenandGitHub 0d6fb68a88 fix(tui): no space between agent and user message (#598) 2025-07-02 05:12:49 -05:00
Jean du PlessisandGitHub 242b886434 fix: Small typo in CLI --model flag description (#577) 2025-07-02 05:10:58 -05:00
Daniel VélezandGitHub caf465a9da chore: rename OpenCode to opencode (#579) 2025-07-02 05:09:51 -05:00
Dax Raad bbf77c6139 improve ripgrep download
publish / publish (push) Has been cancelled
2025-07-01 22:39:17 -04:00
Dax Raad 53b7e04b86 ci: tweaks 2025-07-01 22:25:53 -04:00
Dax Raad 9e75e3ed18 ignore: read deleted files 2025-07-01 20:45:50 -04:00
Dax Raad 6389858d41 ignore: add file status command 2025-07-01 20:44:12 -04:00
Dax Raad 7e5941e14b ignore: add file status command 2025-07-01 20:39:43 -04:00
Dax Raad c68aeed8d9 ignore: fix file read with diff 2025-07-01 20:08:42 -04:00
Aiden ClineandGitHub b199a609a8 fix: handle null case if tool args are empty for todos (#588) 2025-07-01 18:25:23 -05:00
Frank 4a5a93b3f8 Temporarily add admin unshare api 2025-07-01 18:57:08 -04:00
Dax Raad e99bdcefac fix write tool timeout
publish / publish (push) Has been cancelled
2025-07-01 13:50:57 -04:00
Dax Raad 26dcb85de1 add file watcher 2025-07-01 13:45:25 -04:00
Dax Raad 11d042be25 snapshot functionality 2025-07-01 12:28:34 -04:00
adamdottv 33b5fe236a fix(tui): better message rendering performance
publish / publish (push) Has been cancelled
2025-07-01 07:57:45 -05:00
GitHub Action d56991006c Update download stats 2025-07-01 2025-07-01 12:27:09 +00:00
adamdottv 739a9f71c3 fix(tui): layout issues 2025-07-01 06:41:39 -05:00
aef81fce0b docs: use correct baseUrl for astro editLink (#507)
Co-authored-by: Adam Spiers <opencode@adamspiers.org>
2025-07-01 05:31:18 -05:00
Timo ClasenandGitHub 8f3d7b4038 feat: better model dialog with sorting by release date (#563) 2025-07-01 05:28:32 -05:00
Dax Raad de15e67834 fix lsp diagnostic accurancy
publish / publish (push) Has been cancelled
2025-06-30 22:48:32 -04:00
Dax Raad fea56d8de6 fix loading api key from env for openai compatible providers
publish / publish (push) Has been cancelled
2025-06-30 19:07:51 -04:00
3d71be2b45 Add pyright lsp for Python (#551)
Co-authored-by: Max Rabin <max.rabin@mobileye.com>
2025-06-30 18:17:47 -04:00
adamdottv 58baca2a5b chore: typescript error 2025-06-30 15:46:18 -05:00
adamdottv ef73926db6 chore: include model release date 2025-06-30 15:46:18 -05:00
Dax Raad 9ad1687f04 optimistically boot lsp servers 2025-06-30 16:45:26 -04:00
Jeremy MackandGitHub c573270e66 chore: remove duplicate EditTool in TOOLS array (#556) 2025-06-30 15:32:15 -04:00
Dax Raad 9ebad68274 fix bash tool extra line 2025-06-30 15:31:30 -04:00
Dax Raad 03664ba588 fix formatting of bash tools
publish / publish (push) Has been cancelled
2025-06-30 15:28:59 -04:00
adamdottv 5a107b275c fix(tui): layout issues
publish / publish (push) Has been cancelled
2025-06-30 14:04:56 -05:00
Dax Raad dd5736fe5f add back in file hierarchy in system prompt but limit to 200 items
publish / publish (push) Has been cancelled
2025-06-30 14:46:46 -04:00
adamdottv 9f3ba03965 chore: rework layout primitives
publish / publish (push) Has been cancelled
2025-06-30 12:29:29 -05:00
d090c08ef0 feat: update user and agent messages width and alignment (#515)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-30 11:57:56 -05:00
Dmytro YankovskyiandGitHub 68e82e4d94 fix(#467): more granular bedrock modelID based on aws region (#482) 2025-06-30 11:12:30 -04:00
Dax Raad a4aa0e6f8d docs: readme 2025-06-30 10:56:38 -04:00
GitHub Action 8c1ae2717c Update download stats 2025-06-30 2025-06-30 12:26:30 +00:00
Dax Raad 72d48759d7 add ruby formatter and lsp 2025-06-29 22:00:08 -04:00
986144b377 docs: how to disable mcp server (#543)
publish / publish (push) Has been cancelled
Co-authored-by: GitHub Action <action@github.com>
2025-06-29 21:33:30 -04:00
Dax Raad 1fdb326aa7 ignore: refactoring 2025-06-29 21:30:23 -04:00
463257e7e4 add zig, python, clang, and kotlin formatters
Co-authored-by: Suhas-Koheda <Suhas-Koheda@users.noreply.github.com>
Co-authored-by: Polo123456789 <Polo123456789@users.noreply.github.com>
Co-authored-by: theodore-s-beers <theodore-s-beers@users.noreply.github.com>
Co-authored-by: TylerHillery <TylerHillery@users.noreply.github.com>
2025-06-29 21:27:35 -04:00
Dax Raad 0f41e60bd6 restructure formatters 2025-06-29 21:22:21 -04:00
Polo123456789andGitHub 7df81f7b3e Formatters as plugins (#487) 2025-06-29 21:13:32 -04:00
dd22cb2bb0 chore: add .editorconfig (#536)
Co-authored-by: Adam Spiers <opencode@adamspiers.org>
2025-06-29 21:12:58 -04:00
Dax Raad 248325925f fix issue with costs resetting once chat is completed
publish / publish (push) Has been cancelled
2025-06-29 19:43:03 -04:00
Dax Raad ca48a4f0fb better amazon bedrock caching with anthropic models
publish / publish (push) Has been cancelled
2025-06-29 19:27:07 -04:00
DaxandGitHub 98ee5a3d87 Update STATS.md 2025-06-29 13:04:44 -04:00
GitHub Action 67480e5a1c Update download stats 2025-06-29 2025-06-29 12:23:40 +00:00
GitHub Action 2581a9b54c Update download stats 2025-06-29 2025-06-29 02:00:18 +00:00
Dax Raad 14a293e124 ci: stats 2025-06-28 21:59:14 -04:00
Dax Raad 780419ecae ci: daily stats script 2025-06-28 21:57:46 -04:00
Timo ClasenandGitHub f0962e2d9c Add Option to Disable MCP Servers (#513) 2025-06-28 21:05:31 -04:00
Dax Raad 3a9584a419 fix context display
publish / publish (push) Has been cancelled
2025-06-28 21:01:53 -04:00
adamdottv 196f42cbff fix(tui): share command and error messages
publish / publish (push) Has been cancelled
2025-06-28 17:51:28 -05:00
Dax Raad 322385f6b1 patch for scroll dumping characters into input buffer
publish / publish (push) Has been cancelled
2025-06-28 11:56:47 -04:00
Dax Raad b7446cd7b9 ci: fix
publish / publish (push) Has been cancelled
2025-06-28 09:16:29 -04:00
f618e569ab optimize edit-tool rendering (#463)
publish / publish (push) Has been cancelled
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-06-28 06:01:10 -05:00
Jay V 7b394b91e2 docs: share handle slower code blocks 2025-06-27 20:21:28 -04:00
Jay V 6a7983a4ea docs: adding more share images 2025-06-27 20:03:17 -04:00
Jay V 737146fca1 docs: tweak logo 2025-06-27 19:18:54 -04:00
Jay V 688f3fd12f Merge branch 'jeremyosih-feat/scroll-to-bottom-button' into dev 2025-06-27 19:16:46 -04:00
Jay V 145df08444 docs: share page format 2025-06-27 19:16:33 -04:00
Dax RaadandJay V 8b400515ea smooth out initial onboarding flow 2025-06-27 19:10:42 -04:00
Jay V 289797f56d docs: share cleanup title 2025-06-27 19:10:42 -04:00
adamdottvandJay V be0811ecc3 chore: rework openapi spec and use stainless sdk 2025-06-27 19:10:42 -04:00
Dax RaadandJay V 0676bcd4fd temporary patch for input lag on initial run 2025-06-27 19:10:42 -04:00
Polo123456789andJay V d076def561 feat: Add golang file formatting (#474) 2025-06-27 19:10:42 -04:00
Wendell MisiedjanandJay V e0807d7317 fix: bunproc stdout / stderr parsing, error handling for bun ResolveMessage (#468) 2025-06-27 19:10:42 -04:00
Jay V fa2723f2d0 docs: update logo screenshot 2025-06-27 19:10:42 -04:00
Jay V 87d62514db docs: share page write tool bug 2025-06-27 19:10:42 -04:00
Dax RaadandJay V 2f8cf9146b ci: ignore 2025-06-27 19:10:42 -04:00
Dax RaadandJay V 8e0ec6b037 ci: aur 2025-06-27 19:10:42 -04:00
Dax RaadandJay V 6dc434cb83 ignore: cleanup 2025-06-27 19:10:42 -04:00
Dax RaadandJay V d972c27f03 lazy load formatters 2025-06-27 19:10:42 -04:00
Ryan WinchesterandJay V 9e2bb63688 feat: add elixir file formatting (#458) 2025-06-27 19:10:42 -04:00
adamdottvandJay V 49053b66a9 fix(web): remove system prompts from share page 2025-06-27 19:10:42 -04:00
TheGoddessInariandJay V 47497aef07 scripts/hooks: Change shebang to universal /bin/sh (#453) 2025-06-27 19:10:41 -04:00
adamdottvandJay V 8455029de1 fix(tui): min width on user messages 2025-06-27 19:10:41 -04:00
Dax RaadandJay V 9f07f89384 fix formatting output going into tui 2025-06-27 19:10:41 -04:00
adamdottvandJay V d840d43e8f ignore: more metadata in app info 2025-06-27 19:10:41 -04:00
adamdottvandJay V 9ead2f3dfb fix: don't use prettier for langs it doesn't format 2025-06-27 19:10:41 -04:00
Dax RaadandJay V f3742ddbb8 ignore: run prettier 2025-06-27 19:10:41 -04:00
Dax RaadandJay V b61a841aa8 add auto formatting and experimental hooks feature 2025-06-27 19:10:41 -04:00
Jay V ebcf11e574 docs: lander tweak 2025-06-27 19:10:41 -04:00
Jay V 065f0aaddf docs: tweak lander 2025-06-27 19:10:41 -04:00
Dax Raad c0773dc7c5 smooth out initial onboarding flow 2025-06-27 16:09:59 -04:00
Jay V 1c3c74bd36 docs: share cleanup title 2025-06-27 15:31:21 -04:00
adamdottv 79bbf90b72 chore: rework openapi spec and use stainless sdk 2025-06-27 14:26:25 -05:00
Dax Raad 226a4a7f36 temporary patch for input lag on initial run
publish / publish (push) Has been cancelled
2025-06-27 14:36:03 -04:00
Polo123456789andGitHub df3b424830 feat: Add golang file formatting (#474) 2025-06-27 14:11:09 -04:00
Wendell MisiedjanandGitHub 3cfd9d80bc fix: bunproc stdout / stderr parsing, error handling for bun ResolveMessage (#468) 2025-06-27 14:09:35 -04:00
Jay V e0553b8d2c docs: update logo screenshot 2025-06-27 14:04:09 -04:00
Jay V 391c837b37 docs: share page write tool bug 2025-06-27 13:25:15 -04:00
Dax Raad 5773d9d1a3 ci: ignore
publish / publish (push) Has been cancelled
2025-06-27 12:37:57 -04:00
Dax Raad ce611963c3 ci: aur
publish / publish (push) Has been cancelled
2025-06-27 12:29:13 -04:00
Dax Raad f865cacfb8 ignore: cleanup
publish / publish (push) Has been cancelled
2025-06-27 11:35:57 -04:00
Dax Raad 2ec0611f42 lazy load formatters 2025-06-27 11:33:37 -04:00
Ryan WinchesterandGitHub 334161a30e feat: add elixir file formatting (#458) 2025-06-27 10:15:11 -04:00
adamdottv dbb6e55226 fix(web): remove system prompts from share page 2025-06-27 06:48:44 -05:00
TheGoddessInariandGitHub d0f9260559 scripts/hooks: Change shebang to universal /bin/sh (#453) 2025-06-27 07:40:22 -04:00
adamdottv d2176064e1 fix(tui): min width on user messages 2025-06-27 06:31:13 -05:00
Dax Raad ed8d277e49 fix formatting output going into tui
publish / publish (push) Has been cancelled
2025-06-27 07:29:41 -04:00
adamdottv 59b3268c64 ignore: more metadata in app info 2025-06-27 06:19:27 -05:00
adamdottv d043f67761 fix: don't use prettier for langs it doesn't format
publish / publish (push) Has been cancelled
2025-06-27 05:47:14 -05:00
Dax Raad 51bf193889 ignore: run prettier
publish / publish (push) Has been cancelled
2025-06-26 22:30:44 -04:00
Dax Raad f8b78f08b4 add auto formatting and experimental hooks feature
publish / publish (push) Has been cancelled
2025-06-26 22:17:08 -04:00
Jay V a4f32d602b docs: lander tweak 2025-06-26 19:47:58 -04:00
Jay V dc3dd21cf3 docs: tweak lander 2025-06-26 19:02:44 -04:00
Jeremy OsihandGitHub b4c2fcccf5 Merge branch 'sst:dev' into feat/scroll-to-bottom-button 2025-06-27 00:41:20 +02:00
Jeremy Osihandopencode e950ad5306 feat(web): add scroll to last message button
Add intelligent floating scroll button for long conversations that:
- Only appears when scrolling down (direction-aware)
- Auto-hides after 3 seconds of inactivity
- Stays visible on hover to prevent accidental disappearance
- Uses consistent design patterns with repo styling
- Includes proper accessibility features

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: Jeremy Osih <osih.jeremy@gmail.com>
Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-27 00:38:14 +02:00
Dax Raad 8ca713b737 disable task tool temporarily
publish / publish (push) Has been cancelled
2025-06-26 18:27:49 -04:00
Jay V 5b54554fd5 docs: edit theme doc 2025-06-26 17:56:31 -04:00
Dax Raadandopencode 4bc651f958 fix: improve JSON formatting and add piped output support for run command
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-26 17:32:00 -04:00
Jay V 3b6976a9c8 Merge branch 'rekram1-node-chore/update-config-docs' into dev 2025-06-26 17:24:03 -04:00
Jay V 863d5c1e8e docs: editing rules 2025-06-26 17:23:52 -04:00
adamdottvandJay V 97e19e9677 fix(tui): editor styles were off 2025-06-26 17:22:21 -04:00
adamdottvandJay V b27851461f feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottvandJay V 209687377a feat(tui): more themes 2025-06-26 17:22:21 -04:00
adamdottvandJay V 90face1c09 fix(tui): editor width issues 2025-06-26 17:22:21 -04:00
adamdottvandJay V 936e2ce48b feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 17:22:21 -04:00
adamdottvandJay V 16ee8ee379 fix(tui): chat editor aesthetics 2025-06-26 17:22:21 -04:00
adamdottvandJay V ac39308dad fix(tui): visual issue with modal selected items in system theme 2025-06-26 17:22:21 -04:00
adamdottvandJay V 346b49219d chore: tui agents.md 2025-06-26 17:22:21 -04:00
Jay V d84c1f20c7 docs: social share 2025-06-26 17:22:17 -04:00
adamdottvandJay V dfb8777555 fix(tui): editor spinner colors 2025-06-26 17:21:53 -04:00
Jay V 008af18156 docs: share page responsive diff 2025-06-26 17:21:53 -04:00
adamdottvandJay V ab23167f80 docs: system theme 2025-06-26 17:21:53 -04:00
adamdottvandJay V b17ec46463 fix(tui): make opencode theme default 2025-06-26 17:21:53 -04:00
2e26b58d16 feat: default system theme (#419)
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 17:21:53 -04:00
Mike WallioandJay V 31b56e5a05 Fix undefined is not an object (evaluating 'G.title') (#395) 2025-06-26 17:21:53 -04:00
Juhani PelliandJay V 47c401cf25 fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-26 17:21:53 -04:00
Dax RaadandJay V fab8dc9e6f more edit tool fixes 2025-06-26 17:21:53 -04:00
Dax RaadandJay V f39a2b1f16 integrate gemini-cli strategies for edit tool 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 66830ced4e make edit tool more robust 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 9d3fad754d ignore: typo 2025-06-26 17:21:53 -04:00
Dax RaadandJay V dcd3131f58 add output length errors 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 3d02e07161 fix codex not working 2025-06-26 17:21:53 -04:00
Dax RaadandJay V 4dbc6a43a6 redirect uncaught errors to log file 2025-06-26 17:21:53 -04:00
adamdottv 5394b5188b fix(tui): editor styles were off
publish / publish (push) Has been cancelled
2025-06-26 15:12:26 -05:00
adamdottv 8e680b3957 feat(tui): more themes
publish / publish (push) Has been cancelled
2025-06-26 15:03:30 -05:00
adamdottv 1b8cd796d6 feat(tui): more themes 2025-06-26 14:54:32 -05:00
adamdottv 35fba793d0 fix(tui): editor width issues
publish / publish (push) Has been cancelled
2025-06-26 12:57:11 -05:00
adamdottv 5358d43b74 feat(tui): show lsp diagnostics for edit and write tools 2025-06-26 12:47:17 -05:00
adamdottv f777347bac fix(tui): chat editor aesthetics 2025-06-26 12:44:44 -05:00
adamdottv 17c8b914df fix(tui): visual issue with modal selected items in system theme
publish / publish (push) Has been cancelled
2025-06-26 12:33:06 -05:00
adamdottv 43b467dd12 chore: tui agents.md 2025-06-26 12:28:29 -05:00
Jay V 0e0770921e docs: social share 2025-06-26 13:21:42 -04:00
adamdottv 8edbb74352 fix(tui): editor spinner colors 2025-06-26 12:21:20 -05:00
Jay V e6bfa95758 docs: share page responsive diff 2025-06-26 13:06:41 -04:00
adamdottv e4120b6287 docs: system theme 2025-06-26 11:33:02 -05:00
adamdottv ccbc9e00f2 fix(tui): make opencode theme default 2025-06-26 11:32:25 -05:00
7d13baadc8 feat: default system theme (#419)
publish / publish (push) Has been cancelled
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-26 10:16:07 -05:00
rekram1-node 9acc83697f chore: document AGENTS.md 2025-06-26 08:28:06 -05:00
Mike WallioandGitHub db24bf87c0 Fix undefined is not an object (evaluating 'G.title') (#395)
publish / publish (push) Has been cancelled
2025-06-25 19:40:09 -04:00
Juhani PelliandGitHub f4c0d2d2fd fix: guard against large output limit causing infinite summarize loop (#399) 2025-06-25 19:39:51 -04:00
Dax Raad d240f4c676 more edit tool fixes
publish / publish (push) Has been cancelled
2025-06-25 19:22:54 -04:00
Dax Raad 9c90cdbe08 integrate gemini-cli strategies for edit tool
publish / publish (push) Has been cancelled
2025-06-25 17:56:14 -04:00
Dax Raad fc7af31fe5 make edit tool more robust
publish / publish (push) Has been cancelled
2025-06-25 17:10:48 -04:00
Dax Raad 2f8d23ec66 ignore: typo 2025-06-25 11:02:57 -04:00
Dax Raad 77ae3fb9b9 add output length errors 2025-06-25 11:02:09 -04:00
Dax Raad 4e7f6c47fd fix codex not working
publish / publish (push) Has been cancelled
2025-06-25 10:01:35 -04:00
Dax Raad 50469ed750 redirect uncaught errors to log file
publish / publish (push) Has been cancelled
2025-06-25 08:41:10 -04:00
Dax Raad aaab785493 better error message when bad directory is specified to start in 2025-06-24 22:28:25 -04:00
Dax Raadandopencode 9751937894 Enhance auth command with environment variable display and add models command
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-24 22:24:55 -04:00
Dax Raad 0fc8dfc77e do not print error on ctrl+c during prompts 2025-06-24 22:09:43 -04:00
Dax Raad 81b7df61ec ci: bun lock 2025-06-24 21:14:32 -04:00
Dax Raad 8217b96d4a ci: fix type issue 2025-06-24 21:12:32 -04:00
Dax Raad 7dd0918d32 remove accidental opanai autoloader
publish / publish (push) Has been cancelled
2025-06-24 21:11:11 -04:00
Dax Raad 4b26b43855 added opencode serve command
publish / publish (push) Has been cancelled
2025-06-24 20:52:09 -04:00
Jay V 9d7cfda9fe docs: share page styles 2025-06-24 19:34:35 -04:00
Jay V a3cf18c905 docs: share page bash tool output 2025-06-24 19:28:51 -04:00
Aiden ClineandGitHub 0b1a8ae699 fix: file completions replaced wrong text when paths overlap (#378) 2025-06-24 18:13:15 -05:00
Dax Raad eb70b1e5c8 docs: windows instructions
publish / publish (push) Has been cancelled
2025-06-24 18:54:59 -04:00
Dax Raad 00a3d818b6 ci: windows 2025-06-24 18:46:43 -04:00
Dax Raad 2384c7e734 ci: windows 2025-06-24 18:40:36 -04:00
Dax Raad 1bad3d9894 ci: windows 2025-06-24 18:27:57 -04:00
Dax Raad 4f715e66dc ci: windows 2025-06-24 18:13:15 -04:00
ec001ca02f windows fixes (#374)
Co-authored-by: Matthew Glazar <strager.nds@gmail.com>
2025-06-24 18:05:04 -04:00
JayandGitHub a2d3b9f0c8 docs: Share page diff view improvements (#373) 2025-06-24 17:11:43 -04:00
Dax Raad 9cfb6ff964 ignore: revert
publish / publish (push) Has been cancelled
2025-06-24 14:59:27 -04:00
Dax Raad 6ed661c140 ci: upgrade bun 2025-06-24 14:42:25 -04:00
Dax Raad 9dc00edfc9 potential fix for failing to install provider package on first run 2025-06-24 14:33:35 -04:00
Jay V e063bf888e docs: share code blocks in markdown 2025-06-24 13:53:59 -04:00
6f18475428 feat: delete sessions (#362)
publish / publish (push) Has been cancelled
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-24 11:07:41 -05:00
Dax Raad 3664b09812 remove debug code writing to /tmp/message.json
publish / publish (push) Has been cancelled
2025-06-24 11:16:17 -04:00
Dax Raad 7050cc0ac3 ignore: fix type errors 2025-06-24 11:09:36 -04:00
Dax Raad 4d3d63294d externalize github copilot code 2025-06-24 10:42:19 -04:00
6bc61cbc2d feat(tui): add debounce logic to escape key interrupt (#169)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-24 06:31:02 -05:00
Dax Raad 01d351bebe add HOMEBREW_NO_AUTO_UPDATE to brew upgrades
publish / publish (push) Has been cancelled
2025-06-23 20:36:08 -04:00
Dax Raad dbba4a97aa force use npm registry
publish / publish (push) Has been cancelled
2025-06-23 20:23:37 -04:00
GitMurfandGitHub 0dc586faef fix: typescript error (any) from models (#347) 2025-06-23 18:44:57 -04:00
Dax Raad f19c6b05f2 glob tool should respect .gitignore
publish / publish (push) Has been cancelled
2025-06-23 17:37:32 -04:00
Dax Raad bc34f08333 bundle models.dev at build time and ignore refresh errors
publish / publish (push) Has been cancelled
2025-06-23 14:50:19 -04:00
Dax Raad b7ee16aabd ignore: remove opencode.json 2025-06-23 14:32:57 -04:00
Lucas GrzegorczykandGitHub ed1b0d97bf Fix project folder name starting with "-" in data (#323). Note old session data will still be in the old format in ~/.local/share/opencode/projects - you can remove the leading dash to recover the, 2025-06-23 14:31:51 -04:00
adamdottv 8d3b2fb821 feat(tui): optimistically render user messages
publish / publish (push) Has been cancelled
2025-06-23 12:30:20 -05:00
Jay V fa991920bc fix help copy 2025-06-23 13:00:24 -04:00
adamdottv 5e79e3d7a5 fix(tui): less incorrect escapingn of < and > 2025-06-23 11:32:32 -05:00
adamdottv 966015c9ae fix: overlay border color issues 2025-06-23 11:21:49 -05:00
adamdottv 61f057337a fix: markdown wrapping issue 2025-06-23 11:20:44 -05:00
adamdottv 0b261054a2 chore: unused import 2025-06-23 10:21:57 -05:00
adamdottv e2e481cbb5 docs: disabled_providers 2025-06-23 10:21:25 -05:00
GitMurfandGitHub 5140e83012 feat(copilot): edit headers for better rate limit avoidance (#321) 2025-06-23 10:44:19 -04:00
Dax Raad 100d6212be more graceful mcp failures
publish / publish (push) Has been cancelled
2025-06-22 21:10:05 -04:00
Dax Raad f0e19a6542 aws autoload include more env vars 2025-06-22 20:16:10 -04:00
Dax Raad 00c4d4f9f8 fix double entry of github copilot in auth login
publish / publish (push) Has been cancelled
2025-06-22 19:13:25 -04:00
Martin PalmaandGitHub 6e6fe6e013 Add Github Copilot OAuth authentication flow (#305) 2025-06-22 19:11:37 -04:00
Dax Raad d05b60291e docs: contributing 2025-06-22 17:55:10 -04:00
adamdottv 5162361372 fix(tui): color contrast fixes for nord 2025-06-22 15:17:18 -05:00
adamdottv d271b9f75b fix(tui): help dialog visuals 2025-06-22 14:28:16 -05:00
Márk MagyarandGitHub 333569bed3 ignore: fix typos and formatting (#294) 2025-06-22 14:26:46 -04:00
09b89fdb23 fix: resolve test failures by adding missing zod-openapi import (#301)
Co-authored-by: opencode <noreply@opencode.ai>
2025-06-22 14:25:02 -04:00
0e8c3359d1 combine stdout and stderr in bash tool output (#300)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Dax Raad <d@ironbay.co>
2025-06-22 14:24:35 -04:00
AdamandGitHub 37e0a7050f fix(tui): mouse wheel escape codes leaking into input 2025-06-22 10:26:44 -05:00
adamdottv 774dcb6980 fix(tui): cleanup help dialog
publish / publish (push) Has been cancelled
2025-06-22 06:44:23 -05:00
phantomreactorandGitHub 28bc49ad17 fix: invisible html tags and compact long delay (#304) 2025-06-22 06:29:04 -05:00
adamdottv dc1947838c fix(tui): cleanup modal visuals
publish / publish (push) Has been cancelled
2025-06-22 06:09:23 -05:00
adamdottv 3ea2daaa4c fix(tui): theme dialog visuals 2025-06-22 05:34:22 -05:00
Márk MagyarandGitHub 137e964131 fix: session title generation (#293) 2025-06-21 14:32:11 -05:00
tyrellshawnandGitHub 8efbe497fd Created a Theme inspired by the matrix (#285) 2025-06-21 07:29:49 -05:00
Thomas MeireandGitHub 119d2d966c Add error handling on the calls to the server to debug issue #132 (#137) 2025-06-21 07:24:39 -05:00
Dax Raad 194415e785 footer clarifies it's showing context usage, not input token usage 2025-06-20 22:52:51 -04:00
Dax Raad 1684042fb6 huge optimization for token usage with anthropic
publish / publish (push) Has been cancelled
2025-06-20 22:43:04 -04:00
Dax Raadandopencode 59f0004d34 Add --method option to upgrade command for manual installation method selection
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-20 20:48:23 -04:00
Dax Raad da35a64fa1 handle brew upgrades better 2025-06-20 20:27:23 -04:00
Dax Raad 460338ca53 make IDs more random
publish / publish (push) Has been cancelled
2025-06-20 17:39:59 -04:00
Saatvik AryaandGitHub 53c18a64b4 docs: add API client generation instructions to README and AGENTS.md (#273) 2025-06-20 17:27:58 -04:00
Saatvik AryaandGitHub b8144c5654 fix: return false for missing AWS_PROFILE in amazon-bedrock provider (#277) 2025-06-20 17:27:27 -04:00
adamdottv 9081e17fcc fix(tui): visual tweaks to themes
publish / publish (push) Has been cancelled
2025-06-20 15:49:51 -05:00
adamdottv ef3fd5900f docs: cleanup casing
publish / publish (push) Has been cancelled
2025-06-20 15:35:25 -05:00
adamdottv 453d690c11 docs: new themes docs 2025-06-20 15:31:38 -05:00
adamdottv c45be6a645 feat(tui): one dark theme 2025-06-20 15:14:23 -05:00
adamdottv 7b9b177088 feat(tui): kanagawa theme 2025-06-20 15:14:23 -05:00
adamdottv 3cee5b0470 feat(tui): gruvbox theme 2025-06-20 15:14:23 -05:00
adamdottv 9246d1c901 feat(tui): catppuccin theme 2025-06-20 15:14:22 -05:00
adamdottv cc12abc83e feat(tui): nord theme 2025-06-20 15:14:22 -05:00
adamdottv 4f7e4a9436 feat(tui): custom themes 2025-06-20 15:14:22 -05:00
Márk MagyarandGitHub eee396f903 feat(tui): theme switcher with preview (#264) 2025-06-20 15:14:05 -05:00
Jay V 0d2f8e175a docs: share bugs 2025-06-20 15:50:12 -04:00
Jay V 4df40e0d9b docs: share page bugs 2025-06-20 15:50:12 -04:00
Dax Raad b72e17a8b7 fix issue with conversations hanging 2025-06-20 15:49:49 -04:00
Dax Raad 61160dc220 docs: readme 2025-06-20 15:22:41 -04:00
Dax Raadandopencode 98734ff28c Consolidate session context handling and add global config support
publish / publish (push) Has been cancelled
Refactored context file discovery by removing separate SessionContext module and integrating functionality into SystemPrompt.context(). Added support for finding AGENTS.md and CLAUDE.md files in global config directories.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-20 15:14:12 -04:00
9991352663 feat: forward provider options from model config (#202)
publish / publish (push) Has been cancelled
Co-authored-by: Dax Raad <d@ironbay.co>
2025-06-20 15:03:41 -04:00
91c4da5dbd fix(#243): claude on aws bedrock (#241)
publish / publish (push) Has been cancelled
Co-authored-by: Dax Raad <d@ironbay.co>
2025-06-20 14:57:33 -04:00
nibaandGitHub 2fd0e7dd6b chore: use client_id everywhere (#260) 2025-06-20 14:56:33 -04:00
adamdottv d50b7ad481 docs: theme schema update 2025-06-20 13:51:32 -05:00
adamdottv df95c49401 docs: theme schema 2025-06-20 13:00:32 -05:00
adamdottv 8b73c52f00 chore(tui): rename theme colors 2025-06-20 13:00:31 -05:00
Jay V 5603098d17 docs: add config 2025-06-20 13:22:31 -04:00
Jay V f436a50125 docs: share header 2025-06-20 13:12:35 -04:00
Jay V e19e977591 docs: test 2025-06-20 13:02:05 -04:00
Jay V addbe295b1 docs: test 2025-06-20 12:59:32 -04:00
Jay V 9a573dedc6 docs: test 2025-06-20 12:56:00 -04:00
adamdottv 9ea0d71e8d fix(tui): async load messages on theme/session switch 2025-06-20 11:25:21 -05:00
adamdottv b1a3599017 fix(tui): input latency optimization 2025-06-20 11:08:08 -05:00
adamdottv 7b0329f67f fix(tui): fetch tool more defensive
publish / publish (push) Has been cancelled
2025-06-20 09:00:28 -05:00
adamdottv 311b9c74dd fix(tui): typeahead open/close perf 2025-06-20 08:20:10 -05:00
adamdottv f7e8dd2ff8 chore: fix typescript issues 2025-06-20 07:48:42 -05:00
adamdottv 40b1dd7ef2 fix(tui): insert newline correctly positioned 2025-06-20 07:42:04 -05:00
adamdottv 261e76e0a3 fix(tui): input feels laggy 2025-06-20 07:31:45 -05:00
Dax Raad a300bfaccb docs: remove opencode.json 2025-06-20 01:00:15 -04:00
Dax Raad 41dba0db08 config validation
publish / publish (push) Has been cancelled
2025-06-20 00:57:28 -04:00
Rohan GodhaandGitHub 6674c6083a fix: phantom input bug on wsl (#200) 2025-06-19 20:08:56 -05:00
Tom WatkinsandGitHub f6afa2c6bb docs: fix typo in config.mdx (#218) 2025-06-19 21:08:21 -04:00
Dax Raad b2fb0508ea fix for azure models not liking tool definitions
publish / publish (push) Has been cancelled
2025-06-19 18:28:42 -04:00
Jay V 93f4252bb1 docs: tweak lander 2025-06-19 18:19:35 -04:00
JayandGitHub 46ab9c16dd docs: Update README.md 2025-06-19 18:19:06 -04:00
Dax Raad d869df4fee remove unused permission timeout
publish / publish (push) Has been cancelled
2025-06-19 18:00:53 -04:00
Dax Raad b99d4650ec temporarily disable project details in system prompt
publish / publish (push) Has been cancelled
2025-06-19 17:37:23 -04:00
Frank 261bb7f110 Infra: fix DO tag 2025-06-19 17:20:13 -04:00
Dax Raad 0515fbb260 fix gopls download spewing into terminal
publish / publish (push) Has been cancelled
2025-06-19 17:08:58 -04:00
adamdottv 88211d8c5b fix(tui): upgrade notification
publish / publish (push) Has been cancelled
2025-06-19 16:03:45 -05:00
Jay V a812f95b9d docs: share 2025-06-19 16:57:42 -04:00
adamdottv 3728a12bee fix(tui): better help on home
publish / publish (push) Has been cancelled
2025-06-19 15:56:28 -05:00
Jay V af07e51213 docs: tweak 2025-06-19 16:40:15 -04:00
Jay V 3113788c92 docs: copy 2025-06-19 16:39:36 -04:00
Jay V efb5fe6d4e docs: styles 2025-06-19 16:38:37 -04:00
Jay V 54dd6c644d docs: adding to config 2025-06-19 16:36:17 -04:00
Dax Raad 39ad8f2667 ignore: do migration 2025-06-19 16:32:32 -04:00
Jay V c4a2c84e53 docs: readme
publish / publish (push) Has been cancelled
2025-06-19 16:29:20 -04:00
Jay V 44fe012812 docs: edits 2025-06-19 16:28:11 -04:00
Jay V f5e7f079ea Copy changes 2025-06-19 16:28:03 -04:00
adamdottv 15a8936806 fix(tui): better tool titles 2025-06-19 15:11:53 -05:00
adamdottv 4e4cff49c0 feat(tui): better task tool rendering
publish / publish (push) Has been cancelled
2025-06-19 15:02:13 -05:00
adamdottv 5540503bee fix(tui): sorted tool arg maps 2025-06-19 14:07:33 -05:00
adamdottv 193718034b fix: typescript error 2025-06-19 13:57:25 -05:00
adamdottv 72108c0296 fix(tui): sorted tool arg maps 2025-06-19 13:56:09 -05:00
Dax Raad ec1c9f8cd1 use production share url
publish / publish (push) Has been cancelled
2025-06-19 14:21:00 -04:00
Dax Raad a85b0a370e ci: share 2025-06-19 13:26:15 -04:00
Dax Raadandopencode e7784d2864 add schema descriptions to config fields
Enhance configuration schema with descriptive text for all fields to improve developer experience and auto-generated documentation.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-19 13:12:13 -04:00
Dax Raad 97c4815444 fix task agent performance issues
publish / publish (push) Has been cancelled
2025-06-19 13:00:57 -04:00
Dax Raad 7d1a1663c8 allow selecting model and continuing previous session for opencode run 2025-06-19 13:00:57 -04:00
adamdottv 24c0ce6e53 fix(tui): vscode and mac terminal colors 2025-06-19 11:46:08 -05:00
adamdottv 4cdc86612c fix(tui): overlay border backgrounds 2025-06-19 11:41:30 -05:00
Jay V f1f3f8d12c ignore: share version 2025-06-19 12:20:30 -04:00
adamdottv e78d3b54bf chore: cleanup logs 2025-06-19 10:52:45 -05:00
adamdottv f8a7cd372d fix(tui): toast placement and overlay rendering 2025-06-19 10:45:10 -05:00
adamdottv f48eac638d feat(tui): more toast messages 2025-06-19 10:41:59 -05:00
adamdottv e1f12f93eb feat(tui): toast messages 2025-06-19 10:12:29 -05:00
Dax Raad 7ca8334a8b fix webfetch tool when returning html as text
publish / publish (push) Has been cancelled
2025-06-19 10:43:54 -04:00
Dax Raad f1a2b2eba4 support token caching for anthropic via openrouter
publish / publish (push) Has been cancelled
2025-06-19 10:32:14 -04:00
adamdottv 4b132656df feat(tui): copy share url to clipboard
publish / publish (push) Has been cancelled
2025-06-19 09:06:25 -05:00
Dax Raad 26bab00dab remove opencode_ prefixes from tool names. unfortunately this will break
publish / publish (push) Has been cancelled
all old sessions and share links. we'll be more backwards compatible in
the future once we're more stable.
2025-06-19 09:59:44 -04:00
adamdottv 568c04753e feat(tui): expand input to fit message
publish / publish (push) Has been cancelled
2025-06-19 08:45:27 -05:00
Dax Raad 4a06e164d2 ensure session.info is synced when shared
publish / publish (push) Has been cancelled
2025-06-19 09:41:11 -04:00
adamdottv c57b52c300 fix: include schema in converted toml config 2025-06-19 06:02:02 -05:00
Guillermo Antony Cava NuñezandGitHub 0b8f48f17f Fixes tool tip layering (#199) 2025-06-19 00:23:29 -04:00
Dax Raad 3862184ccb hooks 2025-06-19 00:20:03 -04:00
Frank 8619c50976 Update SST 2025-06-18 23:38:06 -04:00
JoshandGitHub bb6b56b72a fix: incorrect command on main screen for exiting application (#201)
publish / publish (push) Has been cancelled
2025-06-18 23:19:43 -04:00
Dax Raad 1252b65166 stop loading models.dev format from global config
publish / publish (push) Has been cancelled
2025-06-18 23:08:51 -04:00
Dax Raad 6840276dad docs: update README 2025-06-18 23:03:54 -04:00
Dax Raad bd8c3cd0f1 BREAKING CONFIG CHANGE
publish / publish (push) Has been cancelled
We have changed the config format yet again - but this should be the
final time. You can see the readme for more details but the summary is

- got rid of global providers config
- got rid of global toml
- global config is now in `~/.config/opencode/config.json`
- it will be merged with any project level config
2025-06-18 23:01:19 -04:00
Dax Raad e5e9b3e3c0 rework config 2025-06-18 23:01:19 -04:00
Frank 1e8a681de9 Render version 2025-06-18 22:26:51 -04:00
Jay V a834bedc17 ignore: share copy link 2025-06-18 20:18:10 -04:00
Dax Raad 6a3392385e support global config 2025-06-18 18:56:52 -04:00
Jay V 6a00e063c4 ignore: share logo 2025-06-18 18:33:51 -04:00
Jay V 73a0ce2b7d ignore: share 2025-06-18 18:22:19 -04:00
Jay V 4d1afd01fa ignore: share 2025-06-18 18:21:44 -04:00
Jay V 801d5f47bd ignore: share favicon 2025-06-18 18:10:22 -04:00
Jay V b6caae9708 ignore: share 2025-06-18 18:01:34 -04:00
adamdottv 183ca64ef9 feat(tui): show provider next to model 2025-06-18 16:09:49 -05:00
adamdottv 8c32cfe829 chore: tui style tweaks 2025-06-18 15:59:58 -05:00
Jay V 73dcc88da1 ignore: share 2025-06-18 16:54:33 -04:00
Jay V 14bded65dc ignore: share 2025-06-18 16:54:33 -04:00
adamdottv 87d1d3fb62 fix(tui): file completion quirks 2025-06-18 15:51:26 -05:00
Frank e054454109 Api: only return session messages 2025-06-18 16:20:34 -04:00
Dax Raad a6142cf975 ignore: types 2025-06-18 16:20:03 -04:00
Jay V 69332e5fa3 ignore: share 2025-06-18 16:15:22 -04:00
Jay V 20201ba3c4 ignore: share 2025-06-18 16:15:11 -04:00
Dax Raad 658067186a ignore: share page stuff 2025-06-18 16:13:33 -04:00
adamdottv ac777b77cf fix(tui): modal visuals 2025-06-18 15:12:24 -05:00
Dax Raad 5944ae2023 share types 2025-06-18 15:34:13 -04:00
Jay V 2f10961ba8 ignore: share 2025-06-18 15:32:40 -04:00
adamdottv fae97978a3 chore: cleanup logs 2025-06-18 14:18:46 -05:00
Dax Raadandopencode 3423415e49 docs: improve keybinds configuration format in README
publish / publish (push) Has been cancelled
Update keybinds configuration example to use proper TOML table syntax instead of dot notation for better readability and standard TOML formatting.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-18 15:10:46 -04:00
adamdottv 1d0bfc2b2a fix(tui): help dialog sorting 2025-06-18 14:06:20 -05:00
adamdottv bd46cf0f86 feat(tui): configurable keybinds and mouse scroll 2025-06-18 13:56:51 -05:00
Dax Raad d4157d9a96 ctrl+c should gracefully clean up pending sessions
publish / publish (push) Has been cancelled
2025-06-18 14:11:49 -04:00
Jay V 6e4ef585d8 ignore: share error styles 2025-06-18 14:10:14 -04:00
Dax Raad e05c3b7a76 fix panic when invalid config
publish / publish (push) Has been cancelled
2025-06-18 14:03:16 -04:00
Dax Raad f99904bc1c track version on session info
publish / publish (push) Has been cancelled
2025-06-18 13:40:36 -04:00
Jay V b796d6763f ignore: share page styles 2025-06-18 12:53:48 -04:00
Dax Raad c1250abdf8 implemented diff trimming 2025-06-18 11:20:40 -04:00
Dax Raad ebe51534a1 allow setting options in global provider store
publish / publish (push) Has been cancelled
2025-06-18 11:06:16 -04:00
Dax Raad b8bbee4718 fix issue with provider cache
publish / publish (push) Has been cancelled
2025-06-18 10:56:23 -04:00
Dax Raad 8f852b396f fix deploys 2025-06-18 10:47:07 -04:00
Dax Raad ae4d089c06 remove call to npm causing noticible delay when starting chat
publish / publish (push) Has been cancelled
2025-06-18 10:35:41 -04:00
Dax Raad 5110fbdaf9 fix issue when running opencode in empty directory
publish / publish (push) Has been cancelled
2025-06-18 10:29:09 -04:00
Dax Raad e6ddb474fc ignore: sync
publish / publish (push) Has been cancelled
2025-06-18 08:36:25 -04:00
SBSTNandGitHub 0dc71774ce Add Everforest Theme (#170) 2025-06-18 05:55:38 -05:00
Dax Raad b470466e30 integrate cache read/write data
publish / publish (push) Has been cancelled
2025-06-17 20:51:39 -04:00
Jay V d1f9311931 ignore: share page polish 2025-06-17 20:26:12 -04:00
Dax Raadandopencode 1c58023df9 improve anthropic oauth token caching and authentication handling
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-17 13:23:15 -04:00
Dax Raad 4e0aa58b7e ignore: fix 2025-06-17 13:04:26 -04:00
Dax Raad 23ee34b35f state 2025-06-17 12:29:28 -04:00
Dax Raad 674c9a5220 support disabling providers from automatically being added
publish / publish (push) Has been cancelled
2025-06-17 12:23:04 -04:00
Dax Raad 54c86ed43a docs: readme 2025-06-17 12:17:45 -04:00
Dax Raad 676d75ee75 docs: update README
publish / publish (push) Has been cancelled
2025-06-17 12:14:38 -04:00
Dax Raad 70dc0a12f2 docs: readme 2025-06-17 12:12:33 -04:00
Dax Raad d579c5e8aa support global config for providers 2025-06-17 12:10:44 -04:00
Dax Raad ee91f31313 fix issue with tool schemas and google
publish / publish (push) Has been cancelled
2025-06-17 11:27:07 -04:00
Dax Raad 57b3051024 fix agent getting caught in summary loop
publish / publish (push) Has been cancelled
2025-06-17 10:50:03 -04:00
Dax Raad ae5cf3cc23 ci: fix
publish / publish (push) Has been cancelled
2025-06-17 10:38:01 -04:00
Dax Raadandopencode 68e1b3c46c Fix TypeScript compilation errors and consolidate version handling
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-17 10:27:49 -04:00
adamdottv 2d68814abc feat: better collapsed tool call visuals
publish / publish (push) Has been cancelled
2025-06-17 08:35:18 -05:00
adamdottv a5da5127fa chore: consolidate chat page into tui.go 2025-06-17 07:09:04 -05:00
Dax Raadandopencode b5a4439704 Add autoshare configuration and improve run command UI
Enables automatic session sharing via global config or flag, enhances UI with logo display and provider/model info positioning.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-17 01:45:32 -04:00
Dax Raad 9c5616521d do not autoupgrade snapshot builds
publish / publish (push) Has been cancelled
2025-06-17 01:18:32 -04:00
Dax Raad 3fe163416d autoupgrade
publish / publish (push) Has been cancelled
2025-06-17 01:05:05 -04:00
DaxandGitHub d054f88130 Improve upgrade command with installation method detection (#158) 2025-06-17 00:07:17 -04:00
JayandGitHub b929b4f4b9 docs: Update README.md 2025-06-16 21:01:38 -04:00
Jay V 4c0c83b02d docs: readme 2025-06-16 20:10:19 -04:00
adamdottv d6d45bdc63 feat: share and init commands
publish / publish (push) Has been cancelled
2025-06-16 15:58:52 -05:00
Dax Raad 13a83721b0 ci: fixed ci issue 2025-06-16 16:58:25 -04:00
Dax Raad f0edffbae9 docs: readme 2025-06-16 16:53:43 -04:00
Dax Raad 8131bee49a ignore: logs 2025-06-16 16:02:45 -04:00
Dax Raad b5f44ae13f docs: update readme 2025-06-16 15:42:35 -04:00
Miles TillandGitHub 0d23f2a7fd fix: incorrect lipgloss version (#131) 2025-06-16 14:35:46 -05:00
Dax Raad ac096d84ad remove windows builds
publish / publish (push) Has been cancelled
2025-06-16 15:11:14 -04:00
Dax Raad fcaf0e6dbf opencode auth login: validation on provider id and better error messages
publish / publish (push) Has been cancelled
2025-06-16 15:09:49 -04:00
Dax Raad 19e259d90d docs: readme 2025-06-16 15:04:32 -04:00
Dax Raad 2c9fd1e776 BREAKING CHANGE: the config structure has changed, custom providers have an npm field now to specify which npm package to load. see examples in README.md 2025-06-16 15:02:25 -04:00
Dax Raad 63996c4189 limit to 4 system prompts cached
publish / publish (push) Has been cancelled
2025-06-16 14:51:59 -04:00
adamdottv c7bb7ce4de fix: include cached tokens in tui 2025-06-16 12:59:38 -05:00
adamdottv c8eb1b24c3 feat: believe it or not, even faster tui init
publish / publish (push) Has been cancelled
2025-06-16 12:34:34 -05:00
adamdottv b9f894f1e9 feat: even faster tui init 2025-06-16 12:24:18 -05:00
adamdottv 7c0d10a4ce feat: faster tui init
publish / publish (push) Has been cancelled
2025-06-16 11:54:55 -05:00
Dax Raad 06af406146 properly track cache token counts
publish / publish (push) Has been cancelled
2025-06-16 12:43:22 -04:00
Dax Raad 0e3458b112 fix cache-control
publish / publish (push) Has been cancelled
2025-06-16 12:07:01 -04:00
adamdottv 2d15c683e0 fix: default provider and model 2025-06-16 10:51:01 -05:00
adamdottv 3c94d26570 chore: remove status service 2025-06-16 10:45:19 -05:00
Dax Raad 1a553e525f enable prompt caching for anthropic
publish / publish (push) Has been cancelled
2025-06-16 11:41:54 -04:00
adamdottv 3c4e966216 fix: spinner background color 2025-06-16 10:03:44 -05:00
Dax Raad 0721620ed8 docs: readme 2025-06-16 10:44:48 -04:00
Thomas MeireandGitHub 9fc6734f32 ignore: remove log files and add them to gitignore (#138) 2025-06-16 09:30:07 -04:00
JacobandGitHub e1733a423d fix: typo and literal wording in packages/opencode/AGENTS.md (#134) 2025-06-16 08:18:29 -05:00
Dax Raad d42e3db7e0 docs: update README 2025-06-15 21:43:20 -04:00
Dax Raad cdb26f6d83 docs: readme 2025-06-15 21:39:02 -04:00
Dax Raadandopencode fe05edaa79 enhance ripgrep files function with query filtering and limit support
publish / publish (push) Has been cancelled
🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-15 21:26:32 -04:00
Dax Raad 7d174767b0 first pass making system prompt less fast
publish / publish (push) Has been cancelled
2025-06-15 20:25:04 -04:00
George PotoshinandGitHub c5eefd1752 Fix: Improve Help UI Readability (Issue #99) (#117) 2025-06-15 18:38:44 -05:00
adamdottv 77a6b3bdd6 fix: background color rendering issues
publish / publish (push) Has been cancelled
2025-06-15 15:07:05 -05:00
Pierre B.andGitHub 7effff56c0 fix: spelling, grammar and typos (#121) 2025-06-15 14:23:18 -05:00
568 changed files with 67291 additions and 20429 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
insert_final_newline = true
end_of_line = lf
indent_style = space
indent_size = 2
max_line_length = 80
+4 -3
View File
@@ -3,7 +3,8 @@ name: deploy
on:
push:
branches:
- dontlook
- dev
- production
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -16,10 +17,10 @@ jobs:
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
bun-version: 1.2.17
- run: bun install
- run: bun sst deploy --stage=dev
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+14
View File
@@ -0,0 +1,14 @@
name: discord
on:
release:
types: [published] # fires only when a release is published
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: Send nicely-formatted embed to Discord
uses: SethCohen/github-releases-to-discord@v1
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
+24
View File
@@ -0,0 +1,24 @@
name: opencode
on:
issue_comment:
types: [created]
jobs:
opencode:
if: startsWith(github.event.comment.body, 'hey opencode')
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run opencode
uses: sst/opencode/sdks/github@github-v1
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
model: anthropic/claude-sonnet-4-20250514
@@ -0,0 +1,30 @@
name: publish-github-action
on:
workflow_dispatch:
push:
tags:
- "github-v*.*.*"
- "!github-v1"
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- run: git fetch --force --tags
- name: Publish
run: |
git config --global user.email "opencode@sst.dev"
git config --global user.name "opencode"
./script/publish
working-directory: ./sdks/github
+36
View File
@@ -0,0 +1,36 @@
name: publish-vscode
on:
workflow_dispatch:
push:
tags:
- "vscode-v*.*.*"
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.17
- run: git fetch --force --tags
- run: bun install -g @vscode/vsce
- name: Publish
run: |
bun install
./script/publish
working-directory: ./sdks/vscode
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OPENVSX_TOKEN: ${{ secrets.OPENVSX_TOKEN }}
+3 -1
View File
@@ -7,6 +7,8 @@ on:
- dev
tags:
- "*"
- "!vscode-v*"
- "!github-v*"
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -32,7 +34,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.16
bun-version: 1.2.17
- name: Install makepkg
run: |
+32
View File
@@ -0,0 +1,32 @@
name: stats
on:
schedule:
- cron: "0 12 * * *" # Run daily at 12:00 UTC
workflow_dispatch: # Allow manual trigger
jobs:
stats:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Run stats script
run: bun scripts/stats.ts
- name: Commit stats
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add STATS.md
git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)"
git push
+1
View File
@@ -5,3 +5,4 @@ node_modules
.env
.idea
.vscode
openapi.json
+13
View File
@@ -0,0 +1,13 @@
## Style
- prefer single word variable/function names
- avoid try catch where possible - prefer to let exceptions bubble up
- avoid else statements where possible
- do not make useless helper functions - inline functionality unless the
function is reusable or composable
- prefer Bun apis
## Workflow
- you can regenerate the golang sdk by calling ./scripts/stainless.ts
- we use bun for everything
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 OpenCode
Copyright (c) 2025 opencode
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+74 -104
View File
@@ -1,8 +1,22 @@
[![OpenCode Terminal UI](screenshot.png)](https://github.com/sst/opencode)
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/web/src/assets/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/web/src/assets/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/web/src/assets/logo-ornate-light.svg" alt="opencode logo">
</picture>
</a>
</p>
<p align="center">AI coding agent, built for the terminal.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
AI coding agent, built for the terminal.
[![opencode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
⚠️ **Note:** version 0.1.x is a full rewrite and we do not have proper documentation for it yet. Should have this out week of June 17th 2025 📚
---
### Installation
@@ -16,125 +30,81 @@ brew install sst/tap/opencode # macOS
paru -S opencode-bin # Arch Linux
```
> **Note:** Remove previous versions < 0.1.x first if installed
> [!TIP]
> Remove versions older than 0.1.x before installing.
### Providers
#### Installation Directory
The recommended approach is to sign up for claude pro or max and do `opencode auth login` and select Anthropic. It is the most cost effective way to use this tool.
The install script respects the following priority order for the installation path:
Additionally opencode is powered by the provider list at [models.dev](https://models.dev) so you can use `opencode auth login` to configure api keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path
3. `$HOME/bin` - Standard user binary directory (if exists or can be created)
4. `$HOME/.opencode/bin` - Default fallback
```bash
$ opencode auth login
┌ Add credential
◆ Select provider
│ ● Anthropic (recommended)
│ ○ OpenAI
│ ○ Google
│ ○ Amazon Bedrock
│ ○ Azure
│ ○ DeepSeek
│ ○ Groq
│ ...
# Examples
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
The models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
### Documentation
If there are additional providers you want to use you can submit a PR to the [models.dev repo](https://github.com/sst/models.dev). If configuring just for yourself check out the Config section below
### Project Config
Project configuration is optional. You can place an `opencode.json` file in the root of your repo and it will be loaded.
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json"
}
```
#### MCP
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json",
"mcp": {
"localmcp": {
"type": "local",
"command": ["bun", "x", "my-mcp-command"],
"environment": {
"MY_ENV_VAR": "my_env_var_value"
}
},
"remotemcp": {
"type": "remote",
"url": "https://my-mcp-server.com"
}
}
}
```
#### Providers
You can use opencode with any provider listed at [here](https://ai-sdk.dev/providers/ai-sdk-providers). Use the npm package name as the key in your config. Note we use v5 of the ai-sdk and not all providers support that yet.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"@ai-sdk/openai-compatible": {
"name": "ollama",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"models": {
"llama2": {
"name": "llama2"
}
}
}
}
}
```
For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs).
### Contributing
To run opencode locally you need
opencode is an opinionated tool so any fundamental feature needs to go through a
design process with the core team.
- bun
- golang 1.24.x
> [!IMPORTANT]
> We do not accept PRs for core features.
To run
However we still merge a ton of PRs - you can contribute:
```
- Bug fixes
- Improvements to LLM performance
- Support for new providers
- Fixes for env specific quirks
- Missing standard behavior
- Documentation
Take a look at the git history to see what kind of PRs we end up merging.
> [!NOTE]
> If you do not follow the above guidelines we might close your PR.
To run opencode locally you need.
- Bun
- Golang 1.24.x
And run.
```bash
$ bun install
$ cd packages/opencode
$ bun run src/index.ts
$ bun run packages/opencode/src/index.ts
```
#### Development Notes
**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients.
### FAQ
#### How do I use this with OpenRouter
#### How is this different than Claude Code?
OpenRouter is not yet in the models.dev database but you can configure it manually.
It's very similar to Claude Code in terms of capability. Here are the key differences:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"@openrouter/ai-sdk-provider": {
"name": "OpenRouter",
"options": {
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"models": {
"anthropic/claude-3.5-sonnet": {
"name": "Claude 3.5 Sonnet"
}
}
}
}
}
```
- 100% open source
- Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
#### What's the other repo?
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
---
**Join our community** [Discord](https://discord.gg/opencode) | [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
+27
View File
@@ -0,0 +1,27 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | ---------------- | ----------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-10 | 43,796 (+5,744) | 71,402 (+6,934) | 115,198 (+12,678) |
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
+1218 -267
View File
File diff suppressed because it is too large Load Diff
+28 -26
View File
@@ -5,32 +5,35 @@ go 1.24.0
require (
github.com/BurntSushi/toml v1.5.0
github.com/alecthomas/chroma/v2 v2.18.0
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1
github.com/charmbracelet/x/ansi v0.8.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
github.com/charmbracelet/x/ansi v0.9.3
github.com/google/uuid v1.6.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
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/oapi-codegen/runtime v1.1.1
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 => ./packages/tui/input
)
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
require (
dario.cat/mergo v1.0.2 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/charmbracelet/bubbletea v1.3.4 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 // indirect
github.com/charmbracelet/x/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/fsnotify/fsnotify v1.8.0 // indirect
@@ -38,7 +41,6 @@ require (
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/google/uuid v1.6.0 // 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
@@ -51,43 +53,43 @@ require (
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.24.0 // indirect
golang.org/x/tools v0.31.0 // 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
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.20250501183327-ad3bc78c6a81 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/disintegration/imaging v1.6.2
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // 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 // indirect
github.com/rivo/uniseg v0.4.7
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/pflag v1.0.6 // 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/image v0.26.0
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0 // indirect
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
)
+36 -49
View File
@@ -4,15 +4,12 @@ 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/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
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/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
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=
@@ -23,33 +20,28 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
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/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
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 v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3/go.mod h1:ZFDg5oPjyRYrPAa3iFrtP1DO8xy+LUQxd9JFHEcuwJY=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81/go.mod h1:poPFOXFTsJsnLbkV3H2KxAAXT7pdjxxLujLocWjkyzM=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 h1:fsWj8NF5njyMVzELc7++HsvRDvgz3VcgGAUgWBDWWWM=
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197/go.mod h1:xseGeVftoP9rVI+/8WKYrJFH6ior6iERGvklwwHz5+s=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
@@ -62,15 +54,11 @@ github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2
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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
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=
@@ -116,7 +104,6 @@ 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/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
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=
@@ -126,16 +113,12 @@ 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/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
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=
@@ -158,8 +141,6 @@ 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/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
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=
@@ -200,14 +181,22 @@ 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/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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=
@@ -227,14 +216,13 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
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.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
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.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
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=
@@ -245,15 +233,15 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT
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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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=
@@ -267,34 +255,33 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
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-20210809222454-d867a43fc93e/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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
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.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
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=
+11 -14
View File
@@ -4,13 +4,18 @@ export const domain = (() => {
return `${$app.stage}.dev.opencode.ai`
})()
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
const bucket = new sst.cloudflare.Bucket("Bucket")
export const api = new sst.cloudflare.Worker("Api", {
domain: `api.${domain}`,
handler: "packages/function/src/api.ts",
environment: {
WEB_DOMAIN: domain,
},
url: true,
link: [bucket],
link: [bucket, GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY],
transform: {
worker: (args) => {
args.logpush = true
@@ -23,29 +28,21 @@ export const api = new sst.cloudflare.Worker("Api", {
},
])
args.migrations = {
oldTag: "v1",
newTag: "v1",
// Note: when releasing the next tag, make sure all stages use tag v2
oldTag: $app.stage === "production" ? "" : "v1",
newTag: $app.stage === "production" ? "" : "v1",
//newSqliteClasses: ["SyncServer"],
}
},
},
})
// new sst.cloudflare.StaticSite("Web", {
// path: "packages/web",
// domain,
// environment: {
// VITE_API_URL: api.url,
// },
// build: {
// command: "bun run build",
// output: "dist",
// },
// })
new sst.cloudflare.x.Astro("Web", {
domain,
path: "packages/web",
environment: {
// For astro config
SST_STAGE: $app.stage,
VITE_API_URL: api.url,
},
})
+1 -2
View File
@@ -48,7 +48,7 @@ if [ -z "$requested_version" ]; then
url="https://github.com/sst/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
if [[ $? -ne 0 ]]; then
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo "${RED}Failed to fetch version information${NC}"
exit 1
fi
@@ -186,4 +186,3 @@ if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
echo "$INSTALL_DIR" >> $GITHUB_PATH
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
fi
+20 -8
View File
@@ -1,16 +1,28 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"@ai-sdk/openai-compatible": {
"name": "ollama",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"openrouter": {
"models": {
"qwen3": {},
"deepseek-r1": {},
"llama2": {}
"moonshotai/kimi-k2": {
"options": {
"provider": {
"order": ["baseten"],
"allow_fallbacks": false
}
}
}
}
},
"huggingface": {
"models": {
"Qwen/Qwen3-235B-A22B-Instruct-2507:fireworks-ai": {}
}
}
},
"mcp": {
"weather": {
"type": "local",
"command": ["opencode", "x", "@h1deya/mcp-server-weather"]
}
}
}
+11 -9
View File
@@ -5,7 +5,10 @@
"type": "module",
"packageManager": "bun@1.2.14",
"scripts": {
"typecheck": "bun run --filter='*' typecheck"
"dev": "bun run packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
"stainless": "./scripts/stainless",
"postinstall": "./scripts/hooks"
},
"workspaces": {
"packages": [
@@ -14,13 +17,13 @@
"catalog": {
"typescript": "5.8.2",
"@types/node": "22.13.9",
"zod": "3.24.2",
"ai": "4.3.16"
"zod": "3.25.49",
"ai": "5.0.0-beta.21"
}
},
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.4"
"sst": "3.17.8"
},
"repository": {
"type": "git",
@@ -28,14 +31,13 @@
},
"license": "MIT",
"prettier": {
"semi": false
},
"overrides": {
"zod": "3.24.2"
"semi": false,
"printWidth": 120
},
"trustedDependencies": [
"esbuild",
"protobufjs",
"sharp"
]
],
"patchedDependencies": {}
}
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"context"
"encoding/json"
"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/internal/api"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/internal/util"
)
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 mode *string = flag.String("mode", "", "mode to begin with")
flag.Parse()
url := os.Getenv("OPENCODE_SERVER")
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
var appInfo opencode.App
err := json.Unmarshal([]byte(appInfoStr), &appInfo)
if err != nil {
slog.Error("Failed to unmarshal app info", "error", err)
os.Exit(1)
}
modesStr := os.Getenv("OPENCODE_MODES")
var modes []opencode.Mode
err = json.Unmarshal([]byte(modesStr), &modes)
if err != nil {
slog.Error("Failed to unmarshal modes", "error", err)
os.Exit(1)
}
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
}
}
}
httpClient := opencode.NewClient(
option.WithBaseURL(url),
)
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", "app", appInfoStr, "modes", modesStr)
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, appInfo, modes, httpClient, model, prompt, mode)
if err != nil {
panic(err)
}
program := tea.NewProgram(
tui.NewModel(app_),
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)
for stream.Next() {
evt := stream.Current().AsUnion()
if _, ok := evt.(opencode.EventListResponseEventStorageWrite); ok {
continue
}
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)
program.Quit()
}()
// Run the TUI
result, err := program.Run()
if err != nil {
slog.Error("TUI error", "error", err)
}
slog.Info("TUI exited", "result", result)
}
+99
View File
@@ -0,0 +1,99 @@
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/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/packages/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/fsnotify/fsnotify v1.8.0 // 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 // indirect
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
)
+315
View File
@@ -0,0 +1,315 @@
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/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw=
github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
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=
@@ -0,0 +1,14 @@
//go:build !windows
// +build !windows
package input
import (
"io"
"github.com/muesli/cancelreader"
)
func newCancelreader(r io.Reader, _ int) (cancelreader.CancelReader, error) {
return cancelreader.NewReader(r) //nolint:wrapcheck
}
@@ -0,0 +1,143 @@
//go:build windows
// +build windows
package input
import (
"fmt"
"io"
"os"
"sync"
xwindows "github.com/charmbracelet/x/windows"
"github.com/muesli/cancelreader"
"golang.org/x/sys/windows"
)
type conInputReader struct {
cancelMixin
conin windows.Handle
originalMode uint32
}
var _ cancelreader.CancelReader = &conInputReader{}
func newCancelreader(r io.Reader, flags int) (cancelreader.CancelReader, error) {
fallback := func(io.Reader) (cancelreader.CancelReader, error) {
return cancelreader.NewReader(r)
}
var dummy uint32
if f, ok := r.(cancelreader.File); !ok || f.Fd() != os.Stdin.Fd() ||
// If data was piped to the standard input, it does not emit events
// anymore. We can detect this if the console mode cannot be set anymore,
// in this case, we fallback to the default cancelreader implementation.
windows.GetConsoleMode(windows.Handle(f.Fd()), &dummy) != nil {
return fallback(r)
}
conin, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
if err != nil {
return fallback(r)
}
// Discard any pending input events.
if err := xwindows.FlushConsoleInputBuffer(conin); err != nil {
return fallback(r)
}
modes := []uint32{
windows.ENABLE_WINDOW_INPUT,
windows.ENABLE_EXTENDED_FLAGS,
}
// Enabling mouse mode implicitly blocks console text selection. Thus, we
// need to enable it only if the mouse mode is requested.
// In order to toggle mouse mode, the caller must recreate the reader with
// the appropriate flag toggled.
if flags&FlagMouseMode != 0 {
modes = append(modes, windows.ENABLE_MOUSE_INPUT)
}
originalMode, err := prepareConsole(conin, modes...)
if err != nil {
return nil, fmt.Errorf("failed to prepare console input: %w", err)
}
return &conInputReader{
conin: conin,
originalMode: originalMode,
}, nil
}
// Cancel implements cancelreader.CancelReader.
func (r *conInputReader) Cancel() bool {
r.setCanceled()
return windows.CancelIoEx(r.conin, nil) == nil || windows.CancelIo(r.conin) == nil
}
// Close implements cancelreader.CancelReader.
func (r *conInputReader) Close() error {
if r.originalMode != 0 {
err := windows.SetConsoleMode(r.conin, r.originalMode)
if err != nil {
return fmt.Errorf("reset console mode: %w", err)
}
}
return nil
}
// Read implements cancelreader.CancelReader.
func (r *conInputReader) Read(data []byte) (int, error) {
if r.isCanceled() {
return 0, cancelreader.ErrCanceled
}
var n uint32
if err := windows.ReadFile(r.conin, data, &n, nil); err != nil {
return int(n), fmt.Errorf("read console input: %w", err)
}
return int(n), nil
}
func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) {
err = windows.GetConsoleMode(input, &originalMode)
if err != nil {
return 0, fmt.Errorf("get console mode: %w", err)
}
var newMode uint32
for _, mode := range modes {
newMode |= mode
}
err = windows.SetConsoleMode(input, newMode)
if err != nil {
return 0, fmt.Errorf("set console mode: %w", err)
}
return originalMode, nil
}
// cancelMixin represents a goroutine-safe cancelation status.
type cancelMixin struct {
unsafeCanceled bool
lock sync.Mutex
}
func (c *cancelMixin) setCanceled() {
c.lock.Lock()
defer c.lock.Unlock()
c.unsafeCanceled = true
}
func (c *cancelMixin) isCanceled() bool {
c.lock.Lock()
defer c.lock.Unlock()
return c.unsafeCanceled
}
+25
View File
@@ -0,0 +1,25 @@
package input
import "github.com/charmbracelet/x/ansi"
// ClipboardSelection represents a clipboard selection. The most common
// clipboard selections are "system" and "primary" and selections.
type ClipboardSelection = byte
// Clipboard selections.
const (
SystemClipboard ClipboardSelection = ansi.SystemClipboard
PrimaryClipboard ClipboardSelection = ansi.PrimaryClipboard
)
// ClipboardEvent is a clipboard read message event. This message is emitted when
// a terminal receives an OSC52 clipboard read message event.
type ClipboardEvent struct {
Content string
Selection ClipboardSelection
}
// String returns the string representation of the clipboard message.
func (e ClipboardEvent) String() string {
return e.Content
}
+136
View File
@@ -0,0 +1,136 @@
package input
import (
"fmt"
"image/color"
"math"
)
// ForegroundColorEvent represents a foreground color event. This event is
// emitted when the terminal requests the terminal foreground color using
// [ansi.RequestForegroundColor].
type ForegroundColorEvent struct{ color.Color }
// String returns the hex representation of the color.
func (e ForegroundColorEvent) String() string {
return colorToHex(e.Color)
}
// IsDark returns whether the color is dark.
func (e ForegroundColorEvent) IsDark() bool {
return isDarkColor(e.Color)
}
// BackgroundColorEvent represents a background color event. This event is
// emitted when the terminal requests the terminal background color using
// [ansi.RequestBackgroundColor].
type BackgroundColorEvent struct{ color.Color }
// String returns the hex representation of the color.
func (e BackgroundColorEvent) String() string {
return colorToHex(e)
}
// IsDark returns whether the color is dark.
func (e BackgroundColorEvent) IsDark() bool {
return isDarkColor(e.Color)
}
// CursorColorEvent represents a cursor color change event. This event is
// emitted when the program requests the terminal cursor color using
// [ansi.RequestCursorColor].
type CursorColorEvent struct{ color.Color }
// String returns the hex representation of the color.
func (e CursorColorEvent) String() string {
return colorToHex(e)
}
// IsDark returns whether the color is dark.
func (e CursorColorEvent) IsDark() bool {
return isDarkColor(e)
}
type shiftable interface {
~uint | ~uint16 | ~uint32 | ~uint64
}
func shift[T shiftable](x T) T {
if x > 0xff {
x >>= 8
}
return x
}
func colorToHex(c color.Color) string {
if c == nil {
return ""
}
r, g, b, _ := c.RGBA()
return fmt.Sprintf("#%02x%02x%02x", shift(r), shift(g), shift(b))
}
func getMaxMin(a, b, c float64) (ma, mi float64) {
if a > b {
ma = a
mi = b
} else {
ma = b
mi = a
}
if c > ma {
ma = c
} else if c < mi {
mi = c
}
return ma, mi
}
func round(x float64) float64 {
return math.Round(x*1000) / 1000
}
// rgbToHSL converts an RGB triple to an HSL triple.
func rgbToHSL(r, g, b uint8) (h, s, l float64) {
// convert uint32 pre-multiplied value to uint8
// The r,g,b values are divided by 255 to change the range from 0..255 to 0..1:
Rnot := float64(r) / 255
Gnot := float64(g) / 255
Bnot := float64(b) / 255
Cmax, Cmin := getMaxMin(Rnot, Gnot, Bnot)
Δ := Cmax - Cmin
// Lightness calculation:
l = (Cmax + Cmin) / 2
// Hue and Saturation Calculation:
if Δ == 0 {
h = 0
s = 0
} else {
switch Cmax {
case Rnot:
h = 60 * (math.Mod((Gnot-Bnot)/Δ, 6))
case Gnot:
h = 60 * (((Bnot - Rnot) / Δ) + 2)
case Bnot:
h = 60 * (((Rnot - Gnot) / Δ) + 4)
}
if h < 0 {
h += 360
}
s = Δ / (1 - math.Abs((2*l)-1))
}
return h, round(s), round(l)
}
// isDarkColor returns whether the given color is dark.
func isDarkColor(c color.Color) bool {
if c == nil {
return true
}
r, g, b, _ := c.RGBA()
_, _, l := rgbToHSL(uint8(r>>8), uint8(g>>8), uint8(b>>8)) //nolint:gosec
return l < 0.5
}
+7
View File
@@ -0,0 +1,7 @@
package input
import "image"
// CursorPositionEvent represents a cursor position event. Where X is the
// zero-based column and Y is the zero-based row.
type CursorPositionEvent image.Point
+18
View File
@@ -0,0 +1,18 @@
package input
import "github.com/charmbracelet/x/ansi"
// PrimaryDeviceAttributesEvent is an event that represents the terminal
// primary device attributes.
type PrimaryDeviceAttributesEvent []int
func parsePrimaryDevAttrs(params ansi.Params) Event {
// Primary Device Attributes
da1 := make(PrimaryDeviceAttributesEvent, len(params))
for i, p := range params {
if !p.HasMore() {
da1[i] = p.Param(0)
}
}
return da1
}
+6
View File
@@ -0,0 +1,6 @@
// Package input provides a set of utilities for handling input events in a
// terminal environment. It includes support for reading input events, parsing
// escape sequences, and handling clipboard events.
// The package is designed to work with various terminal types and supports
// customization through flags and options.
package input
+192
View File
@@ -0,0 +1,192 @@
//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
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !windows
// +build !windows
package input
// ReadEvents reads input events from the terminal.
//
// It reads the events available in the input buffer and returns them.
func (d *Reader) ReadEvents() ([]Event, error) {
return d.readEvents()
}
// parseWin32InputKeyEvent parses a Win32 input key events. This function is
// only available on Windows.
func (p *Parser) parseWin32InputKeyEvent(*win32InputState, uint16, uint16, rune, bool, uint32, uint16) Event {
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package input
import (
"io"
"strings"
"testing"
)
func BenchmarkDriver(b *testing.B) {
input := "\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~"
rdr := strings.NewReader(input)
drv, err := NewReader(rdr, "dumb", 0)
if err != nil {
b.Fatalf("could not create driver: %v", err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
rdr.Reset(input)
if _, err := drv.ReadEvents(); err != nil && err != io.EOF {
b.Errorf("error reading input: %v", err)
}
}
}
+642
View File
@@ -0,0 +1,642 @@
//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()
}
@@ -0,0 +1,271 @@
package input
import (
"encoding/binary"
"image/color"
"reflect"
"testing"
"unicode/utf16"
"github.com/charmbracelet/x/ansi"
xwindows "github.com/charmbracelet/x/windows"
"golang.org/x/sys/windows"
)
func TestWindowsInputEvents(t *testing.T) {
cases := []struct {
name string
events []xwindows.InputRecord
expected []Event
sequence bool // indicates that the input events are ANSI sequence or utf16
}{
{
name: "single key event",
events: []xwindows.InputRecord{
encodeKeyEvent(xwindows.KeyEventRecord{
KeyDown: true,
Char: 'a',
VirtualKeyCode: 'A',
}),
},
expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Text: "a"}},
},
{
name: "single key event with control key",
events: []xwindows.InputRecord{
encodeKeyEvent(xwindows.KeyEventRecord{
KeyDown: true,
Char: 'a',
VirtualKeyCode: 'A',
ControlKeyState: xwindows.LEFT_CTRL_PRESSED,
}),
},
expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Mod: ModCtrl}},
},
{
name: "escape alt key event",
events: []xwindows.InputRecord{
encodeKeyEvent(xwindows.KeyEventRecord{
KeyDown: true,
Char: ansi.ESC,
VirtualKeyCode: ansi.ESC,
ControlKeyState: xwindows.LEFT_ALT_PRESSED,
}),
},
expected: []Event{KeyPressEvent{Code: ansi.ESC, BaseCode: ansi.ESC, Mod: ModAlt}},
},
{
name: "single shifted key event",
events: []xwindows.InputRecord{
encodeKeyEvent(xwindows.KeyEventRecord{
KeyDown: true,
Char: 'A',
VirtualKeyCode: 'A',
ControlKeyState: xwindows.SHIFT_PRESSED,
}),
},
expected: []Event{KeyPressEvent{Code: 'A', BaseCode: 'a', Text: "A", Mod: ModShift}},
},
{
name: "utf16 rune",
events: encodeUtf16Rune('😊'), // smiley emoji '😊'
expected: []Event{
KeyPressEvent{Code: '😊', Text: "😊"},
},
sequence: true,
},
{
name: "background color response",
events: encodeSequence("\x1b]11;rgb:ff/ff/ff\x07"),
expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
sequence: true,
},
{
name: "st terminated background color response",
events: encodeSequence("\x1b]11;rgb:ffff/ffff/ffff\x1b\\"),
expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
sequence: true,
},
{
name: "simple mouse event",
events: []xwindows.InputRecord{
encodeMouseEvent(xwindows.MouseEventRecord{
MousePositon: windows.Coord{X: 10, Y: 20},
ButtonState: xwindows.FROM_LEFT_1ST_BUTTON_PRESSED,
EventFlags: 0,
}),
encodeMouseEvent(xwindows.MouseEventRecord{
MousePositon: windows.Coord{X: 10, Y: 20},
EventFlags: 0,
}),
},
expected: []Event{
MouseClickEvent{Button: MouseLeft, X: 10, Y: 20},
MouseReleaseEvent{Button: MouseLeft, X: 10, Y: 20},
},
},
{
name: "focus event",
events: []xwindows.InputRecord{
encodeFocusEvent(xwindows.FocusEventRecord{
SetFocus: true,
}),
encodeFocusEvent(xwindows.FocusEventRecord{
SetFocus: false,
}),
},
expected: []Event{
FocusEvent{},
BlurEvent{},
},
},
{
name: "window size event",
events: []xwindows.InputRecord{
encodeWindowBufferSizeEvent(xwindows.WindowBufferSizeRecord{
Size: windows.Coord{X: 10, Y: 20},
}),
},
expected: []Event{
WindowSizeEvent{Width: 10, Height: 20},
},
},
}
// p is the parser to parse the input events
var p Parser
// keep track of the state of the driver to handle ANSI sequences and utf16
var state win32InputState
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.sequence {
var Event Event
for _, ev := range tc.events {
if ev.EventType != xwindows.KEY_EVENT {
t.Fatalf("expected key event, got %v", ev.EventType)
}
key := ev.KeyEvent()
Event = p.parseWin32InputKeyEvent(&state, key.VirtualKeyCode, key.VirtualScanCode, key.Char, key.KeyDown, key.ControlKeyState, key.RepeatCount)
}
if len(tc.expected) != 1 {
t.Fatalf("expected 1 event, got %d", len(tc.expected))
}
if !reflect.DeepEqual(Event, tc.expected[0]) {
t.Errorf("expected %v, got %v", tc.expected[0], Event)
}
} else {
if len(tc.events) != len(tc.expected) {
t.Fatalf("expected %d events, got %d", len(tc.expected), len(tc.events))
}
for j, ev := range tc.events {
Event := p.parseConInputEvent(ev, &state)
if !reflect.DeepEqual(Event, tc.expected[j]) {
t.Errorf("expected %#v, got %#v", tc.expected[j], Event)
}
}
}
})
}
}
func boolToUint32(b bool) uint32 {
if b {
return 1
}
return 0
}
func encodeMenuEvent(menu xwindows.MenuEventRecord) xwindows.InputRecord {
var bts [16]byte
binary.LittleEndian.PutUint32(bts[0:4], menu.CommandID)
return xwindows.InputRecord{
EventType: xwindows.MENU_EVENT,
Event: bts,
}
}
func encodeWindowBufferSizeEvent(size xwindows.WindowBufferSizeRecord) xwindows.InputRecord {
var bts [16]byte
binary.LittleEndian.PutUint16(bts[0:2], uint16(size.Size.X))
binary.LittleEndian.PutUint16(bts[2:4], uint16(size.Size.Y))
return xwindows.InputRecord{
EventType: xwindows.WINDOW_BUFFER_SIZE_EVENT,
Event: bts,
}
}
func encodeFocusEvent(focus xwindows.FocusEventRecord) xwindows.InputRecord {
var bts [16]byte
if focus.SetFocus {
bts[0] = 1
}
return xwindows.InputRecord{
EventType: xwindows.FOCUS_EVENT,
Event: bts,
}
}
func encodeMouseEvent(mouse xwindows.MouseEventRecord) xwindows.InputRecord {
var bts [16]byte
binary.LittleEndian.PutUint16(bts[0:2], uint16(mouse.MousePositon.X))
binary.LittleEndian.PutUint16(bts[2:4], uint16(mouse.MousePositon.Y))
binary.LittleEndian.PutUint32(bts[4:8], mouse.ButtonState)
binary.LittleEndian.PutUint32(bts[8:12], mouse.ControlKeyState)
binary.LittleEndian.PutUint32(bts[12:16], mouse.EventFlags)
return xwindows.InputRecord{
EventType: xwindows.MOUSE_EVENT,
Event: bts,
}
}
func encodeKeyEvent(key xwindows.KeyEventRecord) xwindows.InputRecord {
var bts [16]byte
binary.LittleEndian.PutUint32(bts[0:4], boolToUint32(key.KeyDown))
binary.LittleEndian.PutUint16(bts[4:6], key.RepeatCount)
binary.LittleEndian.PutUint16(bts[6:8], key.VirtualKeyCode)
binary.LittleEndian.PutUint16(bts[8:10], key.VirtualScanCode)
binary.LittleEndian.PutUint16(bts[10:12], uint16(key.Char))
binary.LittleEndian.PutUint32(bts[12:16], key.ControlKeyState)
return xwindows.InputRecord{
EventType: xwindows.KEY_EVENT,
Event: bts,
}
}
// encodeSequence encodes a string of ANSI escape sequences into a slice of
// Windows input key records.
func encodeSequence(s string) (evs []xwindows.InputRecord) {
var state byte
for len(s) > 0 {
seq, _, n, newState := ansi.DecodeSequence(s, state, nil)
for i := 0; i < n; i++ {
evs = append(evs, encodeKeyEvent(xwindows.KeyEventRecord{
KeyDown: true,
Char: rune(seq[i]),
}))
}
state = newState
s = s[n:]
}
return
}
func encodeUtf16Rune(r rune) []xwindows.InputRecord {
r1, r2 := utf16.EncodeRune(r)
return encodeUtf16Pair(r1, r2)
}
func encodeUtf16Pair(r1, r2 rune) []xwindows.InputRecord {
return []xwindows.InputRecord{
encodeKeyEvent(xwindows.KeyEventRecord{
KeyDown: true,
Char: r1,
}),
encodeKeyEvent(xwindows.KeyEventRecord{
KeyDown: true,
Char: r2,
}),
}
}
+9
View File
@@ -0,0 +1,9 @@
package input
// FocusEvent represents a terminal focus event.
// This occurs when the terminal gains focus.
type FocusEvent struct{}
// BlurEvent represents a terminal blur event.
// This occurs when the terminal loses focus.
type BlurEvent struct{}
+27
View File
@@ -0,0 +1,27 @@
package input
import (
"testing"
)
func TestFocus(t *testing.T) {
var p Parser
_, e := p.parseSequence([]byte("\x1b[I"))
switch e.(type) {
case FocusEvent:
// ok
default:
t.Error("invalid sequence")
}
}
func TestBlur(t *testing.T) {
var p Parser
_, e := p.parseSequence([]byte("\x1b[O"))
switch e.(type) {
case BlurEvent:
// ok
default:
t.Error("invalid sequence")
}
}
+18
View File
@@ -0,0 +1,18 @@
module github.com/charmbracelet/x/input
go 1.23.0
require (
github.com/charmbracelet/x/ansi v0.9.3
github.com/charmbracelet/x/windows v0.2.1
github.com/muesli/cancelreader v0.2.2
github.com/rivo/uniseg v0.4.7
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
golang.org/x/sys v0.33.0
)
require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
)
+19
View File
@@ -0,0 +1,19 @@
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+45
View File
@@ -0,0 +1,45 @@
package input
import (
"fmt"
"strings"
)
// Event represents a terminal event.
type Event any
// UnknownEvent represents an unknown event.
type UnknownEvent string
// String returns a string representation of the unknown event.
func (e UnknownEvent) String() string {
return fmt.Sprintf("%q", string(e))
}
// MultiEvent represents multiple messages event.
type MultiEvent []Event
// String returns a string representation of the multiple messages event.
func (e MultiEvent) String() string {
var sb strings.Builder
for _, ev := range e {
sb.WriteString(fmt.Sprintf("%v\n", ev))
}
return sb.String()
}
// WindowSizeEvent is used to report the terminal size. Note that Windows does
// not have support for reporting resizes via SIGWINCH signals and relies on
// the Windows Console API to report window size changes.
type WindowSizeEvent struct {
Width int
Height int
}
// WindowOpEvent is a window operation (XTWINOPS) report event. This is used to
// report various window operations such as reporting the window size or cell
// size.
type WindowOpEvent struct {
Op int
Args []int
}
+574
View File
@@ -0,0 +1,574 @@
package input
import (
"fmt"
"strings"
"unicode"
"github.com/charmbracelet/x/ansi"
)
const (
// KeyExtended is a special key code used to signify that a key event
// contains multiple runes.
KeyExtended = unicode.MaxRune + 1
)
// Special key symbols.
const (
// Special keys.
KeyUp rune = KeyExtended + iota + 1
KeyDown
KeyRight
KeyLeft
KeyBegin
KeyFind
KeyInsert
KeyDelete
KeySelect
KeyPgUp
KeyPgDown
KeyHome
KeyEnd
// Keypad keys.
KeyKpEnter
KeyKpEqual
KeyKpMultiply
KeyKpPlus
KeyKpComma
KeyKpMinus
KeyKpDecimal
KeyKpDivide
KeyKp0
KeyKp1
KeyKp2
KeyKp3
KeyKp4
KeyKp5
KeyKp6
KeyKp7
KeyKp8
KeyKp9
//nolint:godox
// The following are keys defined in the Kitty keyboard protocol.
// TODO: Investigate the names of these keys.
KeyKpSep
KeyKpUp
KeyKpDown
KeyKpLeft
KeyKpRight
KeyKpPgUp
KeyKpPgDown
KeyKpHome
KeyKpEnd
KeyKpInsert
KeyKpDelete
KeyKpBegin
// Function keys.
KeyF1
KeyF2
KeyF3
KeyF4
KeyF5
KeyF6
KeyF7
KeyF8
KeyF9
KeyF10
KeyF11
KeyF12
KeyF13
KeyF14
KeyF15
KeyF16
KeyF17
KeyF18
KeyF19
KeyF20
KeyF21
KeyF22
KeyF23
KeyF24
KeyF25
KeyF26
KeyF27
KeyF28
KeyF29
KeyF30
KeyF31
KeyF32
KeyF33
KeyF34
KeyF35
KeyF36
KeyF37
KeyF38
KeyF39
KeyF40
KeyF41
KeyF42
KeyF43
KeyF44
KeyF45
KeyF46
KeyF47
KeyF48
KeyF49
KeyF50
KeyF51
KeyF52
KeyF53
KeyF54
KeyF55
KeyF56
KeyF57
KeyF58
KeyF59
KeyF60
KeyF61
KeyF62
KeyF63
//nolint:godox
// The following are keys defined in the Kitty keyboard protocol.
// TODO: Investigate the names of these keys.
KeyCapsLock
KeyScrollLock
KeyNumLock
KeyPrintScreen
KeyPause
KeyMenu
KeyMediaPlay
KeyMediaPause
KeyMediaPlayPause
KeyMediaReverse
KeyMediaStop
KeyMediaFastForward
KeyMediaRewind
KeyMediaNext
KeyMediaPrev
KeyMediaRecord
KeyLowerVol
KeyRaiseVol
KeyMute
KeyLeftShift
KeyLeftAlt
KeyLeftCtrl
KeyLeftSuper
KeyLeftHyper
KeyLeftMeta
KeyRightShift
KeyRightAlt
KeyRightCtrl
KeyRightSuper
KeyRightHyper
KeyRightMeta
KeyIsoLevel3Shift
KeyIsoLevel5Shift
// Special names in C0.
KeyBackspace = rune(ansi.DEL)
KeyTab = rune(ansi.HT)
KeyEnter = rune(ansi.CR)
KeyReturn = KeyEnter
KeyEscape = rune(ansi.ESC)
KeyEsc = KeyEscape
// Special names in G0.
KeySpace = rune(ansi.SP)
)
// KeyPressEvent represents a key press event.
type KeyPressEvent Key
// String implements [fmt.Stringer] and is quite useful for matching key
// events. For details, on what this returns see [Key.String].
func (k KeyPressEvent) String() string {
return Key(k).String()
}
// Keystroke returns the keystroke representation of the [Key]. While less type
// safe than looking at the individual fields, it will usually be more
// convenient and readable to use this method when matching against keys.
//
// Note that modifier keys are always printed in the following order:
// - ctrl
// - alt
// - shift
// - meta
// - hyper
// - super
//
// For example, you'll always see "ctrl+shift+alt+a" and never
// "shift+ctrl+alt+a".
func (k KeyPressEvent) Keystroke() string {
return Key(k).Keystroke()
}
// Key returns the underlying key event. This is a syntactic sugar for casting
// the key event to a [Key].
func (k KeyPressEvent) Key() Key {
return Key(k)
}
// KeyReleaseEvent represents a key release event.
type KeyReleaseEvent Key
// String implements [fmt.Stringer] and is quite useful for matching key
// events. For details, on what this returns see [Key.String].
func (k KeyReleaseEvent) String() string {
return Key(k).String()
}
// Keystroke returns the keystroke representation of the [Key]. While less type
// safe than looking at the individual fields, it will usually be more
// convenient and readable to use this method when matching against keys.
//
// Note that modifier keys are always printed in the following order:
// - ctrl
// - alt
// - shift
// - meta
// - hyper
// - super
//
// For example, you'll always see "ctrl+shift+alt+a" and never
// "shift+ctrl+alt+a".
func (k KeyReleaseEvent) Keystroke() string {
return Key(k).Keystroke()
}
// Key returns the underlying key event. This is a convenience method and
// syntactic sugar to satisfy the [KeyEvent] interface, and cast the key event to
// [Key].
func (k KeyReleaseEvent) Key() Key {
return Key(k)
}
// KeyEvent represents a key event. This can be either a key press or a key
// release event.
type KeyEvent interface {
fmt.Stringer
// Key returns the underlying key event.
Key() Key
}
// Key represents a Key press or release event. It contains information about
// the Key pressed, like the runes, the type of Key, and the modifiers pressed.
// There are a couple general patterns you could use to check for key presses
// or releases:
//
// // Switch on the string representation of the key (shorter)
// switch ev := ev.(type) {
// case KeyPressEvent:
// switch ev.String() {
// case "enter":
// fmt.Println("you pressed enter!")
// case "a":
// fmt.Println("you pressed a!")
// }
// }
//
// // Switch on the key type (more foolproof)
// switch ev := ev.(type) {
// case KeyEvent:
// // catch both KeyPressEvent and KeyReleaseEvent
// switch key := ev.Key(); key.Code {
// case KeyEnter:
// fmt.Println("you pressed enter!")
// default:
// switch key.Text {
// case "a":
// fmt.Println("you pressed a!")
// }
// }
// }
//
// Note that [Key.Text] will be empty for special keys like [KeyEnter],
// [KeyTab], and for keys that don't represent printable characters like key
// combos with modifier keys. In other words, [Key.Text] is populated only for
// keys that represent printable characters shifted or unshifted (like 'a',
// 'A', '1', '!', etc.).
type Key struct {
// Text contains the actual characters received. This usually the same as
// [Key.Code]. When [Key.Text] is non-empty, it indicates that the key
// pressed represents printable character(s).
Text string
// Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on.
Mod KeyMod
// Code represents the key pressed. This is usually a special key like
// [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'.
Code rune
// ShiftedCode is the actual, shifted key pressed by the user. For example,
// if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will
// be 'A' and [Key.Code] will be 'a'.
//
// In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the
// unshifted key on the keyboard.
//
// This is only available with the Kitty Keyboard Protocol or the Windows
// Console API.
ShiftedCode rune
// BaseCode is the key pressed according to the standard PC-101 key layout.
// On international keyboards, this is the key that would be pressed if the
// keyboard was set to US PC-101 layout.
//
// For example, if the user presses 'q' on a French AZERTY keyboard,
// [Key.BaseCode] will be 'q'.
//
// This is only available with the Kitty Keyboard Protocol or the Windows
// Console API.
BaseCode rune
// IsRepeat indicates whether the key is being held down and sending events
// repeatedly.
//
// This is only available with the Kitty Keyboard Protocol or the Windows
// Console API.
IsRepeat bool
}
// String implements [fmt.Stringer] and is quite useful for matching key
// events. It will return the textual representation of the [Key] if there is
// one, otherwise, it will fallback to [Key.Keystroke].
//
// For example, you'll always get "?" and instead of "shift+/" on a US ANSI
// keyboard.
func (k Key) String() string {
if len(k.Text) > 0 && k.Text != " " {
return k.Text
}
return k.Keystroke()
}
// Keystroke returns the keystroke representation of the [Key]. While less type
// safe than looking at the individual fields, it will usually be more
// convenient and readable to use this method when matching against keys.
//
// Note that modifier keys are always printed in the following order:
// - ctrl
// - alt
// - shift
// - meta
// - hyper
// - super
//
// For example, you'll always see "ctrl+shift+alt+a" and never
// "shift+ctrl+alt+a".
func (k Key) Keystroke() string {
var sb strings.Builder
if k.Mod.Contains(ModCtrl) && k.Code != KeyLeftCtrl && k.Code != KeyRightCtrl {
sb.WriteString("ctrl+")
}
if k.Mod.Contains(ModAlt) && k.Code != KeyLeftAlt && k.Code != KeyRightAlt {
sb.WriteString("alt+")
}
if k.Mod.Contains(ModShift) && k.Code != KeyLeftShift && k.Code != KeyRightShift {
sb.WriteString("shift+")
}
if k.Mod.Contains(ModMeta) && k.Code != KeyLeftMeta && k.Code != KeyRightMeta {
sb.WriteString("meta+")
}
if k.Mod.Contains(ModHyper) && k.Code != KeyLeftHyper && k.Code != KeyRightHyper {
sb.WriteString("hyper+")
}
if k.Mod.Contains(ModSuper) && k.Code != KeyLeftSuper && k.Code != KeyRightSuper {
sb.WriteString("super+")
}
if kt, ok := keyTypeString[k.Code]; ok {
sb.WriteString(kt)
} else {
code := k.Code
if k.BaseCode != 0 {
// If a [Key.BaseCode] is present, use it to represent a key using the standard
// PC-101 key layout.
code = k.BaseCode
}
switch code {
case KeySpace:
// Space is the only invisible printable character.
sb.WriteString("space")
case KeyExtended:
// Write the actual text of the key when the key contains multiple
// runes.
sb.WriteString(k.Text)
default:
sb.WriteRune(code)
}
}
return sb.String()
}
var keyTypeString = map[rune]string{
KeyEnter: "enter",
KeyTab: "tab",
KeyBackspace: "backspace",
KeyEscape: "esc",
KeySpace: "space",
KeyUp: "up",
KeyDown: "down",
KeyLeft: "left",
KeyRight: "right",
KeyBegin: "begin",
KeyFind: "find",
KeyInsert: "insert",
KeyDelete: "delete",
KeySelect: "select",
KeyPgUp: "pgup",
KeyPgDown: "pgdown",
KeyHome: "home",
KeyEnd: "end",
KeyKpEnter: "kpenter",
KeyKpEqual: "kpequal",
KeyKpMultiply: "kpmul",
KeyKpPlus: "kpplus",
KeyKpComma: "kpcomma",
KeyKpMinus: "kpminus",
KeyKpDecimal: "kpperiod",
KeyKpDivide: "kpdiv",
KeyKp0: "kp0",
KeyKp1: "kp1",
KeyKp2: "kp2",
KeyKp3: "kp3",
KeyKp4: "kp4",
KeyKp5: "kp5",
KeyKp6: "kp6",
KeyKp7: "kp7",
KeyKp8: "kp8",
KeyKp9: "kp9",
// Kitty keyboard extension
KeyKpSep: "kpsep",
KeyKpUp: "kpup",
KeyKpDown: "kpdown",
KeyKpLeft: "kpleft",
KeyKpRight: "kpright",
KeyKpPgUp: "kppgup",
KeyKpPgDown: "kppgdown",
KeyKpHome: "kphome",
KeyKpEnd: "kpend",
KeyKpInsert: "kpinsert",
KeyKpDelete: "kpdelete",
KeyKpBegin: "kpbegin",
KeyF1: "f1",
KeyF2: "f2",
KeyF3: "f3",
KeyF4: "f4",
KeyF5: "f5",
KeyF6: "f6",
KeyF7: "f7",
KeyF8: "f8",
KeyF9: "f9",
KeyF10: "f10",
KeyF11: "f11",
KeyF12: "f12",
KeyF13: "f13",
KeyF14: "f14",
KeyF15: "f15",
KeyF16: "f16",
KeyF17: "f17",
KeyF18: "f18",
KeyF19: "f19",
KeyF20: "f20",
KeyF21: "f21",
KeyF22: "f22",
KeyF23: "f23",
KeyF24: "f24",
KeyF25: "f25",
KeyF26: "f26",
KeyF27: "f27",
KeyF28: "f28",
KeyF29: "f29",
KeyF30: "f30",
KeyF31: "f31",
KeyF32: "f32",
KeyF33: "f33",
KeyF34: "f34",
KeyF35: "f35",
KeyF36: "f36",
KeyF37: "f37",
KeyF38: "f38",
KeyF39: "f39",
KeyF40: "f40",
KeyF41: "f41",
KeyF42: "f42",
KeyF43: "f43",
KeyF44: "f44",
KeyF45: "f45",
KeyF46: "f46",
KeyF47: "f47",
KeyF48: "f48",
KeyF49: "f49",
KeyF50: "f50",
KeyF51: "f51",
KeyF52: "f52",
KeyF53: "f53",
KeyF54: "f54",
KeyF55: "f55",
KeyF56: "f56",
KeyF57: "f57",
KeyF58: "f58",
KeyF59: "f59",
KeyF60: "f60",
KeyF61: "f61",
KeyF62: "f62",
KeyF63: "f63",
// Kitty keyboard extension
KeyCapsLock: "capslock",
KeyScrollLock: "scrolllock",
KeyNumLock: "numlock",
KeyPrintScreen: "printscreen",
KeyPause: "pause",
KeyMenu: "menu",
KeyMediaPlay: "mediaplay",
KeyMediaPause: "mediapause",
KeyMediaPlayPause: "mediaplaypause",
KeyMediaReverse: "mediareverse",
KeyMediaStop: "mediastop",
KeyMediaFastForward: "mediafastforward",
KeyMediaRewind: "mediarewind",
KeyMediaNext: "medianext",
KeyMediaPrev: "mediaprev",
KeyMediaRecord: "mediarecord",
KeyLowerVol: "lowervol",
KeyRaiseVol: "raisevol",
KeyMute: "mute",
KeyLeftShift: "leftshift",
KeyLeftAlt: "leftalt",
KeyLeftCtrl: "leftctrl",
KeyLeftSuper: "leftsuper",
KeyLeftHyper: "lefthyper",
KeyLeftMeta: "leftmeta",
KeyRightShift: "rightshift",
KeyRightAlt: "rightalt",
KeyRightCtrl: "rightctrl",
KeyRightSuper: "rightsuper",
KeyRightHyper: "righthyper",
KeyRightMeta: "rightmeta",
KeyIsoLevel3Shift: "isolevel3shift",
KeyIsoLevel5Shift: "isolevel5shift",
}
+880
View File
@@ -0,0 +1,880 @@
package input
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"image/color"
"io"
"math/rand"
"reflect"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/ansi/kitty"
)
var sequences = buildKeysTable(FlagTerminfo, "dumb")
func TestKeyString(t *testing.T) {
t.Run("alt+space", func(t *testing.T) {
k := KeyPressEvent{Code: KeySpace, Mod: ModAlt}
if got := k.String(); got != "alt+space" {
t.Fatalf(`expected a "alt+space", got %q`, got)
}
})
t.Run("runes", func(t *testing.T) {
k := KeyPressEvent{Code: 'a', Text: "a"}
if got := k.String(); got != "a" {
t.Fatalf(`expected an "a", got %q`, got)
}
})
t.Run("invalid", func(t *testing.T) {
k := KeyPressEvent{Code: 99999}
if got := k.String(); got != "𘚟" {
t.Fatalf(`expected a "unknown", got %q`, got)
}
})
t.Run("space", func(t *testing.T) {
k := KeyPressEvent{Code: KeySpace, Text: " "}
if got := k.String(); got != "space" {
t.Fatalf(`expected a "space", got %q`, got)
}
})
t.Run("shift+space", func(t *testing.T) {
k := KeyPressEvent{Code: KeySpace, Mod: ModShift}
if got := k.String(); got != "shift+space" {
t.Fatalf(`expected a "shift+space", got %q`, got)
}
})
t.Run("?", func(t *testing.T) {
k := KeyPressEvent{Code: '/', Mod: ModShift, Text: "?"}
if got := k.String(); got != "?" {
t.Fatalf(`expected a "?", got %q`, got)
}
})
}
type seqTest struct {
seq []byte
Events []Event
}
var f3CurPosRegexp = regexp.MustCompile(`\x1b\[1;(\d+)R`)
// buildBaseSeqTests returns sequence tests that are valid for the
// detectSequence() function.
func buildBaseSeqTests() []seqTest {
td := []seqTest{}
for seq, key := range sequences {
k := KeyPressEvent(key)
st := seqTest{seq: []byte(seq), Events: []Event{k}}
// XXX: This is a special case to handle F3 key sequence and cursor
// position report having the same sequence. See [parseCsi] for more
// information.
if f3CurPosRegexp.MatchString(seq) {
st.Events = []Event{k, CursorPositionEvent{Y: 0, X: int(key.Mod)}}
}
td = append(td, st)
}
// Additional special cases.
td = append(td,
// Unrecognized CSI sequence.
seqTest{
[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
[]Event{
UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}),
},
},
// A lone space character.
seqTest{
[]byte{' '},
[]Event{
KeyPressEvent{Code: KeySpace, Text: " "},
},
},
// An escape character with the alt modifier.
seqTest{
[]byte{'\x1b', ' '},
[]Event{
KeyPressEvent{Code: KeySpace, Mod: ModAlt},
},
},
)
return td
}
func TestParseSequence(t *testing.T) {
td := buildBaseSeqTests()
td = append(td,
// Background color.
seqTest{
[]byte("\x1b]11;rgb:1234/1234/1234\x07"),
[]Event{BackgroundColorEvent{
Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
}},
},
seqTest{
[]byte("\x1b]11;rgb:1234/1234/1234\x1b\\"),
[]Event{BackgroundColorEvent{
Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
}},
},
seqTest{
[]byte("\x1b]11;rgb:1234/1234/1234\x1b"), // Incomplete sequences are ignored.
[]Event{
UnknownEvent("\x1b]11;rgb:1234/1234/1234\x1b"),
},
},
// Kitty Graphics response.
seqTest{
[]byte("\x1b_Ga=t;OK\x1b\\"),
[]Event{KittyGraphicsEvent{
Options: kitty.Options{Action: kitty.Transmit},
Payload: []byte("OK"),
}},
},
seqTest{
[]byte("\x1b_Gi=99,I=13;OK\x1b\\"),
[]Event{KittyGraphicsEvent{
Options: kitty.Options{ID: 99, Number: 13},
Payload: []byte("OK"),
}},
},
seqTest{
[]byte("\x1b_Gi=1337,q=1;EINVAL:your face\x1b\\"),
[]Event{KittyGraphicsEvent{
Options: kitty.Options{ID: 1337, Quite: 1},
Payload: []byte("EINVAL:your face"),
}},
},
// Xterm modifyOtherKeys CSI 27 ; <modifier> ; <code> ~
seqTest{
[]byte("\x1b[27;3;20320~"),
[]Event{KeyPressEvent{Code: '你', Mod: ModAlt}},
},
seqTest{
[]byte("\x1b[27;3;65~"),
[]Event{KeyPressEvent{Code: 'A', Mod: ModAlt}},
},
seqTest{
[]byte("\x1b[27;3;8~"),
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
},
seqTest{
[]byte("\x1b[27;3;27~"),
[]Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
},
seqTest{
[]byte("\x1b[27;3;127~"),
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
},
// Xterm report window text area size.
seqTest{
[]byte("\x1b[4;24;80t"),
[]Event{
WindowOpEvent{Op: 4, Args: []int{24, 80}},
},
},
// Kitty keyboard / CSI u (fixterms)
seqTest{
[]byte("\x1b[1B"),
[]Event{KeyPressEvent{Code: KeyDown}},
},
seqTest{
[]byte("\x1b[1;B"),
[]Event{KeyPressEvent{Code: KeyDown}},
},
seqTest{
[]byte("\x1b[1;4B"),
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
},
seqTest{
[]byte("\x1b[1;4:1B"),
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
},
seqTest{
[]byte("\x1b[1;4:2B"),
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown, IsRepeat: true}},
},
seqTest{
[]byte("\x1b[1;4:3B"),
[]Event{KeyReleaseEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
},
seqTest{
[]byte("\x1b[8~"),
[]Event{KeyPressEvent{Code: KeyEnd}},
},
seqTest{
[]byte("\x1b[8;~"),
[]Event{KeyPressEvent{Code: KeyEnd}},
},
seqTest{
[]byte("\x1b[8;10~"),
[]Event{KeyPressEvent{Mod: ModShift | ModMeta, Code: KeyEnd}},
},
seqTest{
[]byte("\x1b[27;4u"),
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyEscape}},
},
seqTest{
[]byte("\x1b[127;4u"),
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyBackspace}},
},
seqTest{
[]byte("\x1b[57358;4u"),
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyCapsLock}},
},
seqTest{
[]byte("\x1b[9;2u"),
[]Event{KeyPressEvent{Mod: ModShift, Code: KeyTab}},
},
seqTest{
[]byte("\x1b[195;u"),
[]Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
},
seqTest{
[]byte("\x1b[20320;2u"),
[]Event{KeyPressEvent{Text: "你", Mod: ModShift, Code: '你'}},
},
seqTest{
[]byte("\x1b[195;:1u"),
[]Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
},
seqTest{
[]byte("\x1b[195;2:3u"),
[]Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
},
seqTest{
[]byte("\x1b[195;2:2u"),
[]Event{KeyPressEvent{Code: 'Ã', Text: "Ã", IsRepeat: true, Mod: ModShift}},
},
seqTest{
[]byte("\x1b[195;2:1u"),
[]Event{KeyPressEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
},
seqTest{
[]byte("\x1b[195;2:3u"),
[]Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
},
seqTest{
[]byte("\x1b[97;2;65u"),
[]Event{KeyPressEvent{Code: 'a', Text: "A", Mod: ModShift}},
},
seqTest{
[]byte("\x1b[97;;229u"),
[]Event{KeyPressEvent{Code: 'a', Text: "å"}},
},
// focus/blur
seqTest{
[]byte{'\x1b', '[', 'I'},
[]Event{
FocusEvent{},
},
},
seqTest{
[]byte{'\x1b', '[', 'O'},
[]Event{
BlurEvent{},
},
},
// Mouse event.
seqTest{
[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
[]Event{
MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
},
},
// SGR Mouse event.
seqTest{
[]byte("\x1b[<0;33;17M"),
[]Event{
MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
},
},
// Runes.
seqTest{
[]byte{'a'},
[]Event{
KeyPressEvent{Code: 'a', Text: "a"},
},
},
seqTest{
[]byte{'\x1b', 'a'},
[]Event{
KeyPressEvent{Code: 'a', Mod: ModAlt},
},
},
seqTest{
[]byte{'a', 'a', 'a'},
[]Event{
KeyPressEvent{Code: 'a', Text: "a"},
KeyPressEvent{Code: 'a', Text: "a"},
KeyPressEvent{Code: 'a', Text: "a"},
},
},
// Multi-byte rune.
seqTest{
[]byte("☃"),
[]Event{
KeyPressEvent{Code: '☃', Text: "☃"},
},
},
seqTest{
[]byte("\x1b☃"),
[]Event{
KeyPressEvent{Code: '☃', Mod: ModAlt},
},
},
// Standalone control characters.
seqTest{
[]byte{'\x1b'},
[]Event{
KeyPressEvent{Code: KeyEscape},
},
},
seqTest{
[]byte{ansi.SOH},
[]Event{
KeyPressEvent{Code: 'a', Mod: ModCtrl},
},
},
seqTest{
[]byte{'\x1b', ansi.SOH},
[]Event{
KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
},
},
seqTest{
[]byte{ansi.NUL},
[]Event{
KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
},
},
seqTest{
[]byte{'\x1b', ansi.NUL},
[]Event{
KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt},
},
},
// C1 control characters.
seqTest{
[]byte{'\x80'},
[]Event{
KeyPressEvent{Code: rune(0x80 - '@'), Mod: ModCtrl | ModAlt},
},
},
)
if runtime.GOOS != "windows" {
// Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows.
// This is incorrect, but it makes our test fail if we try it out.
td = append(td, seqTest{
[]byte{'\xfe'},
[]Event{
UnknownEvent(rune(0xfe)),
},
})
}
var p Parser
for _, tc := range td {
t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
var events []Event
buf := tc.seq
for len(buf) > 0 {
width, Event := p.parseSequence(buf)
switch Event := Event.(type) {
case MultiEvent:
events = append(events, Event...)
default:
events = append(events, Event)
}
buf = buf[width:]
}
if !reflect.DeepEqual(tc.Events, events) {
t.Errorf("\nexpected event for %q:\n %#v\ngot:\n %#v", tc.seq, tc.Events, events)
}
})
}
}
func TestReadLongInput(t *testing.T) {
expect := make([]Event, 1000)
for i := range 1000 {
expect[i] = KeyPressEvent{Code: 'a', Text: "a"}
}
input := strings.Repeat("a", 1000)
drv, err := NewReader(strings.NewReader(input), "dumb", 0)
if err != nil {
t.Fatalf("unexpected input driver error: %v", err)
}
var Events []Event
for {
events, err := drv.ReadEvents()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("unexpected input error: %v", err)
}
Events = append(Events, events...)
}
if !reflect.DeepEqual(expect, Events) {
t.Errorf("unexpected messages, expected:\n %+v\ngot:\n %+v", expect, Events)
}
}
func TestReadInput(t *testing.T) {
type test struct {
keyname string
in []byte
out []Event
}
testData := []test{
{
"a",
[]byte{'a'},
[]Event{
KeyPressEvent{Code: 'a', Text: "a"},
},
},
{
"space",
[]byte{' '},
[]Event{
KeyPressEvent{Code: KeySpace, Text: " "},
},
},
{
"a alt+a",
[]byte{'a', '\x1b', 'a'},
[]Event{
KeyPressEvent{Code: 'a', Text: "a"},
KeyPressEvent{Code: 'a', Mod: ModAlt},
},
},
{
"a alt+a a",
[]byte{'a', '\x1b', 'a', 'a'},
[]Event{
KeyPressEvent{Code: 'a', Text: "a"},
KeyPressEvent{Code: 'a', Mod: ModAlt},
KeyPressEvent{Code: 'a', Text: "a"},
},
},
{
"ctrl+a",
[]byte{byte(ansi.SOH)},
[]Event{
KeyPressEvent{Code: 'a', Mod: ModCtrl},
},
},
{
"ctrl+a ctrl+b",
[]byte{byte(ansi.SOH), byte(ansi.STX)},
[]Event{
KeyPressEvent{Code: 'a', Mod: ModCtrl},
KeyPressEvent{Code: 'b', Mod: ModCtrl},
},
},
{
"alt+a",
[]byte{byte(0x1b), 'a'},
[]Event{
KeyPressEvent{Code: 'a', Mod: ModAlt},
},
},
{
"a b c d",
[]byte{'a', 'b', 'c', 'd'},
[]Event{
KeyPressEvent{Code: 'a', Text: "a"},
KeyPressEvent{Code: 'b', Text: "b"},
KeyPressEvent{Code: 'c', Text: "c"},
KeyPressEvent{Code: 'd', Text: "d"},
},
},
{
"up",
[]byte("\x1b[A"),
[]Event{
KeyPressEvent{Code: KeyUp},
},
},
{
"wheel up",
[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
[]Event{
MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
},
},
{
"left motion release",
[]byte{
'\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33),
'\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33),
},
[]Event{
MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
MouseReleaseEvent{X: 64, Y: 32, Button: MouseNone},
},
},
{
"shift+tab",
[]byte{'\x1b', '[', 'Z'},
[]Event{
KeyPressEvent{Code: KeyTab, Mod: ModShift},
},
},
{
"enter",
[]byte{'\r'},
[]Event{KeyPressEvent{Code: KeyEnter}},
},
{
"alt+enter",
[]byte{'\x1b', '\r'},
[]Event{
KeyPressEvent{Code: KeyEnter, Mod: ModAlt},
},
},
{
"insert",
[]byte{'\x1b', '[', '2', '~'},
[]Event{
KeyPressEvent{Code: KeyInsert},
},
},
{
"ctrl+alt+a",
[]byte{'\x1b', byte(ansi.SOH)},
[]Event{
KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
},
},
{
"CSI?----X?",
[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
[]Event{UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})},
},
// Powershell sequences.
{
"up",
[]byte{'\x1b', 'O', 'A'},
[]Event{KeyPressEvent{Code: KeyUp}},
},
{
"down",
[]byte{'\x1b', 'O', 'B'},
[]Event{KeyPressEvent{Code: KeyDown}},
},
{
"right",
[]byte{'\x1b', 'O', 'C'},
[]Event{KeyPressEvent{Code: KeyRight}},
},
{
"left",
[]byte{'\x1b', 'O', 'D'},
[]Event{KeyPressEvent{Code: KeyLeft}},
},
{
"alt+enter",
[]byte{'\x1b', '\x0d'},
[]Event{KeyPressEvent{Code: KeyEnter, Mod: ModAlt}},
},
{
"alt+backspace",
[]byte{'\x1b', '\x7f'},
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
},
{
"ctrl+space",
[]byte{'\x00'},
[]Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl}},
},
{
"ctrl+alt+space",
[]byte{'\x1b', '\x00'},
[]Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt}},
},
{
"esc",
[]byte{'\x1b'},
[]Event{KeyPressEvent{Code: KeyEscape}},
},
{
"alt+esc",
[]byte{'\x1b', '\x1b'},
[]Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
},
{
"a b o",
[]byte{
'\x1b', '[', '2', '0', '0', '~',
'a', ' ', 'b',
'\x1b', '[', '2', '0', '1', '~',
'o',
},
[]Event{
PasteStartEvent{},
PasteEvent("a b"),
PasteEndEvent{},
KeyPressEvent{Code: 'o', Text: "o"},
},
},
{
"a\x03\nb",
[]byte{
'\x1b', '[', '2', '0', '0', '~',
'a', '\x03', '\n', 'b',
'\x1b', '[', '2', '0', '1', '~',
},
[]Event{
PasteStartEvent{},
PasteEvent("a\x03\nb"),
PasteEndEvent{},
},
},
{
"?0xfe?",
[]byte{'\xfe'},
[]Event{
UnknownEvent(rune(0xfe)),
},
},
{
"a ?0xfe? b",
[]byte{'a', '\xfe', ' ', 'b'},
[]Event{
KeyPressEvent{Code: 'a', Text: "a"},
UnknownEvent(rune(0xfe)),
KeyPressEvent{Code: KeySpace, Text: " "},
KeyPressEvent{Code: 'b', Text: "b"},
},
},
}
for i, td := range testData {
t.Run(fmt.Sprintf("%d: %s", i, td.keyname), func(t *testing.T) {
Events := testReadInputs(t, bytes.NewReader(td.in))
var buf strings.Builder
for i, Event := range Events {
if i > 0 {
buf.WriteByte(' ')
}
if s, ok := Event.(fmt.Stringer); ok {
buf.WriteString(s.String())
} else {
fmt.Fprintf(&buf, "%#v:%T", Event, Event)
}
}
if len(Events) != len(td.out) {
t.Fatalf("unexpected message list length: got %d, expected %d\n got: %#v\n expected: %#v\n", len(Events), len(td.out), Events, td.out)
}
if !reflect.DeepEqual(td.out, Events) {
t.Fatalf("expected:\n%#v\ngot:\n%#v", td.out, Events)
}
})
}
}
func testReadInputs(t *testing.T, input io.Reader) []Event {
// We'll check that the input reader finishes at the end
// without error.
var wg sync.WaitGroup
var inputErr error
ctx, cancel := context.WithCancel(context.Background())
defer func() {
cancel()
wg.Wait()
if inputErr != nil && !errors.Is(inputErr, io.EOF) {
t.Fatalf("unexpected input error: %v", inputErr)
}
}()
dr, err := NewReader(input, "dumb", 0)
if err != nil {
t.Fatalf("unexpected input driver error: %v", err)
}
// The messages we're consuming.
EventsC := make(chan Event)
// Start the reader in the background.
wg.Add(1)
go func() {
defer wg.Done()
var events []Event
events, inputErr = dr.ReadEvents()
out:
for _, ev := range events {
select {
case EventsC <- ev:
case <-ctx.Done():
break out
}
}
EventsC <- nil
}()
var Events []Event
loop:
for {
select {
case Event := <-EventsC:
if Event == nil {
// end of input marker for the test.
break loop
}
Events = append(Events, Event)
case <-time.After(2 * time.Second):
t.Errorf("timeout waiting for input event")
break loop
}
}
return Events
}
// randTest defines the test input and expected output for a sequence
// of interleaved control sequences and control characters.
type randTest struct {
data []byte
lengths []int
names []string
}
// seed is the random seed to randomize the input. This helps check
// that all the sequences get ultimately exercised.
var seed = flag.Int64("seed", 0, "random seed (0 to autoselect)")
// genRandomData generates a randomized test, with a random seed unless
// the seed flag was set.
func genRandomData(logfn func(int64), length int) randTest {
// We'll use a random source. However, we give the user the option
// to override it to a specific value for reproduceability.
s := *seed
if s == 0 {
s = time.Now().UnixNano()
}
// Inform the user so they know what to reuse to get the same data.
logfn(s)
return genRandomDataWithSeed(s, length)
}
// genRandomDataWithSeed generates a randomized test with a fixed seed.
func genRandomDataWithSeed(s int64, length int) randTest {
src := rand.NewSource(s)
r := rand.New(src)
// allseqs contains all the sequences, in sorted order. We sort
// to make the test deterministic (when the seed is also fixed).
type seqpair struct {
seq string
name string
}
var allseqs []seqpair
for seq, key := range sequences {
allseqs = append(allseqs, seqpair{seq, key.String()})
}
sort.Slice(allseqs, func(i, j int) bool { return allseqs[i].seq < allseqs[j].seq })
// res contains the computed test.
var res randTest
for len(res.data) < length {
alt := r.Intn(2)
prefix := ""
esclen := 0
if alt == 1 {
prefix = "alt+"
esclen = 1
}
kind := r.Intn(3)
switch kind {
case 0:
// A control character.
if alt == 1 {
res.data = append(res.data, '\x1b')
}
res.data = append(res.data, 1)
res.names = append(res.names, "ctrl+"+prefix+"a")
res.lengths = append(res.lengths, 1+esclen)
case 1, 2:
// A sequence.
seqi := r.Intn(len(allseqs))
s := allseqs[seqi]
if strings.Contains(s.name, "alt+") || strings.Contains(s.name, "meta+") {
esclen = 0
prefix = ""
alt = 0
}
if alt == 1 {
res.data = append(res.data, '\x1b')
}
res.data = append(res.data, s.seq...)
if strings.HasPrefix(s.name, "ctrl+") {
prefix = "ctrl+" + prefix
}
name := prefix + strings.TrimPrefix(s.name, "ctrl+")
res.names = append(res.names, name)
res.lengths = append(res.lengths, len(s.seq)+esclen)
}
}
return res
}
func FuzzParseSequence(f *testing.F) {
var p Parser
for seq := range sequences {
f.Add(seq)
}
f.Add("\x1b]52;?\x07") // OSC 52
f.Add("\x1b]11;rgb:0000/0000/0000\x1b\\") // OSC 11
f.Add("\x1bP>|charm terminal(0.1.2)\x1b\\") // DCS (XTVERSION)
f.Add("\x1b_Gi=123\x1b\\") // APC
f.Fuzz(func(t *testing.T, seq string) {
n, _ := p.parseSequence([]byte(seq))
if n == 0 && seq != "" {
t.Errorf("expected a non-zero width for %q", seq)
}
})
}
// BenchmarkDetectSequenceMap benchmarks the map-based sequence
// detector.
func BenchmarkDetectSequenceMap(b *testing.B) {
var p Parser
td := genRandomDataWithSeed(123, 10000)
for i := 0; i < b.N; i++ {
for j, w := 0, 0; j < len(td.data); j += w {
w, _ = p.parseSequence(td.data[j:])
}
}
}
+353
View File
@@ -0,0 +1,353 @@
package input
import (
"unicode"
"unicode/utf8"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/ansi/kitty"
)
// KittyGraphicsEvent represents a Kitty Graphics response event.
//
// See https://sw.kovidgoyal.net/kitty/graphics-protocol/
type KittyGraphicsEvent struct {
Options kitty.Options
Payload []byte
}
// KittyEnhancementsEvent represents a Kitty enhancements event.
type KittyEnhancementsEvent int
// Kitty keyboard enhancement constants.
// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
const (
KittyDisambiguateEscapeCodes KittyEnhancementsEvent = 1 << iota
KittyReportEventTypes
KittyReportAlternateKeys
KittyReportAllKeysAsEscapeCodes
KittyReportAssociatedText
)
// Contains reports whether m contains the given enhancements.
func (e KittyEnhancementsEvent) Contains(enhancements KittyEnhancementsEvent) bool {
return e&enhancements == enhancements
}
// Kitty Clipboard Control Sequences.
var kittyKeyMap = map[int]Key{
ansi.BS: {Code: KeyBackspace},
ansi.HT: {Code: KeyTab},
ansi.CR: {Code: KeyEnter},
ansi.ESC: {Code: KeyEscape},
ansi.DEL: {Code: KeyBackspace},
57344: {Code: KeyEscape},
57345: {Code: KeyEnter},
57346: {Code: KeyTab},
57347: {Code: KeyBackspace},
57348: {Code: KeyInsert},
57349: {Code: KeyDelete},
57350: {Code: KeyLeft},
57351: {Code: KeyRight},
57352: {Code: KeyUp},
57353: {Code: KeyDown},
57354: {Code: KeyPgUp},
57355: {Code: KeyPgDown},
57356: {Code: KeyHome},
57357: {Code: KeyEnd},
57358: {Code: KeyCapsLock},
57359: {Code: KeyScrollLock},
57360: {Code: KeyNumLock},
57361: {Code: KeyPrintScreen},
57362: {Code: KeyPause},
57363: {Code: KeyMenu},
57364: {Code: KeyF1},
57365: {Code: KeyF2},
57366: {Code: KeyF3},
57367: {Code: KeyF4},
57368: {Code: KeyF5},
57369: {Code: KeyF6},
57370: {Code: KeyF7},
57371: {Code: KeyF8},
57372: {Code: KeyF9},
57373: {Code: KeyF10},
57374: {Code: KeyF11},
57375: {Code: KeyF12},
57376: {Code: KeyF13},
57377: {Code: KeyF14},
57378: {Code: KeyF15},
57379: {Code: KeyF16},
57380: {Code: KeyF17},
57381: {Code: KeyF18},
57382: {Code: KeyF19},
57383: {Code: KeyF20},
57384: {Code: KeyF21},
57385: {Code: KeyF22},
57386: {Code: KeyF23},
57387: {Code: KeyF24},
57388: {Code: KeyF25},
57389: {Code: KeyF26},
57390: {Code: KeyF27},
57391: {Code: KeyF28},
57392: {Code: KeyF29},
57393: {Code: KeyF30},
57394: {Code: KeyF31},
57395: {Code: KeyF32},
57396: {Code: KeyF33},
57397: {Code: KeyF34},
57398: {Code: KeyF35},
57399: {Code: KeyKp0},
57400: {Code: KeyKp1},
57401: {Code: KeyKp2},
57402: {Code: KeyKp3},
57403: {Code: KeyKp4},
57404: {Code: KeyKp5},
57405: {Code: KeyKp6},
57406: {Code: KeyKp7},
57407: {Code: KeyKp8},
57408: {Code: KeyKp9},
57409: {Code: KeyKpDecimal},
57410: {Code: KeyKpDivide},
57411: {Code: KeyKpMultiply},
57412: {Code: KeyKpMinus},
57413: {Code: KeyKpPlus},
57414: {Code: KeyKpEnter},
57415: {Code: KeyKpEqual},
57416: {Code: KeyKpSep},
57417: {Code: KeyKpLeft},
57418: {Code: KeyKpRight},
57419: {Code: KeyKpUp},
57420: {Code: KeyKpDown},
57421: {Code: KeyKpPgUp},
57422: {Code: KeyKpPgDown},
57423: {Code: KeyKpHome},
57424: {Code: KeyKpEnd},
57425: {Code: KeyKpInsert},
57426: {Code: KeyKpDelete},
57427: {Code: KeyKpBegin},
57428: {Code: KeyMediaPlay},
57429: {Code: KeyMediaPause},
57430: {Code: KeyMediaPlayPause},
57431: {Code: KeyMediaReverse},
57432: {Code: KeyMediaStop},
57433: {Code: KeyMediaFastForward},
57434: {Code: KeyMediaRewind},
57435: {Code: KeyMediaNext},
57436: {Code: KeyMediaPrev},
57437: {Code: KeyMediaRecord},
57438: {Code: KeyLowerVol},
57439: {Code: KeyRaiseVol},
57440: {Code: KeyMute},
57441: {Code: KeyLeftShift},
57442: {Code: KeyLeftCtrl},
57443: {Code: KeyLeftAlt},
57444: {Code: KeyLeftSuper},
57445: {Code: KeyLeftHyper},
57446: {Code: KeyLeftMeta},
57447: {Code: KeyRightShift},
57448: {Code: KeyRightCtrl},
57449: {Code: KeyRightAlt},
57450: {Code: KeyRightSuper},
57451: {Code: KeyRightHyper},
57452: {Code: KeyRightMeta},
57453: {Code: KeyIsoLevel3Shift},
57454: {Code: KeyIsoLevel5Shift},
}
func init() {
// These are some faulty C0 mappings some terminals such as WezTerm have
// and doesn't follow the specs.
kittyKeyMap[ansi.NUL] = Key{Code: KeySpace, Mod: ModCtrl}
for i := ansi.SOH; i <= ansi.SUB; i++ {
if _, ok := kittyKeyMap[i]; !ok {
kittyKeyMap[i] = Key{Code: rune(i + 0x60), Mod: ModCtrl}
}
}
for i := ansi.FS; i <= ansi.US; i++ {
if _, ok := kittyKeyMap[i]; !ok {
kittyKeyMap[i] = Key{Code: rune(i + 0x40), Mod: ModCtrl}
}
}
}
const (
kittyShift = 1 << iota
kittyAlt
kittyCtrl
kittySuper
kittyHyper
kittyMeta
kittyCapsLock
kittyNumLock
)
func fromKittyMod(mod int) KeyMod {
var m KeyMod
if mod&kittyShift != 0 {
m |= ModShift
}
if mod&kittyAlt != 0 {
m |= ModAlt
}
if mod&kittyCtrl != 0 {
m |= ModCtrl
}
if mod&kittySuper != 0 {
m |= ModSuper
}
if mod&kittyHyper != 0 {
m |= ModHyper
}
if mod&kittyMeta != 0 {
m |= ModMeta
}
if mod&kittyCapsLock != 0 {
m |= ModCapsLock
}
if mod&kittyNumLock != 0 {
m |= ModNumLock
}
return m
}
// parseKittyKeyboard parses a Kitty Keyboard Protocol sequence.
//
// In `CSI u`, this is parsed as:
//
// CSI codepoint ; modifiers u
// codepoint: ASCII Dec value
//
// The Kitty Keyboard Protocol extends this with optional components that can be
// enabled progressively. The full sequence is parsed as:
//
// CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u
//
// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/
func parseKittyKeyboard(params ansi.Params) (Event Event) {
var isRelease bool
var key Key
// The index of parameters separated by semicolons ';'. Sub parameters are
// separated by colons ':'.
var paramIdx int
var sudIdx int // The sub parameter index
for _, p := range params {
// Kitty Keyboard Protocol has 3 optional components.
switch paramIdx {
case 0:
switch sudIdx {
case 0:
var foundKey bool
code := p.Param(1) // CSI u has a default value of 1
key, foundKey = kittyKeyMap[code]
if !foundKey {
r := rune(code)
if !utf8.ValidRune(r) {
r = utf8.RuneError
}
key.Code = r
}
case 2:
// shifted key + base key
if b := rune(p.Param(1)); unicode.IsPrint(b) {
// XXX: When alternate key reporting is enabled, the protocol
// can return 3 things, the unicode codepoint of the key,
// the shifted codepoint of the key, and the standard
// PC-101 key layout codepoint.
// This is useful to create an unambiguous mapping of keys
// when using a different language layout.
key.BaseCode = b
}
fallthrough
case 1:
// shifted key
if s := rune(p.Param(1)); unicode.IsPrint(s) {
// XXX: We swap keys here because we want the shifted key
// to be the Rune that is returned by the event.
// For example, shift+a should produce "A" not "a".
// In such a case, we set AltRune to the original key "a"
// and Rune to "A".
key.ShiftedCode = s
}
}
case 1:
switch sudIdx {
case 0:
mod := p.Param(1)
if mod > 1 {
key.Mod = fromKittyMod(mod - 1)
if key.Mod > ModShift {
// XXX: We need to clear the text if we have a modifier key
// other than a [ModShift] key.
key.Text = ""
}
}
case 1:
switch p.Param(1) {
case 2:
key.IsRepeat = true
case 3:
isRelease = true
}
case 2:
}
case 2:
if code := p.Param(0); code != 0 {
key.Text += string(rune(code))
}
}
sudIdx++
if !p.HasMore() {
paramIdx++
sudIdx = 0
}
}
//nolint:nestif
if len(key.Text) == 0 && unicode.IsPrint(key.Code) &&
(key.Mod <= ModShift || key.Mod == ModCapsLock || key.Mod == ModShift|ModCapsLock) {
if key.Mod == 0 {
key.Text = string(key.Code)
} else {
desiredCase := unicode.ToLower
if key.Mod.Contains(ModShift) || key.Mod.Contains(ModCapsLock) {
desiredCase = unicode.ToUpper
}
if key.ShiftedCode != 0 {
key.Text = string(key.ShiftedCode)
} else {
key.Text = string(desiredCase(key.Code))
}
}
}
if isRelease {
return KeyReleaseEvent(key)
}
return KeyPressEvent(key)
}
// parseKittyKeyboardExt parses a Kitty Keyboard Protocol sequence extensions
// for non CSI u sequences. This includes things like CSI A, SS3 A and others,
// and CSI ~.
func parseKittyKeyboardExt(params ansi.Params, k KeyPressEvent) Event {
// Handle Kitty keyboard protocol
if len(params) > 2 && // We have at least 3 parameters
params[0].Param(1) == 1 && // The first parameter is 1 (defaults to 1)
params[1].HasMore() { // The second parameter is a subparameter (separated by a ":")
switch params[2].Param(1) { // The third parameter is the event type (defaults to 1)
case 2:
k.IsRepeat = true
case 3:
return KeyReleaseEvent(k)
}
}
return k
}
+37
View File
@@ -0,0 +1,37 @@
package input
// KeyMod represents modifier keys.
type KeyMod int
// Modifier keys.
const (
ModShift KeyMod = 1 << iota
ModAlt
ModCtrl
ModMeta
// These modifiers are used with the Kitty protocol.
// XXX: Meta and Super are swapped in the Kitty protocol,
// this is to preserve compatibility with XTerm modifiers.
ModHyper
ModSuper // Windows/Command keys
// These are key lock states.
ModCapsLock
ModNumLock
ModScrollLock // Defined in Windows API only
)
// Contains reports whether m contains the given modifiers.
//
// Example:
//
// m := ModAlt | ModCtrl
// m.Contains(ModCtrl) // true
// m.Contains(ModAlt | ModCtrl) // true
// m.Contains(ModAlt | ModCtrl | ModShift) // false
func (m KeyMod) Contains(mods KeyMod) bool {
return m&mods == mods
}
+14
View File
@@ -0,0 +1,14 @@
package input
import "github.com/charmbracelet/x/ansi"
// ModeReportEvent is a message that represents a mode report event (DECRPM).
//
// See: https://vt100.net/docs/vt510-rm/DECRPM.html
type ModeReportEvent struct {
// Mode is the mode number.
Mode ansi.Mode
// Value is the mode value.
Value ansi.ModeSetting
}
+292
View File
@@ -0,0 +1,292 @@
package input
import (
"fmt"
"github.com/charmbracelet/x/ansi"
)
// MouseButton represents the button that was pressed during a mouse message.
type MouseButton = ansi.MouseButton
// Mouse event buttons
//
// This is based on X11 mouse button codes.
//
// 1 = left button
// 2 = middle button (pressing the scroll wheel)
// 3 = right button
// 4 = turn scroll wheel up
// 5 = turn scroll wheel down
// 6 = push scroll wheel left
// 7 = push scroll wheel right
// 8 = 4th button (aka browser backward button)
// 9 = 5th button (aka browser forward button)
// 10
// 11
//
// Other buttons are not supported.
const (
MouseNone = ansi.MouseNone
MouseLeft = ansi.MouseLeft
MouseMiddle = ansi.MouseMiddle
MouseRight = ansi.MouseRight
MouseWheelUp = ansi.MouseWheelUp
MouseWheelDown = ansi.MouseWheelDown
MouseWheelLeft = ansi.MouseWheelLeft
MouseWheelRight = ansi.MouseWheelRight
MouseBackward = ansi.MouseBackward
MouseForward = ansi.MouseForward
MouseButton10 = ansi.MouseButton10
MouseButton11 = ansi.MouseButton11
)
// MouseEvent represents a mouse message. This is a generic mouse message that
// can represent any kind of mouse event.
type MouseEvent interface {
fmt.Stringer
// Mouse returns the underlying mouse event.
Mouse() Mouse
}
// Mouse represents a Mouse message. Use [MouseEvent] to represent all mouse
// messages.
//
// The X and Y coordinates are zero-based, with (0,0) being the upper left
// corner of the terminal.
//
// // Catch all mouse events
// switch Event := Event.(type) {
// case MouseEvent:
// m := Event.Mouse()
// fmt.Println("Mouse event:", m.X, m.Y, m)
// }
//
// // Only catch mouse click events
// switch Event := Event.(type) {
// case MouseClickEvent:
// fmt.Println("Mouse click event:", Event.X, Event.Y, Event)
// }
type Mouse struct {
X, Y int
Button MouseButton
Mod KeyMod
}
// String returns a string representation of the mouse message.
func (m Mouse) String() (s string) {
if m.Mod.Contains(ModCtrl) {
s += "ctrl+"
}
if m.Mod.Contains(ModAlt) {
s += "alt+"
}
if m.Mod.Contains(ModShift) {
s += "shift+"
}
str := m.Button.String()
if str == "" {
s += "unknown"
} else if str != "none" { // motion events don't have a button
s += str
}
return s
}
// MouseClickEvent represents a mouse button click event.
type MouseClickEvent Mouse
// String returns a string representation of the mouse click event.
func (e MouseClickEvent) String() string {
return Mouse(e).String()
}
// Mouse returns the underlying mouse event. This is a convenience method and
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
// event to [Mouse].
func (e MouseClickEvent) Mouse() Mouse {
return Mouse(e)
}
// MouseReleaseEvent represents a mouse button release event.
type MouseReleaseEvent Mouse
// String returns a string representation of the mouse release event.
func (e MouseReleaseEvent) String() string {
return Mouse(e).String()
}
// Mouse returns the underlying mouse event. This is a convenience method and
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
// event to [Mouse].
func (e MouseReleaseEvent) Mouse() Mouse {
return Mouse(e)
}
// MouseWheelEvent represents a mouse wheel message event.
type MouseWheelEvent Mouse
// String returns a string representation of the mouse wheel event.
func (e MouseWheelEvent) String() string {
return Mouse(e).String()
}
// Mouse returns the underlying mouse event. This is a convenience method and
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
// event to [Mouse].
func (e MouseWheelEvent) Mouse() Mouse {
return Mouse(e)
}
// MouseMotionEvent represents a mouse motion event.
type MouseMotionEvent Mouse
// String returns a string representation of the mouse motion event.
func (e MouseMotionEvent) String() string {
m := Mouse(e)
if m.Button != 0 {
return m.String() + "+motion"
}
return m.String() + "motion"
}
// Mouse returns the underlying mouse event. This is a convenience method and
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
// event to [Mouse].
func (e MouseMotionEvent) Mouse() Mouse {
return Mouse(e)
}
// Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events
// look like:
//
// ESC [ < Cb ; Cx ; Cy (M or m)
//
// where:
//
// Cb is the encoded button code
// Cx is the x-coordinate of the mouse
// Cy is the y-coordinate of the mouse
// M is for button press, m is for button release
//
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
func parseSGRMouseEvent(cmd ansi.Cmd, params ansi.Params) Event {
x, _, ok := params.Param(1, 1)
if !ok {
x = 1
}
y, _, ok := params.Param(2, 1)
if !ok {
y = 1
}
release := cmd.Final() == 'm'
b, _, _ := params.Param(0, 0)
mod, btn, _, isMotion := parseMouseButton(b)
// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
x--
y--
m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
// Wheel buttons don't have release events
// Motion can be reported as a release event in some terminals (Windows Terminal)
if isWheel(m.Button) {
return MouseWheelEvent(m)
} else if !isMotion && release {
return MouseReleaseEvent(m)
} else if isMotion {
return MouseMotionEvent(m)
}
return MouseClickEvent(m)
}
const x10MouseByteOffset = 32
// Parse X10-encoded mouse events; the simplest kind. The last release of X10
// was December 1986, by the way. The original X10 mouse protocol limits the Cx
// and Cy coordinates to 223 (=255-032).
//
// X10 mouse events look like:
//
// ESC [M Cb Cx Cy
//
// See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
func parseX10MouseEvent(buf []byte) Event {
v := buf[3:6]
b := int(v[0])
if b >= x10MouseByteOffset {
// XXX: b < 32 should be impossible, but we're being defensive.
b -= x10MouseByteOffset
}
mod, btn, isRelease, isMotion := parseMouseButton(b)
// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
x := int(v[1]) - x10MouseByteOffset - 1
y := int(v[2]) - x10MouseByteOffset - 1
m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
if isWheel(m.Button) {
return MouseWheelEvent(m)
} else if isMotion {
return MouseMotionEvent(m)
} else if isRelease {
return MouseReleaseEvent(m)
}
return MouseClickEvent(m)
}
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
func parseMouseButton(b int) (mod KeyMod, btn MouseButton, isRelease bool, isMotion bool) {
// mouse bit shifts
const (
bitShift = 0b0000_0100
bitAlt = 0b0000_1000
bitCtrl = 0b0001_0000
bitMotion = 0b0010_0000
bitWheel = 0b0100_0000
bitAdd = 0b1000_0000 // additional buttons 8-11
bitsMask = 0b0000_0011
)
// Modifiers
if b&bitAlt != 0 {
mod |= ModAlt
}
if b&bitCtrl != 0 {
mod |= ModCtrl
}
if b&bitShift != 0 {
mod |= ModShift
}
if b&bitAdd != 0 {
btn = MouseBackward + MouseButton(b&bitsMask)
} else if b&bitWheel != 0 {
btn = MouseWheelUp + MouseButton(b&bitsMask)
} else {
btn = MouseLeft + MouseButton(b&bitsMask)
// X10 reports a button release as 0b0000_0011 (3)
if b&bitsMask == bitsMask {
btn = MouseNone
isRelease = true
}
}
// Motion bit doesn't get reported for wheel events.
if b&bitMotion != 0 && !isWheel(btn) {
isMotion = true
}
return //nolint:nakedret
}
// isWheel returns true if the mouse event is a wheel event.
func isWheel(btn MouseButton) bool {
return btn >= MouseWheelUp && btn <= MouseWheelRight
}
+481
View File
@@ -0,0 +1,481 @@
package input
import (
"fmt"
"testing"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/ansi/parser"
)
func TestMouseEvent_String(t *testing.T) {
tt := []struct {
name string
event Event
expected string
}{
{
name: "unknown",
event: MouseClickEvent{Button: MouseButton(0xff)},
expected: "unknown",
},
{
name: "left",
event: MouseClickEvent{Button: MouseLeft},
expected: "left",
},
{
name: "right",
event: MouseClickEvent{Button: MouseRight},
expected: "right",
},
{
name: "middle",
event: MouseClickEvent{Button: MouseMiddle},
expected: "middle",
},
{
name: "release",
event: MouseReleaseEvent{Button: MouseNone},
expected: "",
},
{
name: "wheelup",
event: MouseWheelEvent{Button: MouseWheelUp},
expected: "wheelup",
},
{
name: "wheeldown",
event: MouseWheelEvent{Button: MouseWheelDown},
expected: "wheeldown",
},
{
name: "wheelleft",
event: MouseWheelEvent{Button: MouseWheelLeft},
expected: "wheelleft",
},
{
name: "wheelright",
event: MouseWheelEvent{Button: MouseWheelRight},
expected: "wheelright",
},
{
name: "motion",
event: MouseMotionEvent{Button: MouseNone},
expected: "motion",
},
{
name: "shift+left",
event: MouseReleaseEvent{Button: MouseLeft, Mod: ModShift},
expected: "shift+left",
},
{
name: "shift+left", event: MouseClickEvent{Button: MouseLeft, Mod: ModShift},
expected: "shift+left",
},
{
name: "ctrl+shift+left",
event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl | ModShift},
expected: "ctrl+shift+left",
},
{
name: "alt+left",
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt},
expected: "alt+left",
},
{
name: "ctrl+left",
event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl},
expected: "ctrl+left",
},
{
name: "ctrl+alt+left",
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl},
expected: "ctrl+alt+left",
},
{
name: "ctrl+alt+shift+left",
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl | ModShift},
expected: "ctrl+alt+shift+left",
},
{
name: "ignore coordinates",
event: MouseClickEvent{X: 100, Y: 200, Button: MouseLeft},
expected: "left",
},
{
name: "broken type",
event: MouseClickEvent{Button: MouseButton(120)},
expected: "unknown",
},
}
for i := range tt {
tc := tt[i]
t.Run(tc.name, func(t *testing.T) {
actual := fmt.Sprint(tc.event)
if tc.expected != actual {
t.Fatalf("expected %q but got %q",
tc.expected,
actual,
)
}
})
}
}
func TestParseX10MouseDownEvent(t *testing.T) {
encode := func(b byte, x, y int) []byte {
return []byte{
'\x1b',
'[',
'M',
byte(32) + b,
byte(x + 32 + 1),
byte(y + 32 + 1),
}
}
tt := []struct {
name string
buf []byte
expected Event
}{
// Position.
{
name: "zero position",
buf: encode(0b0000_0000, 0, 0),
expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
},
{
name: "max position",
buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1.
expected: MouseClickEvent{X: 222, Y: 222, Button: MouseLeft},
},
// Simple.
{
name: "left",
buf: encode(0b0000_0000, 32, 16),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
},
{
name: "left in motion",
buf: encode(0b0010_0000, 32, 16),
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
},
{
name: "middle",
buf: encode(0b0000_0001, 32, 16),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
},
{
name: "middle in motion",
buf: encode(0b0010_0001, 32, 16),
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
},
{
name: "right",
buf: encode(0b0000_0010, 32, 16),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
},
{
name: "right in motion",
buf: encode(0b0010_0010, 32, 16),
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseRight},
},
{
name: "motion",
buf: encode(0b0010_0011, 32, 16),
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
},
{
name: "wheel up",
buf: encode(0b0100_0000, 32, 16),
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
},
{
name: "wheel down",
buf: encode(0b0100_0001, 32, 16),
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
},
{
name: "wheel left",
buf: encode(0b0100_0010, 32, 16),
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
},
{
name: "wheel right",
buf: encode(0b0100_0011, 32, 16),
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
},
{
name: "release",
buf: encode(0b0000_0011, 32, 16),
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseNone},
},
{
name: "backward",
buf: encode(0b1000_0000, 32, 16),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
},
{
name: "forward",
buf: encode(0b1000_0001, 32, 16),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
},
{
name: "button 10",
buf: encode(0b1000_0010, 32, 16),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton10},
},
{
name: "button 11",
buf: encode(0b1000_0011, 32, 16),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton11},
},
// Combinations.
{
name: "alt+right",
buf: encode(0b0000_1010, 32, 16),
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
},
{
name: "ctrl+right",
buf: encode(0b0001_0010, 32, 16),
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
},
{
name: "left in motion",
buf: encode(0b0010_0000, 32, 16),
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
},
{
name: "alt+right in motion",
buf: encode(0b0010_1010, 32, 16),
expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
},
{
name: "ctrl+right in motion",
buf: encode(0b0011_0010, 32, 16),
expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
},
{
name: "ctrl+alt+right",
buf: encode(0b0001_1010, 32, 16),
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
},
{
name: "ctrl+wheel up",
buf: encode(0b0101_0000, 32, 16),
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelUp},
},
{
name: "alt+wheel down",
buf: encode(0b0100_1001, 32, 16),
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
},
{
name: "ctrl+alt+wheel down",
buf: encode(0b0101_1001, 32, 16),
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
},
// Overflow position.
{
name: "overflow position",
buf: encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1.
expected: MouseMotionEvent{X: -6, Y: -33, Button: MouseLeft},
},
}
for i := range tt {
tc := tt[i]
t.Run(tc.name, func(t *testing.T) {
actual := parseX10MouseEvent(tc.buf)
if tc.expected != actual {
t.Fatalf("expected %#v but got %#v",
tc.expected,
actual,
)
}
})
}
}
func TestParseSGRMouseEvent(t *testing.T) {
type csiSequence struct {
params []ansi.Param
cmd ansi.Cmd
}
encode := func(b, x, y int, r bool) *csiSequence {
re := 'M'
if r {
re = 'm'
}
return &csiSequence{
params: []ansi.Param{
ansi.Param(b),
ansi.Param(x + 1),
ansi.Param(y + 1),
},
cmd: ansi.Cmd(re) | ('<' << parser.PrefixShift),
}
}
tt := []struct {
name string
buf *csiSequence
expected Event
}{
// Position.
{
name: "zero position",
buf: encode(0, 0, 0, false),
expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
},
{
name: "225 position",
buf: encode(0, 225, 225, false),
expected: MouseClickEvent{X: 225, Y: 225, Button: MouseLeft},
},
// Simple.
{
name: "left",
buf: encode(0, 32, 16, false),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
},
{
name: "left in motion",
buf: encode(32, 32, 16, false),
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
},
{
name: "left",
buf: encode(0, 32, 16, true),
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseLeft},
},
{
name: "middle",
buf: encode(1, 32, 16, false),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
},
{
name: "middle in motion",
buf: encode(33, 32, 16, false),
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
},
{
name: "middle",
buf: encode(1, 32, 16, true),
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseMiddle},
},
{
name: "right",
buf: encode(2, 32, 16, false),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
},
{
name: "right",
buf: encode(2, 32, 16, true),
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseRight},
},
{
name: "motion",
buf: encode(35, 32, 16, false),
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
},
{
name: "wheel up",
buf: encode(64, 32, 16, false),
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
},
{
name: "wheel down",
buf: encode(65, 32, 16, false),
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
},
{
name: "wheel left",
buf: encode(66, 32, 16, false),
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
},
{
name: "wheel right",
buf: encode(67, 32, 16, false),
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
},
{
name: "backward",
buf: encode(128, 32, 16, false),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
},
{
name: "backward in motion",
buf: encode(160, 32, 16, false),
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseBackward},
},
{
name: "forward",
buf: encode(129, 32, 16, false),
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
},
{
name: "forward in motion",
buf: encode(161, 32, 16, false),
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseForward},
},
// Combinations.
{
name: "alt+right",
buf: encode(10, 32, 16, false),
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
},
{
name: "ctrl+right",
buf: encode(18, 32, 16, false),
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
},
{
name: "ctrl+alt+right",
buf: encode(26, 32, 16, false),
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
},
{
name: "alt+wheel",
buf: encode(73, 32, 16, false),
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
},
{
name: "ctrl+wheel",
buf: encode(81, 32, 16, false),
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelDown},
},
{
name: "ctrl+alt+wheel",
buf: encode(89, 32, 16, false),
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
},
{
name: "ctrl+alt+shift+wheel",
buf: encode(93, 32, 16, false),
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModShift | ModCtrl, Button: MouseWheelDown},
},
}
for i := range tt {
tc := tt[i]
t.Run(tc.name, func(t *testing.T) {
actual := parseSGRMouseEvent(tc.buf.cmd, tc.buf.params)
if tc.expected != actual {
t.Fatalf("expected %#v but got %#v",
tc.expected,
actual,
)
}
})
}
}
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
package input
import (
"image/color"
"reflect"
"testing"
"github.com/charmbracelet/x/ansi"
)
func TestParseSequence_Events(t *testing.T) {
input := []byte("\x1b\x1b[Ztest\x00\x1b]10;rgb:1234/1234/1234\x07\x1b[27;2;27~\x1b[?1049;2$y\x1b[4;1$y")
want := []Event{
KeyPressEvent{Code: KeyTab, Mod: ModShift | ModAlt},
KeyPressEvent{Code: 't', Text: "t"},
KeyPressEvent{Code: 'e', Text: "e"},
KeyPressEvent{Code: 's', Text: "s"},
KeyPressEvent{Code: 't', Text: "t"},
KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
ForegroundColorEvent{color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}},
KeyPressEvent{Code: KeyEscape, Mod: ModShift},
ModeReportEvent{Mode: ansi.AltScreenSaveCursorMode, Value: ansi.ModeReset},
ModeReportEvent{Mode: ansi.InsertReplaceMode, Value: ansi.ModeSet},
}
var p Parser
for i := 0; len(input) != 0; i++ {
if i >= len(want) {
t.Fatalf("reached end of want events")
}
n, got := p.parseSequence(input)
if !reflect.DeepEqual(got, want[i]) {
t.Errorf("got %#v (%T), want %#v (%T)", got, got, want[i], want[i])
}
input = input[n:]
}
}
func BenchmarkParseSequence(b *testing.B) {
var p Parser
input := []byte("\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~")
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
p.parseSequence(input)
}
}
+13
View File
@@ -0,0 +1,13 @@
package input
// PasteEvent is an message that is emitted when a terminal receives pasted text
// using bracketed-paste.
type PasteEvent string
// PasteStartEvent is an message that is emitted when the terminal starts the
// bracketed-paste text.
type PasteStartEvent struct{}
// PasteEndEvent is an message that is emitted when the terminal ends the
// bracketed-paste text.
type PasteEndEvent struct{}
+389
View File
@@ -0,0 +1,389 @@
package input
import (
"maps"
"strconv"
"github.com/charmbracelet/x/ansi"
)
// buildKeysTable builds a table of key sequences and their corresponding key
// events based on the VT100/VT200, XTerm, and Urxvt terminal specs.
func buildKeysTable(flags int, term string) map[string]Key {
nul := Key{Code: KeySpace, Mod: ModCtrl} // ctrl+@ or ctrl+space
if flags&FlagCtrlAt != 0 {
nul = Key{Code: '@', Mod: ModCtrl}
}
tab := Key{Code: KeyTab} // ctrl+i or tab
if flags&FlagCtrlI != 0 {
tab = Key{Code: 'i', Mod: ModCtrl}
}
enter := Key{Code: KeyEnter} // ctrl+m or enter
if flags&FlagCtrlM != 0 {
enter = Key{Code: 'm', Mod: ModCtrl}
}
esc := Key{Code: KeyEscape} // ctrl+[ or escape
if flags&FlagCtrlOpenBracket != 0 {
esc = Key{Code: '[', Mod: ModCtrl} // ctrl+[ or escape
}
del := Key{Code: KeyBackspace}
if flags&FlagBackspace != 0 {
del.Code = KeyDelete
}
find := Key{Code: KeyHome}
if flags&FlagFind != 0 {
find.Code = KeyFind
}
sel := Key{Code: KeyEnd}
if flags&FlagSelect != 0 {
sel.Code = KeySelect
}
// The following is a table of key sequences and their corresponding key
// events based on the VT100/VT200 terminal specs.
//
// See: https://vt100.net/docs/vt100-ug/chapter3.html#S3.2
// See: https://vt100.net/docs/vt220-rm/chapter3.html
//
// XXX: These keys may be overwritten by other options like XTerm or
// Terminfo.
table := map[string]Key{
// C0 control characters
string(byte(ansi.NUL)): nul,
string(byte(ansi.SOH)): {Code: 'a', Mod: ModCtrl},
string(byte(ansi.STX)): {Code: 'b', Mod: ModCtrl},
string(byte(ansi.ETX)): {Code: 'c', Mod: ModCtrl},
string(byte(ansi.EOT)): {Code: 'd', Mod: ModCtrl},
string(byte(ansi.ENQ)): {Code: 'e', Mod: ModCtrl},
string(byte(ansi.ACK)): {Code: 'f', Mod: ModCtrl},
string(byte(ansi.BEL)): {Code: 'g', Mod: ModCtrl},
string(byte(ansi.BS)): {Code: 'h', Mod: ModCtrl},
string(byte(ansi.HT)): tab,
string(byte(ansi.LF)): {Code: 'j', Mod: ModCtrl},
string(byte(ansi.VT)): {Code: 'k', Mod: ModCtrl},
string(byte(ansi.FF)): {Code: 'l', Mod: ModCtrl},
string(byte(ansi.CR)): enter,
string(byte(ansi.SO)): {Code: 'n', Mod: ModCtrl},
string(byte(ansi.SI)): {Code: 'o', Mod: ModCtrl},
string(byte(ansi.DLE)): {Code: 'p', Mod: ModCtrl},
string(byte(ansi.DC1)): {Code: 'q', Mod: ModCtrl},
string(byte(ansi.DC2)): {Code: 'r', Mod: ModCtrl},
string(byte(ansi.DC3)): {Code: 's', Mod: ModCtrl},
string(byte(ansi.DC4)): {Code: 't', Mod: ModCtrl},
string(byte(ansi.NAK)): {Code: 'u', Mod: ModCtrl},
string(byte(ansi.SYN)): {Code: 'v', Mod: ModCtrl},
string(byte(ansi.ETB)): {Code: 'w', Mod: ModCtrl},
string(byte(ansi.CAN)): {Code: 'x', Mod: ModCtrl},
string(byte(ansi.EM)): {Code: 'y', Mod: ModCtrl},
string(byte(ansi.SUB)): {Code: 'z', Mod: ModCtrl},
string(byte(ansi.ESC)): esc,
string(byte(ansi.FS)): {Code: '\\', Mod: ModCtrl},
string(byte(ansi.GS)): {Code: ']', Mod: ModCtrl},
string(byte(ansi.RS)): {Code: '^', Mod: ModCtrl},
string(byte(ansi.US)): {Code: '_', Mod: ModCtrl},
// Special keys in G0
string(byte(ansi.SP)): {Code: KeySpace, Text: " "},
string(byte(ansi.DEL)): del,
// Special keys
"\x1b[Z": {Code: KeyTab, Mod: ModShift},
"\x1b[1~": find,
"\x1b[2~": {Code: KeyInsert},
"\x1b[3~": {Code: KeyDelete},
"\x1b[4~": sel,
"\x1b[5~": {Code: KeyPgUp},
"\x1b[6~": {Code: KeyPgDown},
"\x1b[7~": {Code: KeyHome},
"\x1b[8~": {Code: KeyEnd},
// Normal mode
"\x1b[A": {Code: KeyUp},
"\x1b[B": {Code: KeyDown},
"\x1b[C": {Code: KeyRight},
"\x1b[D": {Code: KeyLeft},
"\x1b[E": {Code: KeyBegin},
"\x1b[F": {Code: KeyEnd},
"\x1b[H": {Code: KeyHome},
"\x1b[P": {Code: KeyF1},
"\x1b[Q": {Code: KeyF2},
"\x1b[R": {Code: KeyF3},
"\x1b[S": {Code: KeyF4},
// Application Cursor Key Mode (DECCKM)
"\x1bOA": {Code: KeyUp},
"\x1bOB": {Code: KeyDown},
"\x1bOC": {Code: KeyRight},
"\x1bOD": {Code: KeyLeft},
"\x1bOE": {Code: KeyBegin},
"\x1bOF": {Code: KeyEnd},
"\x1bOH": {Code: KeyHome},
"\x1bOP": {Code: KeyF1},
"\x1bOQ": {Code: KeyF2},
"\x1bOR": {Code: KeyF3},
"\x1bOS": {Code: KeyF4},
// Keypad Application Mode (DECKPAM)
"\x1bOM": {Code: KeyKpEnter},
"\x1bOX": {Code: KeyKpEqual},
"\x1bOj": {Code: KeyKpMultiply},
"\x1bOk": {Code: KeyKpPlus},
"\x1bOl": {Code: KeyKpComma},
"\x1bOm": {Code: KeyKpMinus},
"\x1bOn": {Code: KeyKpDecimal},
"\x1bOo": {Code: KeyKpDivide},
"\x1bOp": {Code: KeyKp0},
"\x1bOq": {Code: KeyKp1},
"\x1bOr": {Code: KeyKp2},
"\x1bOs": {Code: KeyKp3},
"\x1bOt": {Code: KeyKp4},
"\x1bOu": {Code: KeyKp5},
"\x1bOv": {Code: KeyKp6},
"\x1bOw": {Code: KeyKp7},
"\x1bOx": {Code: KeyKp8},
"\x1bOy": {Code: KeyKp9},
// Function keys
"\x1b[11~": {Code: KeyF1},
"\x1b[12~": {Code: KeyF2},
"\x1b[13~": {Code: KeyF3},
"\x1b[14~": {Code: KeyF4},
"\x1b[15~": {Code: KeyF5},
"\x1b[17~": {Code: KeyF6},
"\x1b[18~": {Code: KeyF7},
"\x1b[19~": {Code: KeyF8},
"\x1b[20~": {Code: KeyF9},
"\x1b[21~": {Code: KeyF10},
"\x1b[23~": {Code: KeyF11},
"\x1b[24~": {Code: KeyF12},
"\x1b[25~": {Code: KeyF13},
"\x1b[26~": {Code: KeyF14},
"\x1b[28~": {Code: KeyF15},
"\x1b[29~": {Code: KeyF16},
"\x1b[31~": {Code: KeyF17},
"\x1b[32~": {Code: KeyF18},
"\x1b[33~": {Code: KeyF19},
"\x1b[34~": {Code: KeyF20},
}
// CSI ~ sequence keys
csiTildeKeys := map[string]Key{
"1": find, "2": {Code: KeyInsert},
"3": {Code: KeyDelete}, "4": sel,
"5": {Code: KeyPgUp}, "6": {Code: KeyPgDown},
"7": {Code: KeyHome}, "8": {Code: KeyEnd},
// There are no 9 and 10 keys
"11": {Code: KeyF1}, "12": {Code: KeyF2},
"13": {Code: KeyF3}, "14": {Code: KeyF4},
"15": {Code: KeyF5}, "17": {Code: KeyF6},
"18": {Code: KeyF7}, "19": {Code: KeyF8},
"20": {Code: KeyF9}, "21": {Code: KeyF10},
"23": {Code: KeyF11}, "24": {Code: KeyF12},
"25": {Code: KeyF13}, "26": {Code: KeyF14},
"28": {Code: KeyF15}, "29": {Code: KeyF16},
"31": {Code: KeyF17}, "32": {Code: KeyF18},
"33": {Code: KeyF19}, "34": {Code: KeyF20},
}
// URxvt keys
// See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
table["\x1b[a"] = Key{Code: KeyUp, Mod: ModShift}
table["\x1b[b"] = Key{Code: KeyDown, Mod: ModShift}
table["\x1b[c"] = Key{Code: KeyRight, Mod: ModShift}
table["\x1b[d"] = Key{Code: KeyLeft, Mod: ModShift}
table["\x1bOa"] = Key{Code: KeyUp, Mod: ModCtrl}
table["\x1bOb"] = Key{Code: KeyDown, Mod: ModCtrl}
table["\x1bOc"] = Key{Code: KeyRight, Mod: ModCtrl}
table["\x1bOd"] = Key{Code: KeyLeft, Mod: ModCtrl}
//nolint:godox
// TODO: invistigate if shift-ctrl arrow keys collide with DECCKM keys i.e.
// "\x1bOA", "\x1bOB", "\x1bOC", "\x1bOD"
// URxvt modifier CSI ~ keys
for k, v := range csiTildeKeys {
key := v
// Normal (no modifier) already defined part of VT100/VT200
// Shift modifier
key.Mod = ModShift
table["\x1b["+k+"$"] = key
// Ctrl modifier
key.Mod = ModCtrl
table["\x1b["+k+"^"] = key
// Shift-Ctrl modifier
key.Mod = ModShift | ModCtrl
table["\x1b["+k+"@"] = key
}
// URxvt F keys
// Note: Shift + F1-F10 generates F11-F20.
// This means Shift + F1 and Shift + F2 will generate F11 and F12, the same
// applies to Ctrl + Shift F1 & F2.
//
// P.S. Don't like this? Blame URxvt, configure your terminal to use
// different escapes like XTerm, or switch to a better terminal ¯\_(ツ)_/¯
//
// See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
table["\x1b[23$"] = Key{Code: KeyF11, Mod: ModShift}
table["\x1b[24$"] = Key{Code: KeyF12, Mod: ModShift}
table["\x1b[25$"] = Key{Code: KeyF13, Mod: ModShift}
table["\x1b[26$"] = Key{Code: KeyF14, Mod: ModShift}
table["\x1b[28$"] = Key{Code: KeyF15, Mod: ModShift}
table["\x1b[29$"] = Key{Code: KeyF16, Mod: ModShift}
table["\x1b[31$"] = Key{Code: KeyF17, Mod: ModShift}
table["\x1b[32$"] = Key{Code: KeyF18, Mod: ModShift}
table["\x1b[33$"] = Key{Code: KeyF19, Mod: ModShift}
table["\x1b[34$"] = Key{Code: KeyF20, Mod: ModShift}
table["\x1b[11^"] = Key{Code: KeyF1, Mod: ModCtrl}
table["\x1b[12^"] = Key{Code: KeyF2, Mod: ModCtrl}
table["\x1b[13^"] = Key{Code: KeyF3, Mod: ModCtrl}
table["\x1b[14^"] = Key{Code: KeyF4, Mod: ModCtrl}
table["\x1b[15^"] = Key{Code: KeyF5, Mod: ModCtrl}
table["\x1b[17^"] = Key{Code: KeyF6, Mod: ModCtrl}
table["\x1b[18^"] = Key{Code: KeyF7, Mod: ModCtrl}
table["\x1b[19^"] = Key{Code: KeyF8, Mod: ModCtrl}
table["\x1b[20^"] = Key{Code: KeyF9, Mod: ModCtrl}
table["\x1b[21^"] = Key{Code: KeyF10, Mod: ModCtrl}
table["\x1b[23^"] = Key{Code: KeyF11, Mod: ModCtrl}
table["\x1b[24^"] = Key{Code: KeyF12, Mod: ModCtrl}
table["\x1b[25^"] = Key{Code: KeyF13, Mod: ModCtrl}
table["\x1b[26^"] = Key{Code: KeyF14, Mod: ModCtrl}
table["\x1b[28^"] = Key{Code: KeyF15, Mod: ModCtrl}
table["\x1b[29^"] = Key{Code: KeyF16, Mod: ModCtrl}
table["\x1b[31^"] = Key{Code: KeyF17, Mod: ModCtrl}
table["\x1b[32^"] = Key{Code: KeyF18, Mod: ModCtrl}
table["\x1b[33^"] = Key{Code: KeyF19, Mod: ModCtrl}
table["\x1b[34^"] = Key{Code: KeyF20, Mod: ModCtrl}
table["\x1b[23@"] = Key{Code: KeyF11, Mod: ModShift | ModCtrl}
table["\x1b[24@"] = Key{Code: KeyF12, Mod: ModShift | ModCtrl}
table["\x1b[25@"] = Key{Code: KeyF13, Mod: ModShift | ModCtrl}
table["\x1b[26@"] = Key{Code: KeyF14, Mod: ModShift | ModCtrl}
table["\x1b[28@"] = Key{Code: KeyF15, Mod: ModShift | ModCtrl}
table["\x1b[29@"] = Key{Code: KeyF16, Mod: ModShift | ModCtrl}
table["\x1b[31@"] = Key{Code: KeyF17, Mod: ModShift | ModCtrl}
table["\x1b[32@"] = Key{Code: KeyF18, Mod: ModShift | ModCtrl}
table["\x1b[33@"] = Key{Code: KeyF19, Mod: ModShift | ModCtrl}
table["\x1b[34@"] = Key{Code: KeyF20, Mod: ModShift | ModCtrl}
// Register Alt + <key> combinations
// XXX: this must come after URxvt but before XTerm keys to register URxvt
// keys with alt modifier
tmap := map[string]Key{}
for seq, key := range table {
key := key
key.Mod |= ModAlt
key.Text = "" // Clear runes
tmap["\x1b"+seq] = key
}
maps.Copy(table, tmap)
// XTerm modifiers
// These are offset by 1 to be compatible with our Mod type.
// See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-PC-Style-Function-Keys
modifiers := []KeyMod{
ModShift, // 1
ModAlt, // 2
ModShift | ModAlt, // 3
ModCtrl, // 4
ModShift | ModCtrl, // 5
ModAlt | ModCtrl, // 6
ModShift | ModAlt | ModCtrl, // 7
ModMeta, // 8
ModMeta | ModShift, // 9
ModMeta | ModAlt, // 10
ModMeta | ModShift | ModAlt, // 11
ModMeta | ModCtrl, // 12
ModMeta | ModShift | ModCtrl, // 13
ModMeta | ModAlt | ModCtrl, // 14
ModMeta | ModShift | ModAlt | ModCtrl, // 15
}
// SS3 keypad function keys
ss3FuncKeys := map[string]Key{
// These are defined in XTerm
// Taken from Foot keymap.h and XTerm modifyOtherKeys
// https://codeberg.org/dnkl/foot/src/branch/master/keymap.h
"M": {Code: KeyKpEnter}, "X": {Code: KeyKpEqual},
"j": {Code: KeyKpMultiply}, "k": {Code: KeyKpPlus},
"l": {Code: KeyKpComma}, "m": {Code: KeyKpMinus},
"n": {Code: KeyKpDecimal}, "o": {Code: KeyKpDivide},
"p": {Code: KeyKp0}, "q": {Code: KeyKp1},
"r": {Code: KeyKp2}, "s": {Code: KeyKp3},
"t": {Code: KeyKp4}, "u": {Code: KeyKp5},
"v": {Code: KeyKp6}, "w": {Code: KeyKp7},
"x": {Code: KeyKp8}, "y": {Code: KeyKp9},
}
// XTerm keys
csiFuncKeys := map[string]Key{
"A": {Code: KeyUp}, "B": {Code: KeyDown},
"C": {Code: KeyRight}, "D": {Code: KeyLeft},
"E": {Code: KeyBegin}, "F": {Code: KeyEnd},
"H": {Code: KeyHome}, "P": {Code: KeyF1},
"Q": {Code: KeyF2}, "R": {Code: KeyF3},
"S": {Code: KeyF4},
}
// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
modifyOtherKeys := map[int]Key{
ansi.BS: {Code: KeyBackspace},
ansi.HT: {Code: KeyTab},
ansi.CR: {Code: KeyEnter},
ansi.ESC: {Code: KeyEscape},
ansi.DEL: {Code: KeyBackspace},
}
for _, m := range modifiers {
// XTerm modifier offset +1
xtermMod := strconv.Itoa(int(m) + 1)
// CSI 1 ; <modifier> <func>
for k, v := range csiFuncKeys {
// Functions always have a leading 1 param
seq := "\x1b[1;" + xtermMod + k
key := v
key.Mod = m
table[seq] = key
}
// SS3 <modifier> <func>
for k, v := range ss3FuncKeys {
seq := "\x1bO" + xtermMod + k
key := v
key.Mod = m
table[seq] = key
}
// CSI <number> ; <modifier> ~
for k, v := range csiTildeKeys {
seq := "\x1b[" + k + ";" + xtermMod + "~"
key := v
key.Mod = m
table[seq] = key
}
// CSI 27 ; <modifier> ; <code> ~
for k, v := range modifyOtherKeys {
code := strconv.Itoa(k)
seq := "\x1b[27;" + xtermMod + ";" + code + "~"
key := v
key.Mod = m
table[seq] = key
}
}
// Register terminfo keys
// XXX: this might override keys already registered in table
if flags&FlagTerminfo != 0 {
titable := buildTerminfoKeys(flags, term)
maps.Copy(table, titable)
}
return table
}
+54
View File
@@ -0,0 +1,54 @@
package input
import (
"bytes"
"encoding/hex"
"strings"
)
// CapabilityEvent represents a Termcap/Terminfo response event. Termcap
// responses are generated by the terminal in response to RequestTermcap
// (XTGETTCAP) requests.
//
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
type CapabilityEvent string
func parseTermcap(data []byte) CapabilityEvent {
// XTGETTCAP
if len(data) == 0 {
return CapabilityEvent("")
}
var tc strings.Builder
split := bytes.Split(data, []byte{';'})
for _, s := range split {
parts := bytes.SplitN(s, []byte{'='}, 2)
if len(parts) == 0 {
return CapabilityEvent("")
}
name, err := hex.DecodeString(string(parts[0]))
if err != nil || len(name) == 0 {
continue
}
var value []byte
if len(parts) > 1 {
value, err = hex.DecodeString(string(parts[1]))
if err != nil {
continue
}
}
if tc.Len() > 0 {
tc.WriteByte(';')
}
tc.WriteString(string(name))
if len(value) > 0 {
tc.WriteByte('=')
tc.WriteString(string(value))
}
}
return CapabilityEvent(tc.String())
}
+277
View File
@@ -0,0 +1,277 @@
package input
import (
"strings"
"github.com/xo/terminfo"
)
func buildTerminfoKeys(flags int, term string) map[string]Key {
table := make(map[string]Key)
ti, _ := terminfo.Load(term)
if ti == nil {
return table
}
tiTable := defaultTerminfoKeys(flags)
// Default keys
for name, seq := range ti.StringCapsShort() {
if !strings.HasPrefix(name, "k") || len(seq) == 0 {
continue
}
if k, ok := tiTable[name]; ok {
table[string(seq)] = k
}
}
// Extended keys
for name, seq := range ti.ExtStringCapsShort() {
if !strings.HasPrefix(name, "k") || len(seq) == 0 {
continue
}
if k, ok := tiTable[name]; ok {
table[string(seq)] = k
}
}
return table
}
// This returns a map of terminfo keys to key events. It's a mix of ncurses
// terminfo default and user-defined key capabilities.
// Upper-case caps that are defined in the default terminfo database are
// - kNXT
// - kPRV
// - kHOM
// - kEND
// - kDC
// - kIC
// - kLFT
// - kRIT
//
// See https://man7.org/linux/man-pages/man5/terminfo.5.html
// See https://github.com/mirror/ncurses/blob/master/include/Caps-ncurses
func defaultTerminfoKeys(flags int) map[string]Key {
keys := map[string]Key{
"kcuu1": {Code: KeyUp},
"kUP": {Code: KeyUp, Mod: ModShift},
"kUP3": {Code: KeyUp, Mod: ModAlt},
"kUP4": {Code: KeyUp, Mod: ModShift | ModAlt},
"kUP5": {Code: KeyUp, Mod: ModCtrl},
"kUP6": {Code: KeyUp, Mod: ModShift | ModCtrl},
"kUP7": {Code: KeyUp, Mod: ModAlt | ModCtrl},
"kUP8": {Code: KeyUp, Mod: ModShift | ModAlt | ModCtrl},
"kcud1": {Code: KeyDown},
"kDN": {Code: KeyDown, Mod: ModShift},
"kDN3": {Code: KeyDown, Mod: ModAlt},
"kDN4": {Code: KeyDown, Mod: ModShift | ModAlt},
"kDN5": {Code: KeyDown, Mod: ModCtrl},
"kDN7": {Code: KeyDown, Mod: ModAlt | ModCtrl},
"kDN6": {Code: KeyDown, Mod: ModShift | ModCtrl},
"kDN8": {Code: KeyDown, Mod: ModShift | ModAlt | ModCtrl},
"kcub1": {Code: KeyLeft},
"kLFT": {Code: KeyLeft, Mod: ModShift},
"kLFT3": {Code: KeyLeft, Mod: ModAlt},
"kLFT4": {Code: KeyLeft, Mod: ModShift | ModAlt},
"kLFT5": {Code: KeyLeft, Mod: ModCtrl},
"kLFT6": {Code: KeyLeft, Mod: ModShift | ModCtrl},
"kLFT7": {Code: KeyLeft, Mod: ModAlt | ModCtrl},
"kLFT8": {Code: KeyLeft, Mod: ModShift | ModAlt | ModCtrl},
"kcuf1": {Code: KeyRight},
"kRIT": {Code: KeyRight, Mod: ModShift},
"kRIT3": {Code: KeyRight, Mod: ModAlt},
"kRIT4": {Code: KeyRight, Mod: ModShift | ModAlt},
"kRIT5": {Code: KeyRight, Mod: ModCtrl},
"kRIT6": {Code: KeyRight, Mod: ModShift | ModCtrl},
"kRIT7": {Code: KeyRight, Mod: ModAlt | ModCtrl},
"kRIT8": {Code: KeyRight, Mod: ModShift | ModAlt | ModCtrl},
"kich1": {Code: KeyInsert},
"kIC": {Code: KeyInsert, Mod: ModShift},
"kIC3": {Code: KeyInsert, Mod: ModAlt},
"kIC4": {Code: KeyInsert, Mod: ModShift | ModAlt},
"kIC5": {Code: KeyInsert, Mod: ModCtrl},
"kIC6": {Code: KeyInsert, Mod: ModShift | ModCtrl},
"kIC7": {Code: KeyInsert, Mod: ModAlt | ModCtrl},
"kIC8": {Code: KeyInsert, Mod: ModShift | ModAlt | ModCtrl},
"kdch1": {Code: KeyDelete},
"kDC": {Code: KeyDelete, Mod: ModShift},
"kDC3": {Code: KeyDelete, Mod: ModAlt},
"kDC4": {Code: KeyDelete, Mod: ModShift | ModAlt},
"kDC5": {Code: KeyDelete, Mod: ModCtrl},
"kDC6": {Code: KeyDelete, Mod: ModShift | ModCtrl},
"kDC7": {Code: KeyDelete, Mod: ModAlt | ModCtrl},
"kDC8": {Code: KeyDelete, Mod: ModShift | ModAlt | ModCtrl},
"khome": {Code: KeyHome},
"kHOM": {Code: KeyHome, Mod: ModShift},
"kHOM3": {Code: KeyHome, Mod: ModAlt},
"kHOM4": {Code: KeyHome, Mod: ModShift | ModAlt},
"kHOM5": {Code: KeyHome, Mod: ModCtrl},
"kHOM6": {Code: KeyHome, Mod: ModShift | ModCtrl},
"kHOM7": {Code: KeyHome, Mod: ModAlt | ModCtrl},
"kHOM8": {Code: KeyHome, Mod: ModShift | ModAlt | ModCtrl},
"kend": {Code: KeyEnd},
"kEND": {Code: KeyEnd, Mod: ModShift},
"kEND3": {Code: KeyEnd, Mod: ModAlt},
"kEND4": {Code: KeyEnd, Mod: ModShift | ModAlt},
"kEND5": {Code: KeyEnd, Mod: ModCtrl},
"kEND6": {Code: KeyEnd, Mod: ModShift | ModCtrl},
"kEND7": {Code: KeyEnd, Mod: ModAlt | ModCtrl},
"kEND8": {Code: KeyEnd, Mod: ModShift | ModAlt | ModCtrl},
"kpp": {Code: KeyPgUp},
"kprv": {Code: KeyPgUp},
"kPRV": {Code: KeyPgUp, Mod: ModShift},
"kPRV3": {Code: KeyPgUp, Mod: ModAlt},
"kPRV4": {Code: KeyPgUp, Mod: ModShift | ModAlt},
"kPRV5": {Code: KeyPgUp, Mod: ModCtrl},
"kPRV6": {Code: KeyPgUp, Mod: ModShift | ModCtrl},
"kPRV7": {Code: KeyPgUp, Mod: ModAlt | ModCtrl},
"kPRV8": {Code: KeyPgUp, Mod: ModShift | ModAlt | ModCtrl},
"knp": {Code: KeyPgDown},
"knxt": {Code: KeyPgDown},
"kNXT": {Code: KeyPgDown, Mod: ModShift},
"kNXT3": {Code: KeyPgDown, Mod: ModAlt},
"kNXT4": {Code: KeyPgDown, Mod: ModShift | ModAlt},
"kNXT5": {Code: KeyPgDown, Mod: ModCtrl},
"kNXT6": {Code: KeyPgDown, Mod: ModShift | ModCtrl},
"kNXT7": {Code: KeyPgDown, Mod: ModAlt | ModCtrl},
"kNXT8": {Code: KeyPgDown, Mod: ModShift | ModAlt | ModCtrl},
"kbs": {Code: KeyBackspace},
"kcbt": {Code: KeyTab, Mod: ModShift},
// Function keys
// This only includes the first 12 function keys. The rest are treated
// as modifiers of the first 12.
// Take a look at XTerm modifyFunctionKeys
//
// XXX: To use unambiguous function keys, use fixterms or kitty clipboard.
//
// See https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyFunctionKeys
// See https://invisible-island.net/xterm/terminfo.html
"kf1": {Code: KeyF1},
"kf2": {Code: KeyF2},
"kf3": {Code: KeyF3},
"kf4": {Code: KeyF4},
"kf5": {Code: KeyF5},
"kf6": {Code: KeyF6},
"kf7": {Code: KeyF7},
"kf8": {Code: KeyF8},
"kf9": {Code: KeyF9},
"kf10": {Code: KeyF10},
"kf11": {Code: KeyF11},
"kf12": {Code: KeyF12},
"kf13": {Code: KeyF1, Mod: ModShift},
"kf14": {Code: KeyF2, Mod: ModShift},
"kf15": {Code: KeyF3, Mod: ModShift},
"kf16": {Code: KeyF4, Mod: ModShift},
"kf17": {Code: KeyF5, Mod: ModShift},
"kf18": {Code: KeyF6, Mod: ModShift},
"kf19": {Code: KeyF7, Mod: ModShift},
"kf20": {Code: KeyF8, Mod: ModShift},
"kf21": {Code: KeyF9, Mod: ModShift},
"kf22": {Code: KeyF10, Mod: ModShift},
"kf23": {Code: KeyF11, Mod: ModShift},
"kf24": {Code: KeyF12, Mod: ModShift},
"kf25": {Code: KeyF1, Mod: ModCtrl},
"kf26": {Code: KeyF2, Mod: ModCtrl},
"kf27": {Code: KeyF3, Mod: ModCtrl},
"kf28": {Code: KeyF4, Mod: ModCtrl},
"kf29": {Code: KeyF5, Mod: ModCtrl},
"kf30": {Code: KeyF6, Mod: ModCtrl},
"kf31": {Code: KeyF7, Mod: ModCtrl},
"kf32": {Code: KeyF8, Mod: ModCtrl},
"kf33": {Code: KeyF9, Mod: ModCtrl},
"kf34": {Code: KeyF10, Mod: ModCtrl},
"kf35": {Code: KeyF11, Mod: ModCtrl},
"kf36": {Code: KeyF12, Mod: ModCtrl},
"kf37": {Code: KeyF1, Mod: ModShift | ModCtrl},
"kf38": {Code: KeyF2, Mod: ModShift | ModCtrl},
"kf39": {Code: KeyF3, Mod: ModShift | ModCtrl},
"kf40": {Code: KeyF4, Mod: ModShift | ModCtrl},
"kf41": {Code: KeyF5, Mod: ModShift | ModCtrl},
"kf42": {Code: KeyF6, Mod: ModShift | ModCtrl},
"kf43": {Code: KeyF7, Mod: ModShift | ModCtrl},
"kf44": {Code: KeyF8, Mod: ModShift | ModCtrl},
"kf45": {Code: KeyF9, Mod: ModShift | ModCtrl},
"kf46": {Code: KeyF10, Mod: ModShift | ModCtrl},
"kf47": {Code: KeyF11, Mod: ModShift | ModCtrl},
"kf48": {Code: KeyF12, Mod: ModShift | ModCtrl},
"kf49": {Code: KeyF1, Mod: ModAlt},
"kf50": {Code: KeyF2, Mod: ModAlt},
"kf51": {Code: KeyF3, Mod: ModAlt},
"kf52": {Code: KeyF4, Mod: ModAlt},
"kf53": {Code: KeyF5, Mod: ModAlt},
"kf54": {Code: KeyF6, Mod: ModAlt},
"kf55": {Code: KeyF7, Mod: ModAlt},
"kf56": {Code: KeyF8, Mod: ModAlt},
"kf57": {Code: KeyF9, Mod: ModAlt},
"kf58": {Code: KeyF10, Mod: ModAlt},
"kf59": {Code: KeyF11, Mod: ModAlt},
"kf60": {Code: KeyF12, Mod: ModAlt},
"kf61": {Code: KeyF1, Mod: ModShift | ModAlt},
"kf62": {Code: KeyF2, Mod: ModShift | ModAlt},
"kf63": {Code: KeyF3, Mod: ModShift | ModAlt},
}
// Preserve F keys from F13 to F63 instead of using them for F-keys
// modifiers.
if flags&FlagFKeys != 0 {
keys["kf13"] = Key{Code: KeyF13}
keys["kf14"] = Key{Code: KeyF14}
keys["kf15"] = Key{Code: KeyF15}
keys["kf16"] = Key{Code: KeyF16}
keys["kf17"] = Key{Code: KeyF17}
keys["kf18"] = Key{Code: KeyF18}
keys["kf19"] = Key{Code: KeyF19}
keys["kf20"] = Key{Code: KeyF20}
keys["kf21"] = Key{Code: KeyF21}
keys["kf22"] = Key{Code: KeyF22}
keys["kf23"] = Key{Code: KeyF23}
keys["kf24"] = Key{Code: KeyF24}
keys["kf25"] = Key{Code: KeyF25}
keys["kf26"] = Key{Code: KeyF26}
keys["kf27"] = Key{Code: KeyF27}
keys["kf28"] = Key{Code: KeyF28}
keys["kf29"] = Key{Code: KeyF29}
keys["kf30"] = Key{Code: KeyF30}
keys["kf31"] = Key{Code: KeyF31}
keys["kf32"] = Key{Code: KeyF32}
keys["kf33"] = Key{Code: KeyF33}
keys["kf34"] = Key{Code: KeyF34}
keys["kf35"] = Key{Code: KeyF35}
keys["kf36"] = Key{Code: KeyF36}
keys["kf37"] = Key{Code: KeyF37}
keys["kf38"] = Key{Code: KeyF38}
keys["kf39"] = Key{Code: KeyF39}
keys["kf40"] = Key{Code: KeyF40}
keys["kf41"] = Key{Code: KeyF41}
keys["kf42"] = Key{Code: KeyF42}
keys["kf43"] = Key{Code: KeyF43}
keys["kf44"] = Key{Code: KeyF44}
keys["kf45"] = Key{Code: KeyF45}
keys["kf46"] = Key{Code: KeyF46}
keys["kf47"] = Key{Code: KeyF47}
keys["kf48"] = Key{Code: KeyF48}
keys["kf49"] = Key{Code: KeyF49}
keys["kf50"] = Key{Code: KeyF50}
keys["kf51"] = Key{Code: KeyF51}
keys["kf52"] = Key{Code: KeyF52}
keys["kf53"] = Key{Code: KeyF53}
keys["kf54"] = Key{Code: KeyF54}
keys["kf55"] = Key{Code: KeyF55}
keys["kf56"] = Key{Code: KeyF56}
keys["kf57"] = Key{Code: KeyF57}
keys["kf58"] = Key{Code: KeyF58}
keys["kf59"] = Key{Code: KeyF59}
keys["kf60"] = Key{Code: KeyF60}
keys["kf61"] = Key{Code: KeyF61}
keys["kf62"] = Key{Code: KeyF62}
keys["kf63"] = Key{Code: KeyF63}
}
return keys
}
+47
View File
@@ -0,0 +1,47 @@
package input
import (
"github.com/charmbracelet/x/ansi"
)
func parseXTermModifyOtherKeys(params ansi.Params) Event {
// XTerm modify other keys starts with ESC [ 27 ; <modifier> ; <code> ~
xmod, _, _ := params.Param(1, 1)
xrune, _, _ := params.Param(2, 1)
mod := KeyMod(xmod - 1)
r := rune(xrune)
switch r {
case ansi.BS:
return KeyPressEvent{Mod: mod, Code: KeyBackspace}
case ansi.HT:
return KeyPressEvent{Mod: mod, Code: KeyTab}
case ansi.CR:
return KeyPressEvent{Mod: mod, Code: KeyEnter}
case ansi.ESC:
return KeyPressEvent{Mod: mod, Code: KeyEscape}
case ansi.DEL:
return KeyPressEvent{Mod: mod, Code: KeyBackspace}
}
// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
k := KeyPressEvent{Code: r, Mod: mod}
if k.Mod <= ModShift {
k.Text = string(r)
}
return k
}
// TerminalVersionEvent is a message that represents the terminal version.
type TerminalVersionEvent string
// ModifyOtherKeysEvent represents a modifyOtherKeys event.
//
// 0: disable
// 1: enable mode 1
// 2: enable mode 2
//
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_
// See: https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyOtherKeys
type ModifyOtherKeysEvent uint8
+41
View File
@@ -0,0 +1,41 @@
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
}
}
+588
View File
@@ -0,0 +1,588 @@
package app
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"log/slog"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/id"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type Message struct {
Info opencode.MessageUnion
Parts []opencode.PartUnion
}
type App struct {
Info opencode.App
Modes []opencode.Mode
Providers []opencode.Provider
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *State
ModeIndex int
Mode *opencode.Mode
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []Message
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
IntitialMode *string
compactCancel context.CancelFunc
IsLeaderSequence bool
}
type SessionCreatedMsg = struct {
Session *opencode.Session
}
type SessionSelectedMsg = *opencode.Session
type MessageRevertedMsg struct {
Session opencode.Session
Message Message
}
type SessionUnrevertedMsg struct {
Session opencode.Session
}
type SessionLoadedMsg struct{}
type ModelSelectedMsg struct {
Provider opencode.Provider
Model opencode.Model
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendPrompt = Prompt
type SetEditorContentMsg struct {
Text string
}
type FileRenderedMsg struct {
FilePath string
}
func New(
ctx context.Context,
version string,
appInfo opencode.App,
modes []opencode.Mode,
httpClient *opencode.Client,
initialModel *string,
initialPrompt *string,
initialMode *string,
) (*App, error) {
util.RootPath = appInfo.Path.Root
util.CwdPath = appInfo.Path.Cwd
configInfo, err := httpClient.Config.Get(ctx)
if err != nil {
return nil, err
}
if configInfo.Keybinds.Leader == "" {
configInfo.Keybinds.Leader = "ctrl+x"
}
appStatePath := filepath.Join(appInfo.Path.State, "tui")
appState, err := LoadState(appStatePath)
if err != nil {
appState = NewState()
SaveState(appStatePath, appState)
}
if appState.ModeModel == nil {
appState.ModeModel = make(map[string]ModeModel)
}
if configInfo.Theme != "" {
appState.Theme = configInfo.Theme
}
themeEnv := os.Getenv("OPENCODE_THEME")
if themeEnv != "" {
appState.Theme = themeEnv
}
var modeIndex int
var mode *opencode.Mode
modeName := "build"
if appState.Mode != "" {
modeName = appState.Mode
}
if initialMode != nil && *initialMode != "" {
modeName = *initialMode
}
for i, m := range modes {
if m.Name == modeName {
modeIndex = i
break
}
}
mode = &modes[modeIndex]
if mode.Model.ModelID != "" {
appState.ModeModel[mode.Name] = ModeModel{
ProviderID: mode.Model.ProviderID,
ModelID: mode.Model.ModelID,
}
}
if err := theme.LoadThemesFromDirectories(
appInfo.Path.Config,
appInfo.Path.Root,
appInfo.Path.Cwd,
); err != nil {
slog.Warn("Failed to load themes from directories", "error", err)
}
if appState.Theme != "" {
if appState.Theme == "system" && styles.Terminal != nil {
theme.UpdateSystemTheme(
styles.Terminal.Background,
styles.Terminal.BackgroundIsDark,
)
}
theme.SetTheme(appState.Theme)
}
slog.Debug("Loaded config", "config", configInfo)
app := &App{
Info: appInfo,
Modes: modes,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
ModeIndex: modeIndex,
Mode: mode,
Session: &opencode.Session{},
Messages: []Message{},
Commands: commands.LoadFromConfig(configInfo),
InitialModel: initialModel,
InitialPrompt: initialPrompt,
IntitialMode: initialMode,
}
if app.Version != "dev" {
delete(app.Commands, commands.MessagesUndoCommand)
delete(app.Commands, commands.MessagesRedoCommand)
}
return app, nil
}
func (a *App) Keybind(commandName commands.CommandName) string {
command := a.Commands[commandName]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = a.Config.Keybinds.Leader + " " + kb.Key
}
return key
}
func (a *App) Key(commandName commands.CommandName) string {
t := theme.CurrentTheme()
base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
muted := styles.NewStyle().
Background(t.Background()).
Foreground(t.TextMuted()).
Faint(true).
Render
command := a.Commands[commandName]
key := a.Keybind(commandName)
return base(key) + muted(" "+command.Description)
}
func SetClipboard(text string) tea.Cmd {
var cmds []tea.Cmd
cmds = append(cmds, func() tea.Msg {
clipboard.Write(clipboard.FmtText, []byte(text))
return nil
})
// try to set the clipboard using OSC52 for terminals that support it
cmds = append(cmds, tea.SetClipboard(text))
return tea.Sequence(cmds...)
}
func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
if forward {
a.ModeIndex++
if a.ModeIndex >= len(a.Modes) {
a.ModeIndex = 0
}
} else {
a.ModeIndex--
if a.ModeIndex < 0 {
a.ModeIndex = len(a.Modes) - 1
}
}
a.Mode = &a.Modes[a.ModeIndex]
modelID := a.Mode.Model.ModelID
providerID := a.Mode.Model.ProviderID
if modelID == "" {
if model, ok := a.State.ModeModel[a.Mode.Name]; ok {
modelID = model.ModelID
providerID = model.ProviderID
}
}
if modelID != "" {
for _, provider := range a.Providers {
if provider.ID == providerID {
a.Provider = &provider
for _, model := range provider.Models {
if model.ID == modelID {
a.Model = &model
break
}
}
break
}
}
}
a.State.Mode = a.Mode.Name
return a, a.SaveState()
}
func (a *App) SwitchMode() (*App, tea.Cmd) {
return a.cycleMode(true)
}
func (a *App) SwitchModeReverse() (*App, tea.Cmd) {
return a.cycleMode(false)
}
func (a *App) InitializeProvider() tea.Cmd {
providersResponse, err := a.Client.App.Providers(context.Background())
if err != nil {
slog.Error("Failed to list providers", "error", err)
// TODO: notify user
return nil
}
providers := providersResponse.Providers
var defaultProvider *opencode.Provider
var defaultModel *opencode.Model
var anthropic *opencode.Provider
for _, provider := range providers {
if provider.ID == "anthropic" {
anthropic = &provider
}
}
// default to anthropic if available
if anthropic != nil {
defaultProvider = anthropic
defaultModel = getDefaultModel(providersResponse, *anthropic)
}
for _, provider := range providers {
if defaultProvider == nil || defaultModel == nil {
defaultProvider = &provider
defaultModel = getDefaultModel(providersResponse, provider)
}
providers = append(providers, provider)
}
if len(providers) == 0 {
slog.Error("No providers configured")
return nil
}
a.Providers = providers
// retains backwards compatibility with old state format
if model, ok := a.State.ModeModel[a.State.Mode]; ok {
a.State.Provider = model.ProviderID
a.State.Model = model.ModelID
}
var currentProvider *opencode.Provider
var currentModel *opencode.Model
for _, provider := range providers {
if provider.ID == a.State.Provider {
currentProvider = &provider
for _, model := range provider.Models {
if model.ID == a.State.Model {
currentModel = &model
}
}
}
}
if currentProvider == nil || currentModel == nil {
currentProvider = defaultProvider
currentModel = defaultModel
}
var initialProvider *opencode.Provider
var initialModel *opencode.Model
if a.InitialModel != nil && *a.InitialModel != "" {
splits := strings.Split(*a.InitialModel, "/")
for _, provider := range providers {
if provider.ID == splits[0] {
initialProvider = &provider
for _, model := range provider.Models {
modelID := strings.Join(splits[1:], "/")
if model.ID == modelID {
initialModel = &model
}
}
}
}
}
if initialProvider != nil && initialModel != nil {
currentProvider = initialProvider
currentModel = initialModel
}
var cmds []tea.Cmd
cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
}))
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
}
return tea.Sequence(cmds...)
}
func getDefaultModel(
response *opencode.AppProvidersResponse,
provider opencode.Provider,
) *opencode.Model {
if match, ok := response.Default[provider.ID]; ok {
model := provider.Models[match]
return &model
} else {
for _, model := range provider.Models {
return &model
}
}
return nil
}
func (a *App) IsBusy() bool {
if len(a.Messages) == 0 {
return false
}
lastMessage := a.Messages[len(a.Messages)-1]
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
return casted.Time.Completed == 0
}
return true
}
func (a *App) SaveState() tea.Cmd {
return func() tea.Msg {
err := SaveState(a.StatePath, a.State)
if err != nil {
slog.Error("Failed to save state", "error", err)
}
return nil
}
}
func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
cmds := []tea.Cmd{}
session, err := a.CreateSession(ctx)
if err != nil {
// status.Error(err.Error())
return nil
}
a.Session = session
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
go func() {
_, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
MessageID: opencode.F(id.Ascending(id.Message)),
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
})
if err != nil {
slog.Error("Failed to initialize project", "error", err)
// status.Error(err.Error())
}
}()
return tea.Batch(cmds...)
}
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
if a.compactCancel != nil {
a.compactCancel()
}
compactCtx, cancel := context.WithCancel(ctx)
a.compactCancel = cancel
go func() {
defer func() {
a.compactCancel = nil
}()
_, err := a.Client.Session.Summarize(
compactCtx,
a.Session.ID,
opencode.SessionSummarizeParams{
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
},
)
if err != nil {
if compactCtx.Err() != context.Canceled {
slog.Error("Failed to compact session", "error", err)
}
}
}()
return nil
}
func (a *App) MarkProjectInitialized(ctx context.Context) error {
_, err := a.Client.App.Init(ctx)
if err != nil {
slog.Error("Failed to mark project as initialized", "error", err)
return err
}
return nil
}
func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
session, err := a.Client.Session.New(ctx)
if err != nil {
return nil, err
}
return session, nil
}
func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
var cmds []tea.Cmd
if a.Session.ID == "" {
session, err := a.CreateSession(ctx)
if err != nil {
return a, toast.NewErrorToast(err.Error())
}
a.Session = session
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
}
messageID := id.Ascending(id.Message)
message := prompt.ToMessage(messageID, a.Session.ID)
a.Messages = append(a.Messages, message)
cmds = append(cmds, func() tea.Msg {
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
Mode: opencode.F(a.Mode.Name),
MessageID: opencode.F(messageID),
Parts: opencode.F(message.ToSessionChatParams()),
})
if err != nil {
errormsg := fmt.Sprintf("failed to send message: %v", err)
slog.Error(errormsg)
return toast.NewErrorToast(errormsg)()
}
return nil
})
// The actual response will come through SSE
// For now, just return success
return a, tea.Batch(cmds...)
}
func (a *App) Cancel(ctx context.Context, sessionID string) error {
// Cancel any running compact operation
if a.compactCancel != nil {
a.compactCancel()
a.compactCancel = nil
}
_, err := a.Client.Session.Abort(ctx, sessionID)
if err != nil {
slog.Error("Failed to cancel session", "error", err)
return err
}
return nil
}
func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
response, err := a.Client.Session.List(ctx)
if err != nil {
return nil, err
}
if response == nil {
return []opencode.Session{}, nil
}
sessions := *response
return sessions, nil
}
func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
_, err := a.Client.Session.Delete(ctx, sessionID)
if err != nil {
slog.Error("Failed to delete session", "error", err)
return err
}
return nil
}
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
response, err := a.Client.Session.Messages(ctx, sessionId)
if err != nil {
return nil, err
}
if response == nil {
return []Message{}, nil
}
messages := []Message{}
for _, message := range *response {
msg := Message{
Info: message.Info.AsUnion(),
Parts: []opencode.PartUnion{},
}
for _, part := range message.Parts {
msg.Parts = append(msg.Parts, part.AsUnion())
}
messages = append(messages, msg)
}
return messages, nil
}
func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
response, err := a.Client.App.Providers(ctx)
if err != nil {
return nil, err
}
if response == nil {
return []opencode.Provider{}, nil
}
providers := *response
return providers.Providers, nil
}
// func (a *App) loadCustomKeybinds() {
//
// }
+303
View File
@@ -0,0 +1,303 @@
package app
import (
"errors"
"time"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/attachment"
"github.com/sst/opencode/internal/id"
)
type Prompt struct {
Text string `toml:"text"`
Attachments []*attachment.Attachment `toml:"attachments"`
}
func (p Prompt) ToMessage(
messageID string,
sessionID string,
) Message {
message := opencode.UserMessage{
ID: messageID,
SessionID: sessionID,
Role: opencode.UserMessageRoleUser,
Time: opencode.UserMessageTime{
Created: float64(time.Now().UnixMilli()),
},
}
text := p.Text
textAttachments := []*attachment.Attachment{}
for _, attachment := range p.Attachments {
if attachment.Type == "text" {
textAttachments = append(textAttachments, attachment)
}
}
for i := 0; i < len(textAttachments)-1; i++ {
for j := i + 1; j < len(textAttachments); j++ {
if textAttachments[i].StartIndex < textAttachments[j].StartIndex {
textAttachments[i], textAttachments[j] = textAttachments[j], textAttachments[i]
}
}
}
for _, att := range textAttachments {
if source, ok := att.GetTextSource(); ok {
text = text[:att.StartIndex] + source.Value + text[att.EndIndex:]
}
}
parts := []opencode.PartUnion{opencode.TextPart{
ID: id.Ascending(id.Part),
MessageID: messageID,
SessionID: sessionID,
Type: opencode.TextPartTypeText,
Text: text,
}}
for _, attachment := range p.Attachments {
text := opencode.FilePartSourceText{
Start: int64(attachment.StartIndex),
End: int64(attachment.EndIndex),
Value: attachment.Display,
}
source := &opencode.FilePartSource{}
switch attachment.Type {
case "text":
continue
case "file":
if fileSource, ok := attachment.GetFileSource(); ok {
source = &opencode.FilePartSource{
Text: text,
Path: fileSource.Path,
Type: opencode.FilePartSourceTypeFile,
}
}
case "symbol":
if symbolSource, ok := attachment.GetSymbolSource(); ok {
source = &opencode.FilePartSource{
Text: text,
Path: symbolSource.Path,
Type: opencode.FilePartSourceTypeSymbol,
Kind: int64(symbolSource.Kind),
Name: symbolSource.Name,
Range: opencode.SymbolSourceRange{
Start: opencode.SymbolSourceRangeStart{
Line: float64(symbolSource.Range.Start.Line),
Character: float64(symbolSource.Range.Start.Char),
},
End: opencode.SymbolSourceRangeEnd{
Line: float64(symbolSource.Range.End.Line),
Character: float64(symbolSource.Range.End.Char),
},
},
}
}
}
parts = append(parts, opencode.FilePart{
ID: id.Ascending(id.Part),
MessageID: messageID,
SessionID: sessionID,
Type: opencode.FilePartTypeFile,
Filename: attachment.Filename,
Mime: attachment.MediaType,
URL: attachment.URL,
Source: *source,
})
}
return Message{
Info: message,
Parts: parts,
}
}
func (m Message) ToPrompt() (*Prompt, error) {
switch m.Info.(type) {
case opencode.UserMessage:
text := ""
attachments := []*attachment.Attachment{}
for _, part := range m.Parts {
switch p := part.(type) {
case opencode.TextPart:
if p.Synthetic {
continue
}
text += p.Text + " "
case opencode.FilePart:
switch p.Source.Type {
case "file":
attachments = append(attachments, &attachment.Attachment{
ID: p.ID,
Type: "file",
Display: p.Source.Text.Value,
URL: p.URL,
Filename: p.Filename,
MediaType: p.Mime,
StartIndex: int(p.Source.Text.Start),
EndIndex: int(p.Source.Text.End),
Source: &attachment.FileSource{
Path: p.Source.Path,
Mime: p.Mime,
},
})
case "symbol":
r := p.Source.Range.(opencode.SymbolSourceRange)
attachments = append(attachments, &attachment.Attachment{
ID: p.ID,
Type: "symbol",
Display: p.Source.Text.Value,
URL: p.URL,
Filename: p.Filename,
MediaType: p.Mime,
StartIndex: int(p.Source.Text.Start),
EndIndex: int(p.Source.Text.End),
Source: &attachment.SymbolSource{
Path: p.Source.Path,
Name: p.Source.Name,
Kind: int(p.Source.Kind),
Range: attachment.SymbolRange{
Start: attachment.Position{
Line: int(r.Start.Line),
Char: int(r.Start.Character),
},
End: attachment.Position{
Line: int(r.End.Line),
Char: int(r.End.Character),
},
},
},
})
}
}
}
return &Prompt{
Text: text,
Attachments: attachments,
}, nil
}
return nil, errors.New("unknown message type")
}
func (m Message) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
parts := []opencode.SessionChatParamsPartUnion{}
for _, part := range m.Parts {
switch p := part.(type) {
case opencode.TextPart:
parts = append(parts, opencode.TextPartInputParam{
ID: opencode.F(p.ID),
Type: opencode.F(opencode.TextPartInputTypeText),
Text: opencode.F(p.Text),
Synthetic: opencode.F(p.Synthetic),
Time: opencode.F(opencode.TextPartInputTimeParam{
Start: opencode.F(p.Time.Start),
End: opencode.F(p.Time.End),
}),
})
case opencode.FilePart:
var source opencode.FilePartSourceUnionParam
switch p.Source.Type {
case "file":
source = opencode.FileSourceParam{
Type: opencode.F(opencode.FileSourceTypeFile),
Path: opencode.F(p.Source.Path),
Text: opencode.F(opencode.FilePartSourceTextParam{
Start: opencode.F(int64(p.Source.Text.Start)),
End: opencode.F(int64(p.Source.Text.End)),
Value: opencode.F(p.Source.Text.Value),
}),
}
case "symbol":
source = opencode.SymbolSourceParam{
Type: opencode.F(opencode.SymbolSourceTypeSymbol),
Path: opencode.F(p.Source.Path),
Name: opencode.F(p.Source.Name),
Kind: opencode.F(p.Source.Kind),
Range: opencode.F(opencode.SymbolSourceRangeParam{
Start: opencode.F(opencode.SymbolSourceRangeStartParam{
Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Line)),
Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Character)),
}),
End: opencode.F(opencode.SymbolSourceRangeEndParam{
Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Line)),
Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Character)),
}),
}),
Text: opencode.F(opencode.FilePartSourceTextParam{
Value: opencode.F(p.Source.Text.Value),
Start: opencode.F(p.Source.Text.Start),
End: opencode.F(p.Source.Text.End),
}),
}
}
parts = append(parts, opencode.FilePartInputParam{
ID: opencode.F(p.ID),
Type: opencode.F(opencode.FilePartInputTypeFile),
Mime: opencode.F(p.Mime),
URL: opencode.F(p.URL),
Filename: opencode.F(p.Filename),
Source: opencode.F(source),
})
}
}
return parts
}
func (p Prompt) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
parts := []opencode.SessionChatParamsPartUnion{
opencode.TextPartInputParam{
Type: opencode.F(opencode.TextPartInputTypeText),
Text: opencode.F(p.Text),
},
}
for _, att := range p.Attachments {
filePart := opencode.FilePartInputParam{
Type: opencode.F(opencode.FilePartInputTypeFile),
Mime: opencode.F(att.MediaType),
URL: opencode.F(att.URL),
Filename: opencode.F(att.Filename),
}
switch att.Type {
case "file":
if fs, ok := att.GetFileSource(); ok {
filePart.Source = opencode.F(
opencode.FilePartSourceUnionParam(opencode.FileSourceParam{
Type: opencode.F(opencode.FileSourceTypeFile),
Path: opencode.F(fs.Path),
Text: opencode.F(opencode.FilePartSourceTextParam{
Start: opencode.F(int64(att.StartIndex)),
End: opencode.F(int64(att.EndIndex)),
Value: opencode.F(att.Display),
}),
}),
)
}
case "symbol":
if ss, ok := att.GetSymbolSource(); ok {
filePart.Source = opencode.F(
opencode.FilePartSourceUnionParam(opencode.SymbolSourceParam{
Type: opencode.F(opencode.SymbolSourceTypeSymbol),
Path: opencode.F(ss.Path),
Name: opencode.F(ss.Name),
Kind: opencode.F(int64(ss.Kind)),
Range: opencode.F(opencode.SymbolSourceRangeParam{
Start: opencode.F(opencode.SymbolSourceRangeStartParam{
Line: opencode.F(float64(ss.Range.Start.Line)),
Character: opencode.F(float64(ss.Range.Start.Char)),
}),
End: opencode.F(opencode.SymbolSourceRangeEndParam{
Line: opencode.F(float64(ss.Range.End.Line)),
Character: opencode.F(float64(ss.Range.End.Char)),
}),
}),
Text: opencode.F(opencode.FilePartSourceTextParam{
Start: opencode.F(int64(att.StartIndex)),
End: opencode.F(int64(att.EndIndex)),
Value: opencode.F(att.Display),
}),
}),
)
}
}
parts = append(parts, filePart)
}
return parts
}
+123
View File
@@ -0,0 +1,123 @@
package app
import (
"bufio"
"fmt"
"log/slog"
"os"
"time"
"github.com/BurntSushi/toml"
)
type ModelUsage struct {
ProviderID string `toml:"provider_id"`
ModelID string `toml:"model_id"`
LastUsed time.Time `toml:"last_used"`
}
type ModeModel struct {
ProviderID string `toml:"provider_id"`
ModelID string `toml:"model_id"`
}
type State struct {
Theme string `toml:"theme"`
ModeModel map[string]ModeModel `toml:"mode_model"`
Provider string `toml:"provider"`
Model string `toml:"model"`
Mode string `toml:"mode"`
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
MessageHistory []Prompt `toml:"message_history"`
}
func NewState() *State {
return &State{
Theme: "opencode",
Mode: "build",
ModeModel: make(map[string]ModeModel),
RecentlyUsedModels: make([]ModelUsage, 0),
MessageHistory: make([]Prompt, 0),
}
}
// UpdateModelUsage updates the recently used models list with the specified model
func (s *State) UpdateModelUsage(providerID, modelID string) {
now := time.Now()
// Check if this model is already in the list
for i, usage := range s.RecentlyUsedModels {
if usage.ProviderID == providerID && usage.ModelID == modelID {
s.RecentlyUsedModels[i].LastUsed = now
usage := s.RecentlyUsedModels[i]
copy(s.RecentlyUsedModels[1:i+1], s.RecentlyUsedModels[0:i])
s.RecentlyUsedModels[0] = usage
return
}
}
newUsage := ModelUsage{
ProviderID: providerID,
ModelID: modelID,
LastUsed: now,
}
// Prepend to slice and limit to last 50 entries
s.RecentlyUsedModels = append([]ModelUsage{newUsage}, s.RecentlyUsedModels...)
if len(s.RecentlyUsedModels) > 50 {
s.RecentlyUsedModels = s.RecentlyUsedModels[:50]
}
}
func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
for i, usage := range s.RecentlyUsedModels {
if usage.ProviderID == providerID && usage.ModelID == modelID {
s.RecentlyUsedModels = append(s.RecentlyUsedModels[:i], s.RecentlyUsedModels[i+1:]...)
return
}
}
}
func (s *State) AddPromptToHistory(prompt Prompt) {
s.MessageHistory = append([]Prompt{prompt}, s.MessageHistory...)
if len(s.MessageHistory) > 50 {
s.MessageHistory = s.MessageHistory[:50]
}
}
// SaveState writes the provided Config struct to the specified TOML file.
// It will create the file if it doesn't exist, or overwrite it if it does.
func SaveState(filePath string, state *State) error {
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
}
defer file.Close()
writer := bufio.NewWriter(file)
encoder := toml.NewEncoder(writer)
if err := encoder.Encode(state); err != nil {
return fmt.Errorf("failed to encode state to TOML file %s: %w", filePath, err)
}
if err := writer.Flush(); err != nil {
return fmt.Errorf("failed to flush writer for state file %s: %w", filePath, err)
}
slog.Debug("State saved to file", "file", filePath)
return nil
}
// LoadState loads the state from the specified TOML file.
// It returns a pointer to the State struct and an error if any issues occur.
func LoadState(filePath string) (*State, error) {
var state State
if _, err := toml.DecodeFile(filePath, &state); err != nil {
if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
return nil, fmt.Errorf("state file not found at %s: %w", filePath, statErr)
}
return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
}
return &state, nil
}
@@ -0,0 +1,77 @@
package attachment
import (
"github.com/google/uuid"
)
type TextSource struct {
Value string `toml:"value"`
}
type FileSource struct {
Path string `toml:"path"`
Mime string `toml:"mime"`
Data []byte `toml:"data,omitempty"` // Optional for image data
}
type SymbolSource struct {
Path string `toml:"path"`
Name string `toml:"name"`
Kind int `toml:"kind"`
Range SymbolRange `toml:"range"`
}
type SymbolRange struct {
Start Position `toml:"start"`
End Position `toml:"end"`
}
type Position struct {
Line int `toml:"line"`
Char int `toml:"char"`
}
type Attachment struct {
ID string `toml:"id"`
Type string `toml:"type"`
Display string `toml:"display"`
URL string `toml:"url"`
Filename string `toml:"filename"`
MediaType string `toml:"media_type"`
StartIndex int `toml:"start_index"`
EndIndex int `toml:"end_index"`
Source any `toml:"source,omitempty"`
}
// NewAttachment creates a new attachment with a unique ID
func NewAttachment() *Attachment {
return &Attachment{
ID: uuid.NewString(),
}
}
func (a *Attachment) GetTextSource() (*TextSource, bool) {
if a.Type != "text" {
return nil, false
}
ts, ok := a.Source.(*TextSource)
return ts, ok
}
// GetFileSource returns the source as FileSource if the attachment is a file type
func (a *Attachment) GetFileSource() (*FileSource, bool) {
if a.Type != "file" {
return nil, false
}
fs, ok := a.Source.(*FileSource)
return fs, ok
}
// GetSymbolSource returns the source as SymbolSource if the attachment is a symbol type
func (a *Attachment) GetSymbolSource() (*SymbolSource, bool) {
if a.Type != "symbol" {
return nil, false
}
ss, ok := a.Source.(*SymbolSource)
return ss, ok
}
@@ -0,0 +1,155 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
/*
Package clipboard provides cross platform clipboard access and supports
macOS/Linux/Windows/Android/iOS platform. Before interacting with the
clipboard, one must call Init to assert if it is possible to use this
package:
err := clipboard.Init()
if err != nil {
panic(err)
}
The most common operations are `Read` and `Write`. To use them:
// write/read text format data of the clipboard, and
// the byte buffer regarding the text are UTF8 encoded.
clipboard.Write(clipboard.FmtText, []byte("text data"))
clipboard.Read(clipboard.FmtText)
// write/read image format data of the clipboard, and
// the byte buffer regarding the image are PNG encoded.
clipboard.Write(clipboard.FmtImage, []byte("image data"))
clipboard.Read(clipboard.FmtImage)
Note that read/write regarding image format assumes that the bytes are
PNG encoded since it serves the alpha blending purpose that might be
used in other graphical software.
In addition, `clipboard.Write` returns a channel that can receive an
empty struct as a signal, which indicates the corresponding write call
to the clipboard is outdated, meaning the clipboard has been overwritten
by others and the previously written data is lost. For instance:
changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
select {
case <-changed:
println(`"text data" is no longer available from clipboard.`)
}
You can ignore the returning channel if you don't need this type of
notification. Furthermore, when you need more than just knowing whether
clipboard data is changed, use the watcher API:
ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
for data := range ch {
// print out clipboard data whenever it is changed
println(string(data))
}
*/
package clipboard
import (
"context"
"errors"
"fmt"
"os"
"sync"
)
var (
// activate only for running tests.
debug = false
errUnavailable = errors.New("clipboard unavailable")
errUnsupported = errors.New("unsupported format")
errNoCgo = errors.New("clipboard: cannot use when CGO_ENABLED=0")
)
// Format represents the format of clipboard data.
type Format int
// All sorts of supported clipboard data
const (
// FmtText indicates plain text clipboard format
FmtText Format = iota
// FmtImage indicates image/png clipboard format
FmtImage
)
var (
// Due to the limitation on operating systems (such as darwin),
// concurrent read can even cause panic, use a global lock to
// guarantee one read at a time.
lock = sync.Mutex{}
initOnce sync.Once
initError error
)
// Init initializes the clipboard package. It returns an error
// if the clipboard is not available to use. This may happen if the
// target system lacks required dependency, such as libx11-dev in X11
// environment. For example,
//
// err := clipboard.Init()
// if err != nil {
// panic(err)
// }
//
// If Init returns an error, any subsequent Read/Write/Watch call
// may result in an unrecoverable panic.
func Init() error {
initOnce.Do(func() {
initError = initialize()
})
return initError
}
// Read returns a chunk of bytes of the clipboard data if it presents
// in the desired format t presents. Otherwise, it returns nil.
func Read(t Format) []byte {
lock.Lock()
defer lock.Unlock()
buf, err := read(t)
if err != nil {
if debug {
fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err)
}
return nil
}
return buf
}
// Write writes a given buffer to the clipboard in a specified format.
// Write returned a receive-only channel can receive an empty struct
// as a signal, which indicates the clipboard has been overwritten from
// this write.
// If format t indicates an image, then the given buf assumes
// the image data is PNG encoded.
func Write(t Format, buf []byte) <-chan struct{} {
lock.Lock()
defer lock.Unlock()
changed, err := write(t, buf)
if err != nil {
if debug {
fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err)
}
return nil
}
return changed
}
// Watch returns a receive-only channel that received the clipboard data
// whenever any change of clipboard data in the desired format happens.
//
// The returned channel will be closed if the given context is canceled.
func Watch(ctx context.Context, t Format) <-chan []byte {
return watch(ctx, t)
}
@@ -0,0 +1,266 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build darwin
package clipboard
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
var (
lastChangeCount int64
changeCountMu sync.Mutex
)
func initialize() error { return nil }
func read(t Format) (buf []byte, err error) {
switch t {
case FmtText:
return readText()
case FmtImage:
return readImage()
default:
return nil, errUnsupported
}
}
func readText() ([]byte, error) {
// Check if clipboard contains string data
checkScript := `
try
set clipboardTypes to (clipboard info)
repeat with aType in clipboardTypes
if (first item of aType) is string then
return "hastext"
end if
end repeat
return "notext"
on error
return "error"
end try
`
cmd := exec.Command("osascript", "-e", checkScript)
checkOut, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
checkOut = bytes.TrimSpace(checkOut)
if !bytes.Equal(checkOut, []byte("hastext")) {
return nil, errUnavailable
}
// Now get the actual text
cmd = exec.Command("osascript", "-e", "get the clipboard")
out, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
// Remove trailing newline that osascript adds
out = bytes.TrimSuffix(out, []byte("\n"))
// If clipboard was set to empty string, return nil
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func readImage() ([]byte, error) {
// AppleScript to read image data from clipboard as base64
script := `
try
set theData to the clipboard as «class PNGf»
return theData
on error
return ""
end try
`
cmd := exec.Command("osascript", "-e", script)
out, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
// Check if we got any data
out = bytes.TrimSpace(out)
if len(out) == 0 {
return nil, errUnavailable
}
// The output is in hex format (e.g., «data PNGf89504E...»)
// We need to extract and convert it
outStr := string(out)
if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") {
return nil, errUnavailable
}
// Extract hex data
hexData := strings.TrimPrefix(outStr, "«data PNGf")
hexData = strings.TrimSuffix(hexData, "»")
// Convert hex to bytes
buf := make([]byte, len(hexData)/2)
for i := 0; i < len(hexData); i += 2 {
b, err := strconv.ParseUint(hexData[i:i+2], 16, 8)
if err != nil {
return nil, errUnavailable
}
buf[i/2] = byte(b)
}
return buf, nil
}
// write writes the given data to clipboard and
// returns true if success or false if failed.
func write(t Format, buf []byte) (<-chan struct{}, error) {
var err error
switch t {
case FmtText:
err = writeText(buf)
case FmtImage:
err = writeImage(buf)
default:
return nil, errUnsupported
}
if err != nil {
return nil, err
}
// Update change count
changeCountMu.Lock()
lastChangeCount++
currentCount := lastChangeCount
changeCountMu.Unlock()
// use unbuffered channel to prevent goroutine leak
changed := make(chan struct{}, 1)
go func() {
for {
time.Sleep(time.Second)
changeCountMu.Lock()
if lastChangeCount != currentCount {
changeCountMu.Unlock()
changed <- struct{}{}
close(changed)
return
}
changeCountMu.Unlock()
}
}()
return changed, nil
}
func writeText(buf []byte) error {
if len(buf) == 0 {
// Clear clipboard
script := `set the clipboard to ""`
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
// Escape the text for AppleScript
text := string(buf)
text = strings.ReplaceAll(text, "\\", "\\\\")
text = strings.ReplaceAll(text, "\"", "\\\"")
script := fmt.Sprintf(`set the clipboard to "%s"`, text)
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
func writeImage(buf []byte) error {
if len(buf) == 0 {
// Clear clipboard
script := `set the clipboard to ""`
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
// Create a temporary file to store the PNG data
tmpFile, err := os.CreateTemp("", "clipboard*.png")
if err != nil {
return errUnavailable
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(buf); err != nil {
tmpFile.Close()
return errUnavailable
}
tmpFile.Close()
// Use osascript to set clipboard to the image file
script := fmt.Sprintf(`
set theFile to POSIX file "%s"
set theImage to read theFile as «class PNGf»
set the clipboard to theImage
`, tmpFile.Name())
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
ti := time.NewTicker(time.Second)
// Get initial clipboard content
var lastContent []byte
if b := Read(t); b != nil {
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
go func() {
defer close(recv)
defer ti.Stop()
for {
select {
case <-ctx.Done():
return
case <-ti.C:
b := Read(t)
if b == nil {
continue
}
// Check if content changed
if !bytes.Equal(lastContent, b) {
recv <- b
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
}
}
}()
return recv
}
@@ -0,0 +1,311 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build linux
package clipboard
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
"sync"
"time"
)
var (
// Clipboard tools in order of preference
clipboardTools = []struct {
name string
readCmd []string
writeCmd []string
readImg []string
writeImg []string
available bool
}{
{
name: "xclip",
readCmd: []string{"xclip", "-selection", "clipboard", "-o"},
writeCmd: []string{"xclip", "-selection", "clipboard"},
readImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
writeImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png"},
},
{
name: "xsel",
readCmd: []string{"xsel", "--clipboard", "--output"},
writeCmd: []string{"xsel", "--clipboard", "--input"},
readImg: []string{"xsel", "--clipboard", "--output"},
writeImg: []string{"xsel", "--clipboard", "--input"},
},
{
name: "wl-copy",
readCmd: []string{"wl-paste", "-n"},
writeCmd: []string{"wl-copy"},
readImg: []string{"wl-paste", "-t", "image/png", "-n"},
writeImg: []string{"wl-copy", "-t", "image/png"},
},
}
selectedTool int = -1
toolMutex sync.Mutex
lastChangeTime time.Time
changeTimeMu sync.Mutex
)
func initialize() error {
toolMutex.Lock()
defer toolMutex.Unlock()
if selectedTool >= 0 {
return nil // Already initialized
}
order := []string{"xclip", "xsel", "wl-copy"}
if os.Getenv("WAYLAND_DISPLAY") != "" {
order = []string{"wl-copy", "xclip", "xsel"}
}
for _, name := range order {
for i, tool := range clipboardTools {
if tool.name == name {
cmd := exec.Command("which", tool.name)
if err := cmd.Run(); err == nil {
clipboardTools[i].available = true
if selectedTool < 0 {
selectedTool = i
slog.Debug("Clipboard tool found", "tool", tool.name)
}
}
break
}
}
}
if selectedTool < 0 {
slog.Warn(
"No clipboard utility found on system. Copy/paste functionality will be disabled. See https://opencode.ai/docs/troubleshooting/ for more information.",
)
return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
For X11 systems:
apt install -y xclip
# or
apt install -y xsel
For Wayland systems:
apt install -y wl-clipboard
If running in a headless environment, you may also need:
apt install -y xvfb
# and run:
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
export DISPLAY=:99.0`, errUnavailable)
}
return nil
}
func read(t Format) (buf []byte, err error) {
// Ensure clipboard is initialized before attempting to read
if err := initialize(); err != nil {
slog.Debug("Clipboard read failed: not initialized", "error", err)
return nil, err
}
toolMutex.Lock()
tool := clipboardTools[selectedTool]
toolMutex.Unlock()
switch t {
case FmtText:
return readText(tool)
case FmtImage:
return readImage(tool)
default:
return nil, errUnsupported
}
}
func readText(tool struct {
name string
readCmd []string
writeCmd []string
readImg []string
writeImg []string
available bool
}) ([]byte, error) {
// First check if clipboard contains text
cmd := exec.Command(tool.readCmd[0], tool.readCmd[1:]...)
out, err := cmd.Output()
if err != nil {
// Check if it's because clipboard contains non-text data
if tool.name == "xclip" {
// xclip returns error when clipboard doesn't contain requested type
checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
targets, _ := checkCmd.Output()
if bytes.Contains(targets, []byte("image/png")) &&
!bytes.Contains(targets, []byte("UTF8_STRING")) {
return nil, errUnavailable
}
}
return nil, errUnavailable
}
return out, nil
}
func readImage(tool struct {
name string
readCmd []string
writeCmd []string
readImg []string
writeImg []string
available bool
}) ([]byte, error) {
if tool.name == "xsel" {
// xsel doesn't support image types well, return error
return nil, errUnavailable
}
cmd := exec.Command(tool.readImg[0], tool.readImg[1:]...)
out, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
// Verify it's PNG data
if len(out) < 8 ||
!bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
return nil, errUnavailable
}
return out, nil
}
func write(t Format, buf []byte) (<-chan struct{}, error) {
// Ensure clipboard is initialized before attempting to write
if err := initialize(); err != nil {
return nil, err
}
toolMutex.Lock()
tool := clipboardTools[selectedTool]
toolMutex.Unlock()
var cmd *exec.Cmd
switch t {
case FmtText:
if len(buf) == 0 {
// Write empty string
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
cmd.Stdin = bytes.NewReader([]byte{})
} else {
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
cmd.Stdin = bytes.NewReader(buf)
}
case FmtImage:
if tool.name == "xsel" {
// xsel doesn't support image types well
return nil, errUnavailable
}
if len(buf) == 0 {
// Clear clipboard
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
cmd.Stdin = bytes.NewReader([]byte{})
} else {
cmd = exec.Command(tool.writeImg[0], tool.writeImg[1:]...)
cmd.Stdin = bytes.NewReader(buf)
}
default:
return nil, errUnsupported
}
if err := cmd.Run(); err != nil {
return nil, errUnavailable
}
// Update change time
changeTimeMu.Lock()
lastChangeTime = time.Now()
currentTime := lastChangeTime
changeTimeMu.Unlock()
// Create change notification channel
changed := make(chan struct{}, 1)
go func() {
for {
time.Sleep(time.Second)
changeTimeMu.Lock()
if !lastChangeTime.Equal(currentTime) {
changeTimeMu.Unlock()
changed <- struct{}{}
close(changed)
return
}
changeTimeMu.Unlock()
}
}()
return changed, nil
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
// Ensure clipboard is initialized before starting watch
if err := initialize(); err != nil {
close(recv)
return recv
}
ti := time.NewTicker(time.Second)
// Get initial clipboard content
var lastContent []byte
if b := Read(t); b != nil {
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
go func() {
defer close(recv)
defer ti.Stop()
for {
select {
case <-ctx.Done():
return
case <-ti.C:
b := Read(t)
if b == nil {
continue
}
// Check if content changed
if !bytes.Equal(lastContent, b) {
recv <- b
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
}
}
}()
return recv
}
// Helper function to check clipboard content type for xclip
func getClipboardTargets() []string {
cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
out, err := cmd.Output()
if err != nil {
return nil
}
return strings.Split(string(out), "\n")
}
@@ -0,0 +1,25 @@
//go:build !windows && !darwin && !linux && !cgo
package clipboard
import "context"
func initialize() error {
return errNoCgo
}
func read(t Format) (buf []byte, err error) {
panic("clipboard: cannot use when CGO_ENABLED=0")
}
func readc(t string) ([]byte, error) {
panic("clipboard: cannot use when CGO_ENABLED=0")
}
func write(t Format, buf []byte) (<-chan struct{}, error) {
panic("clipboard: cannot use when CGO_ENABLED=0")
}
func watch(ctx context.Context, t Format) <-chan []byte {
panic("clipboard: cannot use when CGO_ENABLED=0")
}
@@ -0,0 +1,551 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build windows
package clipboard
// Interacting with Clipboard on Windows:
// https://docs.microsoft.com/zh-cn/windows/win32/dataxchg/using-the-clipboard
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"reflect"
"runtime"
"syscall"
"time"
"unicode/utf16"
"unsafe"
"golang.org/x/image/bmp"
)
func initialize() error { return nil }
// readText reads the clipboard and returns the text data if presents.
// The caller is responsible for opening/closing the clipboard before
// calling this function.
func readText() (buf []byte, err error) {
hMem, _, err := getClipboardData.Call(cFmtUnicodeText)
if hMem == 0 {
return nil, err
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return nil, err
}
defer gUnlock.Call(hMem)
// Find NUL terminator
n := 0
for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ {
ptr = unsafe.Pointer(uintptr(ptr) +
unsafe.Sizeof(*((*uint16)(unsafe.Pointer(p)))))
}
var s []uint16
h := (*reflect.SliceHeader)(unsafe.Pointer(&s))
h.Data = p
h.Len = n
h.Cap = n
return []byte(string(utf16.Decode(s))), nil
}
// writeText writes given data to the clipboard. It is the caller's
// responsibility for opening/closing the clipboard before calling
// this function.
func writeText(buf []byte) error {
r, _, err := emptyClipboard.Call()
if r == 0 {
return fmt.Errorf("failed to clear clipboard: %w", err)
}
// empty text, we are done here.
if len(buf) == 0 {
return nil
}
s, err := syscall.UTF16FromString(string(buf))
if err != nil {
return fmt.Errorf("failed to convert given string: %w", err)
}
hMem, _, err := gAlloc.Call(gmemMoveable, uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
if hMem == 0 {
return fmt.Errorf("failed to alloc global memory: %w", err)
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return fmt.Errorf("failed to lock global memory: %w", err)
}
defer gUnlock.Call(hMem)
// no return value
memMove.Call(p, uintptr(unsafe.Pointer(&s[0])),
uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
v, _, err := setClipboardData.Call(cFmtUnicodeText, hMem)
if v == 0 {
gFree.Call(hMem)
return fmt.Errorf("failed to set text to clipboard: %w", err)
}
return nil
}
// readImage reads the clipboard and returns PNG encoded image data
// if presents. The caller is responsible for opening/closing the
// clipboard before calling this function.
func readImage() ([]byte, error) {
hMem, _, err := getClipboardData.Call(cFmtDIBV5)
if hMem == 0 {
// second chance to try FmtDIB
return readImageDib()
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return nil, err
}
defer gUnlock.Call(hMem)
// inspect header information
info := (*bitmapV5Header)(unsafe.Pointer(p))
// maybe deal with other formats?
if info.BitCount != 32 {
return nil, errUnsupported
}
var data []byte
sh := (*reflect.SliceHeader)(unsafe.Pointer(&data))
sh.Data = uintptr(p)
sh.Cap = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
sh.Len = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
img := image.NewRGBA(image.Rect(0, 0, int(info.Width), int(info.Height)))
offset := int(info.Size)
stride := int(info.Width)
for y := 0; y < int(info.Height); y++ {
for x := 0; x < int(info.Width); x++ {
idx := offset + 4*(y*stride+x)
xhat := (x + int(info.Width)) % int(info.Width)
yhat := int(info.Height) - 1 - y
r := data[idx+2]
g := data[idx+1]
b := data[idx+0]
a := data[idx+3]
img.SetRGBA(xhat, yhat, color.RGBA{r, g, b, a})
}
}
// always use PNG encoding.
var buf bytes.Buffer
png.Encode(&buf, img)
return buf.Bytes(), nil
}
func readImageDib() ([]byte, error) {
const (
fileHeaderLen = 14
infoHeaderLen = 40
cFmtDIB = 8
)
hClipDat, _, err := getClipboardData.Call(cFmtDIB)
if err != nil {
return nil, errors.New("not dib format data: " + err.Error())
}
pMemBlk, _, err := gLock.Call(hClipDat)
if pMemBlk == 0 {
return nil, errors.New("failed to call global lock: " + err.Error())
}
defer gUnlock.Call(hClipDat)
bmpHeader := (*bitmapHeader)(unsafe.Pointer(pMemBlk))
dataSize := bmpHeader.SizeImage + fileHeaderLen + infoHeaderLen
if bmpHeader.SizeImage == 0 && bmpHeader.Compression == 0 {
iSizeImage := bmpHeader.Height * ((bmpHeader.Width*uint32(bmpHeader.BitCount)/8 + 3) &^ 3)
dataSize += iSizeImage
}
buf := new(bytes.Buffer)
binary.Write(buf, binary.LittleEndian, uint16('B')|(uint16('M')<<8))
binary.Write(buf, binary.LittleEndian, uint32(dataSize))
binary.Write(buf, binary.LittleEndian, uint32(0))
const sizeof_colorbar = 0
binary.Write(buf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar))
j := 0
for i := fileHeaderLen; i < int(dataSize); i++ {
binary.Write(buf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j))))
j++
}
return bmpToPng(buf)
}
func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) {
var f bytes.Buffer
original_image, err := bmp.Decode(bmpBuf)
if err != nil {
return nil, err
}
err = png.Encode(&f, original_image)
if err != nil {
return nil, err
}
return f.Bytes(), nil
}
func writeImage(buf []byte) error {
r, _, err := emptyClipboard.Call()
if r == 0 {
return fmt.Errorf("failed to clear clipboard: %w", err)
}
// empty text, we are done here.
if len(buf) == 0 {
return nil
}
img, err := png.Decode(bytes.NewReader(buf))
if err != nil {
return fmt.Errorf("input bytes is not PNG encoded: %w", err)
}
offset := unsafe.Sizeof(bitmapV5Header{})
width := img.Bounds().Dx()
height := img.Bounds().Dy()
imageSize := 4 * width * height
data := make([]byte, int(offset)+imageSize)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
idx := int(offset) + 4*(y*width+x)
r, g, b, a := img.At(x, height-1-y).RGBA()
data[idx+2] = uint8(r)
data[idx+1] = uint8(g)
data[idx+0] = uint8(b)
data[idx+3] = uint8(a)
}
}
info := bitmapV5Header{}
info.Size = uint32(offset)
info.Width = int32(width)
info.Height = int32(height)
info.Planes = 1
info.Compression = 0 // BI_RGB
info.SizeImage = uint32(4 * info.Width * info.Height)
info.RedMask = 0xff0000 // default mask
info.GreenMask = 0xff00
info.BlueMask = 0xff
info.AlphaMask = 0xff000000
info.BitCount = 32 // we only deal with 32 bpp at the moment.
// Use calibrated RGB values as Go's image/png assumes linear color space.
// Other options:
// - LCS_CALIBRATED_RGB = 0x00000000
// - LCS_sRGB = 0x73524742
// - LCS_WINDOWS_COLOR_SPACE = 0x57696E20
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/eb4bbd50-b3ce-4917-895c-be31f214797f
info.CSType = 0x73524742
// Use GL_IMAGES for GamutMappingIntent
// Other options:
// - LCS_GM_ABS_COLORIMETRIC = 0x00000008
// - LCS_GM_BUSINESS = 0x00000001
// - LCS_GM_GRAPHICS = 0x00000002
// - LCS_GM_IMAGES = 0x00000004
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/9fec0834-607d-427d-abd5-ab240fb0db38
info.Intent = 4 // LCS_GM_IMAGES
infob := make([]byte, int(unsafe.Sizeof(info)))
for i, v := range *(*[unsafe.Sizeof(info)]byte)(unsafe.Pointer(&info)) {
infob[i] = v
}
copy(data[:], infob[:])
hMem, _, err := gAlloc.Call(gmemMoveable,
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
if hMem == 0 {
return fmt.Errorf("failed to alloc global memory: %w", err)
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return fmt.Errorf("failed to lock global memory: %w", err)
}
defer gUnlock.Call(hMem)
memMove.Call(p, uintptr(unsafe.Pointer(&data[0])),
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
v, _, err := setClipboardData.Call(cFmtDIBV5, hMem)
if v == 0 {
gFree.Call(hMem)
return fmt.Errorf("failed to set text to clipboard: %w", err)
}
return nil
}
func read(t Format) (buf []byte, err error) {
// On Windows, OpenClipboard and CloseClipboard must be executed on
// the same thread. Thus, lock the OS thread for further execution.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
var format uintptr
switch t {
case FmtImage:
format = cFmtDIBV5
case FmtText:
fallthrough
default:
format = cFmtUnicodeText
}
// check if clipboard is avaliable for the requested format
r, _, err := isClipboardFormatAvailable.Call(format)
if r == 0 {
return nil, errUnavailable
}
// try again until open clipboard successed
for {
r, _, _ = openClipboard.Call()
if r == 0 {
continue
}
break
}
defer closeClipboard.Call()
switch format {
case cFmtDIBV5:
return readImage()
case cFmtUnicodeText:
fallthrough
default:
return readText()
}
}
// write writes the given data to clipboard and
// returns true if success or false if failed.
func write(t Format, buf []byte) (<-chan struct{}, error) {
errch := make(chan error)
changed := make(chan struct{}, 1)
go func() {
// make sure GetClipboardSequenceNumber happens with
// OpenClipboard on the same thread.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
for {
r, _, _ := openClipboard.Call(0)
if r == 0 {
continue
}
break
}
// var param uintptr
switch t {
case FmtImage:
err := writeImage(buf)
if err != nil {
errch <- err
closeClipboard.Call()
return
}
case FmtText:
fallthrough
default:
// param = cFmtUnicodeText
err := writeText(buf)
if err != nil {
errch <- err
closeClipboard.Call()
return
}
}
// Close the clipboard otherwise other applications cannot
// paste the data.
closeClipboard.Call()
cnt, _, _ := getClipboardSequenceNumber.Call()
errch <- nil
for {
time.Sleep(time.Second)
cur, _, _ := getClipboardSequenceNumber.Call()
if cur != cnt {
changed <- struct{}{}
close(changed)
return
}
}
}()
err := <-errch
if err != nil {
return nil, err
}
return changed, nil
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
ready := make(chan struct{})
go func() {
// not sure if we are too slow or the user too fast :)
ti := time.NewTicker(time.Second)
cnt, _, _ := getClipboardSequenceNumber.Call()
ready <- struct{}{}
for {
select {
case <-ctx.Done():
close(recv)
return
case <-ti.C:
cur, _, _ := getClipboardSequenceNumber.Call()
if cnt != cur {
b := Read(t)
if b == nil {
continue
}
recv <- b
cnt = cur
}
}
}
}()
<-ready
return recv
}
const (
cFmtBitmap = 2 // Win+PrintScreen
cFmtUnicodeText = 13
cFmtDIBV5 = 17
// Screenshot taken from special shortcut is in different format (why??), see:
// https://jpsoft.com/forums/threads/detecting-clipboard-format.5225/
cFmtDataObject = 49161 // Shift+Win+s, returned from enumClipboardFormats
gmemMoveable = 0x0002
)
// BITMAPV5Header structure, see:
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header
type bitmapV5Header struct {
Size uint32
Width int32
Height int32
Planes uint16
BitCount uint16
Compression uint32
SizeImage uint32
XPelsPerMeter int32
YPelsPerMeter int32
ClrUsed uint32
ClrImportant uint32
RedMask uint32
GreenMask uint32
BlueMask uint32
AlphaMask uint32
CSType uint32
Endpoints struct {
CiexyzRed, CiexyzGreen, CiexyzBlue struct {
CiexyzX, CiexyzY, CiexyzZ int32 // FXPT2DOT30
}
}
GammaRed uint32
GammaGreen uint32
GammaBlue uint32
Intent uint32
ProfileData uint32
ProfileSize uint32
Reserved uint32
}
type bitmapHeader struct {
Size uint32
Width uint32
Height uint32
PLanes uint16
BitCount uint16
Compression uint32
SizeImage uint32
XPelsPerMeter uint32
YPelsPerMeter uint32
ClrUsed uint32
ClrImportant uint32
}
// Calling a Windows DLL, see:
// https://github.com/golang/go/wiki/WindowsDLLs
var (
user32 = syscall.MustLoadDLL("user32")
// Opens the clipboard for examination and prevents other
// applications from modifying the clipboard content.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard
openClipboard = user32.MustFindProc("OpenClipboard")
// Closes the clipboard.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closeclipboard
closeClipboard = user32.MustFindProc("CloseClipboard")
// Empties the clipboard and frees handles to data in the clipboard.
// The function then assigns ownership of the clipboard to the
// window that currently has the clipboard open.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-emptyclipboard
emptyClipboard = user32.MustFindProc("EmptyClipboard")
// Retrieves data from the clipboard in a specified format.
// The clipboard must have been opened previously.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboarddata
getClipboardData = user32.MustFindProc("GetClipboardData")
// Places data on the clipboard in a specified clipboard format.
// The window must be the current clipboard owner, and the
// application must have called the OpenClipboard function. (When
// responding to the WM_RENDERFORMAT message, the clipboard owner
// must not call OpenClipboard before calling SetClipboardData.)
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
setClipboardData = user32.MustFindProc("SetClipboardData")
// Determines whether the clipboard contains data in the specified format.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
// Clipboard data formats are stored in an ordered list. To perform
// an enumeration of clipboard data formats, you make a series of
// calls to the EnumClipboardFormats function. For each call, the
// format parameter specifies an available clipboard format, and the
// function returns the next available clipboard format.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
enumClipboardFormats = user32.MustFindProc("EnumClipboardFormats")
// Retrieves the clipboard sequence number for the current window station.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboardsequencenumber
getClipboardSequenceNumber = user32.MustFindProc("GetClipboardSequenceNumber")
// Registers a new clipboard format. This format can then be used as
// a valid clipboard format.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformata
registerClipboardFormatA = user32.MustFindProc("RegisterClipboardFormatA")
kernel32 = syscall.NewLazyDLL("kernel32")
// Locks a global memory object and returns a pointer to the first
// byte of the object's memory block.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock
gLock = kernel32.NewProc("GlobalLock")
// Decrements the lock count associated with a memory object that was
// allocated with GMEM_MOVEABLE. This function has no effect on memory
// objects allocated with GMEM_FIXED.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalunlock
gUnlock = kernel32.NewProc("GlobalUnlock")
// Allocates the specified number of bytes from the heap.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc
gAlloc = kernel32.NewProc("GlobalAlloc")
// Frees the specified global memory object and invalidates its handle.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalfree
gFree = kernel32.NewProc("GlobalFree")
memMove = kernel32.NewProc("RtlMoveMemory")
)
@@ -0,0 +1,389 @@
package commands
import (
"encoding/json"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
)
type ExecuteCommandMsg Command
type ExecuteCommandsMsg []Command
type CommandExecutedMsg Command
type Keybinding struct {
RequiresLeader bool
Key string
}
func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
key := k.Key
key = strings.TrimSpace(key)
return key == msg.String() && (k.RequiresLeader == leader)
}
type CommandName string
type Command struct {
Name CommandName
Description string
Keybindings []Keybinding
Trigger []string
}
func (c Command) Keys() []string {
var keys []string
for _, k := range c.Keybindings {
keys = append(keys, k.Key)
}
return keys
}
func (c Command) HasTrigger() bool {
return len(c.Trigger) > 0
}
func (c Command) PrimaryTrigger() string {
if len(c.Trigger) > 0 {
return c.Trigger[0]
}
return ""
}
func (c Command) MatchesTrigger(trigger string) bool {
return slices.Contains(c.Trigger, trigger)
}
type CommandRegistry map[CommandName]Command
func (r CommandRegistry) Sorted() []Command {
var commands []Command
for _, command := range r {
commands = append(commands, command)
}
slices.SortFunc(commands, func(a, b Command) int {
// Priority order: session_new, session_share, model_list, app_help first, app_exit last
priorityOrder := map[CommandName]int{
SessionNewCommand: 0,
AppHelpCommand: 1,
SessionShareCommand: 2,
ModelListCommand: 3,
}
aPriority, aHasPriority := priorityOrder[a.Name]
bPriority, bHasPriority := priorityOrder[b.Name]
if aHasPriority && bHasPriority {
return aPriority - bPriority
}
if aHasPriority {
return -1
}
if bHasPriority {
return 1
}
if a.Name == AppExitCommand {
return 1
}
if b.Name == AppExitCommand {
return -1
}
return strings.Compare(string(a.Name), string(b.Name))
})
return commands
}
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
var matched []Command
for _, command := range r.Sorted() {
if command.Matches(msg, leader) {
matched = append(matched, command)
}
}
return matched
}
const (
AppHelpCommand CommandName = "app_help"
SwitchModeCommand CommandName = "switch_mode"
SwitchModeReverseCommand CommandName = "switch_mode_reverse"
EditorOpenCommand CommandName = "editor_open"
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
SessionShareCommand CommandName = "session_share"
SessionUnshareCommand CommandName = "session_unshare"
SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact"
SessionExportCommand CommandName = "session_export"
ToolDetailsCommand CommandName = "tool_details"
ModelListCommand CommandName = "model_list"
ThemeListCommand CommandName = "theme_list"
FileListCommand CommandName = "file_list"
FileCloseCommand CommandName = "file_close"
FileSearchCommand CommandName = "file_search"
FileDiffToggleCommand CommandName = "file_diff_toggle"
ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline"
MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
MessagesPreviousCommand CommandName = "messages_previous"
MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last"
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
MessagesCopyCommand CommandName = "messages_copy"
MessagesUndoCommand CommandName = "messages_undo"
MessagesRedoCommand CommandName = "messages_redo"
AppExitCommand CommandName = "app_exit"
)
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
for _, binding := range k.Keybindings {
if binding.Matches(msg, leader) {
return true
}
}
return false
}
func parseBindings(bindings ...string) []Keybinding {
var parsedBindings []Keybinding
for _, binding := range bindings {
for p := range strings.SplitSeq(binding, ",") {
requireLeader := strings.HasPrefix(p, "<leader>")
keybinding := strings.ReplaceAll(p, "<leader>", "")
keybinding = strings.TrimSpace(keybinding)
parsedBindings = append(parsedBindings, Keybinding{
RequiresLeader: requireLeader,
Key: keybinding,
})
}
}
return parsedBindings
}
func LoadFromConfig(config *opencode.Config) CommandRegistry {
defaults := []Command{
{
Name: AppHelpCommand,
Description: "show help",
Keybindings: parseBindings("<leader>h"),
Trigger: []string{"help"},
},
{
Name: SwitchModeCommand,
Description: "next mode",
Keybindings: parseBindings("tab"),
},
{
Name: SwitchModeReverseCommand,
Description: "previous mode",
Keybindings: parseBindings("shift+tab"),
},
{
Name: EditorOpenCommand,
Description: "open editor",
Keybindings: parseBindings("<leader>e"),
Trigger: []string{"editor"},
},
{
Name: SessionExportCommand,
Description: "export conversation",
Keybindings: parseBindings("<leader>x"),
Trigger: []string{"export"},
},
{
Name: SessionNewCommand,
Description: "new session",
Keybindings: parseBindings("<leader>n"),
Trigger: []string{"new", "clear"},
},
{
Name: SessionListCommand,
Description: "list sessions",
Keybindings: parseBindings("<leader>l"),
Trigger: []string{"sessions", "resume", "continue"},
},
{
Name: SessionShareCommand,
Description: "share session",
Keybindings: parseBindings("<leader>s"),
Trigger: []string{"share"},
},
{
Name: SessionUnshareCommand,
Description: "unshare session",
Keybindings: parseBindings("<leader>u"),
Trigger: []string{"unshare"},
},
{
Name: SessionInterruptCommand,
Description: "interrupt session",
Keybindings: parseBindings("esc"),
},
{
Name: SessionCompactCommand,
Description: "compact the session",
Keybindings: parseBindings("<leader>c"),
Trigger: []string{"compact", "summarize"},
},
{
Name: ToolDetailsCommand,
Description: "toggle tool details",
Keybindings: parseBindings("<leader>d"),
Trigger: []string{"details"},
},
{
Name: ModelListCommand,
Description: "list models",
Keybindings: parseBindings("<leader>m"),
Trigger: []string{"models"},
},
{
Name: ThemeListCommand,
Description: "list themes",
Keybindings: parseBindings("<leader>t"),
Trigger: []string{"themes"},
},
// {
// Name: FileListCommand,
// Description: "list files",
// Keybindings: parseBindings("<leader>f"),
// Trigger: []string{"files"},
// },
{
Name: FileCloseCommand,
Description: "close file",
Keybindings: parseBindings("esc"),
},
{
Name: FileSearchCommand,
Description: "search file",
Keybindings: parseBindings("<leader>/"),
},
{
Name: FileDiffToggleCommand,
Description: "split/unified diff",
Keybindings: parseBindings("<leader>v"),
},
{
Name: ProjectInitCommand,
Description: "create/update AGENTS.md",
Keybindings: parseBindings("<leader>i"),
Trigger: []string{"init"},
},
{
Name: InputClearCommand,
Description: "clear input",
Keybindings: parseBindings("ctrl+c"),
},
{
Name: InputPasteCommand,
Description: "paste content",
Keybindings: parseBindings("ctrl+v", "super+v"),
},
{
Name: InputSubmitCommand,
Description: "submit message",
Keybindings: parseBindings("enter"),
},
{
Name: InputNewlineCommand,
Description: "insert newline",
Keybindings: parseBindings("shift+enter", "ctrl+j"),
},
{
Name: MessagesPageUpCommand,
Description: "page up",
Keybindings: parseBindings("pgup"),
},
{
Name: MessagesPageDownCommand,
Description: "page down",
Keybindings: parseBindings("pgdown"),
},
{
Name: MessagesHalfPageUpCommand,
Description: "half page up",
Keybindings: parseBindings("ctrl+alt+u"),
},
{
Name: MessagesHalfPageDownCommand,
Description: "half page down",
Keybindings: parseBindings("ctrl+alt+d"),
},
{
Name: MessagesPreviousCommand,
Description: "previous message",
Keybindings: parseBindings("ctrl+up"),
},
{
Name: MessagesNextCommand,
Description: "next message",
Keybindings: parseBindings("ctrl+down"),
},
{
Name: MessagesFirstCommand,
Description: "first message",
Keybindings: parseBindings("ctrl+g"),
},
{
Name: MessagesLastCommand,
Description: "last message",
Keybindings: parseBindings("ctrl+alt+g"),
},
{
Name: MessagesLayoutToggleCommand,
Description: "toggle layout",
Keybindings: parseBindings("<leader>p"),
},
{
Name: MessagesCopyCommand,
Description: "copy message",
Keybindings: parseBindings("<leader>y"),
},
{
Name: MessagesUndoCommand,
Description: "undo last message",
Keybindings: parseBindings("<leader>u"),
Trigger: []string{"undo"},
},
{
Name: MessagesRedoCommand,
Description: "redo message",
Keybindings: parseBindings("<leader>r"),
Trigger: []string{"redo"},
},
{
Name: AppExitCommand,
Description: "exit the app",
Keybindings: parseBindings("ctrl+c", "<leader>q"),
Trigger: []string{"exit", "quit", "q"},
},
}
registry := make(CommandRegistry)
keybinds := map[string]string{}
marshalled, _ := json.Marshal(config.Keybinds)
json.Unmarshal(marshalled, &keybinds)
for _, command := range defaults {
// Remove share/unshare commands if sharing is disabled
if config.Share == opencode.ConfigShareDisabled &&
(command.Name == SessionShareCommand || command.Name == SessionUnshareCommand) {
continue
}
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
if keybind == "none" {
continue
}
command.Keybindings = parseBindings(keybind)
}
registry[command.Name] = command
}
return registry
}
@@ -0,0 +1,110 @@
package completions
import (
"sort"
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type CommandCompletionProvider struct {
app *app.App
}
func NewCommandCompletionProvider(app *app.App) CompletionProvider {
return &CommandCompletionProvider{app: app}
}
func (c *CommandCompletionProvider) GetId() string {
return "commands"
}
func (c *CommandCompletionProvider) GetEmptyMessage() string {
return "no matching commands"
}
func (c *CommandCompletionProvider) getCommandCompletionItem(
cmd commands.Command,
space int,
) CompletionSuggestion {
displayFunc := func(s styles.Style) string {
t := theme.CurrentTheme()
spacer := strings.Repeat(" ", space)
display := " /" + cmd.PrimaryTrigger() + s.
Foreground(t.TextMuted()).
Render(spacer+cmd.Description)
return display
}
value := string(cmd.Name)
return CompletionSuggestion{
Display: displayFunc,
Value: value,
ProviderID: c.GetId(),
RawData: cmd,
}
}
func (c *CommandCompletionProvider) GetChildEntries(
query string,
) ([]CompletionSuggestion, error) {
commands := c.app.Commands
space := 1
for _, cmd := range c.app.Commands {
if cmd.HasTrigger() && lipgloss.Width(cmd.PrimaryTrigger()) > space {
space = lipgloss.Width(cmd.PrimaryTrigger())
}
}
space += 2
sorted := commands.Sorted()
if query == "" {
// If no query, return all commands
items := []CompletionSuggestion{}
for _, cmd := range sorted {
if !cmd.HasTrigger() {
continue
}
space := space - lipgloss.Width(cmd.PrimaryTrigger())
items = append(items, c.getCommandCompletionItem(cmd, space))
}
return items, nil
}
var commandNames []string
commandMap := make(map[string]CompletionSuggestion)
for _, cmd := range sorted {
if !cmd.HasTrigger() {
continue
}
space := space - lipgloss.Width(cmd.PrimaryTrigger())
for _, trigger := range cmd.Trigger {
commandNames = append(commandNames, trigger)
commandMap[trigger] = c.getCommandCompletionItem(cmd, space)
}
}
matches := fuzzy.RankFindFold(query, commandNames)
sort.Sort(matches)
// Convert matches to completion items, deduplicating by command name
items := []CompletionSuggestion{}
seen := make(map[string]bool)
for _, match := range matches {
if item, ok := commandMap[match.Target]; ok {
// Use the command's value (name) as the deduplication key
if !seen[item.Value] {
seen[item.Value] = true
items = append(items, item)
}
}
}
return items, nil
}
@@ -0,0 +1,126 @@
package completions
import (
"context"
"log/slog"
"sort"
"strconv"
"strings"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type filesContextGroup struct {
app *app.App
gitFiles []CompletionSuggestion
}
func (cg *filesContextGroup) GetId() string {
return "files"
}
func (cg *filesContextGroup) GetEmptyMessage() string {
return "no matching files"
}
func (cg *filesContextGroup) getGitFiles() []CompletionSuggestion {
items := make([]CompletionSuggestion, 0)
status, _ := cg.app.Client.File.Status(context.Background())
if status != nil {
files := *status
sort.Slice(files, func(i, j int) bool {
return files[i].Added+files[i].Removed > files[j].Added+files[j].Removed
})
for _, file := range files {
displayFunc := func(s styles.Style) string {
t := theme.CurrentTheme()
green := s.Foreground(t.Success()).Render
red := s.Foreground(t.Error()).Render
display := file.Path
if file.Added > 0 {
display += green(" +" + strconv.Itoa(int(file.Added)))
}
if file.Removed > 0 {
display += red(" -" + strconv.Itoa(int(file.Removed)))
}
return display
}
item := CompletionSuggestion{
Display: displayFunc,
Value: file.Path,
ProviderID: cg.GetId(),
RawData: file,
}
items = append(items, item)
}
}
return items
}
func (cg *filesContextGroup) GetChildEntries(
query string,
) ([]CompletionSuggestion, error) {
items := make([]CompletionSuggestion, 0)
query = strings.TrimSpace(query)
if query == "" {
items = append(items, cg.gitFiles...)
}
files, err := cg.app.Client.Find.Files(
context.Background(),
opencode.FindFilesParams{Query: opencode.F(query)},
)
if err != nil {
slog.Error("Failed to get completion items", "error", err)
return items, err
}
if files == nil {
return items, nil
}
for _, file := range *files {
exists := false
for _, existing := range cg.gitFiles {
if existing.Value == file {
if query != "" {
items = append(items, existing)
}
exists = true
}
}
if !exists {
displayFunc := func(s styles.Style) string {
// t := theme.CurrentTheme()
// return s.Foreground(t.Text()).Render(file)
return s.Render(file)
}
item := CompletionSuggestion{
Display: displayFunc,
Value: file,
ProviderID: cg.GetId(),
RawData: file,
}
items = append(items, item)
}
}
return items, nil
}
func NewFileContextGroup(app *app.App) CompletionProvider {
cg := &filesContextGroup{
app: app,
}
go func() {
cg.gitFiles = cg.getGitFiles()
}()
return cg
}
@@ -0,0 +1,8 @@
package completions
// CompletionProvider defines the interface for completion data providers
type CompletionProvider interface {
GetId() string
GetChildEntries(query string) ([]CompletionSuggestion, error)
GetEmptyMessage() string
}
@@ -0,0 +1,24 @@
package completions
import "github.com/sst/opencode/internal/styles"
// CompletionSuggestion represents a data-only completion suggestion
// with no styling or rendering logic
type CompletionSuggestion struct {
// The text to be displayed in the list. May contain minimal inline
// ANSI styling if intrinsic to the data (e.g., git diff colors).
Display func(styles.Style) string
// The value to be used when the item is selected (e.g., inserted into the editor).
Value string
// An optional, longer description to be displayed.
Description string
// The ID of the provider that generated this suggestion.
ProviderID string
// The raw, underlying data object (e.g., opencode.Symbol, commands.Command).
// This allows the selection handler to perform rich actions.
RawData any
}
@@ -0,0 +1,119 @@
package completions
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type symbolsContextGroup struct {
app *app.App
}
func (cg *symbolsContextGroup) GetId() string {
return "symbols"
}
func (cg *symbolsContextGroup) GetEmptyMessage() string {
return "no matching symbols"
}
type SymbolKind int
const (
SymbolKindFile SymbolKind = 1
SymbolKindModule SymbolKind = 2
SymbolKindNamespace SymbolKind = 3
SymbolKindPackage SymbolKind = 4
SymbolKindClass SymbolKind = 5
SymbolKindMethod SymbolKind = 6
SymbolKindProperty SymbolKind = 7
SymbolKindField SymbolKind = 8
SymbolKindConstructor SymbolKind = 9
SymbolKindEnum SymbolKind = 10
SymbolKindInterface SymbolKind = 11
SymbolKindFunction SymbolKind = 12
SymbolKindVariable SymbolKind = 13
SymbolKindConstant SymbolKind = 14
SymbolKindString SymbolKind = 15
SymbolKindNumber SymbolKind = 16
SymbolKindBoolean SymbolKind = 17
SymbolKindArray SymbolKind = 18
SymbolKindObject SymbolKind = 19
SymbolKindKey SymbolKind = 20
SymbolKindNull SymbolKind = 21
SymbolKindEnumMember SymbolKind = 22
SymbolKindStruct SymbolKind = 23
SymbolKindEvent SymbolKind = 24
SymbolKindOperator SymbolKind = 25
SymbolKindTypeParameter SymbolKind = 26
)
func (cg *symbolsContextGroup) GetChildEntries(
query string,
) ([]CompletionSuggestion, error) {
items := make([]CompletionSuggestion, 0)
query = strings.TrimSpace(query)
if query == "" {
return items, nil
}
symbols, err := cg.app.Client.Find.Symbols(
context.Background(),
opencode.FindSymbolsParams{Query: opencode.F(query)},
)
if err != nil {
slog.Error("Failed to get symbol completion items", "error", err)
return items, err
}
if symbols == nil {
return items, nil
}
for _, sym := range *symbols {
parts := strings.Split(sym.Name, ".")
lastPart := parts[len(parts)-1]
start := int(sym.Location.Range.Start.Line)
end := int(sym.Location.Range.End.Line)
displayFunc := func(s styles.Style) string {
t := theme.CurrentTheme()
base := s.Foreground(t.Text()).Render
muted := s.Foreground(t.TextMuted()).Render
display := base(lastPart)
uriParts := strings.Split(sym.Location.Uri, "/")
lastTwoParts := uriParts[len(uriParts)-2:]
joined := strings.Join(lastTwoParts, "/")
display += muted(fmt.Sprintf(" %s", joined))
display += muted(fmt.Sprintf(":L%d-%d", start, end))
return display
}
value := fmt.Sprintf("%s?start=%d&end=%d", sym.Location.Uri, start, end)
item := CompletionSuggestion{
Display: displayFunc,
Value: value,
ProviderID: cg.GetId(),
RawData: sym,
}
items = append(items, item)
}
return items, nil
}
func NewSymbolsContextGroup(app *app.App) CompletionProvider {
return &symbolsContextGroup{
app: app,
}
}
@@ -1,28 +1,28 @@
package chat
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"hash/fnv"
"sync"
)
// MessageCache caches rendered messages to avoid re-rendering
type MessageCache struct {
// PartCache caches rendered messages to avoid re-rendering
type PartCache struct {
mu sync.RWMutex
cache map[string]string
}
// NewMessageCache creates a new message cache
func NewMessageCache() *MessageCache {
return &MessageCache{
// NewPartCache creates a new message cache
func NewPartCache() *PartCache {
return &PartCache{
cache: make(map[string]string),
}
}
// generateKey creates a unique key for a message based on its content and rendering parameters
func (c *MessageCache) GenerateKey(params ...any) string {
h := sha256.New()
func (c *PartCache) GenerateKey(params ...any) string {
h := fnv.New64a()
for _, param := range params {
h.Write(fmt.Appendf(nil, ":%v", param))
}
@@ -30,7 +30,7 @@ func (c *MessageCache) GenerateKey(params ...any) string {
}
// Get retrieves a cached rendered message
func (c *MessageCache) Get(key string) (string, bool) {
func (c *PartCache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -39,14 +39,14 @@ func (c *MessageCache) Get(key string) (string, bool) {
}
// Set stores a rendered message in the cache
func (c *MessageCache) Set(key string, content string) {
func (c *PartCache) Set(key string, content string) {
c.mu.Lock()
defer c.mu.Unlock()
c.cache[key] = content
}
// Clear removes all entries from the cache
func (c *MessageCache) Clear() {
func (c *PartCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
@@ -54,7 +54,7 @@ func (c *MessageCache) Clear() {
}
// Size returns the number of cached entries
func (c *MessageCache) Size() int {
func (c *PartCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -0,0 +1,794 @@
package chat
import (
"encoding/base64"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/google/uuid"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/attachment"
"github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type EditorComponent interface {
tea.Model
tea.ViewModel
Content() string
Lines() int
Value() string
Length() int
Focused() bool
Focus() (tea.Model, tea.Cmd)
Blur()
Submit() (tea.Model, tea.Cmd)
Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
SetValue(value string)
SetValueWithAttachments(value string)
SetInterruptKeyInDebounce(inDebounce bool)
SetExitKeyInDebounce(inDebounce bool)
RestoreFromHistory(index int)
}
type editorComponent struct {
app *app.App
width int
textarea textarea.Model
spinner spinner.Model
interruptKeyInDebounce bool
exitKeyInDebounce bool
historyIndex int // -1 means current (not in history)
currentText string // Store current text when navigating history
pasteCounter int
reverted bool
}
func (m *editorComponent) Init() tea.Cmd {
return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
}
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width - 4
return m, nil
case spinner.TickMsg:
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyPressMsg:
// Handle up/down arrows and ctrl+p/ctrl+n for history navigation
switch msg.String() {
case "up", "ctrl+p":
// Only navigate history if cursor is at the first line and column (for arrow keys)
// or allow ctrl+p from anywhere
if (msg.String() == "ctrl+p" || (m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0)) && len(m.app.State.MessageHistory) > 0 {
if m.historyIndex == -1 {
// Save current text before entering history
m.currentText = m.textarea.Value()
m.textarea.MoveToBegin()
}
// Move up in history (older messages)
if m.historyIndex < len(m.app.State.MessageHistory)-1 {
m.historyIndex++
m.RestoreFromHistory(m.historyIndex)
m.textarea.MoveToBegin()
}
return m, nil
}
case "down", "ctrl+n":
// Only navigate history if cursor is at the last line and we're in history navigation (for arrow keys)
// or allow ctrl+n from anywhere if we're in history navigation
if (msg.String() == "ctrl+n" || m.textarea.IsCursorAtEnd()) && m.historyIndex > -1 {
// Move down in history (newer messages)
m.historyIndex--
if m.historyIndex == -1 {
// Restore current text
m.textarea.Reset()
m.textarea.SetValue(m.currentText)
m.currentText = ""
} else {
m.RestoreFromHistory(m.historyIndex)
m.textarea.MoveToEnd()
}
return m, nil
} else if m.historyIndex > -1 && msg.String() == "down" {
m.textarea.MoveToEnd()
return m, nil
}
}
// Reset history navigation on any other input
if m.historyIndex != -1 {
m.historyIndex = -1
m.currentText = ""
}
// Maximize editor responsiveness for printable characters
if msg.Text != "" {
m.reverted = false
m.textarea, cmd = m.textarea.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
case app.MessageRevertedMsg:
if msg.Session.ID == m.app.Session.ID {
switch msg.Message.Info.(type) {
case opencode.UserMessage:
prompt, err := msg.Message.ToPrompt()
if err != nil {
return m, toast.NewErrorToast("Failed to revert message")
}
m.RestoreFromPrompt(*prompt)
m.textarea.MoveToEnd()
m.reverted = true
return m, nil
}
}
case app.SessionUnrevertedMsg:
if msg.Session.ID == m.app.Session.ID {
if m.reverted {
updated, cmd := m.Clear()
m = updated.(*editorComponent)
return m, cmd
}
return m, nil
}
case tea.PasteMsg:
text := string(msg)
if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" {
statPath := filePath
if !filepath.IsAbs(filePath) {
statPath = filepath.Join(m.app.Info.Path.Cwd, filePath)
}
if _, err := os.Stat(statPath); err == nil {
attachment := m.createAttachmentFromPath(filePath)
if attachment != nil {
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
}
}
}
text = strings.ReplaceAll(text, "\\", "")
text, err := strconv.Unquote(`"` + text + `"`)
if err != nil {
slog.Error("Failed to unquote text", "error", err)
text := string(msg)
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(msg))
}
return m, nil
}
if _, err := os.Stat(text); err != nil {
slog.Error("Failed to paste file", "error", err)
text := string(msg)
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(msg))
}
return m, nil
}
filePath := text
attachment := m.createAttachmentFromFile(filePath)
if attachment == nil {
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(msg))
}
return m, nil
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
case tea.ClipboardMsg:
text := string(msg)
// Check if the pasted text is long and should be summarized
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(text))
}
case dialog.ThemeSelectedMsg:
m.textarea = updateTextareaStyles(m.textarea)
m.spinner = createSpinner()
return m, tea.Batch(m.textarea.Focus(), m.spinner.Tick)
case dialog.CompletionSelectedMsg:
switch msg.Item.ProviderID {
case "commands":
commandName := strings.TrimPrefix(msg.Item.Value, "/")
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
return m, tea.Batch(cmds...)
case "files":
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.Item.Value + " ")
return m, nil
}
// The range to replace is from the '@' up to the current cursor position.
// Replace the search term (e.g., "@search") with an empty string first.
cursorCol := m.textarea.CursorColumn()
m.textarea.ReplaceRange(atIndex, cursorCol, "")
// Now, insert the attachment at the position where the '@' was.
// The cursor is now at `atIndex` after the replacement.
filePath := msg.Item.Value
attachment := m.createAttachmentFromPath(filePath)
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
case "symbols":
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.Item.Value + " ")
return m, nil
}
cursorCol := m.textarea.CursorColumn()
m.textarea.ReplaceRange(atIndex, cursorCol, "")
symbol := msg.Item.RawData.(opencode.Symbol)
parts := strings.Split(symbol.Name, ".")
lastPart := parts[len(parts)-1]
attachment := &attachment.Attachment{
ID: uuid.NewString(),
Type: "symbol",
Display: "@" + lastPart,
URL: msg.Item.Value,
Filename: lastPart,
MediaType: "text/plain",
Source: &attachment.SymbolSource{
Path: symbol.Location.Uri,
Name: symbol.Name,
Kind: int(symbol.Kind),
Range: attachment.SymbolRange{
Start: attachment.Position{
Line: int(symbol.Location.Range.Start.Line),
Char: int(symbol.Location.Range.Start.Character),
},
End: attachment.Position{
Line: int(symbol.Location.Range.End.Line),
Char: int(symbol.Location.Range.End.Character),
},
},
},
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
default:
slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
return m, nil
}
}
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
m.textarea, cmd = m.textarea.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *editorComponent) Content() string {
width := m.width
if m.app.Session.ID == "" {
width = min(width, 80)
}
t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
promptStyle := styles.NewStyle().Foreground(t.Primary()).
Padding(0, 0, 0, 1).
Bold(true)
prompt := promptStyle.Render(">")
m.textarea.SetWidth(width - 6)
textarea := lipgloss.JoinHorizontal(
lipgloss.Top,
prompt,
m.textarea.View(),
)
borderForeground := t.Border()
if m.app.IsLeaderSequence {
borderForeground = t.Accent()
}
textarea = styles.NewStyle().
Background(t.BackgroundElement()).
Width(width).
PaddingTop(1).
PaddingBottom(1).
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(borderForeground).
BorderBackground(t.Background()).
BorderLeft(true).
BorderRight(true).
Render(textarea)
hint := base(m.getSubmitKeyText()) + muted(" send ")
if m.exitKeyInDebounce {
keyText := m.getExitKeyText()
hint = base(keyText+" again") + muted(" to exit")
} else if m.app.IsBusy() {
keyText := m.getInterruptKeyText()
if m.interruptKeyInDebounce {
hint = muted(
"working",
) + m.spinner.View() + muted(
" ",
) + base(
keyText+" again",
) + muted(
" interrupt",
)
} else {
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
}
}
model := ""
if m.app.Model != nil {
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
}
space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
info := hint + spacer + model
info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
content := strings.Join([]string{"", textarea, info}, "\n")
return content
}
func (m *editorComponent) View() string {
width := m.width
if m.app.Session.ID == "" {
width = min(width, 80)
}
if m.Lines() > 1 {
return lipgloss.Place(
width,
5,
lipgloss.Center,
lipgloss.Center,
"",
styles.WhitespaceStyle(theme.CurrentTheme().Background()),
)
}
return m.Content()
}
func (m *editorComponent) Focused() bool {
return m.textarea.Focused()
}
func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
return m, m.textarea.Focus()
}
func (m *editorComponent) Blur() {
m.textarea.Blur()
}
func (m *editorComponent) Lines() int {
return m.textarea.LineCount()
}
func (m *editorComponent) Value() string {
return m.textarea.Value()
}
func (m *editorComponent) Length() int {
return m.textarea.Length()
}
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
value := strings.TrimSpace(m.Value())
if value == "" {
return m, nil
}
switch value {
case "exit", "quit", "q", ":q":
return m, tea.Quit
}
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
backslashCol := m.textarea.CurrentRowLength() - 1
m.textarea.ReplaceRange(backslashCol, backslashCol+1, "")
m.textarea.InsertString("\n")
return m, nil
}
var cmds []tea.Cmd
attachments := m.textarea.GetAttachments()
prompt := app.Prompt{Text: value, Attachments: attachments}
m.app.State.AddPromptToHistory(prompt)
cmds = append(cmds, m.app.SaveState())
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt)))
return m, tea.Batch(cmds...)
}
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
m.textarea.Reset()
m.historyIndex = -1
m.currentText = ""
m.pasteCounter = 0
return m, nil
}
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
imageBytes := clipboard.Read(clipboard.FmtImage)
if imageBytes != nil {
attachmentCount := len(m.textarea.GetAttachments())
attachmentIndex := attachmentCount + 1
base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
attachment := &attachment.Attachment{
ID: uuid.NewString(),
Type: "file",
MediaType: "image/png",
Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
Source: &attachment.FileSource{
Path: fmt.Sprintf("image-%d.png", attachmentIndex),
Mime: "image/png",
Data: imageBytes,
},
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
}
textBytes := clipboard.Read(clipboard.FmtText)
if textBytes != nil {
text := string(textBytes)
// Check if the pasted text is long and should be summarized
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(text))
}
return m, nil
}
// fallback to reading the clipboard using OSC52
return m, tea.ReadClipboard
}
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
m.textarea.Newline()
return m, nil
}
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce
}
func (m *editorComponent) SetValue(value string) {
m.textarea.SetValue(value)
}
func (m *editorComponent) SetValueWithAttachments(value string) {
m.textarea.Reset()
i := 0
for i < len(value) {
// Check if filepath and add attachment
if value[i] == '@' {
start := i + 1
end := start
for end < len(value) && value[end] != ' ' && value[end] != '\t' && value[end] != '\n' && value[end] != '\r' {
end++
}
if end > start {
filePath := value[start:end]
slog.Debug("test", "filePath", filePath)
if _, err := os.Stat(filepath.Join(m.app.Info.Path.Cwd, filePath)); err == nil {
slog.Debug("test", "found", true)
attachment := m.createAttachmentFromFile(filePath)
if attachment != nil {
m.textarea.InsertAttachment(attachment)
i = end
continue
}
}
}
}
// Not a valid file path, insert the character normally
m.textarea.InsertRune(rune(value[i]))
i++
}
}
func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
m.exitKeyInDebounce = inDebounce
}
func (m *editorComponent) getInterruptKeyText() string {
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
}
func (m *editorComponent) getSubmitKeyText() string {
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
}
func (m *editorComponent) getExitKeyText() string {
return m.app.Commands[commands.AppExitCommand].Keys()[0]
}
// shouldSummarizePastedText determines if pasted text should be summarized
func (m *editorComponent) shouldSummarizePastedText(text string) bool {
lines := strings.Split(text, "\n")
lineCount := len(lines)
charCount := len(text)
// Consider text long if it has more than 3 lines or more than 150 characters
return lineCount > 3 || charCount > 150
}
// handleLongPaste handles long pasted text by creating a summary attachment
func (m *editorComponent) handleLongPaste(text string) {
lines := strings.Split(text, "\n")
lineCount := len(lines)
// Increment paste counter
m.pasteCounter++
// Create attachment with full text as base64 encoded data
fileBytes := []byte(text)
base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes)
url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText)
fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter)
displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount)
attachment := &attachment.Attachment{
ID: uuid.NewString(),
Type: "text",
MediaType: "text/plain",
Display: displayText,
URL: url,
Filename: fileName,
Source: &attachment.TextSource{
Value: text,
},
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
}
func updateTextareaStyles(ta textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.BackgroundElement()
textColor := t.Text()
textMutedColor := t.TextMuted()
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Blurred.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Focused.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Attachment = styles.NewStyle().
Foreground(t.Secondary()).
Background(bgColor).
Lipgloss()
ta.Styles.SelectedAttachment = styles.NewStyle().
Foreground(t.Text()).
Background(t.Secondary()).
Lipgloss()
ta.Styles.Cursor.Color = t.Primary()
return ta
}
func createSpinner() spinner.Model {
t := theme.CurrentTheme()
return spinner.New(
spinner.WithSpinner(spinner.Ellipsis),
spinner.WithStyle(
styles.NewStyle().
Background(t.Background()).
Foreground(t.TextMuted()).
Width(3).
Lipgloss(),
),
)
}
func NewEditorComponent(app *app.App) EditorComponent {
s := createSpinner()
ta := textarea.New()
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
ta = updateTextareaStyles(ta)
m := &editorComponent{
app: app,
textarea: ta,
spinner: s,
interruptKeyInDebounce: false,
historyIndex: -1,
pasteCounter: 0,
}
return m
}
func (m *editorComponent) RestoreFromPrompt(prompt app.Prompt) {
m.textarea.Reset()
m.textarea.SetValue(prompt.Text)
// Sort attachments by start index in reverse order (process from end to beginning)
// This prevents index shifting issues
attachmentsCopy := make([]*attachment.Attachment, len(prompt.Attachments))
copy(attachmentsCopy, prompt.Attachments)
for i := 0; i < len(attachmentsCopy)-1; i++ {
for j := i + 1; j < len(attachmentsCopy); j++ {
if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex {
attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i]
}
}
}
for _, att := range attachmentsCopy {
m.textarea.SetCursorColumn(att.StartIndex)
m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "")
m.textarea.InsertAttachment(att)
}
}
// RestoreFromHistory restores a message from history at the given index
func (m *editorComponent) RestoreFromHistory(index int) {
if index < 0 || index >= len(m.app.State.MessageHistory) {
return
}
entry := m.app.State.MessageHistory[index]
m.RestoreFromPrompt(entry)
}
func getMediaTypeFromExtension(ext string) string {
switch strings.ToLower(ext) {
case ".jpg":
return "image/jpeg"
case ".png", ".jpeg", ".gif", ".webp":
return "image/" + ext[1:]
case ".pdf":
return "application/pdf"
default:
return "text/plain"
}
}
func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment {
ext := strings.ToLower(filepath.Ext(filePath))
mediaType := getMediaTypeFromExtension(ext)
absolutePath := filePath
if !filepath.IsAbs(filePath) {
absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
}
// For text files, create a simple file reference
if mediaType == "text/plain" {
return &attachment.Attachment{
ID: uuid.NewString(),
Type: "file",
Display: "@" + filePath,
URL: fmt.Sprintf("file://./%s", filePath),
Filename: filePath,
MediaType: mediaType,
Source: &attachment.FileSource{
Path: absolutePath,
Mime: mediaType,
},
}
}
// For binary files (images, PDFs), read and encode
fileBytes, err := os.ReadFile(filePath)
if err != nil {
slog.Error("Failed to read file", "error", err)
return nil
}
base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
attachmentCount := len(m.textarea.GetAttachments())
attachmentIndex := attachmentCount + 1
label := "File"
if strings.HasPrefix(mediaType, "image/") {
label = "Image"
}
return &attachment.Attachment{
ID: uuid.NewString(),
Type: "file",
MediaType: mediaType,
Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
URL: url,
Filename: filePath,
Source: &attachment.FileSource{
Path: absolutePath,
Mime: mediaType,
Data: fileBytes,
},
}
}
func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment {
extension := filepath.Ext(filePath)
mediaType := getMediaTypeFromExtension(extension)
absolutePath := filePath
if !filepath.IsAbs(filePath) {
absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
}
return &attachment.Attachment{
ID: uuid.NewString(),
Type: "file",
Display: "@" + filePath,
URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
Filename: filePath,
MediaType: mediaType,
Source: &attachment.FileSource{
Path: absolutePath,
Mime: mediaType,
},
}
}
@@ -0,0 +1,719 @@
package chat
import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/charmbracelet/x/ansi"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type blockRenderer struct {
textColor compat.AdaptiveColor
border bool
borderColor *compat.AdaptiveColor
borderColorRight bool
paddingTop int
paddingBottom int
paddingLeft int
paddingRight int
marginTop int
marginBottom int
}
type renderingOption func(*blockRenderer)
func WithTextColor(color compat.AdaptiveColor) renderingOption {
return func(c *blockRenderer) {
c.textColor = color
}
}
func WithNoBorder() renderingOption {
return func(c *blockRenderer) {
c.border = false
}
}
func WithBorderColor(color compat.AdaptiveColor) renderingOption {
return func(c *blockRenderer) {
c.borderColor = &color
}
}
func WithBorderColorRight(color compat.AdaptiveColor) renderingOption {
return func(c *blockRenderer) {
c.borderColorRight = true
c.borderColor = &color
}
}
func WithMarginTop(padding int) renderingOption {
return func(c *blockRenderer) {
c.marginTop = padding
}
}
func WithMarginBottom(padding int) renderingOption {
return func(c *blockRenderer) {
c.marginBottom = padding
}
}
func WithPadding(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingTop = padding
c.paddingBottom = padding
c.paddingLeft = padding
c.paddingRight = padding
}
}
func WithPaddingLeft(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingLeft = padding
}
}
func WithPaddingRight(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingRight = padding
}
}
func WithPaddingTop(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingTop = padding
}
}
func WithPaddingBottom(padding int) renderingOption {
return func(c *blockRenderer) {
c.paddingBottom = padding
}
}
func renderContentBlock(
app *app.App,
content string,
width int,
options ...renderingOption,
) string {
t := theme.CurrentTheme()
renderer := &blockRenderer{
textColor: t.TextMuted(),
border: true,
paddingTop: 1,
paddingBottom: 1,
paddingLeft: 2,
paddingRight: 2,
}
for _, option := range options {
option(renderer)
}
borderColor := t.BackgroundPanel()
if renderer.borderColor != nil {
borderColor = *renderer.borderColor
}
style := styles.NewStyle().
Foreground(renderer.textColor).
Background(t.BackgroundPanel()).
PaddingTop(renderer.paddingTop).
PaddingBottom(renderer.paddingBottom).
PaddingLeft(renderer.paddingLeft).
PaddingRight(renderer.paddingRight).
AlignHorizontal(lipgloss.Left)
if renderer.border {
style = style.
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(true).
BorderLeftForeground(borderColor).
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundPanel()).
BorderRightBackground(t.Background())
if renderer.borderColorRight {
style = style.
BorderLeftBackground(t.Background()).
BorderLeftForeground(t.BackgroundPanel()).
BorderRightForeground(borderColor).
BorderRightBackground(t.Background())
}
}
content = style.Render(content)
if renderer.marginTop > 0 {
for range renderer.marginTop {
content = "\n" + content
}
}
if renderer.marginBottom > 0 {
for range renderer.marginBottom {
content = content + "\n"
}
}
return content
}
func renderText(
app *app.App,
message opencode.MessageUnion,
text string,
author string,
showToolDetails bool,
width int,
extra string,
toolCalls ...opencode.ToolPart,
) string {
t := theme.CurrentTheme()
var ts time.Time
backgroundColor := t.BackgroundPanel()
var content string
switch casted := message.(type) {
case opencode.AssistantMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
content = util.ToMarkdown(text, width, backgroundColor)
case opencode.UserMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
text = ansi.WordwrapWc(text, width-6, " -")
lines := strings.Split(text, "\n")
for i, line := range lines {
words := strings.Fields(line)
for i, word := range words {
if strings.HasPrefix(word, "@") {
words[i] = base.Foreground(t.Secondary()).Render(word + " ")
} else {
words[i] = base.Render(word + " ")
}
}
lines[i] = strings.Join(words, "")
}
text = strings.Join(lines, "\n")
content = base.Width(width - 6).Render(text)
}
timestamp := ts.
Local().
Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
// don't show the date if it's today
timestamp = timestamp[12:]
}
info := fmt.Sprintf("%s (%s)", author, timestamp)
info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
content = content + "\n\n"
for _, toolCall := range toolCalls {
title := renderToolTitle(toolCall, width)
style := styles.NewStyle()
if toolCall.State.Status == opencode.ToolPartStateStatusError {
style = style.Foreground(t.Error())
}
title = style.Render(title)
title = "∟ " + title + "\n"
content = content + title
}
}
sections := []string{content, info}
if extra != "" {
sections = append(sections, "\n"+extra)
}
content = strings.Join(sections, "\n")
switch message.(type) {
case opencode.UserMessage:
return renderContentBlock(
app,
content,
width,
WithTextColor(t.Text()),
WithBorderColorRight(t.Secondary()),
)
case opencode.AssistantMessage:
return renderContentBlock(
app,
content,
width,
WithBorderColor(t.Accent()),
)
}
return ""
}
func renderToolDetails(
app *app.App,
toolCall opencode.ToolPart,
width int,
) string {
measure := util.Measure("chat.renderToolDetails")
defer measure("tool", toolCall.Tool)
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.Tool) {
return ""
}
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
title := renderToolTitle(toolCall, width)
return renderContentBlock(app, title, width)
}
var result *string
if toolCall.State.Output != "" {
result = &toolCall.State.Output
}
toolInputMap := make(map[string]any)
if toolCall.State.Input != nil {
value := toolCall.State.Input
if m, ok := value.(map[string]any); ok {
toolInputMap = m
keys := make([]string, 0, len(toolInputMap))
for key := range toolInputMap {
keys = append(keys, key)
}
slices.Sort(keys)
}
}
body := ""
t := theme.CurrentTheme()
backgroundColor := t.BackgroundPanel()
borderColor := t.BackgroundPanel()
defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
if toolCall.State.Metadata != nil {
metadata := toolCall.State.Metadata.(map[string]any)
switch toolCall.Tool {
case "read":
var preview any
if metadata != nil {
preview = metadata["preview"]
}
if preview != nil && toolInputMap["filePath"] != nil {
filename := toolInputMap["filePath"].(string)
body = preview.(string)
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
}
case "edit":
if filename, ok := toolInputMap["filePath"].(string); ok {
var diffField any
if metadata != nil {
diffField = metadata["diff"]
}
if diffField != nil {
patch := diffField.(string)
var formattedDiff string
if width < 120 {
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(width-2),
)
} else {
formattedDiff, _ = diff.FormatDiff(
filename,
patch,
diff.WithWidth(width-2),
)
}
body = strings.TrimSpace(formattedDiff)
style := styles.NewStyle().
Background(backgroundColor).
Foreground(t.TextMuted()).
Padding(1, 2).
Width(width - 4)
if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" {
diagnostics = style.Render(diagnostics)
body += "\n" + diagnostics
}
title := renderToolTitle(toolCall, width)
title = style.Render(title)
content := title + "\n" + body
content = renderContentBlock(
app,
content,
width,
WithPadding(0),
WithBorderColor(borderColor),
)
return content
}
}
case "write":
if filename, ok := toolInputMap["filePath"].(string); ok {
if content, ok := toolInputMap["content"].(string); ok {
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-4); diagnostics != "" {
body += "\n\n" + diagnostics
}
}
}
case "bash":
command := toolInputMap["command"].(string)
body = fmt.Sprintf("```console\n$ %s\n", command)
stdout := metadata["stdout"]
if stdout != nil {
body += ansi.Strip(fmt.Sprintf("%s", stdout))
}
body += "```"
body = util.ToMarkdown(body, width, backgroundColor)
case "webfetch":
if format, ok := toolInputMap["format"].(string); ok && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = util.ToMarkdown(body, width, backgroundColor)
}
}
case "todowrite":
todos := metadata["todos"]
if todos != nil {
for _, item := range todos.([]any) {
todo := item.(map[string]any)
content := todo["content"].(string)
switch todo["status"] {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
case "cancelled":
// strike through cancelled todo
body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
case "in_progress":
// highlight in progress todo
body += fmt.Sprintf("- [ ] `%s`\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = util.ToMarkdown(body, width, backgroundColor)
}
case "task":
summary := metadata["summary"]
if summary != nil {
toolcalls := summary.([]any)
steps := []string{}
for _, item := range toolcalls {
data, _ := json.Marshal(item)
var toolCall opencode.ToolPart
_ = json.Unmarshal(data, &toolCall)
step := renderToolTitle(toolCall, width)
step = "∟ " + step
steps = append(steps, step)
}
body = strings.Join(steps, "\n")
}
body = defaultStyle(body)
default:
if result == nil {
empty := ""
result = &empty
}
body = *result
body = util.TruncateHeight(body, 10)
body = defaultStyle(body)
}
}
error := ""
if toolCall.State.Status == opencode.ToolPartStateStatusError {
error = toolCall.State.Error
}
if error != "" {
body = styles.NewStyle().
Width(width - 6).
Foreground(t.Error()).
Background(backgroundColor).
Render(error)
}
if body == "" && error == "" && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
body = defaultStyle(body)
}
if body == "" {
body = defaultStyle("")
}
title := renderToolTitle(toolCall, width)
content := title + "\n\n" + body
return renderContentBlock(app, content, width, WithBorderColor(borderColor))
}
func renderToolName(name string) string {
switch name {
case "webfetch":
return "Fetch"
default:
normalizedName := name
if after, ok := strings.CutPrefix(name, "opencode_"); ok {
normalizedName = after
}
return cases.Title(language.Und).String(normalizedName)
}
}
func getTodoPhase(metadata map[string]any) string {
todos, ok := metadata["todos"].([]any)
if !ok || len(todos) == 0 {
return "Plan"
}
counts := map[string]int{"pending": 0, "completed": 0}
for _, item := range todos {
if todo, ok := item.(map[string]any); ok {
if status, ok := todo["status"].(string); ok {
counts[status]++
}
}
}
total := len(todos)
switch {
case counts["pending"] == total:
return "Creating plan"
case counts["completed"] == total:
return "Completing plan"
default:
return "Updating plan"
}
}
func getTodoTitle(toolCall opencode.ToolPart) string {
if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
if metadata, ok := toolCall.State.Metadata.(map[string]any); ok {
return getTodoPhase(metadata)
}
}
return "Plan"
}
func renderToolTitle(
toolCall opencode.ToolPart,
width int,
) string {
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
title := renderToolAction(toolCall.Tool)
return styles.NewStyle().Width(width - 6).Render(title)
}
toolArgs := ""
toolArgsMap := make(map[string]any)
if toolCall.State.Input != nil {
value := toolCall.State.Input
if m, ok := value.(map[string]any); ok {
toolArgsMap = m
keys := make([]string, 0, len(toolArgsMap))
for key := range toolArgsMap {
keys = append(keys, key)
}
slices.Sort(keys)
firstKey := ""
if len(keys) > 0 {
firstKey = keys[0]
}
toolArgs = renderArgs(&toolArgsMap, firstKey)
}
}
title := renderToolName(toolCall.Tool)
switch toolCall.Tool {
case "read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("%s %s", title, toolArgs)
case "edit", "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("%s %s", title, util.Relative(filename))
}
case "bash", "task":
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("%s %s", title, description)
}
case "webfetch":
toolArgs = renderArgs(&toolArgsMap, "url")
title = fmt.Sprintf("%s %s", title, toolArgs)
case "todowrite":
title = getTodoTitle(toolCall)
case "todoread":
return "Plan"
default:
toolName := renderToolName(toolCall.Tool)
title = fmt.Sprintf("%s %s", toolName, toolArgs)
}
title = truncate.StringWithTail(title, uint(width-6), "...")
return title
}
func renderToolAction(name string) string {
switch name {
case "task":
return "Planning..."
case "bash":
return "Writing command..."
case "edit":
return "Preparing edit..."
case "webfetch":
return "Fetching from the web..."
case "glob":
return "Finding files..."
case "grep":
return "Searching content..."
case "list":
return "Listing directory..."
case "read":
return "Reading file..."
case "write":
return "Preparing write..."
case "todowrite", "todoread":
return "Planning..."
case "patch":
return "Preparing patch..."
}
return "Working..."
}
func renderArgs(args *map[string]any, titleKey string) string {
if args == nil || len(*args) == 0 {
return ""
}
keys := make([]string, 0, len(*args))
for key := range *args {
keys = append(keys, key)
}
slices.Sort(keys)
title := ""
parts := []string{}
for _, key := range keys {
value := (*args)[key]
if value == nil {
continue
}
if key == "filePath" || key == "path" {
value = util.Relative(value.(string))
}
if key == titleKey {
title = fmt.Sprintf("%s", value)
continue
}
parts = append(parts, fmt.Sprintf("%s=%v", key, value))
}
if len(parts) == 0 {
return title
}
return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
}
// Diagnostic represents an LSP diagnostic
type Diagnostic struct {
Range struct {
Start struct {
Line int `json:"line"`
Character int `json:"character"`
} `json:"start"`
} `json:"range"`
Severity int `json:"severity"`
Message string `json:"message"`
}
// renderDiagnostics formats LSP diagnostics for display in the TUI
func renderDiagnostics(
metadata map[string]any,
filePath string,
backgroundColor compat.AdaptiveColor,
width int,
) string {
if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
var errorDiagnostics []string
for _, diagInterface := range fileDiagnostics {
diagMap, ok := diagInterface.(map[string]any)
if !ok {
continue
}
// Parse the diagnostic
var diag Diagnostic
diagBytes, err := json.Marshal(diagMap)
if err != nil {
continue
}
if err := json.Unmarshal(diagBytes, &diag); err != nil {
continue
}
// Only show error diagnostics (severity === 1)
if diag.Severity != 1 {
continue
}
line := diag.Range.Start.Line + 1 // 1-based
column := diag.Range.Start.Character + 1 // 1-based
errorDiagnostics = append(
errorDiagnostics,
fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
)
}
if len(errorDiagnostics) == 0 {
return ""
}
t := theme.CurrentTheme()
var result strings.Builder
for _, diagnostic := range errorDiagnostics {
if result.Len() > 0 {
result.WriteString("\n\n")
}
diagnostic = ansi.WordwrapWc(diagnostic, width, " -")
result.WriteString(
styles.NewStyle().
Background(backgroundColor).
Foreground(t.Error()).
Render(diagnostic),
)
}
return result.String()
}
}
return ""
// diagnosticsData should be a map[string][]Diagnostic
// strDiagnosticsData := diagnosticsData.Raw()
// diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
// fileDiagnostics, ok := diagnosticsMap[filePath]
// if !ok {
// return ""
// }
// diagnosticsList, ok := fileDiagnostics.([]any)
// if !ok {
// return ""
// }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,247 @@
package commands
import (
"fmt"
"runtime"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type CommandsComponent interface {
tea.ViewModel
SetSize(width, height int) tea.Cmd
SetBackgroundColor(color compat.AdaptiveColor)
}
type commandsComponent struct {
app *app.App
width, height int
showKeybinds bool
showAll bool
showVscode bool
background *compat.AdaptiveColor
limit *int
}
func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
c.width = width
c.height = height
return nil
}
func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
c.background = &color
}
func (c *commandsComponent) View() string {
t := theme.CurrentTheme()
triggerStyle := styles.NewStyle().Foreground(t.Primary()).Bold(true)
descriptionStyle := styles.NewStyle().Foreground(t.Text())
keybindStyle := styles.NewStyle().Foreground(t.TextMuted())
if c.background != nil {
triggerStyle = triggerStyle.Background(*c.background)
descriptionStyle = descriptionStyle.Background(*c.background)
keybindStyle = keybindStyle.Background(*c.background)
}
var commandsToShow []commands.Command
var triggeredCommands []commands.Command
var untriggeredCommands []commands.Command
for _, cmd := range c.app.Commands.Sorted() {
if c.showAll || cmd.HasTrigger() {
if cmd.HasTrigger() {
triggeredCommands = append(triggeredCommands, cmd)
} else if c.showAll {
untriggeredCommands = append(untriggeredCommands, cmd)
}
}
}
// Combine triggered commands first, then untriggered
commandsToShow = append(commandsToShow, triggeredCommands...)
commandsToShow = append(commandsToShow, untriggeredCommands...)
if c.limit != nil && len(commandsToShow) > *c.limit {
commandsToShow = commandsToShow[:*c.limit]
}
if c.showVscode {
ctrlKey := "ctrl"
if runtime.GOOS == "darwin" {
ctrlKey = "cmd"
}
commandsToShow = append(commandsToShow,
// empty line
commands.Command{
Name: "",
Description: "",
},
commands.Command{
Name: commands.CommandName(util.Ide()),
Description: "open opencode",
Keybindings: []commands.Keybinding{
{Key: ctrlKey + "+esc", RequiresLeader: false},
},
},
commands.Command{
Name: commands.CommandName(util.Ide()),
Description: "reference file",
Keybindings: []commands.Keybinding{
{Key: ctrlKey + "+opt+k", RequiresLeader: false},
},
},
)
}
if len(commandsToShow) == 0 {
muted := styles.NewStyle().Foreground(theme.CurrentTheme().TextMuted())
if c.showAll {
return muted.Render("No commands available")
}
return muted.Render("No commands with triggers available")
}
// Calculate column widths
maxTriggerWidth := 0
maxDescriptionWidth := 0
maxKeybindWidth := 0
// Prepare command data
type commandRow struct {
trigger string
description string
keybinds string
}
rows := make([]commandRow, 0, len(commandsToShow))
for _, cmd := range commandsToShow {
trigger := ""
if cmd.HasTrigger() {
trigger = "/" + cmd.PrimaryTrigger()
} else {
trigger = string(cmd.Name)
}
description := cmd.Description
// Format keybindings
var keybindStrs []string
if c.showKeybinds {
for _, kb := range cmd.Keybindings {
if kb.RequiresLeader {
keybindStrs = append(keybindStrs, c.app.Config.Keybinds.Leader+" "+kb.Key)
} else {
keybindStrs = append(keybindStrs, kb.Key)
}
}
}
keybinds := strings.Join(keybindStrs, ", ")
rows = append(rows, commandRow{
trigger: trigger,
description: description,
keybinds: keybinds,
})
// Update max widths
if len(trigger) > maxTriggerWidth {
maxTriggerWidth = len(trigger)
}
if len(description) > maxDescriptionWidth {
maxDescriptionWidth = len(description)
}
if len(keybinds) > maxKeybindWidth {
maxKeybindWidth = len(keybinds)
}
}
// Add padding between columns
columnPadding := 3
// Build the output
var output strings.Builder
maxWidth := 0
for _, row := range rows {
// Pad each column to align properly
trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
description := fmt.Sprintf("%-*s", maxDescriptionWidth, row.description)
// Apply styles and combine
line := triggerStyle.Render(trigger) +
triggerStyle.Render(strings.Repeat(" ", columnPadding)) +
descriptionStyle.Render(description)
if c.showKeybinds && row.keybinds != "" {
line += keybindStyle.Render(strings.Repeat(" ", columnPadding)) +
keybindStyle.Render(row.keybinds)
}
output.WriteString(line + "\n")
maxWidth = max(maxWidth, lipgloss.Width(line))
}
// Remove trailing newline
result := strings.TrimSuffix(output.String(), "\n")
if c.background != nil {
result = styles.NewStyle().Background(*c.background).Width(maxWidth).Render(result)
}
return result
}
type Option func(*commandsComponent)
func WithKeybinds(show bool) Option {
return func(c *commandsComponent) {
c.showKeybinds = show
}
}
func WithBackground(background compat.AdaptiveColor) Option {
return func(c *commandsComponent) {
c.background = &background
}
}
func WithLimit(limit int) Option {
return func(c *commandsComponent) {
c.limit = &limit
}
}
func WithShowAll(showAll bool) Option {
return func(c *commandsComponent) {
c.showAll = showAll
}
}
func WithVscode(showVscode bool) Option {
return func(c *commandsComponent) {
c.showVscode = showVscode
}
}
func New(app *app.App, opts ...Option) CommandsComponent {
c := &commandsComponent{
app: app,
background: nil,
showKeybinds: true,
showAll: false,
}
for _, opt := range opts {
opt(c)
}
return c
}
@@ -0,0 +1,283 @@
package dialog
import (
"log/slog"
"sort"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode/internal/completions"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type CompletionSelectedMsg struct {
Item completions.CompletionSuggestion
SearchString string
}
type CompletionDialogCompleteItemMsg struct {
Value string
}
type CompletionDialogCloseMsg struct{}
type CompletionDialog interface {
tea.Model
tea.ViewModel
SetWidth(width int)
IsEmpty() bool
}
type completionDialogComponent struct {
query string
providers []completions.CompletionProvider
width int
height int
pseudoSearchTextArea textarea.Model
list list.List[completions.CompletionSuggestion]
trigger string
}
type completionDialogKeyMap struct {
Complete key.Binding
Cancel key.Binding
}
var completionDialogKeys = completionDialogKeyMap{
Complete: key.NewBinding(
key.WithKeys("tab", "enter", "right"),
),
Cancel: key.NewBinding(
key.WithKeys("space", " ", "esc", "backspace", "ctrl+h", "ctrl+c"),
),
}
func (c *completionDialogComponent) Init() tea.Cmd {
return nil
}
func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
return func() tea.Msg {
allItems := make([]completions.CompletionSuggestion, 0)
providersWithResults := 0
// Collect results from all providers
for _, provider := range c.providers {
items, err := provider.GetChildEntries(query)
if err != nil {
slog.Error(
"Failed to get completion items",
"provider",
provider.GetId(),
"error",
err,
)
continue
}
if len(items) > 0 {
providersWithResults++
allItems = append(allItems, items...)
}
}
// If there's a query, use fuzzy ranking to sort results
if query != "" && providersWithResults > 1 {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Background(t.BackgroundElement())
// Create a slice of display values for fuzzy matching
displayValues := make([]string, len(allItems))
for i, item := range allItems {
displayValues[i] = item.Display(baseStyle)
}
matches := fuzzy.RankFindFold(query, displayValues)
sort.Sort(matches)
// Reorder items based on fuzzy ranking
rankedItems := make([]completions.CompletionSuggestion, 0, len(matches))
for _, match := range matches {
rankedItems = append(rankedItems, allItems[match.OriginalIndex])
}
return rankedItems
}
return allItems
}
}
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case []completions.CompletionSuggestion:
c.list.SetItems(msg)
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) {
var cmd tea.Cmd
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
cmds = append(cmds, cmd)
fullValue := c.pseudoSearchTextArea.Value()
query := strings.TrimPrefix(fullValue, c.trigger)
if query != c.query {
c.query = query
cmds = append(cmds, c.getAllCompletions(query))
}
u, cmd := c.list.Update(msg)
c.list = u.(list.List[completions.CompletionSuggestion])
cmds = append(cmds, cmd)
}
switch {
case key.Matches(msg, completionDialogKeys.Complete):
item, i := c.list.GetSelectedItem()
if i == -1 {
return c, nil
}
return c, c.complete(item)
case key.Matches(msg, completionDialogKeys.Cancel):
value := c.pseudoSearchTextArea.Value()
width := lipgloss.Width(value)
triggerWidth := lipgloss.Width(c.trigger)
// Only close on backspace when there are no characters left, unless we're back to just the trigger
if (msg.String() != "backspace" && msg.String() != "ctrl+h") || (width <= triggerWidth && value != c.trigger) {
return c, c.close()
}
}
return c, tea.Batch(cmds...)
} else {
cmds = append(cmds, c.getAllCompletions(""))
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
return c, tea.Batch(cmds...)
}
}
return c, tea.Batch(cmds...)
}
func (c *completionDialogComponent) View() string {
t := theme.CurrentTheme()
c.list.SetMaxWidth(c.width)
return styles.NewStyle().
Padding(0, 1).
Foreground(t.Text()).
Background(t.BackgroundElement()).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(true).
BorderForeground(t.Border()).
BorderBackground(t.Background()).
Width(c.width).
Render(c.list.View())
}
func (c *completionDialogComponent) SetWidth(width int) {
c.width = width
}
func (c *completionDialogComponent) IsEmpty() bool {
return c.list.IsEmpty()
}
func (c *completionDialogComponent) complete(item completions.CompletionSuggestion) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
Item: item,
}),
c.close(),
)
}
func (c *completionDialogComponent) close() tea.Cmd {
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func NewCompletionDialogComponent(
trigger string,
providers ...completions.CompletionProvider,
) CompletionDialog {
ti := textarea.New()
ti.SetValue(trigger)
// Use a generic empty message if we have multiple providers
emptyMessage := "no matching items"
if len(providers) == 1 {
emptyMessage = providers[0].GetEmptyMessage()
}
// Define render function for completion suggestions
renderFunc := func(item completions.CompletionSuggestion, selected bool, width int, baseStyle styles.Style) string {
t := theme.CurrentTheme()
style := baseStyle
if selected {
style = style.Background(t.BackgroundElement()).Foreground(t.Primary())
} else {
style = style.Background(t.BackgroundElement()).Foreground(t.Text())
}
// The item.Display string already has any inline colors from the provider
truncatedStr := truncate.String(item.Display(style), uint(width-4))
return style.Width(width - 4).Render(truncatedStr)
}
// Define selectable function - all completion suggestions are selectable
selectableFunc := func(item completions.CompletionSuggestion) bool {
return true
}
li := list.NewListComponent(
list.WithItems([]completions.CompletionSuggestion{}),
list.WithMaxVisibleHeight[completions.CompletionSuggestion](7),
list.WithFallbackMessage[completions.CompletionSuggestion](emptyMessage),
list.WithAlphaNumericKeys[completions.CompletionSuggestion](false),
list.WithRenderFunc(renderFunc),
list.WithSelectableFunc(selectableFunc),
)
c := &completionDialogComponent{
query: "",
providers: providers,
pseudoSearchTextArea: ti,
list: li,
trigger: trigger,
}
// Load initial items from all providers
go func() {
allItems := make([]completions.CompletionSuggestion, 0)
for _, provider := range providers {
items, err := provider.GetChildEntries("")
if err != nil {
slog.Error(
"Failed to get completion items",
"provider",
provider.GetId(),
"error",
err,
)
continue
}
allItems = append(allItems, items...)
}
li.SetItems(allItems)
}()
return c
}
@@ -0,0 +1,236 @@
package dialog
import (
"log/slog"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/completions"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
const (
findDialogWidth = 76
)
type FindSelectedMsg struct {
FilePath string
}
type FindDialogCloseMsg struct{}
type findInitialSuggestionsMsg struct {
suggestions []completions.CompletionSuggestion
}
type FindDialog interface {
layout.Modal
tea.Model
tea.ViewModel
SetWidth(width int)
SetHeight(height int)
IsEmpty() bool
}
// findItem is a custom list item for file suggestions
type findItem struct {
suggestion completions.CompletionSuggestion
}
func (f findItem) Render(
selected bool,
width int,
baseStyle styles.Style,
) string {
t := theme.CurrentTheme()
itemStyle := baseStyle.
Background(t.BackgroundPanel()).
Foreground(t.TextMuted())
if selected {
itemStyle = itemStyle.Foreground(t.Primary())
}
return itemStyle.PaddingLeft(1).Render(f.suggestion.Display(itemStyle))
}
func (f findItem) Selectable() bool {
return true
}
type findDialogComponent struct {
completionProvider completions.CompletionProvider
allSuggestions []completions.CompletionSuggestion
width, height int
modal *modal.Modal
searchDialog *SearchDialog
dialogWidth int
}
func (f *findDialogComponent) Init() tea.Cmd {
return tea.Batch(
f.loadInitialSuggestions(),
f.searchDialog.Init(),
)
}
func (f *findDialogComponent) loadInitialSuggestions() tea.Cmd {
return func() tea.Msg {
items, err := f.completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get initial completion items", "error", err)
return findInitialSuggestionsMsg{suggestions: []completions.CompletionSuggestion{}}
}
return findInitialSuggestionsMsg{suggestions: items}
}
}
func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case findInitialSuggestionsMsg:
// Handle initial suggestions setup
f.allSuggestions = msg.suggestions
// Calculate dialog width
f.dialogWidth = f.calculateDialogWidth()
// Initialize search dialog with calculated width
f.searchDialog = NewSearchDialog("Search files...", 10)
f.searchDialog.SetWidth(f.dialogWidth)
// Convert to list items
items := make([]list.Item, len(f.allSuggestions))
for i, suggestion := range f.allSuggestions {
items[i] = findItem{suggestion: suggestion}
}
f.searchDialog.SetItems(items)
// Update modal with calculated width
f.modal = modal.New(
modal.WithTitle("Find Files"),
modal.WithMaxWidth(f.dialogWidth+4),
)
return f, f.searchDialog.Init()
case []completions.CompletionSuggestion:
// Store suggestions and convert to findItem for the search dialog
f.allSuggestions = msg
items := make([]list.Item, len(msg))
for i, suggestion := range msg {
items[i] = findItem{suggestion: suggestion}
}
f.searchDialog.SetItems(items)
return f, nil
case SearchSelectionMsg:
// Handle selection from search dialog - now we can directly access the suggestion
if item, ok := msg.Item.(findItem); ok {
return f, f.selectFile(item.suggestion)
}
return f, nil
case SearchCancelledMsg:
return f, f.Close()
case SearchQueryChangedMsg:
// Update completion items based on search query
return f, func() tea.Msg {
items, err := f.completionProvider.GetChildEntries(msg.Query)
if err != nil {
slog.Error("Failed to get completion items", "error", err)
return []completions.CompletionSuggestion{}
}
return items
}
case tea.WindowSizeMsg:
f.width = msg.Width
f.height = msg.Height
// Recalculate width based on new viewport size
oldWidth := f.dialogWidth
f.dialogWidth = f.calculateDialogWidth()
if oldWidth != f.dialogWidth {
f.searchDialog.SetWidth(f.dialogWidth)
// Update modal max width too
f.modal = modal.New(
modal.WithTitle("Find Files"),
modal.WithMaxWidth(f.dialogWidth+4),
)
}
f.searchDialog.SetHeight(msg.Height)
}
// Forward all other messages to the search dialog
updatedDialog, cmd := f.searchDialog.Update(msg)
f.searchDialog = updatedDialog.(*SearchDialog)
return f, cmd
}
func (f *findDialogComponent) View() string {
return f.searchDialog.View()
}
func (f *findDialogComponent) calculateDialogWidth() int {
// Use fixed width unless viewport is smaller
if f.width > 0 && f.width < findDialogWidth+10 {
return f.width - 10
}
return findDialogWidth
}
func (f *findDialogComponent) SetWidth(width int) {
f.width = width
f.searchDialog.SetWidth(f.dialogWidth)
}
func (f *findDialogComponent) SetHeight(height int) {
f.height = height
}
func (f *findDialogComponent) IsEmpty() bool {
return f.searchDialog.GetQuery() == ""
}
func (f *findDialogComponent) selectFile(item completions.CompletionSuggestion) tea.Cmd {
return tea.Sequence(
f.Close(),
util.CmdHandler(FindSelectedMsg{
FilePath: item.Value,
}),
)
}
func (f *findDialogComponent) Render(background string) string {
return f.modal.Render(f.View(), background)
}
func (f *findDialogComponent) Close() tea.Cmd {
f.searchDialog.SetQuery("")
f.searchDialog.Blur()
return util.CmdHandler(modal.CloseModalMsg{})
}
func NewFindDialog(completionProvider completions.CompletionProvider) FindDialog {
component := &findDialogComponent{
completionProvider: completionProvider,
dialogWidth: findDialogWidth,
allSuggestions: []completions.CompletionSuggestion{},
}
// Create search dialog and modal with fixed width
component.searchDialog = NewSearchDialog("Search files...", 10)
component.searchDialog.SetWidth(findDialogWidth)
component.modal = modal.New(
modal.WithTitle("Find Files"),
modal.WithMaxWidth(findDialogWidth+4),
)
return component
}
@@ -0,0 +1,80 @@
package dialog
import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/app"
commandsComponent "github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/viewport"
)
type helpDialog struct {
width int
height int
modal *modal.Modal
app *app.App
commandsComponent commandsComponent.CommandsComponent
viewport viewport.Model
}
func (h *helpDialog) Init() tea.Cmd {
return h.viewport.Init()
}
func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
h.width = msg.Width
h.height = msg.Height
// Set viewport size with some padding for the modal, but cap at reasonable width
maxWidth := min(80, msg.Width-8)
h.viewport = viewport.New(viewport.WithWidth(maxWidth-4), viewport.WithHeight(msg.Height-6))
h.commandsComponent.SetSize(maxWidth-4, msg.Height-6)
}
// Update viewport content
h.viewport.SetContent(h.commandsComponent.View())
// Update viewport
var vpCmd tea.Cmd
h.viewport, vpCmd = h.viewport.Update(msg)
cmds = append(cmds, vpCmd)
return h, tea.Batch(cmds...)
}
func (h *helpDialog) View() string {
t := theme.CurrentTheme()
h.commandsComponent.SetBackgroundColor(t.BackgroundPanel())
return h.viewport.View()
}
func (h *helpDialog) Render(background string) string {
return h.modal.Render(h.View(), background)
}
func (h *helpDialog) Close() tea.Cmd {
return nil
}
type HelpDialog interface {
layout.Modal
}
func NewHelpDialog(app *app.App) HelpDialog {
vp := viewport.New(viewport.WithHeight(12))
return &helpDialog{
app: app,
commandsComponent: commandsComponent.New(app,
commandsComponent.WithBackground(theme.CurrentTheme().BackgroundPanel()),
commandsComponent.WithShowAll(true),
commandsComponent.WithKeybinds(true),
),
modal: modal.New(modal.WithTitle("Help"), modal.WithMaxWidth(80)),
viewport: vp,
}
}
@@ -94,7 +94,7 @@ func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View implements tea.Model.
func (m InitDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
baseStyle := styles.NewStyle().Foreground(t.Text())
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
@@ -0,0 +1,457 @@
package dialog
import (
"context"
"fmt"
"sort"
"time"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
const (
numVisibleModels = 10
minDialogWidth = 40
maxDialogWidth = 80
maxRecentModels = 5
)
// ModelDialog interface for the model selection dialog
type ModelDialog interface {
layout.Modal
}
type modelDialog struct {
app *app.App
allModels []ModelWithProvider
width int
height int
modal *modal.Modal
searchDialog *SearchDialog
dialogWidth int
}
type ModelWithProvider struct {
Model opencode.Model
Provider opencode.Provider
}
// modelItem is a custom list item for model selections
type modelItem struct {
model ModelWithProvider
}
func (m modelItem) Render(
selected bool,
width int,
baseStyle styles.Style,
) string {
t := theme.CurrentTheme()
itemStyle := baseStyle.
Background(t.BackgroundPanel()).
Foreground(t.Text())
if selected {
itemStyle = itemStyle.Foreground(t.Primary())
}
providerStyle := baseStyle.
Foreground(t.TextMuted()).
Background(t.BackgroundPanel())
modelPart := itemStyle.Render(m.model.Model.Name)
providerPart := providerStyle.Render(fmt.Sprintf(" %s", m.model.Provider.Name))
combinedText := modelPart + providerPart
return baseStyle.
Background(t.BackgroundPanel()).
PaddingLeft(1).
Render(combinedText)
}
func (m modelItem) Selectable() bool {
return true
}
type modelKeyMap struct {
Enter key.Binding
Escape key.Binding
}
var modelKeys = modelKeyMap{
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select model"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
}
func (m *modelDialog) Init() tea.Cmd {
m.setupAllModels()
return m.searchDialog.Init()
}
func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case SearchSelectionMsg:
// Handle selection from search dialog
if item, ok := msg.Item.(modelItem); ok {
return m, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(
app.ModelSelectedMsg{
Provider: item.model.Provider,
Model: item.model.Model,
}),
)
}
return m, util.CmdHandler(modal.CloseModalMsg{})
case SearchCancelledMsg:
return m, util.CmdHandler(modal.CloseModalMsg{})
case SearchRemoveItemMsg:
if item, ok := msg.Item.(modelItem); ok {
if m.isModelInRecentSection(item.model, msg.Index) {
m.app.State.RemoveModelFromRecentlyUsed(item.model.Provider.ID, item.model.Model.ID)
items := m.buildDisplayList(m.searchDialog.GetQuery())
m.searchDialog.SetItems(items)
return m, m.app.SaveState()
}
}
return m, nil
case SearchQueryChangedMsg:
// Update the list based on search query
items := m.buildDisplayList(msg.Query)
m.searchDialog.SetItems(items)
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.searchDialog.SetWidth(m.dialogWidth)
m.searchDialog.SetHeight(msg.Height)
}
updatedDialog, cmd := m.searchDialog.Update(msg)
m.searchDialog = updatedDialog.(*SearchDialog)
return m, cmd
}
func (m *modelDialog) View() string {
return m.searchDialog.View()
}
func (m *modelDialog) calculateOptimalWidth(models []ModelWithProvider) int {
maxWidth := minDialogWidth
for _, model := range models {
// Calculate the width needed for this item: "ModelName (ProviderName)"
// Add 4 for the parentheses, space, and some padding
itemWidth := len(model.Model.Name) + len(model.Provider.Name) + 4
if itemWidth > maxWidth {
maxWidth = itemWidth
}
}
if maxWidth > maxDialogWidth {
maxWidth = maxDialogWidth
}
return maxWidth
}
func (m *modelDialog) setupAllModels() {
providers, _ := m.app.ListProviders(context.Background())
m.allModels = make([]ModelWithProvider, 0)
for _, provider := range providers {
for _, model := range provider.Models {
m.allModels = append(m.allModels, ModelWithProvider{
Model: model,
Provider: provider,
})
}
}
m.sortModels()
// Calculate optimal width based on all models
m.dialogWidth = m.calculateOptimalWidth(m.allModels)
// Initialize search dialog
m.searchDialog = NewSearchDialog("Search models...", numVisibleModels)
m.searchDialog.SetWidth(m.dialogWidth)
// Build initial display list (empty query shows grouped view)
items := m.buildDisplayList("")
m.searchDialog.SetItems(items)
}
func (m *modelDialog) sortModels() {
sort.Slice(m.allModels, func(i, j int) bool {
modelA := m.allModels[i]
modelB := m.allModels[j]
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
// If both have usage times, sort by most recent first
if !usageA.IsZero() && !usageB.IsZero() {
return usageA.After(usageB)
}
// If only one has usage time, it goes first
if !usageA.IsZero() && usageB.IsZero() {
return true
}
if usageA.IsZero() && !usageB.IsZero() {
return false
}
// If neither has usage time, sort by release date desc if available
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
if !dateA.IsZero() && !dateB.IsZero() {
return dateA.After(dateB)
}
}
// If only one has release date, it goes first
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
return true
}
if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
return false
}
// If neither has usage time nor release date, fall back to alphabetical sorting
return modelA.Model.Name < modelB.Model.Name
})
}
func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
return parsed
}
return time.Time{}
}
func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
for _, usage := range m.app.State.RecentlyUsedModels {
if usage.ProviderID == providerID && usage.ModelID == modelID {
return usage.LastUsed
}
}
return time.Time{}
}
// buildDisplayList creates the list items based on search query
func (m *modelDialog) buildDisplayList(query string) []list.Item {
if query != "" {
// Search mode: use fuzzy matching
return m.buildSearchResults(query)
} else {
// Grouped mode: show Recent section and provider groups
return m.buildGroupedResults()
}
}
// buildSearchResults creates a flat list of search results using fuzzy matching
func (m *modelDialog) buildSearchResults(query string) []list.Item {
type modelMatch struct {
model ModelWithProvider
score int
}
modelNames := []string{}
modelMap := make(map[string]ModelWithProvider)
// Create search strings and perform fuzzy matching
for _, model := range m.allModels {
searchStr := fmt.Sprintf("%s %s", model.Model.Name, model.Provider.Name)
modelNames = append(modelNames, searchStr)
modelMap[searchStr] = model
searchStr = fmt.Sprintf("%s %s", model.Provider.Name, model.Model.Name)
modelNames = append(modelNames, searchStr)
modelMap[searchStr] = model
}
matches := fuzzy.RankFindFold(query, modelNames)
sort.Sort(matches)
items := []list.Item{}
seenModels := make(map[string]bool)
for _, match := range matches {
model := modelMap[match.Target]
// Create a unique key to avoid duplicates
key := fmt.Sprintf("%s:%s", model.Provider.ID, model.Model.ID)
if seenModels[key] {
continue
}
seenModels[key] = true
items = append(items, modelItem{model: model})
}
return items
}
// buildGroupedResults creates a grouped list with Recent section and provider groups
func (m *modelDialog) buildGroupedResults() []list.Item {
var items []list.Item
// Add Recent section
recentModels := m.getRecentModels(maxRecentModels)
if len(recentModels) > 0 {
items = append(items, list.HeaderItem("Recent"))
for _, model := range recentModels {
items = append(items, modelItem{model: model})
}
}
// Group models by provider
providerGroups := make(map[string][]ModelWithProvider)
for _, model := range m.allModels {
providerName := model.Provider.Name
providerGroups[providerName] = append(providerGroups[providerName], model)
}
// Get sorted provider names for consistent order
var providerNames []string
for name := range providerGroups {
providerNames = append(providerNames, name)
}
sort.Strings(providerNames)
// Add provider groups
for _, providerName := range providerNames {
models := providerGroups[providerName]
// Sort models within provider group
sort.Slice(models, func(i, j int) bool {
modelA := models[i]
modelB := models[j]
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
// Sort by usage time first, then by release date, then alphabetically
if !usageA.IsZero() && !usageB.IsZero() {
return usageA.After(usageB)
}
if !usageA.IsZero() && usageB.IsZero() {
return true
}
if usageA.IsZero() && !usageB.IsZero() {
return false
}
// Sort by release date if available
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
if !dateA.IsZero() && !dateB.IsZero() {
return dateA.After(dateB)
}
}
return modelA.Model.Name < modelB.Model.Name
})
// Add provider header
items = append(items, list.HeaderItem(providerName))
// Add models in this provider group
for _, model := range models {
items = append(items, modelItem{model: model})
}
}
return items
}
// getRecentModels returns the most recently used models
func (m *modelDialog) getRecentModels(limit int) []ModelWithProvider {
var recentModels []ModelWithProvider
// Get recent models from app state
for _, usage := range m.app.State.RecentlyUsedModels {
if len(recentModels) >= limit {
break
}
// Find the corresponding model
for _, model := range m.allModels {
if model.Provider.ID == usage.ProviderID && model.Model.ID == usage.ModelID {
recentModels = append(recentModels, model)
break
}
}
}
return recentModels
}
func (m *modelDialog) isModelInRecentSection(model ModelWithProvider, index int) bool {
// Only check if we're in grouped mode (no search query)
if m.searchDialog.GetQuery() != "" {
return false
}
recentModels := m.getRecentModels(maxRecentModels)
if len(recentModels) == 0 {
return false
}
// Index 0 is the "Recent" header, so recent models are at indices 1 to len(recentModels)
if index >= 1 && index <= len(recentModels) {
if index-1 < len(recentModels) {
recentModel := recentModels[index-1]
return recentModel.Provider.ID == model.Provider.ID &&
recentModel.Model.ID == model.Model.ID
}
}
return false
}
func (m *modelDialog) Render(background string) string {
return m.modal.Render(m.View(), background)
}
func (s *modelDialog) Close() tea.Cmd {
return nil
}
func NewModelDialog(app *app.App) ModelDialog {
dialog := &modelDialog{
app: app,
}
dialog.setupAllModels()
dialog.modal = modal.New(
modal.WithTitle("Select Model"),
modal.WithMaxWidth(dialog.dialogWidth+4),
)
return dialog
}
@@ -0,0 +1,247 @@
package dialog
import (
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
// SearchQueryChangedMsg is emitted when the search query changes
type SearchQueryChangedMsg struct {
Query string
}
// SearchSelectionMsg is emitted when an item is selected
type SearchSelectionMsg struct {
Item any
Index int
}
// SearchCancelledMsg is emitted when the search is cancelled
type SearchCancelledMsg struct{}
// SearchRemoveItemMsg is emitted when Ctrl+X is pressed to remove an item
type SearchRemoveItemMsg struct {
Item any
Index int
}
// SearchDialog is a reusable component that combines a text input with a list
type SearchDialog struct {
textInput textinput.Model
list list.List[list.Item]
width int
height int
focused bool
}
type searchKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Escape key.Binding
Remove key.Binding
}
var searchKeys = searchKeyMap{
Up: key.NewBinding(
key.WithKeys("up", "ctrl+p"),
key.WithHelp("↑", "previous item"),
),
Down: key.NewBinding(
key.WithKeys("down", "ctrl+n"),
key.WithHelp("↓", "next item"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
Remove: key.NewBinding(
key.WithKeys("ctrl+x"),
key.WithHelp("ctrl+x", "remove from recent"),
),
}
// NewSearchDialog creates a new SearchDialog
func NewSearchDialog(placeholder string, maxVisibleHeight int) *SearchDialog {
t := theme.CurrentTheme()
bgColor := t.BackgroundElement()
textColor := t.Text()
textMutedColor := t.TextMuted()
ti := textinput.New()
ti.Placeholder = placeholder
ti.Styles.Blurred.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ti.Styles.Blurred.Text = styles.NewStyle().
Foreground(textColor).
Background(bgColor).
Lipgloss()
ti.Styles.Focused.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
ti.Styles.Focused.Text = styles.NewStyle().
Foreground(textColor).
Background(bgColor).
Lipgloss()
ti.Styles.Focused.Prompt = styles.NewStyle().
Background(bgColor).
Lipgloss()
ti.Styles.Cursor.Color = t.Primary()
ti.VirtualCursor = true
ti.Prompt = " "
ti.CharLimit = -1
ti.Focus()
emptyList := list.NewListComponent(
list.WithItems([]list.Item{}),
list.WithMaxVisibleHeight[list.Item](maxVisibleHeight),
list.WithFallbackMessage[list.Item](" No items"),
list.WithAlphaNumericKeys[list.Item](false),
list.WithRenderFunc(
func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
return item.Render(selected, width, baseStyle)
},
),
list.WithSelectableFunc(func(item list.Item) bool {
return item.Selectable()
}),
)
return &SearchDialog{
textInput: ti,
list: emptyList,
focused: true,
}
}
func (s *SearchDialog) Init() tea.Cmd {
return textinput.Blink
}
func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
value := s.textInput.Value()
if value == "" {
return s, nil
}
s.textInput.Reset()
cmds = append(cmds, func() tea.Msg {
return SearchQueryChangedMsg{Query: ""}
})
}
switch {
case key.Matches(msg, searchKeys.Escape):
return s, func() tea.Msg { return SearchCancelledMsg{} }
case key.Matches(msg, searchKeys.Enter):
if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
return s, func() tea.Msg {
return SearchSelectionMsg{Item: selectedItem, Index: idx}
}
}
case key.Matches(msg, searchKeys.Remove):
if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
return s, func() tea.Msg {
return SearchRemoveItemMsg{Item: selectedItem, Index: idx}
}
}
case key.Matches(msg, searchKeys.Up):
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(list.List[list.Item])
if cmd != nil {
cmds = append(cmds, cmd)
}
case key.Matches(msg, searchKeys.Down):
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(list.List[list.Item])
if cmd != nil {
cmds = append(cmds, cmd)
}
default:
oldValue := s.textInput.Value()
var cmd tea.Cmd
s.textInput, cmd = s.textInput.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
if newValue := s.textInput.Value(); newValue != oldValue {
cmds = append(cmds, func() tea.Msg {
return SearchQueryChangedMsg{Query: newValue}
})
}
}
}
return s, tea.Batch(cmds...)
}
func (s *SearchDialog) View() string {
s.list.SetMaxWidth(s.width)
listView := s.list.View()
listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleHeight(), lipgloss.Top, listView)
textinput := s.textInput.View()
return textinput + "\n\n" + listView
}
// SetWidth sets the width of the search dialog
func (s *SearchDialog) SetWidth(width int) {
s.width = width
s.textInput.SetWidth(width - 2) // Account for padding and borders
}
// SetHeight sets the height of the search dialog
func (s *SearchDialog) SetHeight(height int) {
s.height = height
}
// SetItems updates the list items
func (s *SearchDialog) SetItems(items []list.Item) {
s.list.SetItems(items)
}
// GetQuery returns the current search query
func (s *SearchDialog) GetQuery() string {
return s.textInput.Value()
}
// SetQuery sets the search query
func (s *SearchDialog) SetQuery(query string) {
s.textInput.SetValue(query)
}
// Focus focuses the search dialog
func (s *SearchDialog) Focus() {
s.focused = true
s.textInput.Focus()
}
// Blur removes focus from the search dialog
func (s *SearchDialog) Blur() {
s.focused = false
s.textInput.Blur()
}
@@ -0,0 +1,282 @@
package dialog
import (
"context"
"strings"
"slices"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// SessionDialog interface for the session switching dialog
type SessionDialog interface {
layout.Modal
}
// sessionItem is a custom list item for sessions that can show delete confirmation
type sessionItem struct {
title string
isDeleteConfirming bool
isCurrentSession bool
}
func (s sessionItem) Render(
selected bool,
width int,
isFirstInViewport bool,
baseStyle styles.Style,
) string {
t := theme.CurrentTheme()
var text string
if s.isDeleteConfirming {
text = "Press again to confirm delete"
} else {
if s.isCurrentSession {
text = "● " + s.title
} else {
text = s.title
}
}
truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
var itemStyle styles.Style
if selected {
if s.isDeleteConfirming {
// Red background for delete confirmation
itemStyle = baseStyle.
Background(t.Error()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
} else if s.isCurrentSession {
// Different style for current session when selected
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1).
Bold(true)
} else {
// Normal selection
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
}
} else {
if s.isDeleteConfirming {
// Red text for delete confirmation when not selected
itemStyle = baseStyle.
Foreground(t.Error()).
PaddingLeft(1)
} else if s.isCurrentSession {
// Highlight current session when not selected
itemStyle = baseStyle.
Foreground(t.Primary()).
PaddingLeft(1).
Bold(true)
} else {
itemStyle = baseStyle.
PaddingLeft(1)
}
}
return itemStyle.Render(truncatedStr)
}
func (s sessionItem) Selectable() bool {
return true
}
type sessionDialog struct {
width int
height int
modal *modal.Modal
sessions []opencode.Session
list list.List[sessionItem]
app *app.App
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
}
func (s *sessionDialog) Init() tea.Cmd {
return nil
}
func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.width = msg.Width
s.height = msg.Height
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
case tea.KeyPressMsg:
switch msg.String() {
case "enter":
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
selectedSession := s.sessions[idx]
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
)
}
case "n":
s.app.Session = &opencode.Session{}
s.app.Messages = []app.Message{}
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionClearedMsg{}),
)
case "x", "delete", "backspace":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
if s.deleteConfirmation == idx {
// Second press - actually delete the session
sessionToDelete := s.sessions[idx]
return s, tea.Sequence(
func() tea.Msg {
s.sessions = slices.Delete(s.sessions, idx, idx+1)
s.deleteConfirmation = -1
s.updateListItems()
return nil
},
s.deleteSession(sessionToDelete.ID),
)
} else {
// First press - enter delete confirmation mode
s.deleteConfirmation = idx
s.updateListItems()
return s, nil
}
}
case "esc":
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
}
}
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(list.List[sessionItem])
return s, cmd
}
func (s *sessionDialog) Render(background string) string {
listView := s.list.View()
t := theme.CurrentTheme()
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
leftHelp := keyStyle("n") + mutedStyle(" new session")
rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
bgColor := t.BackgroundPanel()
helpText := layout.Render(layout.FlexOptions{
Direction: layout.Row,
Justify: layout.JustifySpaceBetween,
Width: layout.Current.Container.Width - 14,
Background: &bgColor,
}, layout.FlexItem{View: leftHelp}, layout.FlexItem{View: rightHelp})
helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
content := strings.Join([]string{listView, helpText}, "\n")
return s.modal.Render(content, background)
}
func (s *sessionDialog) updateListItems() {
_, currentIdx := s.list.GetSelectedItem()
var items []sessionItem
for i, sess := range s.sessions {
item := sessionItem{
title: sess.Title,
isDeleteConfirming: s.deleteConfirmation == i,
isCurrentSession: s.app.Session != nil && s.app.Session.ID == sess.ID,
}
items = append(items, item)
}
s.list.SetItems(items)
s.list.SetSelectedIndex(currentIdx)
}
func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
if err := s.app.DeleteSession(ctx, sessionID); err != nil {
return toast.NewErrorToast("Failed to delete session: " + err.Error())()
}
return nil
}
}
func (s *sessionDialog) Close() tea.Cmd {
return nil
}
// NewSessionDialog creates a new session switching dialog
func NewSessionDialog(app *app.App) SessionDialog {
sessions, _ := app.ListSessions(context.Background())
var filteredSessions []opencode.Session
var items []sessionItem
for _, sess := range sessions {
if sess.ParentID != "" {
continue
}
filteredSessions = append(filteredSessions, sess)
items = append(items, sessionItem{
title: sess.Title,
isDeleteConfirming: false,
isCurrentSession: app.Session != nil && app.Session.ID == sess.ID,
})
}
listComponent := list.NewListComponent(
list.WithItems(items),
list.WithMaxVisibleHeight[sessionItem](10),
list.WithFallbackMessage[sessionItem]("No sessions available"),
list.WithAlphaNumericKeys[sessionItem](true),
list.WithRenderFunc(
func(item sessionItem, selected bool, width int, baseStyle styles.Style) string {
return item.Render(selected, width, false, baseStyle)
},
),
list.WithSelectableFunc(func(item sessionItem) bool {
return true
}),
)
listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
return &sessionDialog{
sessions: filteredSessions,
list: listComponent,
app: app,
deleteConfirmation: -1,
modal: modal.New(
modal.WithTitle("Switch Session"),
modal.WithMaxWidth(layout.Current.Container.Width-8),
),
}
}
@@ -0,0 +1,132 @@
package dialog
import (
tea "github.com/charmbracelet/bubbletea/v2"
list "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// ThemeSelectedMsg is sent when the theme is changed
type ThemeSelectedMsg struct {
ThemeName string
}
// ThemeDialog interface for the theme switching dialog
type ThemeDialog interface {
layout.Modal
}
type themeDialog struct {
width int
height int
modal *modal.Modal
list list.List[list.Item]
originalTheme string
themeApplied bool
}
func (t *themeDialog) Init() tea.Cmd {
return nil
}
func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "enter":
if item, idx := t.list.GetSelectedItem(); idx >= 0 {
if stringItem, ok := item.(list.StringItem); ok {
selectedTheme := string(stringItem)
if err := theme.SetTheme(selectedTheme); err != nil {
// status.Error(err.Error())
return t, nil
}
t.themeApplied = true
return t, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
)
}
}
}
}
_, prevIdx := t.list.GetSelectedItem()
var cmd tea.Cmd
listModel, cmd := t.list.Update(msg)
t.list = listModel.(list.List[list.Item])
if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx {
if stringItem, ok := item.(list.StringItem); ok {
theme.SetTheme(string(stringItem))
return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(stringItem)})
}
}
return t, cmd
}
func (t *themeDialog) Render(background string) string {
return t.modal.Render(t.list.View(), background)
}
func (t *themeDialog) Close() tea.Cmd {
if !t.themeApplied {
theme.SetTheme(t.originalTheme)
return util.CmdHandler(ThemeSelectedMsg{ThemeName: t.originalTheme})
}
return nil
}
// NewThemeDialog creates a new theme switching dialog
func NewThemeDialog() ThemeDialog {
themes := theme.AvailableThemes()
currentTheme := theme.CurrentThemeName()
var selectedIdx int
for i, name := range themes {
if name == currentTheme {
selectedIdx = i
}
}
// Convert themes to list items
items := make([]list.Item, len(themes))
for i, theme := range themes {
items[i] = list.StringItem(theme)
}
listComponent := list.NewListComponent(
list.WithItems(items),
list.WithMaxVisibleHeight[list.Item](10),
list.WithFallbackMessage[list.Item]("No themes available"),
list.WithAlphaNumericKeys[list.Item](true),
list.WithRenderFunc(func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
return item.Render(selected, width, baseStyle)
}),
list.WithSelectableFunc(func(item list.Item) bool {
return item.Selectable()
}),
)
// Set the initial selection to the current theme
listComponent.SetSelectedIndex(selectedIdx)
// Set the max width for the list to match the modal width
listComponent.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
return &themeDialog{
list: listComponent,
modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
originalTheme: currentTheme,
themeApplied: false,
}
}
@@ -1,6 +1,7 @@
package diff
import (
"bufio"
"bytes"
"fmt"
"image/color"
@@ -8,6 +9,8 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"unicode/utf8"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
@@ -19,6 +22,7 @@ import (
"github.com/sergi/go-diff/diffmatchpatch"
stylesi "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// -------------------------------------------------------------------------
@@ -34,6 +38,10 @@ const (
LineRemoved // Line removed from the old file
)
var (
ansiRegex = regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
)
// Segment represents a portion of a line for intra-line highlighting
type Segment struct {
Start int
@@ -70,44 +78,6 @@ type linePair struct {
right *DiffLine
}
// -------------------------------------------------------------------------
// Side-by-Side Configuration
// -------------------------------------------------------------------------
// SideBySideConfig configures the rendering of side-by-side diffs
type SideBySideConfig struct {
TotalWidth int
}
// SideBySideOption modifies a SideBySideConfig
type SideBySideOption func(*SideBySideConfig)
// NewSideBySideConfig creates a SideBySideConfig with default values
func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
config := SideBySideConfig{
TotalWidth: 160, // Default width for side-by-side view
}
for _, opt := range opts {
opt(&config)
}
return config
}
// WithTotalWidth sets the total width for side-by-side view
func WithTotalWidth(width int) SideBySideOption {
return func(s *SideBySideConfig) {
if width > 0 {
s.TotalWidth = width
}
}
}
// -------------------------------------------------------------------------
// Unified Configuration
// -------------------------------------------------------------------------
// UnifiedConfig configures the rendering of unified diffs
type UnifiedConfig struct {
Width int
@@ -119,13 +89,22 @@ type UnifiedOption func(*UnifiedConfig)
// NewUnifiedConfig creates a UnifiedConfig with default values
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
config := UnifiedConfig{
Width: 80, // Default width for unified view
Width: 80,
}
for _, opt := range opts {
opt(&config)
}
return config
}
// NewSideBySideConfig creates a SideBySideConfig with default values
func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig {
config := UnifiedConfig{
Width: 160,
}
for _, opt := range opts {
opt(&config)
}
return config
}
@@ -146,101 +125,87 @@ func WithWidth(width int) UnifiedOption {
func ParseUnifiedDiff(diff string) (DiffResult, error) {
var result DiffResult
var currentHunk *Hunk
result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
lines := strings.Split(diff, "\n")
scanner := bufio.NewScanner(strings.NewReader(diff))
var oldLine, newLine int
inFileHeader := true
for _, line := range lines {
// Parse file headers
for scanner.Scan() {
line := scanner.Text()
if inFileHeader {
if strings.HasPrefix(line, "--- a/") {
result.OldFile = strings.TrimPrefix(line, "--- a/")
result.OldFile = line[6:]
continue
}
if strings.HasPrefix(line, "+++ b/") {
result.NewFile = strings.TrimPrefix(line, "+++ b/")
result.NewFile = line[6:]
inFileHeader = false
continue
}
}
// Parse hunk headers
if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
if strings.HasPrefix(line, "@@") {
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
currentHunk = &Hunk{
Header: line,
Lines: []DiffLine{},
Lines: make([]DiffLine, 0, 10), // Pre-allocate
}
oldStart, _ := strconv.Atoi(matches[1])
newStart, _ := strconv.Atoi(matches[3])
oldLine = oldStart
newLine = newStart
// Manual parsing of hunk header is faster than regex
parts := strings.Split(line, " ")
if len(parts) > 2 {
oldRange := strings.Split(parts[1][1:], ",")
newRange := strings.Split(parts[2][1:], ",")
oldLine, _ = strconv.Atoi(oldRange[0])
newLine, _ = strconv.Atoi(newRange[0])
}
continue
}
// Ignore "No newline at end of file" markers
if strings.HasPrefix(line, "\\ No newline at end of file") {
if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
continue
}
if currentHunk == nil {
continue
}
// Process the line based on its prefix
var dl DiffLine
dl.Content = line
if len(line) > 0 {
switch line[0] {
case '+':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: 0,
NewLineNo: newLine,
Kind: LineAdded,
Content: line[1:],
})
dl.Kind = LineAdded
dl.NewLineNo = newLine
dl.Content = line[1:]
newLine++
case '-':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: 0,
Kind: LineRemoved,
Content: line[1:],
})
dl.Kind = LineRemoved
dl.OldLineNo = oldLine
dl.Content = line[1:]
oldLine++
default:
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: newLine,
Kind: LineContext,
Content: line,
})
default: // context line
dl.Kind = LineContext
dl.OldLineNo = oldLine
dl.NewLineNo = newLine
oldLine++
newLine++
}
} else {
// Handle empty lines
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: newLine,
Kind: LineContext,
Content: "",
})
} else { // empty context line
dl.Kind = LineContext
dl.OldLineNo = oldLine
dl.NewLineNo = newLine
oldLine++
newLine++
}
currentHunk.Lines = append(currentHunk.Lines, dl)
}
// Add the last hunk if there is one
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
return result, nil
return result, scanner.Err()
}
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
@@ -441,84 +406,84 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.C
<entry type="TextWhitespace" style="%s"/>
</style>
`,
getColor(t.BackgroundSubtle()), // Background
getColor(t.Text()), // Text
getColor(t.Text()), // Other
getColor(t.Error()), // Error
getChromaColor(t.BackgroundPanel()), // Background
getChromaColor(t.Text()), // Text
getChromaColor(t.Text()), // Other
getChromaColor(t.Error()), // Error
getColor(t.SyntaxKeyword()), // Keyword
getColor(t.SyntaxKeyword()), // KeywordConstant
getColor(t.SyntaxKeyword()), // KeywordDeclaration
getColor(t.SyntaxKeyword()), // KeywordNamespace
getColor(t.SyntaxKeyword()), // KeywordPseudo
getColor(t.SyntaxKeyword()), // KeywordReserved
getColor(t.SyntaxType()), // KeywordType
getChromaColor(t.SyntaxKeyword()), // Keyword
getChromaColor(t.SyntaxKeyword()), // KeywordConstant
getChromaColor(t.SyntaxKeyword()), // KeywordDeclaration
getChromaColor(t.SyntaxKeyword()), // KeywordNamespace
getChromaColor(t.SyntaxKeyword()), // KeywordPseudo
getChromaColor(t.SyntaxKeyword()), // KeywordReserved
getChromaColor(t.SyntaxType()), // KeywordType
getColor(t.Text()), // Name
getColor(t.SyntaxVariable()), // NameAttribute
getColor(t.SyntaxType()), // NameBuiltin
getColor(t.SyntaxVariable()), // NameBuiltinPseudo
getColor(t.SyntaxType()), // NameClass
getColor(t.SyntaxVariable()), // NameConstant
getColor(t.SyntaxFunction()), // NameDecorator
getColor(t.SyntaxVariable()), // NameEntity
getColor(t.SyntaxType()), // NameException
getColor(t.SyntaxFunction()), // NameFunction
getColor(t.Text()), // NameLabel
getColor(t.SyntaxType()), // NameNamespace
getColor(t.SyntaxVariable()), // NameOther
getColor(t.SyntaxKeyword()), // NameTag
getColor(t.SyntaxVariable()), // NameVariable
getColor(t.SyntaxVariable()), // NameVariableClass
getColor(t.SyntaxVariable()), // NameVariableGlobal
getColor(t.SyntaxVariable()), // NameVariableInstance
getChromaColor(t.Text()), // Name
getChromaColor(t.SyntaxVariable()), // NameAttribute
getChromaColor(t.SyntaxType()), // NameBuiltin
getChromaColor(t.SyntaxVariable()), // NameBuiltinPseudo
getChromaColor(t.SyntaxType()), // NameClass
getChromaColor(t.SyntaxVariable()), // NameConstant
getChromaColor(t.SyntaxFunction()), // NameDecorator
getChromaColor(t.SyntaxVariable()), // NameEntity
getChromaColor(t.SyntaxType()), // NameException
getChromaColor(t.SyntaxFunction()), // NameFunction
getChromaColor(t.Text()), // NameLabel
getChromaColor(t.SyntaxType()), // NameNamespace
getChromaColor(t.SyntaxVariable()), // NameOther
getChromaColor(t.SyntaxKeyword()), // NameTag
getChromaColor(t.SyntaxVariable()), // NameVariable
getChromaColor(t.SyntaxVariable()), // NameVariableClass
getChromaColor(t.SyntaxVariable()), // NameVariableGlobal
getChromaColor(t.SyntaxVariable()), // NameVariableInstance
getColor(t.SyntaxString()), // Literal
getColor(t.SyntaxString()), // LiteralDate
getColor(t.SyntaxString()), // LiteralString
getColor(t.SyntaxString()), // LiteralStringBacktick
getColor(t.SyntaxString()), // LiteralStringChar
getColor(t.SyntaxString()), // LiteralStringDoc
getColor(t.SyntaxString()), // LiteralStringDouble
getColor(t.SyntaxString()), // LiteralStringEscape
getColor(t.SyntaxString()), // LiteralStringHeredoc
getColor(t.SyntaxString()), // LiteralStringInterpol
getColor(t.SyntaxString()), // LiteralStringOther
getColor(t.SyntaxString()), // LiteralStringRegex
getColor(t.SyntaxString()), // LiteralStringSingle
getColor(t.SyntaxString()), // LiteralStringSymbol
getChromaColor(t.SyntaxString()), // Literal
getChromaColor(t.SyntaxString()), // LiteralDate
getChromaColor(t.SyntaxString()), // LiteralString
getChromaColor(t.SyntaxString()), // LiteralStringBacktick
getChromaColor(t.SyntaxString()), // LiteralStringChar
getChromaColor(t.SyntaxString()), // LiteralStringDoc
getChromaColor(t.SyntaxString()), // LiteralStringDouble
getChromaColor(t.SyntaxString()), // LiteralStringEscape
getChromaColor(t.SyntaxString()), // LiteralStringHeredoc
getChromaColor(t.SyntaxString()), // LiteralStringInterpol
getChromaColor(t.SyntaxString()), // LiteralStringOther
getChromaColor(t.SyntaxString()), // LiteralStringRegex
getChromaColor(t.SyntaxString()), // LiteralStringSingle
getChromaColor(t.SyntaxString()), // LiteralStringSymbol
getColor(t.SyntaxNumber()), // LiteralNumber
getColor(t.SyntaxNumber()), // LiteralNumberBin
getColor(t.SyntaxNumber()), // LiteralNumberFloat
getColor(t.SyntaxNumber()), // LiteralNumberHex
getColor(t.SyntaxNumber()), // LiteralNumberInteger
getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
getColor(t.SyntaxNumber()), // LiteralNumberOct
getChromaColor(t.SyntaxNumber()), // LiteralNumber
getChromaColor(t.SyntaxNumber()), // LiteralNumberBin
getChromaColor(t.SyntaxNumber()), // LiteralNumberFloat
getChromaColor(t.SyntaxNumber()), // LiteralNumberHex
getChromaColor(t.SyntaxNumber()), // LiteralNumberInteger
getChromaColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
getChromaColor(t.SyntaxNumber()), // LiteralNumberOct
getColor(t.SyntaxOperator()), // Operator
getColor(t.SyntaxKeyword()), // OperatorWord
getColor(t.SyntaxPunctuation()), // Punctuation
getChromaColor(t.SyntaxOperator()), // Operator
getChromaColor(t.SyntaxKeyword()), // OperatorWord
getChromaColor(t.SyntaxPunctuation()), // Punctuation
getColor(t.SyntaxComment()), // Comment
getColor(t.SyntaxComment()), // CommentHashbang
getColor(t.SyntaxComment()), // CommentMultiline
getColor(t.SyntaxComment()), // CommentSingle
getColor(t.SyntaxComment()), // CommentSpecial
getColor(t.SyntaxKeyword()), // CommentPreproc
getChromaColor(t.SyntaxComment()), // Comment
getChromaColor(t.SyntaxComment()), // CommentHashbang
getChromaColor(t.SyntaxComment()), // CommentMultiline
getChromaColor(t.SyntaxComment()), // CommentSingle
getChromaColor(t.SyntaxComment()), // CommentSpecial
getChromaColor(t.SyntaxKeyword()), // CommentPreproc
getColor(t.Text()), // Generic
getColor(t.Error()), // GenericDeleted
getColor(t.Text()), // GenericEmph
getColor(t.Error()), // GenericError
getColor(t.Text()), // GenericHeading
getColor(t.Success()), // GenericInserted
getColor(t.TextMuted()), // GenericOutput
getColor(t.Text()), // GenericPrompt
getColor(t.Text()), // GenericStrong
getColor(t.Text()), // GenericSubheading
getColor(t.Error()), // GenericTraceback
getColor(t.Text()), // TextWhitespace
getChromaColor(t.Text()), // Generic
getChromaColor(t.Error()), // GenericDeleted
getChromaColor(t.Text()), // GenericEmph
getChromaColor(t.Error()), // GenericError
getChromaColor(t.Text()), // GenericHeading
getChromaColor(t.Success()), // GenericInserted
getChromaColor(t.TextMuted()), // GenericOutput
getChromaColor(t.Text()), // GenericPrompt
getChromaColor(t.Text()), // GenericStrong
getChromaColor(t.Text()), // GenericSubheading
getChromaColor(t.Error()), // GenericTraceback
getChromaColor(t.Text()), // TextWhitespace
)
r := strings.NewReader(syntaxThemeXml)
@@ -527,6 +492,9 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.C
// Modify the style to use the provided background
s, err := style.Builder().Transform(
func(t chroma.StyleEntry) chroma.StyleEntry {
if _, ok := bg.(lipgloss.NoColor); ok {
return t
}
r, g, b, _ := bg.RGBA()
t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
return t
@@ -546,10 +514,18 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.C
}
// getColor returns the appropriate hex color string based on terminal background
func getColor(adaptiveColor compat.AdaptiveColor) string {
func getColor(adaptiveColor compat.AdaptiveColor) *string {
return stylesi.AdaptiveColorToString(adaptiveColor)
}
func getChromaColor(adaptiveColor compat.AdaptiveColor) string {
color := stylesi.AdaptiveColorToString(adaptiveColor)
if color == nil {
return ""
}
return *color
}
// highlightLine applies syntax highlighting to a single line
func highlightLine(fileName string, line string, bg color.Color) string {
var buf bytes.Buffer
@@ -561,11 +537,11 @@ func highlightLine(fileName string, line string, bg color.Color) string {
}
// createStyles generates the lipgloss styles needed for rendering diffs
func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
lineNumberStyle = lipgloss.NewStyle().Background(t.DiffLineNumber()).Foreground(t.TextMuted())
func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle stylesi.Style) {
removedLineStyle = stylesi.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = stylesi.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = stylesi.NewStyle().Background(t.DiffContextBg())
lineNumberStyle = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffLineNumber())
return
}
@@ -576,7 +552,6 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
// applyHighlighting applies intra-line highlighting to a piece of text
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg compat.AdaptiveColor) string {
// Find all ANSI sequences in the content
ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
// Build a mapping of visible character positions to their actual indices
@@ -604,7 +579,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
ansiSequences[visibleIdx] = lastAnsiSeq
}
visibleIdx++
i++
// Properly advance by UTF-8 rune, not byte
_, size := utf8.DecodeRuneInString(content[i:])
i += size
}
// Apply highlighting
@@ -613,9 +591,17 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
currentPos := 0
// Get the appropriate color based on terminal background
bgColor := lipgloss.Color(getColor(highlightBg))
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundSubtle()))
bg := getColor(highlightBg)
fg := getColor(theme.CurrentTheme().BackgroundPanel())
var bgColor color.Color
var fgColor color.Color
if bg != nil {
bgColor = lipgloss.Color(*bg)
}
if fg != nil {
fgColor = lipgloss.Color(*fg)
}
for i := 0; i < len(content); {
// Check if we're at an ANSI sequence
isAnsi := false
@@ -643,20 +629,29 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
}
}
// Get current character
char := string(content[i])
// Get current character (properly handle UTF-8)
r, size := utf8.DecodeRuneInString(content[i:])
char := string(r)
if inSelection {
// Get the current styling
currentStyle := ansiSequences[currentPos]
// Apply foreground and background highlight
sb.WriteString("\x1b[38;2;")
r, g, b, _ := fgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
sb.WriteString("\x1b[48;2;")
r, g, b, _ = bgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
if fgColor != nil {
sb.WriteString("\x1b[38;2;")
r, g, b, _ := fgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
} else {
sb.WriteString("\x1b[49m")
}
if bgColor != nil {
sb.WriteString("\x1b[48;2;")
r, g, b, _ := bgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
} else {
sb.WriteString("\x1b[39m")
}
sb.WriteString(char)
// Full reset of all attributes to ensure clean state
@@ -670,23 +665,23 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
}
currentPos++
i++
i += size
}
return sb.String()
}
// renderLinePrefix renders the line number and marker prefix for a diff line
func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle lipgloss.Style, t theme.Theme) string {
func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle stylesi.Style, t theme.Theme) string {
// Style the marker based on line type
var styledMarker string
switch dl.Kind {
case LineRemoved:
styledMarker = lipgloss.NewStyle().Background(t.DiffRemovedBg()).Foreground(t.DiffRemoved()).Render(marker)
styledMarker = stylesi.NewStyle().Foreground(t.DiffRemoved()).Background(t.DiffRemovedBg()).Render(marker)
case LineAdded:
styledMarker = lipgloss.NewStyle().Background(t.DiffAddedBg()).Foreground(t.DiffAdded()).Render(marker)
styledMarker = stylesi.NewStyle().Foreground(t.DiffAdded()).Background(t.DiffAddedBg()).Render(marker)
case LineContext:
styledMarker = lipgloss.NewStyle().Background(t.DiffContextBg()).Foreground(t.TextMuted()).Render(marker)
styledMarker = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffContextBg()).Render(marker)
default:
styledMarker = marker
}
@@ -695,7 +690,7 @@ func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyl
}
// renderLineContent renders the content of a diff line with syntax and intra-line highlighting
func renderLineContent(fileName string, dl DiffLine, bgStyle lipgloss.Style, highlightColor compat.AdaptiveColor, width int, t theme.Theme) string {
func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, highlightColor compat.AdaptiveColor, width int) string {
// Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
@@ -714,7 +709,7 @@ func renderLineContent(fileName string, dl DiffLine, bgStyle lipgloss.Style, hig
ansi.Truncate(
content,
width,
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
"...",
),
)
}
@@ -725,7 +720,7 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
// Determine line style and marker based on line type
var marker string
var bgStyle lipgloss.Style
var bgStyle stylesi.Style
var lineNum string
var highlightColor compat.AdaptiveColor
@@ -733,8 +728,8 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
case LineRemoved:
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
highlightColor = t.DiffHighlightRemoved()
lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d ", dl.OldLineNo)
} else {
@@ -743,8 +738,8 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
case LineAdded:
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
highlightColor = t.DiffHighlightAdded()
lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
highlightColor = t.DiffHighlightAdded() // TODO: handle "none"
if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf(" %7d", dl.NewLineNo)
} else {
@@ -766,7 +761,7 @@ func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) s
// Render the content
prefixWidth := ansi.StringWidth(prefix)
contentWidth := width - prefixWidth
content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth, t)
content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth)
return prefix + content
}
@@ -780,7 +775,7 @@ func renderDiffColumnLine(
t theme.Theme,
) string {
if dl == nil {
contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
contextLineStyle := stylesi.NewStyle().Background(t.DiffContextBg())
return contextLineStyle.Width(colWidth).Render("")
}
@@ -788,7 +783,7 @@ func renderDiffColumnLine(
// Determine line style based on line type and column
var marker string
var bgStyle lipgloss.Style
var bgStyle stylesi.Style
var lineNum string
var highlightColor compat.AdaptiveColor
@@ -798,8 +793,8 @@ func renderDiffColumnLine(
case LineRemoved:
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
highlightColor = t.DiffHighlightRemoved()
lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
case LineAdded:
marker = "?"
bgStyle = contextLineStyle
@@ -818,7 +813,7 @@ func renderDiffColumnLine(
case LineAdded:
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
highlightColor = t.DiffHighlightAdded()
case LineRemoved:
marker = "?"
@@ -849,7 +844,7 @@ func renderDiffColumnLine(
// Render the content
prefixWidth := ansi.StringWidth(prefix)
contentWidth := colWidth - prefixWidth
content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth, t)
content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth)
return prefix + content
}
@@ -881,16 +876,17 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
HighlightIntralineChanges(&hunkCopy)
var sb strings.Builder
for _, line := range hunkCopy.Lines {
sb.WriteString(renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()))
sb.WriteString("\n")
}
sb.Grow(len(hunkCopy.Lines) * config.Width)
util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
})
return sb.String()
}
// RenderSideBySideHunk formats a hunk for side-by-side display
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
// Apply options to create the configuration
config := NewSideBySideConfig(opts...)
@@ -905,16 +901,27 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
pairs := pairLines(hunkCopy.Lines)
// Calculate column width
colWidth := config.TotalWidth / 2
colWidth := config.Width / 2
leftWidth := colWidth
rightWidth := config.TotalWidth - colWidth
rightWidth := config.Width - colWidth
var sb strings.Builder
for _, p := range pairs {
leftStr := renderLeftColumn(fileName, p.left, leftWidth)
rightStr := renderRightColumn(fileName, p.right, rightWidth)
sb.WriteString(leftStr + rightStr + "\n")
}
util.WriteStringsPar(&sb, pairs, func(p linePair) string {
wg := &sync.WaitGroup{}
var leftStr, rightStr string
wg.Add(2)
go func() {
defer wg.Done()
leftStr = renderLeftColumn(fileName, p.left, leftWidth)
}()
go func() {
defer wg.Done()
rightStr = renderRightColumn(fileName, p.right, rightWidth)
}()
wg.Wait()
return leftStr + rightStr + "\n"
})
return sb.String()
}
@@ -927,33 +934,24 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption)
}
var sb strings.Builder
for _, h := range diffResult.Hunks {
sb.WriteString(RenderUnifiedHunk(filename, h, opts...))
}
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
return RenderUnifiedHunk(filename, h, opts...)
})
return sb.String(), nil
}
// FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
// t := theme.CurrentTheme()
func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
diffResult, err := ParseUnifiedDiff(diffText)
if err != nil {
return "", err
}
var sb strings.Builder
// config := NewSideBySideConfig(opts...)
for _, h := range diffResult.Hunks {
// sb.WriteString(
// lipgloss.NewStyle().
// Background(t.DiffHunkHeader()).
// Foreground(t.Background()).
// Width(config.TotalWidth).
// Render(h.Header) + "\n",
// )
sb.WriteString(RenderSideBySideHunk(filename, h, opts...))
}
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
return RenderSideBySideHunk(filename, h, opts...)
})
return sb.String(), nil
}
@@ -0,0 +1,281 @@
package fileviewer
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/internal/viewport"
)
type DiffStyle int
const (
DiffStyleSplit DiffStyle = iota
DiffStyleUnified
)
type Model struct {
app *app.App
width, height int
viewport viewport.Model
filename *string
content *string
isDiff *bool
diffStyle DiffStyle
}
type fileRenderedMsg struct {
content string
}
func New(app *app.App) Model {
vp := viewport.New()
m := Model{
app: app,
viewport: vp,
diffStyle: DiffStyleUnified,
}
if app.State.SplitDiff {
m.diffStyle = DiffStyleSplit
}
return m
}
func (m Model) Init() tea.Cmd {
return m.viewport.Init()
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case fileRenderedMsg:
m.viewport.SetContent(msg.content)
return m, util.CmdHandler(app.FileRenderedMsg{
FilePath: *m.filename,
})
case dialog.ThemeSelectedMsg:
return m, m.render()
case tea.KeyMsg:
switch msg.String() {
// TODO
}
}
vp, cmd := m.viewport.Update(msg)
m.viewport = vp
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
if !m.HasFile() {
return ""
}
header := *m.filename
header = styles.NewStyle().
Padding(1, 2).
Width(m.width).
Background(theme.CurrentTheme().BackgroundElement()).
Foreground(theme.CurrentTheme().Text()).
Render(header)
t := theme.CurrentTheme()
close := m.app.Key(commands.FileCloseCommand)
diffToggle := m.app.Key(commands.FileDiffToggleCommand)
if m.isDiff == nil || *m.isDiff == false {
diffToggle = ""
}
layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand)
background := t.Background()
footer := layout.Render(
layout.FlexOptions{
Background: &background,
Direction: layout.Row,
Justify: layout.JustifyCenter,
Align: layout.AlignStretch,
Width: m.width - 2,
Gap: 5,
},
layout.FlexItem{
View: close,
},
layout.FlexItem{
View: layoutToggle,
},
layout.FlexItem{
View: diffToggle,
},
)
footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer)
return header + "\n" + m.viewport.View() + "\n" + footer
}
func (m *Model) Clear() (Model, tea.Cmd) {
m.filename = nil
m.content = nil
m.isDiff = nil
return *m, m.render()
}
func (m *Model) ToggleDiff() (Model, tea.Cmd) {
switch m.diffStyle {
case DiffStyleSplit:
m.diffStyle = DiffStyleUnified
default:
m.diffStyle = DiffStyleSplit
}
return *m, m.render()
}
func (m *Model) DiffStyle() DiffStyle {
return m.diffStyle
}
func (m Model) HasFile() bool {
return m.filename != nil && m.content != nil
}
func (m Model) Filename() string {
if m.filename == nil {
return ""
}
return *m.filename
}
func (m *Model) SetSize(width, height int) (Model, tea.Cmd) {
if m.width != width || m.height != height {
m.width = width
m.height = height
m.viewport.SetWidth(width)
m.viewport.SetHeight(height - 4)
return *m, m.render()
}
return *m, nil
}
func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) {
m.filename = &filename
m.content = &content
m.isDiff = &isDiff
return *m, m.render()
}
func (m *Model) render() tea.Cmd {
if m.filename == nil || m.content == nil {
m.viewport.SetContent("")
return nil
}
return func() tea.Msg {
t := theme.CurrentTheme()
var rendered string
if m.isDiff != nil && *m.isDiff {
diffResult := ""
var err error
if m.diffStyle == DiffStyleSplit {
diffResult, err = diff.FormatDiff(
*m.filename,
*m.content,
diff.WithWidth(m.width),
)
} else if m.diffStyle == DiffStyleUnified {
diffResult, err = diff.FormatUnifiedDiff(
*m.filename,
*m.content,
diff.WithWidth(m.width),
)
}
if err != nil {
rendered = styles.NewStyle().
Foreground(t.Error()).
Render(fmt.Sprintf("Error rendering diff: %v", err))
} else {
rendered = strings.TrimRight(diffResult, "\n")
}
} else {
rendered = util.RenderFile(
*m.filename,
*m.content,
m.width,
)
}
rendered = styles.NewStyle().
Width(m.width).
Background(t.BackgroundPanel()).
Render(rendered)
return fileRenderedMsg{
content: rendered,
}
}
}
func (m *Model) ScrollTo(line int) {
m.viewport.SetYOffset(line)
}
func (m *Model) ScrollToBottom() {
m.viewport.GotoBottom()
}
func (m *Model) ScrollToTop() {
m.viewport.GotoTop()
}
func (m *Model) PageUp() (Model, tea.Cmd) {
m.viewport.ViewUp()
return *m, nil
}
func (m *Model) PageDown() (Model, tea.Cmd) {
m.viewport.ViewDown()
return *m, nil
}
func (m *Model) HalfPageUp() (Model, tea.Cmd) {
m.viewport.HalfViewUp()
return *m, nil
}
func (m *Model) HalfPageDown() (Model, tea.Cmd) {
m.viewport.HalfViewDown()
return *m, nil
}
func (m Model) AtTop() bool {
return m.viewport.AtTop()
}
func (m Model) AtBottom() bool {
return m.viewport.AtBottom()
}
func (m Model) ScrollPercent() float64 {
return m.viewport.ScrollPercent()
}
func (m Model) TotalLineCount() int {
return m.viewport.TotalLineCount()
}
func (m Model) VisibleLineCount() int {
return m.viewport.VisibleLineCount()
}
@@ -0,0 +1,431 @@
package list
import (
"strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
// Item interface that all list items must implement
type Item interface {
Render(selected bool, width int, baseStyle styles.Style) string
Selectable() bool
}
// RenderFunc defines how to render an item in the list
type RenderFunc[T any] func(item T, selected bool, width int, baseStyle styles.Style) string
// SelectableFunc defines whether an item is selectable
type SelectableFunc[T any] func(item T) bool
// Options holds configuration for the list component
type Options[T any] struct {
items []T
maxVisibleHeight int
fallbackMsg string
useAlphaNumericKeys bool
renderItem RenderFunc[T]
isSelectable SelectableFunc[T]
baseStyle styles.Style
}
// Option is a function that configures the list component
type Option[T any] func(*Options[T])
// WithItems sets the initial items for the list
func WithItems[T any](items []T) Option[T] {
return func(o *Options[T]) {
o.items = items
}
}
// WithMaxVisibleHeight sets the maximum visible height in lines
func WithMaxVisibleHeight[T any](height int) Option[T] {
return func(o *Options[T]) {
o.maxVisibleHeight = height
}
}
// WithFallbackMessage sets the message to show when the list is empty
func WithFallbackMessage[T any](msg string) Option[T] {
return func(o *Options[T]) {
o.fallbackMsg = msg
}
}
// WithAlphaNumericKeys enables j/k navigation keys
func WithAlphaNumericKeys[T any](enabled bool) Option[T] {
return func(o *Options[T]) {
o.useAlphaNumericKeys = enabled
}
}
// WithRenderFunc sets the function to render items
func WithRenderFunc[T any](fn RenderFunc[T]) Option[T] {
return func(o *Options[T]) {
o.renderItem = fn
}
}
// WithSelectableFunc sets the function to determine if items are selectable
func WithSelectableFunc[T any](fn SelectableFunc[T]) Option[T] {
return func(o *Options[T]) {
o.isSelectable = fn
}
}
// WithStyle sets the base style that gets passed to render functions
func WithStyle[T any](style styles.Style) Option[T] {
return func(o *Options[T]) {
o.baseStyle = style
}
}
type List[T any] interface {
tea.Model
tea.ViewModel
SetMaxWidth(maxWidth int)
GetSelectedItem() (item T, idx int)
SetItems(items []T)
GetItems() []T
SetSelectedIndex(idx int)
SetEmptyMessage(msg string)
IsEmpty() bool
GetMaxVisibleHeight() int
}
type listComponent[T any] struct {
fallbackMsg string
items []T
selectedIdx int
maxWidth int
maxVisibleHeight int
useAlphaNumericKeys bool
width int
height int
renderItem RenderFunc[T]
isSelectable SelectableFunc[T]
baseStyle styles.Style
}
type listKeyMap struct {
Up key.Binding
Down key.Binding
UpAlpha key.Binding
DownAlpha key.Binding
}
var simpleListKeys = listKeyMap{
Up: key.NewBinding(
key.WithKeys("up", "ctrl+p"),
key.WithHelp("↑", "previous list item"),
),
Down: key.NewBinding(
key.WithKeys("down", "ctrl+n"),
key.WithHelp("↓", "next list item"),
),
UpAlpha: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "previous list item"),
),
DownAlpha: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "next list item"),
),
}
func (c *listComponent[T]) Init() tea.Cmd {
return nil
}
func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
c.moveUp()
return c, nil
case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
c.moveDown()
return c, nil
}
}
return c, nil
}
// moveUp moves the selection up, skipping non-selectable items
func (c *listComponent[T]) moveUp() {
if len(c.items) == 0 {
return
}
// Find the previous selectable item
for i := c.selectedIdx - 1; i >= 0; i-- {
if c.isSelectable(c.items[i]) {
c.selectedIdx = i
return
}
}
// If no selectable item found above, stay at current position
}
// moveDown moves the selection down, skipping non-selectable items
func (c *listComponent[T]) moveDown() {
if len(c.items) == 0 {
return
}
originalIdx := c.selectedIdx
for {
if c.selectedIdx < len(c.items)-1 {
c.selectedIdx++
} else {
break
}
if c.isSelectable(c.items[c.selectedIdx]) {
return
}
// Prevent infinite loop
if c.selectedIdx == originalIdx {
break
}
}
}
func (c *listComponent[T]) GetSelectedItem() (T, int) {
if len(c.items) > 0 && c.isSelectable(c.items[c.selectedIdx]) {
return c.items[c.selectedIdx], c.selectedIdx
}
var zero T
return zero, -1
}
func (c *listComponent[T]) SetItems(items []T) {
c.items = items
c.selectedIdx = 0
// Ensure initial selection is on a selectable item
if len(items) > 0 && !c.isSelectable(items[0]) {
c.moveDown()
}
}
func (c *listComponent[T]) GetItems() []T {
return c.items
}
func (c *listComponent[T]) SetEmptyMessage(msg string) {
c.fallbackMsg = msg
}
func (c *listComponent[T]) IsEmpty() bool {
return len(c.items) == 0
}
func (c *listComponent[T]) SetMaxWidth(width int) {
c.maxWidth = width
}
func (c *listComponent[T]) SetSelectedIndex(idx int) {
if idx >= 0 && idx < len(c.items) {
c.selectedIdx = idx
}
}
func (c *listComponent[T]) GetMaxVisibleHeight() int {
return c.maxVisibleHeight
}
func (c *listComponent[T]) View() string {
items := c.items
maxWidth := c.maxWidth
if maxWidth == 0 {
maxWidth = 80 // Default width if not set
}
if len(items) <= 0 {
return c.fallbackMsg
}
// Calculate viewport based on actual heights
startIdx, endIdx := c.calculateViewport()
listItems := make([]string, 0, endIdx-startIdx)
for i := startIdx; i < endIdx; i++ {
item := items[i]
// Special handling for HeaderItem to remove top margin on first item
if i == startIdx {
// Check if this is a HeaderItem
if _, ok := any(item).(Item); ok {
if headerItem, isHeader := any(item).(HeaderItem); isHeader {
// Render header without top margin when it's first
t := theme.CurrentTheme()
truncatedStr := truncate.StringWithTail(string(headerItem), uint(maxWidth-1), "...")
headerStyle := c.baseStyle.
Foreground(t.Accent()).
Bold(true).
MarginBottom(0).
PaddingLeft(1)
listItems = append(listItems, headerStyle.Render(truncatedStr))
continue
}
}
}
title := c.renderItem(item, i == c.selectedIdx, maxWidth, c.baseStyle)
listItems = append(listItems, title)
}
return strings.Join(listItems, "\n")
}
// calculateViewport determines which items to show based on available space
func (c *listComponent[T]) calculateViewport() (startIdx, endIdx int) {
items := c.items
if len(items) == 0 {
return 0, 0
}
// Calculate heights of all items
itemHeights := make([]int, len(items))
for i, item := range items {
rendered := c.renderItem(item, false, c.maxWidth, c.baseStyle)
itemHeights[i] = lipgloss.Height(rendered)
}
// Find the range of items that fit within maxVisibleHeight
// Start by trying to center the selected item
start := 0
end := len(items)
// Calculate height from start to selected
heightToSelected := 0
for i := 0; i <= c.selectedIdx && i < len(items); i++ {
heightToSelected += itemHeights[i]
}
// If selected item is beyond visible height, scroll to show it
if heightToSelected > c.maxVisibleHeight {
// Start from selected and work backwards to find start
currentHeight := itemHeights[c.selectedIdx]
start = c.selectedIdx
for i := c.selectedIdx - 1; i >= 0 && currentHeight+itemHeights[i] <= c.maxVisibleHeight; i-- {
currentHeight += itemHeights[i]
start = i
}
}
// Calculate end based on start
currentHeight := 0
for i := start; i < len(items); i++ {
if currentHeight+itemHeights[i] > c.maxVisibleHeight {
end = i
break
}
currentHeight += itemHeights[i]
}
return start, end
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func NewListComponent[T any](opts ...Option[T]) List[T] {
options := &Options[T]{
baseStyle: styles.NewStyle(), // Default empty style
}
for _, opt := range opts {
opt(options)
}
return &listComponent[T]{
fallbackMsg: options.fallbackMsg,
items: options.items,
maxVisibleHeight: options.maxVisibleHeight,
useAlphaNumericKeys: options.useAlphaNumericKeys,
selectedIdx: 0,
renderItem: options.renderItem,
isSelectable: options.isSelectable,
baseStyle: options.baseStyle,
}
}
// StringItem is a simple implementation of Item for string values
type StringItem string
func (s StringItem) Render(selected bool, width int, baseStyle styles.Style) string {
t := theme.CurrentTheme()
truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
var itemStyle styles.Style
if selected {
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
} else {
itemStyle = baseStyle.
Foreground(t.TextMuted()).
PaddingLeft(1)
}
return itemStyle.Render(truncatedStr)
}
func (s StringItem) Selectable() bool {
return true
}
// HeaderItem is a non-selectable header item for grouping
type HeaderItem string
func (h HeaderItem) Render(selected bool, width int, baseStyle styles.Style) string {
t := theme.CurrentTheme()
truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
headerStyle := baseStyle.
Foreground(t.Accent()).
Bold(true).
MarginTop(1).
MarginBottom(0).
PaddingLeft(1)
return headerStyle.Render(truncatedStr)
}
func (h HeaderItem) Selectable() bool {
return false
}
// Ensure StringItem and HeaderItem implement Item
var _ Item = StringItem("")
var _ Item = HeaderItem("")
@@ -0,0 +1,210 @@
package list
import (
"testing"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/styles"
)
// testItem is a simple test implementation of ListItem
type testItem struct {
value string
}
func (t testItem) Render(
selected bool,
width int,
isFirstInViewport bool,
baseStyle styles.Style,
) string {
return t.value
}
func (t testItem) Selectable() bool {
return true
}
// createTestList creates a list with test items for testing
func createTestList() *listComponent[testItem] {
items := []testItem{
{value: "item1"},
{value: "item2"},
{value: "item3"},
}
list := NewListComponent(
WithItems(items),
WithMaxVisibleHeight[testItem](5),
WithFallbackMessage[testItem]("empty"),
WithAlphaNumericKeys[testItem](false),
WithRenderFunc(
func(item testItem, selected bool, width int, baseStyle styles.Style) string {
return item.Render(selected, width, false, baseStyle)
},
),
WithSelectableFunc(func(item testItem) bool {
return item.Selectable()
}),
)
return list.(*listComponent[testItem])
}
func TestArrowKeyNavigation(t *testing.T) {
list := createTestList()
// Test down arrow navigation
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
updatedModel, _ := list.Update(downKey)
list = updatedModel.(*listComponent[testItem])
_, idx := list.GetSelectedItem()
if idx != 1 {
t.Errorf("Expected selected index 1 after down arrow, got %d", idx)
}
// Test up arrow navigation
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
updatedModel, _ = list.Update(upKey)
list = updatedModel.(*listComponent[testItem])
_, idx = list.GetSelectedItem()
if idx != 0 {
t.Errorf("Expected selected index 0 after up arrow, got %d", idx)
}
}
func TestJKKeyNavigation(t *testing.T) {
items := []testItem{
{value: "item1"},
{value: "item2"},
{value: "item3"},
}
// Create list with alpha keys enabled
list := NewListComponent(
WithItems(items),
WithMaxVisibleHeight[testItem](5),
WithFallbackMessage[testItem]("empty"),
WithAlphaNumericKeys[testItem](true),
WithRenderFunc(
func(item testItem, selected bool, width int, baseStyle styles.Style) string {
return item.Render(selected, width, false, baseStyle)
},
),
WithSelectableFunc(func(item testItem) bool {
return item.Selectable()
}),
)
// Test j key (down)
jKey := tea.KeyPressMsg{Code: 'j', Text: "j"}
updatedModel, _ := list.Update(jKey)
list = updatedModel.(*listComponent[testItem])
_, idx := list.GetSelectedItem()
if idx != 1 {
t.Errorf("Expected selected index 1 after 'j' key, got %d", idx)
}
// Test k key (up)
kKey := tea.KeyPressMsg{Code: 'k', Text: "k"}
updatedModel, _ = list.Update(kKey)
list = updatedModel.(*listComponent[testItem])
_, idx = list.GetSelectedItem()
if idx != 0 {
t.Errorf("Expected selected index 0 after 'k' key, got %d", idx)
}
}
func TestCtrlNavigation(t *testing.T) {
list := createTestList()
// Test Ctrl-N (down)
ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
updatedModel, _ := list.Update(ctrlN)
list = updatedModel.(*listComponent[testItem])
_, idx := list.GetSelectedItem()
if idx != 1 {
t.Errorf("Expected selected index 1 after Ctrl-N, got %d", idx)
}
// Test Ctrl-P (up)
ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
updatedModel, _ = list.Update(ctrlP)
list = updatedModel.(*listComponent[testItem])
_, idx = list.GetSelectedItem()
if idx != 0 {
t.Errorf("Expected selected index 0 after Ctrl-P, got %d", idx)
}
}
func TestNavigationBoundaries(t *testing.T) {
list := createTestList()
// Test up arrow at first item (should stay at 0)
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
updatedModel, _ := list.Update(upKey)
list = updatedModel.(*listComponent[testItem])
_, idx := list.GetSelectedItem()
if idx != 0 {
t.Errorf("Expected to stay at index 0 when pressing up at first item, got %d", idx)
}
// Move to last item
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
updatedModel, _ = list.Update(downKey)
list = updatedModel.(*listComponent[testItem])
updatedModel, _ = list.Update(downKey)
list = updatedModel.(*listComponent[testItem])
_, idx = list.GetSelectedItem()
if idx != 2 {
t.Errorf("Expected to be at index 2, got %d", idx)
}
// Test down arrow at last item (should stay at 2)
updatedModel, _ = list.Update(downKey)
list = updatedModel.(*listComponent[testItem])
_, idx = list.GetSelectedItem()
if idx != 2 {
t.Errorf("Expected to stay at index 2 when pressing down at last item, got %d", idx)
}
}
func TestEmptyList(t *testing.T) {
emptyList := NewListComponent(
WithItems([]testItem{}),
WithMaxVisibleHeight[testItem](5),
WithFallbackMessage[testItem]("empty"),
WithAlphaNumericKeys[testItem](false),
WithRenderFunc(
func(item testItem, selected bool, width int, baseStyle styles.Style) string {
return item.Render(selected, width, false, baseStyle)
},
),
WithSelectableFunc(func(item testItem) bool {
return item.Selectable()
}),
)
// Test navigation on empty list (should not crash)
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
updatedModel, _ := emptyList.Update(downKey)
emptyList = updatedModel.(*listComponent[testItem])
updatedModel, _ = emptyList.Update(upKey)
emptyList = updatedModel.(*listComponent[testItem])
updatedModel, _ = emptyList.Update(ctrlN)
emptyList = updatedModel.(*listComponent[testItem])
updatedModel, _ = emptyList.Update(ctrlP)
emptyList = updatedModel.(*listComponent[testItem])
// Verify empty list behavior
_, idx := emptyList.GetSelectedItem()
if idx != -1 {
t.Errorf("Expected index -1 for empty list, got %d", idx)
}
if !emptyList.IsEmpty() {
t.Error("Expected IsEmpty() to return true for empty list")
}
}
@@ -1,6 +1,8 @@
package modal
import (
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
@@ -66,6 +68,10 @@ func New(opts ...ModalOption) *Modal {
return m
}
func (m *Modal) SetTitle(title string) {
m.title = title
}
// Render renders the modal centered on the screen
func (m *Modal) Render(contentView string, background string) string {
t := theme.CurrentTheme()
@@ -84,26 +90,27 @@ func (m *Modal) Render(contentView string, background string) string {
innerWidth := outerWidth - 4
// Base style for the modal
baseStyle := styles.BaseStyle().
Background(t.BackgroundElement()).
Foreground(t.TextMuted())
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel())
// Add title if provided
var finalContent string
if m.title != "" {
titleStyle := baseStyle.
Foreground(t.Primary()).
Foreground(t.Text()).
Bold(true).
Width(innerWidth).
Padding(0, 1)
titleView := titleStyle.Render(m.title)
finalContent = lipgloss.JoinVertical(
lipgloss.Left,
titleView,
contentView,
)
escStyle := baseStyle.Foreground(t.TextMuted())
escText := escStyle.Render("esc")
// Calculate position for esc text
titleWidth := lipgloss.Width(m.title)
escWidth := lipgloss.Width(escText)
spacesNeeded := max(0, innerWidth-titleWidth-escWidth-2)
spacer := strings.Repeat(" ", spacesNeeded)
titleLine := m.title + spacer + escText
titleLine = titleStyle.Render(titleLine)
finalContent = strings.Join([]string{titleLine, "", contentView}, "\n")
} else {
finalContent = contentView
}
@@ -112,14 +119,7 @@ func (m *Modal) Render(contentView string, background string) string {
PaddingTop(1).
PaddingBottom(1).
PaddingLeft(2).
PaddingRight(2).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(true).
BorderLeftForeground(t.BackgroundSubtle()).
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundSubtle()).
BorderRightBackground(t.Background())
PaddingRight(2)
modalView := modalStyle.
Width(outerWidth).
@@ -135,9 +135,11 @@ func (m *Modal) Render(contentView string, background string) string {
col := (bgWidth - modalWidth) / 2
return layout.PlaceOverlay(
col,
col-1, // TODO: whyyyyy
row,
modalView,
background,
layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(t.BorderActive()),
)
}
@@ -3,7 +3,7 @@ package qr
import (
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"rsc.io/qr"
)
@@ -23,9 +23,7 @@ func Generate(text string) (string, int, error) {
}
// Create lipgloss style for QR code with theme colors
qrStyle := lipgloss.NewStyle().
Foreground(t.Text()).
Background(t.Background())
qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
var result strings.Builder
@@ -0,0 +1,148 @@
package status
import (
"os"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type StatusComponent interface {
tea.Model
tea.ViewModel
}
type statusComponent struct {
app *app.App
width int
cwd string
}
func (m statusComponent) Init() tea.Cmd {
return nil
}
func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
return m, nil
}
return m, nil
}
func (m statusComponent) logo() string {
t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render
emphasis := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundElement()).
Bold(true).
Render
open := base("open")
code := emphasis("code ")
version := base(m.app.Version)
return styles.NewStyle().
Background(t.BackgroundElement()).
Padding(0, 1).
Render(open + code + version)
}
func (m statusComponent) View() string {
t := theme.CurrentTheme()
logo := m.logo()
cwd := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundPanel()).
Padding(0, 1).
Render(m.cwd)
var modeBackground compat.AdaptiveColor
var modeForeground compat.AdaptiveColor
switch m.app.ModeIndex {
case 0:
modeBackground = t.BackgroundElement()
modeForeground = t.TextMuted()
case 1:
modeBackground = t.Secondary()
modeForeground = t.BackgroundPanel()
case 2:
modeBackground = t.Accent()
modeForeground = t.BackgroundPanel()
case 3:
modeBackground = t.Success()
modeForeground = t.BackgroundPanel()
case 4:
modeBackground = t.Warning()
modeForeground = t.BackgroundPanel()
case 5:
modeBackground = t.Primary()
modeForeground = t.BackgroundPanel()
case 6:
modeBackground = t.Error()
modeForeground = t.BackgroundPanel()
default:
modeBackground = t.Secondary()
modeForeground = t.BackgroundPanel()
}
command := m.app.Commands[commands.SwitchModeCommand]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = m.app.Config.Keybinds.Leader + " " + kb.Key
}
modeStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
modeNameStyle := modeStyle.Bold(true).Render
modeDescStyle := modeStyle.Render
mode := modeNameStyle(strings.ToUpper(m.app.Mode.Name)) + modeDescStyle(" MODE")
mode = modeStyle.
Padding(0, 1).
BorderLeft(true).
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(modeBackground).
BorderBackground(t.BackgroundPanel()).
Render(mode)
mode = styles.NewStyle().
Faint(true).
Background(t.BackgroundPanel()).
Foreground(t.TextMuted()).
Render(key+" ") +
mode
space := max(
0,
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(mode),
)
spacer := styles.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
status := logo + cwd + spacer + mode
blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status
}
func NewStatusCmp(app *app.App) StatusComponent {
statusComponent := &statusComponent{
app: app,
}
homePath, err := os.UserHomeDir()
cwdPath := app.Info.Path.Cwd
if err == nil && homePath != "" && strings.HasPrefix(cwdPath, homePath) {
cwdPath = "~" + cwdPath[len(homePath):]
}
statusComponent.cwd = cwdPath
return statusComponent
}
@@ -0,0 +1,125 @@
// Package memoization implement a simple memoization cache. It's designed to
// improve performance in textarea.
package textarea
import (
"container/list"
"crypto/sha256"
"fmt"
"sync"
)
// Hasher is an interface that requires a Hash method. The Hash method is
// expected to return a string representation of the hash of the object.
type Hasher interface {
Hash() string
}
// entry is a struct that holds a key-value pair. It is used as an element
// in the evictionList of the MemoCache.
type entry[T any] struct {
key string
value T
}
// MemoCache is a struct that represents a cache with a set capacity. It
// uses an LRU (Least Recently Used) eviction policy. It is safe for
// concurrent use.
type MemoCache[H Hasher, T any] struct {
capacity int
mutex sync.Mutex
cache map[string]*list.Element // The cache holding the results
evictionList *list.List // A list to keep track of the order for LRU
hashableItems map[string]T // This map keeps track of the original hashable items (optional)
}
// NewMemoCache is a function that creates a new MemoCache with a given
// capacity. It returns a pointer to the created MemoCache.
func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
return &MemoCache[H, T]{
capacity: capacity,
cache: make(map[string]*list.Element),
evictionList: list.New(),
hashableItems: make(map[string]T),
}
}
// Capacity is a method that returns the capacity of the MemoCache.
func (m *MemoCache[H, T]) Capacity() int {
return m.capacity
}
// Size is a method that returns the current size of the MemoCache. It is
// the number of items currently stored in the cache.
func (m *MemoCache[H, T]) Size() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.evictionList.Len()
}
// Get is a method that returns the value associated with the given
// hashable item in the MemoCache. If there is no corresponding value, the
// method returns nil.
func (m *MemoCache[H, T]) Get(h H) (T, bool) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
return element.Value.(*entry[T]).value, true
}
var result T
return result, false
}
// Set is a method that sets the value for the given hashable item in the
// MemoCache. If the cache is at capacity, it evicts the least recently
// used item before adding the new item.
func (m *MemoCache[H, T]) Set(h H, value T) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
element.Value.(*entry[T]).value = value
return
}
// Check if the cache is at capacity
if m.evictionList.Len() >= m.capacity {
// Evict the least recently used item from the cache
toEvict := m.evictionList.Back()
if toEvict != nil {
evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
delete(m.cache, evictedEntry.key)
delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
}
}
// Add the value to the cache and the evictionList
newEntry := &entry[T]{
key: hashedKey,
value: value,
}
element := m.evictionList.PushFront(newEntry)
m.cache[hashedKey] = element
m.hashableItems[hashedKey] = value // if you're keeping track of original items
}
// HString is a type that implements the Hasher interface for strings.
type HString string
// Hash is a method that returns the hash of the string.
func (h HString) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
}
// HInt is a type that implements the Hasher interface for integers.
type HInt int
// Hash is a method that returns the hash of the integer.
func (h HInt) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h))))
}
@@ -0,0 +1,102 @@
// Package runeutil provides utility functions for tidying up incoming runes
// from Key messages.
package textarea
import (
"unicode"
"unicode/utf8"
)
// Sanitizer is a helper for bubble widgets that want to process
// Runes from input key messages.
type Sanitizer interface {
// Sanitize removes control characters from runes in a KeyRunes
// message, and optionally replaces newline/carriage return/tabs by a
// specified character.
//
// The rune array is modified in-place if possible. In that case, the
// returned slice is the original slice shortened after the control
// characters have been removed/translated.
Sanitize(runes []rune) []rune
}
// NewSanitizer constructs a rune sanitizer.
func NewSanitizer(opts ...Option) Sanitizer {
s := sanitizer{
replaceNewLine: []rune("\n"),
replaceTab: []rune(" "),
}
for _, o := range opts {
s = o(s)
}
return &s
}
// Option is the type of option that can be passed to Sanitize().
type Option func(sanitizer) sanitizer
// ReplaceTabs replaces tabs by the specified string.
func ReplaceTabs(tabRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceTab = []rune(tabRepl)
return s
}
}
// ReplaceNewlines replaces newline characters by the specified string.
func ReplaceNewlines(nlRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceNewLine = []rune(nlRepl)
return s
}
}
func (s *sanitizer) Sanitize(runes []rune) []rune {
// dstrunes are where we are storing the result.
dstrunes := runes[:0:len(runes)]
// copied indicates whether dstrunes is an alias of runes
// or a copy. We need a copy when dst moves past src.
// We use this as an optimization to avoid allocating
// a new rune slice in the common case where the output
// is smaller or equal to the input.
copied := false
for src := 0; src < len(runes); src++ {
r := runes[src]
switch {
case r == utf8.RuneError:
// skip
case r == '\r' || r == '\n':
if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceNewLine...)
case r == '\t':
if len(dstrunes)+len(s.replaceTab) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceTab...)
case unicode.IsControl(r):
// Other control characters: skip.
default:
// Keep the character.
dstrunes = append(dstrunes, runes[src])
}
}
return dstrunes
}
type sanitizer struct {
replaceNewLine []rune
replaceTab []rune
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,266 @@
package toast
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
// ShowToastMsg is a message to display a toast notification
type ShowToastMsg struct {
Message string
Title *string
Color compat.AdaptiveColor
Duration time.Duration
}
// DismissToastMsg is a message to dismiss a specific toast
type DismissToastMsg struct {
ID string
}
// Toast represents a single toast notification
type Toast struct {
ID string
Message string
Title *string
Color compat.AdaptiveColor
CreatedAt time.Time
Duration time.Duration
}
// ToastManager manages multiple toast notifications
type ToastManager struct {
toasts []Toast
}
// NewToastManager creates a new toast manager
func NewToastManager() *ToastManager {
return &ToastManager{
toasts: []Toast{},
}
}
// Init initializes the toast manager
func (tm *ToastManager) Init() tea.Cmd {
return nil
}
// Update handles messages for the toast manager
func (tm *ToastManager) Update(msg tea.Msg) (*ToastManager, tea.Cmd) {
switch msg := msg.(type) {
case ShowToastMsg:
toast := Toast{
ID: fmt.Sprintf("toast-%d", time.Now().UnixNano()),
Title: msg.Title,
Message: msg.Message,
Color: msg.Color,
CreatedAt: time.Now(),
Duration: msg.Duration,
}
tm.toasts = append(tm.toasts, toast)
// Return command to dismiss after duration
return tm, tea.Tick(toast.Duration, func(t time.Time) tea.Msg {
return DismissToastMsg{ID: toast.ID}
})
case DismissToastMsg:
var newToasts []Toast
for _, t := range tm.toasts {
if t.ID != msg.ID {
newToasts = append(newToasts, t)
}
}
tm.toasts = newToasts
}
return tm, nil
}
// renderSingleToast renders a single toast notification
func (tm *ToastManager) renderSingleToast(toast Toast) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundElement()).
Padding(1, 2)
maxWidth := max(40, layout.Current.Viewport.Width/3)
contentMaxWidth := max(maxWidth-6, 20)
// Build content with wrapping
var content strings.Builder
if toast.Title != nil {
titleStyle := styles.NewStyle().Foreground(toast.Color).
Bold(true)
content.WriteString(titleStyle.Render(*toast.Title))
content.WriteString("\n")
}
// Wrap message text
messageStyle := styles.NewStyle()
contentWidth := lipgloss.Width(toast.Message)
if contentWidth > contentMaxWidth {
messageStyle = messageStyle.Width(contentMaxWidth)
}
content.WriteString(messageStyle.Render(toast.Message))
// Render toast with max width
return baseStyle.MaxWidth(maxWidth).Render(content.String())
}
// View renders all active toasts
func (tm *ToastManager) View() string {
if len(tm.toasts) == 0 {
return ""
}
var toastViews []string
for _, toast := range tm.toasts {
toastView := tm.renderSingleToast(toast)
toastViews = append(toastViews, toastView+"\n")
}
return strings.Join(toastViews, "\n")
}
// RenderOverlay renders the toasts as an overlay on the given background
func (tm *ToastManager) RenderOverlay(background string) string {
if len(tm.toasts) == 0 {
return background
}
bgWidth := lipgloss.Width(background)
bgHeight := lipgloss.Height(background)
result := background
// Start from top with 2 character padding
currentY := 2
// Render each toast individually
for _, toast := range tm.toasts {
// Render individual toast
toastView := tm.renderSingleToast(toast)
toastWidth := lipgloss.Width(toastView)
toastHeight := lipgloss.Height(toastView)
// Position at top-right with 2 character padding from right edge
x := max(bgWidth-toastWidth-4, 0)
// Check if toast fits vertically
if currentY+toastHeight > bgHeight-2 {
// No more room for toasts
break
}
// Place this toast
result = layout.PlaceOverlay(
x,
currentY,
toastView,
result,
layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(toast.Color),
)
// Move down for next toast (add 1 for spacing between toasts)
currentY += toastHeight + 1
}
return result
}
type ToastOptions struct {
Title string
Duration time.Duration
}
type toastOptions struct {
title *string
duration *time.Duration
color *compat.AdaptiveColor
}
type ToastOption func(*toastOptions)
func WithTitle(title string) ToastOption {
return func(t *toastOptions) {
t.title = &title
}
}
func WithDuration(duration time.Duration) ToastOption {
return func(t *toastOptions) {
t.duration = &duration
}
}
func WithColor(color compat.AdaptiveColor) ToastOption {
return func(t *toastOptions) {
t.color = &color
}
}
func NewToast(message string, options ...ToastOption) tea.Cmd {
t := theme.CurrentTheme()
duration := 5 * time.Second
color := t.Primary()
opts := toastOptions{
duration: &duration,
color: &color,
}
for _, option := range options {
option(&opts)
}
return func() tea.Msg {
return ShowToastMsg{
Message: message,
Title: opts.title,
Duration: *opts.duration,
Color: *opts.color,
}
}
}
func NewInfoToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Info()))
return NewToast(
message,
options...,
)
}
func NewSuccessToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Success()))
return NewToast(
message,
options...,
)
}
func NewWarningToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Warning()))
return NewToast(
message,
options...,
)
}
func NewErrorToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Error()))
return NewToast(
message,
options...,
)
}
+96
View File
@@ -0,0 +1,96 @@
package id
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"sync"
"time"
)
const (
PrefixSession = "ses"
PrefixMessage = "msg"
PrefixUser = "usr"
PrefixPart = "prt"
)
const length = 26
var (
lastTimestamp int64
counter int64
mu sync.Mutex
)
type Prefix string
const (
Session Prefix = PrefixSession
Message Prefix = PrefixMessage
User Prefix = PrefixUser
Part Prefix = PrefixPart
)
func ValidatePrefix(id string, prefix Prefix) bool {
return strings.HasPrefix(id, string(prefix))
}
func Ascending(prefix Prefix, given ...string) string {
return generateID(prefix, false, given...)
}
func Descending(prefix Prefix, given ...string) string {
return generateID(prefix, true, given...)
}
func generateID(prefix Prefix, descending bool, given ...string) string {
if len(given) > 0 && given[0] != "" {
if !strings.HasPrefix(given[0], string(prefix)) {
panic(fmt.Sprintf("ID %s does not start with %s", given[0], string(prefix)))
}
return given[0]
}
return generateNewID(prefix, descending)
}
func randomBase62(length int) string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
result := make([]byte, length)
bytes := make([]byte, length)
rand.Read(bytes)
for i := 0; i < length; i++ {
result[i] = chars[bytes[i]%62]
}
return string(result)
}
func generateNewID(prefix Prefix, descending bool) string {
mu.Lock()
defer mu.Unlock()
currentTimestamp := time.Now().UnixMilli()
if currentTimestamp != lastTimestamp {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
now := uint64(currentTimestamp)*0x1000 + uint64(counter)
if descending {
now = ^now
}
timeBytes := make([]byte, 6)
for i := 0; i < 6; i++ {
timeBytes[i] = byte((now >> (40 - 8*i)) & 0xff)
}
return string(prefix) + "_" + hex.EncodeToString(timeBytes) + randomBase62(length-12)
}
+325
View File
@@ -0,0 +1,325 @@
package layout
import (
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type Direction int
const (
Row Direction = iota
Column
)
type Justify int
const (
JustifyStart Justify = iota
JustifyEnd
JustifyCenter
JustifySpaceBetween
JustifySpaceAround
)
type Align int
const (
AlignStart Align = iota
AlignEnd
AlignCenter
AlignStretch // Only applicable in the cross-axis
)
type FlexOptions struct {
Background *compat.AdaptiveColor
Direction Direction
Justify Justify
Align Align
Width int
Height int
Gap int
}
type FlexItem struct {
View string
FixedSize int // Fixed size in the main axis (width for Row, height for Column)
Grow bool // If true, the item will grow to fill available space
}
// Render lays out a series of view strings based on flexbox-like rules.
func Render(opts FlexOptions, items ...FlexItem) string {
if len(items) == 0 {
return ""
}
t := theme.CurrentTheme()
if opts.Background == nil {
background := t.Background()
opts.Background = &background
}
// Calculate dimensions for each item
mainAxisSize := opts.Width
crossAxisSize := opts.Height
if opts.Direction == Column {
mainAxisSize = opts.Height
crossAxisSize = opts.Width
}
// Calculate total fixed size and count grow items
totalFixedSize := 0
growCount := 0
for _, item := range items {
if item.FixedSize > 0 {
totalFixedSize += item.FixedSize
} else if item.Grow {
growCount++
}
}
// Account for gaps between items
totalGapSize := 0
if len(items) > 1 && opts.Gap > 0 {
totalGapSize = opts.Gap * (len(items) - 1)
}
// Calculate available space for grow items
availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0)
// Calculate size for each grow item
growItemSize := 0
if growCount > 0 && availableSpace > 0 {
growItemSize = availableSpace / growCount
}
// Prepare sized views
sizedViews := make([]string, len(items))
actualSizes := make([]int, len(items))
for i, item := range items {
view := item.View
// Determine the size for this item
itemSize := 0
if item.FixedSize > 0 {
itemSize = item.FixedSize
} else if item.Grow && growItemSize > 0 {
itemSize = growItemSize
} else {
// No fixed size and not growing - use natural size
if opts.Direction == Row {
itemSize = lipgloss.Width(view)
} else {
itemSize = lipgloss.Height(view)
}
}
// Apply size constraints
if opts.Direction == Row {
// For row direction, constrain width and handle height alignment
if itemSize > 0 {
view = styles.NewStyle().
Background(*opts.Background).
Width(itemSize).
Height(crossAxisSize).
Render(view)
}
// Apply cross-axis alignment
switch opts.Align {
case AlignCenter:
view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Center,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignEnd:
view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Bottom,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStart:
view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Top,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStretch:
// Already stretched by Height setting above
}
} else {
// For column direction, constrain height and handle width alignment
if itemSize > 0 {
style := styles.NewStyle().
Background(*opts.Background).
Height(itemSize)
// Only set width for stretch alignment
if opts.Align == AlignStretch {
style = style.Width(crossAxisSize)
}
view = style.Render(view)
}
// Apply cross-axis alignment
switch opts.Align {
case AlignCenter:
view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Center,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignEnd:
view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Right,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStart:
view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Left,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStretch:
// Already stretched by Width setting above
}
}
sizedViews[i] = view
if opts.Direction == Row {
actualSizes[i] = lipgloss.Width(view)
} else {
actualSizes[i] = lipgloss.Height(view)
}
}
// Calculate total actual size including gaps
totalActualSize := 0
for _, size := range actualSizes {
totalActualSize += size
}
if len(items) > 1 && opts.Gap > 0 {
totalActualSize += opts.Gap * (len(items) - 1)
}
// Apply justification
remainingSpace := max(mainAxisSize-totalActualSize, 0)
// Calculate spacing based on justification
var spaceBefore, spaceBetween, spaceAfter int
switch opts.Justify {
case JustifyStart:
spaceAfter = remainingSpace
case JustifyEnd:
spaceBefore = remainingSpace
case JustifyCenter:
spaceBefore = remainingSpace / 2
spaceAfter = remainingSpace - spaceBefore
case JustifySpaceBetween:
if len(items) > 1 {
spaceBetween = remainingSpace / (len(items) - 1)
} else {
spaceAfter = remainingSpace
}
case JustifySpaceAround:
if len(items) > 0 {
spaceAround := remainingSpace / (len(items) * 2)
spaceBefore = spaceAround
spaceAfter = spaceAround
spaceBetween = spaceAround * 2
}
}
// Build the final layout
var parts []string
spaceStyle := styles.NewStyle().Background(*opts.Background)
// Add space before if needed
if spaceBefore > 0 {
if opts.Direction == Row {
space := strings.Repeat(" ", spaceBefore)
parts = append(parts, spaceStyle.Render(space))
} else {
// For vertical layout, add empty lines as separate parts
for range spaceBefore {
parts = append(parts, "")
}
}
}
// Add items with spacing
for i, view := range sizedViews {
parts = append(parts, view)
// Add space between items (not after the last one)
if i < len(sizedViews)-1 {
// Add gap first, then any additional spacing from justification
totalSpacing := opts.Gap + spaceBetween
if totalSpacing > 0 {
if opts.Direction == Row {
space := strings.Repeat(" ", totalSpacing)
parts = append(parts, spaceStyle.Render(space))
} else {
// For vertical layout, add empty lines as separate parts
for range totalSpacing {
parts = append(parts, "")
}
}
}
}
}
// Add space after if needed
if spaceAfter > 0 {
if opts.Direction == Row {
space := strings.Repeat(" ", spaceAfter)
parts = append(parts, spaceStyle.Render(space))
} else {
// For vertical layout, add empty lines as separate parts
for range spaceAfter {
parts = append(parts, "")
}
}
}
// Join the parts
if opts.Direction == Row {
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
} else {
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
}
// Helper function to create a simple vertical layout
func Vertical(width, height int, items ...FlexItem) string {
return Render(FlexOptions{
Direction: Column,
Width: width,
Height: height,
Justify: JustifyStart,
Align: AlignStretch,
}, items...)
}
// Helper function to create a simple horizontal layout
func Horizontal(width, height int, items ...FlexItem) string {
return Render(FlexOptions{
Direction: Row,
Width: width,
Height: height,
Justify: JustifyStart,
Align: AlignStretch,
}, items...)
}
@@ -0,0 +1,32 @@
package layout
import (
tea "github.com/charmbracelet/bubbletea/v2"
)
var Current *LayoutInfo
func init() {
Current = &LayoutInfo{
Viewport: Dimensions{Width: 80, Height: 25},
Container: Dimensions{Width: 80, Height: 25},
}
}
type LayoutSize string
type Dimensions struct {
Width int
Height int
}
type LayoutInfo struct {
Viewport Dimensions
Container Dimensions
}
type Modal interface {
tea.Model
Render(background string) string
Close() tea.Cmd
}
@@ -0,0 +1,382 @@
package layout
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
chAnsi "github.com/charmbracelet/x/ansi"
"github.com/muesli/ansi"
"github.com/muesli/reflow/truncate"
"github.com/muesli/termenv"
"github.com/sst/opencode/internal/util"
)
var (
// ANSI escape sequence regex
ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
)
// Split a string into lines, additionally returning the size of the widest line.
func getLines(s string) (lines []string, widest int) {
lines = strings.Split(s, "\n")
for _, l := range lines {
w := ansi.PrintableRuneWidth(l)
if widest < w {
widest = w
}
}
return lines, widest
}
// overlayOptions holds configuration for overlay rendering
type overlayOptions struct {
whitespace *whitespace
border bool
borderColor *compat.AdaptiveColor
}
// OverlayOption sets options for overlay rendering
type OverlayOption func(*overlayOptions)
// PlaceOverlay places fg on top of bg.
func PlaceOverlay(
x, y int,
fg, bg string,
opts ...OverlayOption,
) string {
fgLines, fgWidth := getLines(fg)
bgLines, bgWidth := getLines(bg)
bgHeight := len(bgLines)
fgHeight := len(fgLines)
// Parse options
options := &overlayOptions{
whitespace: &whitespace{},
}
for _, opt := range opts {
opt(options)
}
// Adjust for borders if enabled
if options.border {
// Add space for left and right borders
adjustedFgWidth := fgWidth + 2
// Adjust placement to account for borders
x = util.Clamp(x, 0, bgWidth-adjustedFgWidth)
y = util.Clamp(y, 0, bgHeight-fgHeight)
// Pad all foreground lines to the same width for consistent borders
for i := range fgLines {
lineWidth := ansi.PrintableRuneWidth(fgLines[i])
if lineWidth < fgWidth {
fgLines[i] += strings.Repeat(" ", fgWidth-lineWidth)
}
}
} else {
if fgWidth >= bgWidth && fgHeight >= bgHeight {
// FIXME: return fg or bg?
return fg
}
// TODO: allow placement outside of the bg box?
x = util.Clamp(x, 0, bgWidth-fgWidth)
y = util.Clamp(y, 0, bgHeight-fgHeight)
}
var b strings.Builder
for i, bgLine := range bgLines {
if i > 0 {
b.WriteByte('\n')
}
if i < y || i >= y+fgHeight {
b.WriteString(bgLine)
continue
}
pos := 0
// Handle left side of the line up to the overlay
if x > 0 {
left := truncate.String(bgLine, uint(x))
pos = ansi.PrintableRuneWidth(left)
b.WriteString(left)
if pos < x {
b.WriteString(options.whitespace.render(x - pos))
pos = x
}
}
// Render the overlay content with optional borders
if options.border {
// Get the foreground line
fgLine := fgLines[i-y]
fgLineWidth := ansi.PrintableRuneWidth(fgLine)
// Extract the styles at the border positions
// We need to get the style just before the border position to preserve background
leftStyle := ansiStyle{}
if pos > 0 {
leftStyle = getStyleAtPosition(bgLine, pos-1)
} else {
leftStyle = getStyleAtPosition(bgLine, pos)
}
rightStyle := getStyleAtPosition(bgLine, pos+fgLineWidth)
// Left border - combine background from original with border foreground
leftSeq := combineStyles(leftStyle, options.borderColor)
if leftSeq != "" {
b.WriteString(leftSeq)
}
b.WriteString("┃")
if leftSeq != "" {
b.WriteString("\x1b[0m") // Reset all styles only if we applied any
}
pos++
// Content
b.WriteString(fgLine)
pos += fgLineWidth
// Right border - combine background from original with border foreground
rightSeq := combineStyles(rightStyle, options.borderColor)
if rightSeq != "" {
b.WriteString(rightSeq)
}
b.WriteString("┃")
if rightSeq != "" {
b.WriteString("\x1b[0m") // Reset all styles only if we applied any
}
pos++
} else {
// No border, just render the content
fgLine := fgLines[i-y]
b.WriteString(fgLine)
pos += ansi.PrintableRuneWidth(fgLine)
}
// Handle right side of the line after the overlay
right := cutLeft(bgLine, pos)
bgWidth := ansi.PrintableRuneWidth(bgLine)
rightWidth := ansi.PrintableRuneWidth(right)
if rightWidth <= bgWidth-pos {
b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
}
b.WriteString(right)
}
return b.String()
}
// cutLeft cuts printable characters from the left.
// This function is heavily based on muesli's ansi and truncate packages.
func cutLeft(s string, cutWidth int) string {
return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
}
// ansiStyle represents parsed ANSI style attributes
type ansiStyle struct {
fgColor string
bgColor string
attrs []string
}
// parseANSISequence parses an ANSI escape sequence into its components
func parseANSISequence(seq string) ansiStyle {
style := ansiStyle{}
// Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
return style
}
params := seq[2 : len(seq)-1]
if params == "" {
return style
}
parts := strings.Split(params, ";")
i := 0
for i < len(parts) {
switch parts[i] {
case "0": // Reset
// Mark this as a reset by adding it to attrs
style.attrs = append(style.attrs, "0")
// Don't clear the style here, let the caller handle it
case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
style.attrs = append(style.attrs, parts[i])
case "38": // Foreground color
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
// 256 color mode
style.fgColor = strings.Join(parts[i:i+3], ";")
i += 2
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
// RGB color mode
style.fgColor = strings.Join(parts[i:i+5], ";")
i += 4
}
case "48": // Background color
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
// 256 color mode
style.bgColor = strings.Join(parts[i:i+3], ";")
i += 2
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
// RGB color mode
style.bgColor = strings.Join(parts[i:i+5], ";")
i += 4
}
case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
style.fgColor = parts[i]
case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
style.bgColor = parts[i]
case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
style.fgColor = parts[i]
case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
style.bgColor = parts[i]
}
i++
}
return style
}
// combineStyles creates an ANSI sequence that combines background from one style with foreground from another
func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
return ""
}
var parts []string
// Add attributes
parts = append(parts, bgStyle.attrs...)
// Add background color from the original style
if bgStyle.bgColor != "" {
parts = append(parts, bgStyle.bgColor)
}
// Add foreground color if specified
if fgColor != nil {
// Use the adaptive color which automatically selects based on terminal background
// The RGBA method already handles light/dark selection
r, g, b, _ := fgColor.RGBA()
// RGBA returns 16-bit values, we need 8-bit
parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
}
if len(parts) == 0 {
return ""
}
return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
}
// getStyleAtPosition extracts the active ANSI style at a given visual position
func getStyleAtPosition(s string, targetPos int) ansiStyle {
visualPos := 0
currentStyle := ansiStyle{}
i := 0
for i < len(s) && visualPos <= targetPos {
// Check if we're at an ANSI escape sequence
if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
// Found an ANSI sequence at current position
seq := s[i : i+match[1]]
parsedStyle := parseANSISequence(seq)
// Check if this is a reset sequence
if len(parsedStyle.attrs) > 0 && parsedStyle.attrs[0] == "0" {
// Reset all styles
currentStyle = ansiStyle{}
} else {
// Update current style (merge with existing)
if parsedStyle.fgColor != "" {
currentStyle.fgColor = parsedStyle.fgColor
}
if parsedStyle.bgColor != "" {
currentStyle.bgColor = parsedStyle.bgColor
}
if len(parsedStyle.attrs) > 0 {
currentStyle.attrs = parsedStyle.attrs
}
}
i += match[1]
} else if i < len(s) {
// Regular character
if visualPos == targetPos {
return currentStyle
}
_, size := utf8.DecodeRuneInString(s[i:])
i += size
visualPos++
}
}
return currentStyle
}
type whitespace struct {
style termenv.Style
chars string
}
// Render whitespaces.
func (w whitespace) render(width int) string {
if w.chars == "" {
w.chars = " "
}
r := []rune(w.chars)
j := 0
b := strings.Builder{}
// Cycle through runes and print them into the whitespace.
for i := 0; i < width; {
b.WriteRune(r[j])
j++
if j >= len(r) {
j = 0
}
i += ansi.PrintableRuneWidth(string(r[j]))
}
// Fill any extra gaps white spaces. This might be necessary if any runes
// are more than one cell wide, which could leave a one-rune gap.
short := width - ansi.PrintableRuneWidth(b.String())
if short > 0 {
b.WriteString(strings.Repeat(" ", short))
}
return w.style.Styled(b.String())
}
// WhitespaceOption sets a styling rule for rendering whitespace.
type WhitespaceOption func(*whitespace)
// WithWhitespace sets whitespace options for the overlay
func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
return func(o *overlayOptions) {
for _, opt := range opts {
opt(o.whitespace)
}
}
}
// WithOverlayBorder enables border rendering for the overlay
func WithOverlayBorder() OverlayOption {
return func(o *overlayOptions) {
o.border = true
}
}
// WithOverlayBorderColor sets the border color for the overlay
func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
return func(o *overlayOptions) {
o.borderColor = &color
}
}
@@ -1,6 +1,9 @@
package styles
import "image/color"
type TerminalInfo struct {
Background color.Color
BackgroundIsDark bool
}
@@ -8,6 +11,7 @@ var Terminal *TerminalInfo
func init() {
Terminal = &TerminalInfo{
BackgroundIsDark: false,
Background: color.Black,
BackgroundIsDark: true,
}
}
@@ -3,6 +3,7 @@ package styles
import (
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/lucasb-eyer/go-colorful"
"github.com/sst/opencode/internal/theme"
@@ -29,7 +30,7 @@ func GetMarkdownRenderer(width int, backgroundColor compat.AdaptiveColor) *glamo
// using adaptive colors from the provided theme.
func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.StyleConfig {
t := theme.CurrentTheme()
background := stringPtr(AdaptiveColorToString(backgroundColor))
background := AdaptiveColorToString(backgroundColor)
return ansi.StyleConfig{
Document: ansi.StyleBlock{
@@ -37,12 +38,12 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
BlockPrefix: "",
BlockSuffix: "",
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownText())),
Color: AdaptiveColorToString(t.MarkdownText()),
},
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownBlockQuote())),
Color: AdaptiveColorToString(t.MarkdownBlockQuote()),
Italic: boolPtr(true),
Prefix: "┃ ",
},
@@ -54,108 +55,108 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
StyleBlock: ansi.StyleBlock{
IndentToken: stringPtr(" "),
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownText())),
Color: AdaptiveColorToString(t.MarkdownText()),
},
},
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "# ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
Bold: boolPtr(true),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: boolPtr(true),
Color: stringPtr(AdaptiveColorToString(t.TextMuted())),
Color: AdaptiveColorToString(t.TextMuted()),
},
Emph: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownEmph())),
Color: AdaptiveColorToString(t.MarkdownEmph()),
Italic: boolPtr(true),
},
Strong: ansi.StylePrimitive{
Bold: boolPtr(true),
Color: stringPtr(AdaptiveColorToString(t.MarkdownStrong())),
Color: AdaptiveColorToString(t.MarkdownStrong()),
},
HorizontalRule: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownHorizontalRule())),
Color: AdaptiveColorToString(t.MarkdownHorizontalRule()),
Format: "\n─────────────────────────────────────────\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownListItem())),
Color: AdaptiveColorToString(t.MarkdownListItem()),
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownListEnumeration())),
Color: AdaptiveColorToString(t.MarkdownListEnumeration()),
},
Task: ansi.StyleTask{
Ticked: "[✓] ",
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownLink())),
Color: AdaptiveColorToString(t.MarkdownLink()),
Underline: boolPtr(true),
},
LinkText: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownLinkText())),
Color: AdaptiveColorToString(t.MarkdownLinkText()),
Bold: boolPtr(true),
},
Image: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownImage())),
Color: AdaptiveColorToString(t.MarkdownImage()),
Underline: boolPtr(true),
Format: "🖼 {{.text}}",
},
ImageText: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownImageText())),
Color: AdaptiveColorToString(t.MarkdownImageText()),
Format: "{{.text}}",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownCode())),
Color: AdaptiveColorToString(t.MarkdownCode()),
Prefix: "",
Suffix: "",
},
@@ -165,7 +166,7 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
StylePrimitive: ansi.StylePrimitive{
BackgroundColor: background,
Prefix: " ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownCodeBlock())),
Color: AdaptiveColorToString(t.MarkdownCodeBlock()),
},
},
Chroma: &ansi.Chroma{
@@ -174,116 +175,115 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
},
Text: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownText())),
Color: AdaptiveColorToString(t.MarkdownText()),
},
Error: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.Error())),
Color: AdaptiveColorToString(t.Error()),
},
Comment: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxComment())),
Color: AdaptiveColorToString(t.SyntaxComment()),
},
CommentPreproc: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
Keyword: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
KeywordReserved: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
KeywordNamespace: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
KeywordType: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxType())),
Color: AdaptiveColorToString(t.SyntaxType()),
},
Operator: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxOperator())),
Color: AdaptiveColorToString(t.SyntaxOperator()),
},
Punctuation: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxPunctuation())),
Color: AdaptiveColorToString(t.SyntaxPunctuation()),
},
Name: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxVariable())),
Color: AdaptiveColorToString(t.SyntaxVariable()),
},
NameBuiltin: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxVariable())),
Color: AdaptiveColorToString(t.SyntaxVariable()),
},
NameTag: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
NameAttribute: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxFunction())),
Color: AdaptiveColorToString(t.SyntaxFunction()),
},
NameClass: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxType())),
Color: AdaptiveColorToString(t.SyntaxType()),
},
NameConstant: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxVariable())),
Color: AdaptiveColorToString(t.SyntaxVariable()),
},
NameDecorator: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxFunction())),
Color: AdaptiveColorToString(t.SyntaxFunction()),
},
NameFunction: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxFunction())),
Color: AdaptiveColorToString(t.SyntaxFunction()),
},
LiteralNumber: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxNumber())),
Color: AdaptiveColorToString(t.SyntaxNumber()),
},
LiteralString: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxString())),
Color: AdaptiveColorToString(t.SyntaxString()),
},
LiteralStringEscape: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.SyntaxKeyword())),
Color: AdaptiveColorToString(t.SyntaxKeyword()),
},
GenericDeleted: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.DiffRemoved())),
Color: AdaptiveColorToString(t.DiffRemoved()),
},
GenericEmph: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownEmph())),
Color: AdaptiveColorToString(t.MarkdownEmph()),
Italic: boolPtr(true),
},
GenericInserted: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.DiffAdded())),
Color: AdaptiveColorToString(t.DiffAdded()),
},
GenericStrong: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownStrong())),
Color: AdaptiveColorToString(t.MarkdownStrong()),
Bold: boolPtr(true),
},
GenericSubheading: ansi.StylePrimitive{
BackgroundColor: background,
Color: stringPtr(AdaptiveColorToString(t.MarkdownHeading())),
Color: AdaptiveColorToString(t.MarkdownHeading()),
},
},
},
Table: ansi.StyleTable{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "\n",
BlockSuffix: "\n",
},
},
@@ -293,14 +293,14 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n ",
Color: stringPtr(AdaptiveColorToString(t.MarkdownLinkText())),
Color: AdaptiveColorToString(t.MarkdownLinkText()),
},
Text: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownText())),
Color: AdaptiveColorToString(t.MarkdownText()),
},
Paragraph: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(AdaptiveColorToString(t.MarkdownText())),
Color: AdaptiveColorToString(t.MarkdownText()),
},
},
}
@@ -308,11 +308,17 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
// AdaptiveColorToString converts a compat.AdaptiveColor to the appropriate
// hex color string based on the current terminal background
func AdaptiveColorToString(color compat.AdaptiveColor) string {
func AdaptiveColorToString(color compat.AdaptiveColor) *string {
if Terminal.BackgroundIsDark {
if _, ok := color.Dark.(lipgloss.NoColor); ok {
return nil
}
c1, _ := colorful.MakeColor(color.Dark)
return c1.Hex()
return stringPtr(c1.Hex())
}
if _, ok := color.Light.(lipgloss.NoColor); ok {
return nil
}
c1, _ := colorful.MakeColor(color.Light)
return c1.Hex()
return stringPtr(c1.Hex())
}

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