Compare commits

..
264 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
426 changed files with 23884 additions and 5586 deletions
+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 }}
+2
View File
@@ -7,6 +7,8 @@ on:
- dev
tags:
- "*"
- "!vscode-v*"
- "!github-v*"
concurrency: ${{ github.workflow }}-${{ github.ref }}
+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
+35 -4
View File
@@ -30,7 +30,23 @@ brew install sst/tap/opencode # macOS
paru -S opencode-bin # Arch Linux
```
> **Note:** Remove versions older than 0.1.x before installing
> [!TIP]
> Remove versions older than 0.1.x before installing.
#### Installation Directory
The install script respects the following priority order for the installation path:
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
# 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
```
### Documentation
@@ -38,10 +54,25 @@ For more info on how to configure opencode [**head over to our docs**](https://o
### Contributing
For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes.
opencode is an opinionated tool so any fundamental feature needs to go through a
design process with the core team.
> **Note**: Please talk to us via github issues before spending time working on
> a new feature
> [!IMPORTANT]
> We do not accept PRs for core features.
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.
+25 -13
View File
@@ -1,15 +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) |
| 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) |
+1069 -262
View File
File diff suppressed because it is too large Load Diff
+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 v0.21.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 => ./packages/tui/input
)
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
)
+2
View File
@@ -20,6 +20,8 @@ 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/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/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
+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
+19 -16
View File
@@ -1,25 +1,28 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"openrouter": {
"models": {
"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"]
}
},
"experimental": {
"hook": {
"file_edited": {
".json": [
{
"command": ["bun", "run", "prettier", "$FILE"]
}
]
},
"session_completed": [
{
"command": ["touch", "./node_modules/foo"]
}
]
}
}
}
+2 -4
View File
@@ -18,7 +18,7 @@
"typescript": "5.8.2",
"@types/node": "22.13.9",
"zod": "3.25.49",
"ai": "5.0.0-beta.7"
"ai": "5.0.0-beta.21"
}
},
"devDependencies": {
@@ -39,7 +39,5 @@
"protobufjs",
"sharp"
],
"patchedDependencies": {
"ai@4.3.16": "patches/ai@4.3.16.patch"
}
"patchedDependencies": {}
}
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
"os/signal"
@@ -13,6 +14,7 @@ import (
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"
@@ -50,11 +52,37 @@ func main() {
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),
)
apiHandler := util.NewAPILogHandler(httpClient, "tui", slog.LevelDebug)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
apiHandler := util.NewAPILogHandler(ctx, httpClient, "tui", slog.LevelDebug)
logger := slog.New(apiHandler)
slog.SetDefault(logger)
@@ -68,9 +96,6 @@ func main() {
}()
// Create main context for the application
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode)
if err != nil {
panic(err)
@@ -79,7 +104,6 @@ func main() {
program := tea.NewProgram(
tui.NewModel(app_),
tea.WithAltScreen(),
// tea.WithKeyboardEnhancements(),
tea.WithMouseCellMotion(),
)
@@ -91,6 +115,9 @@ func main() {
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 {
@@ -99,6 +126,8 @@ func main() {
}
}()
go api.Start(ctx, program, httpClient)
// Handle signals in a separate goroutine
go func() {
sig := <-sigChan
@@ -10,7 +10,6 @@ require (
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/charmbracelet/x/input v0.3.7
github.com/google/uuid v1.6.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
@@ -24,7 +23,7 @@ require (
replace (
github.com/charmbracelet/x/input => ./input
github.com/sst/opencode-sdk-go => ./sdk
github.com/sst/opencode/packages/sdk/go => ../sdk/go
)
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
@@ -34,6 +33,7 @@ require (
github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/input v0.3.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
+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=
@@ -31,42 +31,20 @@ type win32InputState struct {
// Reader represents an input event reader. It reads input events and parses
// escape sequences from the terminal input buffer and translates them into
// human-readable events.
// humanreadable events.
type Reader struct {
rd cancelreader.CancelReader
table map[string]Key // table is a lookup table for key sequences.
term string // term is the terminal name $TERM.
// paste is the bracketed paste mode buffer.
// When nil, bracketed paste mode is disabled.
paste []byte
buf [256]byte // do we need a larger buffer?
// partialSeq holds incomplete escape sequences that need more data
partialSeq []byte
// keyState keeps track of the current Windows Console API key events state.
// It is used to decode ANSI escape sequences and utf16 sequences.
keyState win32InputState
parser Parser
logger Logger
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. The reader reads input events
// from the terminal and parses escape sequences into human-readable events. It
// supports reading Terminfo databases. See [Parser] for more information.
//
// Example:
//
// r, _ := input.NewReader(os.Stdin, os.Getenv("TERM"), 0)
// defer r.Close()
// events, _ := r.ReadEvents()
// for _, ev := range events {
// log.Printf("%v", ev)
// }
// 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)
@@ -82,46 +60,38 @@ func NewReader(r io.Reader, termType string, flags int) (*Reader, error) {
}
// SetLogger sets a logger for the reader.
func (d *Reader) SetLogger(l Logger) {
d.logger = l
}
func (d *Reader) SetLogger(l Logger) { d.logger = l }
// Read implements [io.Reader].
func (d *Reader) Read(p []byte) (int, error) {
return d.rd.Read(p) //nolint:wrapcheck
}
// 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()
}
func (d *Reader) Cancel() bool { return d.rd.Cancel() }
// Close closes the underlying reader.
func (d *Reader) Close() error {
return d.rd.Close() //nolint:wrapcheck
}
func (d *Reader) 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 //nolint:wrapcheck
return nil, err
}
var events []Event
// Combine any partial sequence from previous read with new data
// Combine any partial sequence from previous read with new data.
var buf []byte
if len(d.partialSeq) > 0 {
buf = make([]byte, len(d.partialSeq)+nb)
copy(buf, d.partialSeq)
copy(buf[len(d.partialSeq):], d.buf[:nb])
d.partialSeq = nil // clear the partial sequence
d.partialSeq = nil
} else {
buf = d.buf[:nb]
}
// Lookup table first
if bytes.HasPrefix(buf, []byte{'\x1b'}) {
// 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)
@@ -133,24 +103,23 @@ func (d *Reader) readEvents() ([]Event, error) {
var i int
for i < len(buf) {
nb, ev := d.parser.parseSequence(buf[i:])
if d.logger != nil && nb > 0 {
d.logger.Printf("input: %q", buf[i:i+nb])
consumed, ev := d.parser.parseSequence(buf[i:])
if d.logger != nil && consumed > 0 {
d.logger.Printf("input: %q", buf[i:i+consumed])
}
// Handle incomplete sequences - when parseSequence returns (0, nil)
// it means we need more data to complete the sequence
if nb == 0 && ev == nil {
// Store the remaining data for the next read
remaining := len(buf) - i
if remaining > 0 {
d.partialSeq = make([]byte, remaining)
// 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
// 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])
@@ -160,15 +129,9 @@ func (d *Reader) readEvents() ([]Event, error) {
}
switch ev.(type) {
// case UnknownEvent:
// // If the sequence is not recognized by the parser, try looking it up.
// if k, ok := d.table[string(buf[i:i+nb])]; ok {
// ev = KeyPressEvent(k)
// }
case PasteStartEvent:
d.paste = []byte{}
case PasteEndEvent:
// Decode the captured data into runes.
var paste []rune
for len(d.paste) > 0 {
r, w := utf8.DecodeRune(d.paste)
@@ -177,7 +140,7 @@ func (d *Reader) readEvents() ([]Event, error) {
}
d.paste = d.paste[w:]
}
d.paste = nil // reset the buffer
d.paste = nil
events = append(events, PasteEvent(paste))
case nil:
i++
@@ -189,8 +152,41 @@ func (d *Reader) readEvents() ([]Event, error) {
} else {
events = append(events, ev)
}
i += nb
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
}
@@ -463,28 +463,41 @@ func (p *Parser) parseWin32InputKeyEvent(state *win32InputState, vkc uint16, _ u
baseCode = KeyMediaStop
case vkc == xwindows.VK_MEDIA_PLAY_PAUSE:
baseCode = KeyMediaPlayPause
case vkc == xwindows.VK_OEM_1:
baseCode = ';'
case vkc == xwindows.VK_OEM_PLUS:
baseCode = '+'
case vkc == xwindows.VK_OEM_COMMA:
baseCode = ','
case vkc == xwindows.VK_OEM_MINUS:
baseCode = '-'
case vkc == xwindows.VK_OEM_PERIOD:
baseCode = '.'
case vkc == xwindows.VK_OEM_2:
baseCode = '/'
case vkc == xwindows.VK_OEM_3:
baseCode = '`'
case vkc == xwindows.VK_OEM_4:
baseCode = '['
case vkc == xwindows.VK_OEM_5:
baseCode = '\\'
case vkc == xwindows.VK_OEM_6:
baseCode = ']'
case vkc == xwindows.VK_OEM_7:
baseCode = '\''
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) {
@@ -500,20 +513,29 @@ func (p *Parser) parseWin32InputKeyEvent(state *win32InputState, vkc uint16, _ u
// 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) && (cks == 0 ||
cks == xwindows.SHIFT_PRESSED ||
cks == xwindows.CAPSLOCK_ON ||
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
@@ -303,7 +303,8 @@ func (p *Parser) parseCsi(b []byte) (int, Event) {
return i, CursorPositionEvent{Y: row - 1, X: col - 1}
case 'm' | '<'<<parser.PrefixShift, 'M' | '<'<<parser.PrefixShift:
// Handle SGR mouse
if paramsLen == 3 {
if paramsLen >= 3 {
pa = pa[:3]
return i, parseSGRMouseEvent(cmd, pa)
}
case 'm' | '>'<<parser.PrefixShift:
+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
}
}
@@ -3,10 +3,9 @@ package app
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"log/slog"
@@ -15,12 +14,17 @@ import (
"github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/id"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type Message struct {
Info opencode.MessageUnion
Parts []opencode.PartUnion
}
type App struct {
Info opencode.App
Modes []opencode.Mode
@@ -29,13 +33,13 @@ type App struct {
StatePath string
Config *opencode.Config
Client *opencode.Client
State *config.State
State *State
ModeIndex int
Mode *opencode.Mode
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
Messages []opencode.MessageUnion
Messages []Message
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
@@ -44,7 +48,17 @@ type App struct {
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
@@ -52,16 +66,10 @@ type ModelSelectedMsg struct {
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendMsg struct {
Text string
Attachments []opencode.FilePartParam
}
type SendPrompt = Prompt
type SetEditorContentMsg struct {
Text string
}
type OptimisticMessageAddedMsg struct {
Message opencode.MessageUnion
}
type FileRenderedMsg struct {
FilePath string
}
@@ -89,20 +97,25 @@ func New(
}
appStatePath := filepath.Join(appInfo.Path.State, "tui")
appState, err := config.LoadState(appStatePath)
appState, err := LoadState(appStatePath)
if err != nil {
appState = config.NewState()
config.SaveState(appStatePath, appState)
appState = NewState()
SaveState(appStatePath, appState)
}
if appState.ModeModel == nil {
appState.ModeModel = make(map[string]config.ModeModel)
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"
@@ -121,7 +134,7 @@ func New(
mode = &modes[modeIndex]
if mode.Model.ModelID != "" {
appState.ModeModel[mode.Name] = config.ModeModel{
appState.ModeModel[mode.Name] = ModeModel{
ProviderID: mode.Model.ProviderID,
ModelID: mode.Model.ModelID,
}
@@ -158,16 +171,31 @@ func New(
ModeIndex: modeIndex,
Mode: mode,
Session: &opencode.Session{},
Messages: []opencode.MessageUnion{},
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
@@ -177,15 +205,11 @@ func (a *App) Key(commandName commands.CommandName) string {
Faint(true).
Render
command := a.Commands[commandName]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = a.Config.Keybinds.Leader + " " + kb.Key
}
key := a.Keybind(commandName)
return base(key) + muted(" "+command.Description)
}
func (a *App) SetClipboard(text string) tea.Cmd {
func SetClipboard(text string) tea.Cmd {
var cmds []tea.Cmd
cmds = append(cmds, func() tea.Msg {
clipboard.Write(clipboard.FmtText, []byte(text))
@@ -196,10 +220,17 @@ func (a *App) SetClipboard(text string) tea.Cmd {
return tea.Sequence(cmds...)
}
func (a *App) SwitchMode() (*App, tea.Cmd) {
a.ModeIndex++
if a.ModeIndex >= len(a.Modes) {
a.ModeIndex = 0
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]
@@ -228,15 +259,19 @@ func (a *App) SwitchMode() (*App, tea.Cmd) {
}
a.State.Mode = a.Mode.Name
return a, a.SaveState()
}
return a, func() tea.Msg {
a.SaveState()
return nil
}
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.Config.Providers(context.Background())
providersResponse, err := a.Client.App.Providers(context.Background())
if err != nil {
slog.Error("Failed to list providers", "error", err)
// TODO: notify user
@@ -325,13 +360,13 @@ func (a *App) InitializeProvider() tea.Cmd {
Model: *currentModel,
}))
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt}))
cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
}
return tea.Sequence(cmds...)
}
func getDefaultModel(
response *opencode.ConfigProvidersResponse,
response *opencode.AppProvidersResponse,
provider opencode.Provider,
) *opencode.Model {
if match, ok := response.Default[provider.ID]; ok {
@@ -349,18 +384,20 @@ func (a *App) IsBusy() bool {
if len(a.Messages) == 0 {
return false
}
lastMessage := a.Messages[len(a.Messages)-1]
if casted, ok := lastMessage.(opencode.AssistantMessage); ok {
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
return casted.Time.Completed == 0
}
return false
return true
}
func (a *App) SaveState() {
err := config.SaveState(a.StatePath, a.State)
if err != nil {
slog.Error("Failed to save state", "error", err)
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
}
}
@@ -374,10 +411,11 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
}
a.Session = session
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(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),
})
@@ -437,11 +475,7 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
return session, nil
}
func (a *App) SendChatMessage(
ctx context.Context,
text string,
attachments []opencode.FilePartParam,
) (*App, tea.Cmd) {
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)
@@ -449,60 +483,21 @@ func (a *App) SendChatMessage(
return a, toast.NewErrorToast(err.Error())
}
a.Session = session
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
}
optimisticParts := []opencode.UserMessagePart{{
Type: opencode.UserMessagePartTypeText,
Text: text,
}}
if len(attachments) > 0 {
for _, attachment := range attachments {
optimisticParts = append(optimisticParts, opencode.UserMessagePart{
Type: opencode.UserMessagePartTypeFile,
Filename: attachment.Filename.Value,
Mime: attachment.Mime.Value,
URL: attachment.URL.Value,
})
}
}
messageID := id.Ascending(id.Message)
message := prompt.ToMessage(messageID, a.Session.ID)
optimisticMessage := opencode.UserMessage{
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
Role: opencode.UserMessageRoleUser,
Parts: optimisticParts,
SessionID: a.Session.ID,
Time: opencode.UserMessageTime{
Created: float64(time.Now().Unix()),
},
}
a.Messages = append(a.Messages, optimisticMessage)
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
a.Messages = append(a.Messages, message)
cmds = append(cmds, func() tea.Msg {
parts := []opencode.UserMessagePartUnionParam{
opencode.TextPartParam{
Type: opencode.F(opencode.TextPartTypeText),
Text: opencode.F(text),
},
}
if len(attachments) > 0 {
for _, attachment := range attachments {
parts = append(parts, opencode.FilePartParam{
Mime: attachment.Mime,
Type: attachment.Type,
URL: attachment.URL,
Filename: attachment.Filename,
})
}
}
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
Parts: opencode.F(parts),
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)
@@ -527,7 +522,6 @@ func (a *App) Cancel(ctx context.Context, sessionID string) error {
_, err := a.Client.Session.Abort(ctx, sessionID)
if err != nil {
slog.Error("Failed to cancel session", "error", err)
// status.Error(err.Error())
return err
}
return nil
@@ -542,9 +536,6 @@ func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
return []opencode.Session{}, nil
}
sessions := *response
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].Time.Created-sessions[j].Time.Created > 0
})
return sessions, nil
}
@@ -557,20 +548,30 @@ func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
return nil
}
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]opencode.Message, error) {
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
response, err := a.Client.Session.Messages(ctx, sessionId)
if err != nil {
return nil, err
}
if response == nil {
return []opencode.Message{}, nil
return []Message{}, nil
}
messages := []Message{}
for _, message := range *response {
msg := Message{
Info: message.Info.AsUnion(),
Parts: []opencode.PartUnion{},
}
for _, part := range message.Parts {
msg.Parts = append(msg.Parts, part.AsUnion())
}
messages = append(messages, msg)
}
messages := *response
return messages, nil
}
func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
response, err := a.Client.Config.Providers(ctx)
response, err := a.Client.App.Providers(ctx)
if err != nil {
return nil, err
}
+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
}
@@ -1,4 +1,4 @@
package config
package app
import (
"bufio"
@@ -30,6 +30,7 @@ type State struct {
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
MessageHistory []Prompt `toml:"message_history"`
}
func NewState() *State {
@@ -38,6 +39,7 @@ func NewState() *State {
Mode: "build",
ModeModel: make(map[string]ModeModel),
RecentlyUsedModels: make([]ModelUsage, 0),
MessageHistory: make([]Prompt, 0),
}
}
@@ -69,6 +71,22 @@ func (s *State) UpdateModelUsage(providerID, modelID string) {
}
}
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 {
@@ -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
}
@@ -13,6 +13,7 @@ import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
"sync"
@@ -44,7 +45,7 @@ var (
writeImg: []string{"xsel", "--clipboard", "--input"},
},
{
name: "wl-clipboard",
name: "wl-copy",
readCmd: []string{"wl-paste", "-n"},
writeCmd: []string{"wl-copy"},
readImg: []string{"wl-paste", "-t", "image/png", "-n"},
@@ -66,14 +67,23 @@ func initialize() error {
return nil // Already initialized
}
// Check which clipboard tool is available
for i, tool := range clipboardTools {
cmd := exec.Command("which", tool.name)
if err := cmd.Run(); err == nil {
clipboardTools[i].available = true
if selectedTool < 0 {
selectedTool = i
slog.Debug("Clipboard tool found", "tool", tool.name)
order := []string{"xclip", "xsel", "wl-copy"}
if os.Getenv("WAYLAND_DISPLAY") != "" {
order = []string{"wl-copy", "xclip", "xsel"}
}
for _, name := range order {
for i, tool := range clipboardTools {
if tool.name == name {
cmd := exec.Command("which", tool.name)
if err := cmd.Run(); err == nil {
clipboardTools[i].available = true
if selectedTool < 0 {
selectedTool = i
slog.Debug("Clipboard tool found", "tool", tool.name)
}
}
break
}
}
}
@@ -63,17 +63,37 @@ func (r CommandRegistry) Sorted() []Command {
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() {
@@ -87,6 +107,7 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
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"
@@ -94,6 +115,7 @@ const (
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"
@@ -116,7 +138,8 @@ const (
MessagesLastCommand CommandName = "messages_last"
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
MessagesCopyCommand CommandName = "messages_copy"
MessagesRevertCommand CommandName = "messages_revert"
MessagesUndoCommand CommandName = "messages_undo"
MessagesRedoCommand CommandName = "messages_redo"
AppExitCommand CommandName = "app_exit"
)
@@ -155,15 +178,26 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
},
{
Name: SwitchModeCommand,
Description: "switch mode",
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",
@@ -217,12 +251,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>t"),
Trigger: []string{"themes"},
},
{
Name: FileListCommand,
Description: "list files",
Keybindings: parseBindings("<leader>f"),
Trigger: []string{"files"},
},
// {
// Name: FileListCommand,
// Description: "list files",
// Keybindings: parseBindings("<leader>f"),
// Trigger: []string{"files"},
// },
{
Name: FileCloseCommand,
Description: "close file",
@@ -315,15 +349,22 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>y"),
},
{
Name: MessagesRevertCommand,
Description: "revert message",
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"},
Trigger: []string{"exit", "quit", "q"},
},
}
registry := make(CommandRegistry)
@@ -331,7 +372,15 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
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
@@ -8,7 +8,6 @@ import (
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
@@ -17,7 +16,7 @@ type CommandCompletionProvider struct {
app *app.App
}
func NewCommandCompletionProvider(app *app.App) dialog.CompletionProvider {
func NewCommandCompletionProvider(app *app.App) CompletionProvider {
return &CommandCompletionProvider{app: app}
}
@@ -32,24 +31,28 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
func (c *CommandCompletionProvider) getCommandCompletionItem(
cmd commands.Command,
space int,
t theme.Theme,
) dialog.CompletionItemI {
spacer := strings.Repeat(" ", space)
title := " /" + cmd.PrimaryTrigger() + styles.NewStyle().
Foreground(t.TextMuted()).
Render(spacer+cmd.Description)
) 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 dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
return CompletionSuggestion{
Display: displayFunc,
Value: value,
ProviderID: c.GetId(),
})
RawData: cmd,
}
}
func (c *CommandCompletionProvider) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
t := theme.CurrentTheme()
) ([]CompletionSuggestion, error) {
commands := c.app.Commands
space := 1
@@ -63,44 +66,44 @@ func (c *CommandCompletionProvider) GetChildEntries(
sorted := commands.Sorted()
if query == "" {
// If no query, return all commands
items := []dialog.CompletionItemI{}
items := []CompletionSuggestion{}
for _, cmd := range sorted {
if !cmd.HasTrigger() {
continue
}
space := space - lipgloss.Width(cmd.PrimaryTrigger())
items = append(items, c.getCommandCompletionItem(cmd, space, t))
items = append(items, c.getCommandCompletionItem(cmd, space))
}
return items, nil
}
// Use fuzzy matching for commands
var commandNames []string
commandMap := make(map[string]dialog.CompletionItemI)
commandMap := make(map[string]CompletionSuggestion)
for _, cmd := range sorted {
if !cmd.HasTrigger() {
continue
}
space := space - lipgloss.Width(cmd.PrimaryTrigger())
// Add all triggers as searchable options
for _, trigger := range cmd.Trigger {
commandNames = append(commandNames, trigger)
commandMap[trigger] = c.getCommandCompletionItem(cmd, space, t)
commandMap[trigger] = c.getCommandCompletionItem(cmd, space)
}
}
// Find fuzzy matches
matches := fuzzy.RankFind(query, commandNames)
// Sort by score (best matches first)
matches := fuzzy.RankFindFold(query, commandNames)
sort.Sort(matches)
// Convert matches to completion items
items := []dialog.CompletionItemI{}
// 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 {
items = append(items, item)
// 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
@@ -9,14 +9,13 @@ import (
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type filesContextGroup struct {
app *app.App
gitFiles []dialog.CompletionItemI
gitFiles []CompletionSuggestion
}
func (cg *filesContextGroup) GetId() string {
@@ -27,12 +26,8 @@ func (cg *filesContextGroup) GetEmptyMessage() string {
return "no matching files"
}
func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
t := theme.CurrentTheme()
items := make([]dialog.CompletionItemI, 0)
base := styles.NewStyle().Background(t.BackgroundElement())
green := base.Foreground(t.Success()).Render
red := base.Foreground(t.Error()).Render
func (cg *filesContextGroup) getGitFiles() []CompletionSuggestion {
items := make([]CompletionSuggestion, 0)
status, _ := cg.app.Client.File.Status(context.Background())
if status != nil {
@@ -42,19 +37,25 @@ func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
})
for _, file := range files {
title := file.Path
if file.Added > 0 {
title += green(" +" + strconv.Itoa(int(file.Added)))
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
}
if file.Removed > 0 {
title += red(" -" + strconv.Itoa(int(file.Removed)))
}
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
item := CompletionSuggestion{
Display: displayFunc,
Value: file.Path,
ProviderID: cg.GetId(),
Raw: file,
})
RawData: file,
}
items = append(items, item)
}
}
@@ -64,8 +65,8 @@ func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
func (cg *filesContextGroup) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
items := make([]dialog.CompletionItemI, 0)
) ([]CompletionSuggestion, error) {
items := make([]CompletionSuggestion, 0)
query = strings.TrimSpace(query)
if query == "" {
@@ -87,7 +88,7 @@ func (cg *filesContextGroup) GetChildEntries(
for _, file := range *files {
exists := false
for _, existing := range cg.gitFiles {
if existing.GetValue() == file {
if existing.Value == file {
if query != "" {
items = append(items, existing)
}
@@ -95,12 +96,18 @@ func (cg *filesContextGroup) GetChildEntries(
}
}
if !exists {
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: file,
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(),
Raw: file,
})
RawData: file,
}
items = append(items, item)
}
}
@@ -108,7 +115,7 @@ func (cg *filesContextGroup) GetChildEntries(
return items, nil
}
func NewFileContextGroup(app *app.App) dialog.CompletionProvider {
func NewFileContextGroup(app *app.App) CompletionProvider {
cg := &filesContextGroup{
app: app,
}
@@ -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
}
@@ -8,7 +8,6 @@ import (
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
@@ -58,8 +57,8 @@ const (
func (cg *symbolsContextGroup) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
items := make([]dialog.CompletionItemI, 0)
) ([]CompletionSuggestion, error) {
items := make([]CompletionSuggestion, 0)
query = strings.TrimSpace(query)
if query == "" {
@@ -78,40 +77,42 @@ func (cg *symbolsContextGroup) GetChildEntries(
return items, nil
}
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Background(t.BackgroundElement())
base := baseStyle.Render
muted := baseStyle.Foreground(t.TextMuted()).Render
for _, sym := range *symbols {
parts := strings.Split(sym.Name, ".")
lastPart := parts[len(parts)-1]
title := base(lastPart)
uriParts := strings.Split(sym.Location.Uri, "/")
lastTwoParts := uriParts[len(uriParts)-2:]
joined := strings.Join(lastTwoParts, "/")
title += muted(fmt.Sprintf(" %s", joined))
start := int(sym.Location.Range.Start.Line)
end := int(sym.Location.Range.End.Line)
title += muted(fmt.Sprintf(":L%d-%d", start, end))
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 := dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
item := CompletionSuggestion{
Display: displayFunc,
Value: value,
ProviderID: cg.GetId(),
Raw: sym,
})
RawData: sym,
}
items = append(items, item)
}
return items, nil
}
func NewSymbolsContextGroup(app *app.App) dialog.CompletionProvider {
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,
},
}
}
@@ -9,11 +9,11 @@ import (
"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/commands"
"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"
@@ -109,7 +109,6 @@ func WithPaddingBottom(padding int) renderingOption {
func renderContentBlock(
app *app.App,
content string,
highlight bool,
width int,
options ...renderingOption,
) string {
@@ -158,18 +157,6 @@ func renderContentBlock(
BorderRightBackground(t.Background())
}
if highlight {
style = style.
BorderLeftForeground(borderColor).
BorderRightForeground(borderColor)
}
}
if highlight {
style = style.
Foreground(t.Text()).
Background(t.BackgroundElement()).
Bold(true)
}
content = style.Render(content)
@@ -184,32 +171,6 @@ func renderContentBlock(
}
}
if highlight {
copy := app.Key(commands.MessagesCopyCommand)
// revert := app.Key(commands.MessagesRevertCommand)
background := t.Background()
header := layout.Render(
layout.FlexOptions{
Background: &background,
Direction: layout.Row,
Justify: layout.JustifyCenter,
Align: layout.AlignStretch,
Width: width - 2,
Gap: 5,
},
layout.FlexItem{
View: copy,
},
// layout.FlexItem{
// View: revert,
// },
)
header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header)
content = "\n\n\n" + header + "\n\n" + content + "\n\n\n"
}
return content
}
@@ -219,7 +180,6 @@ func renderText(
text string,
author string,
showToolDetails bool,
highlight bool,
width int,
extra string,
toolCalls ...opencode.ToolPart,
@@ -228,9 +188,6 @@ func renderText(
var ts time.Time
backgroundColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
}
var content string
switch casted := message.(type) {
case opencode.AssistantMessage:
@@ -238,8 +195,22 @@ func renderText(
content = util.ToMarkdown(text, width, backgroundColor)
case opencode.UserMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
messageStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6)
content = messageStyle.Render(text)
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.
@@ -277,7 +248,6 @@ func renderText(
return renderContentBlock(
app,
content,
highlight,
width,
WithTextColor(t.Text()),
WithBorderColorRight(t.Secondary()),
@@ -286,7 +256,6 @@ func renderText(
return renderContentBlock(
app,
content,
highlight,
width,
WithBorderColor(t.Accent()),
)
@@ -297,19 +266,18 @@ func renderText(
func renderToolDetails(
app *app.App,
toolCall opencode.ToolPart,
highlight bool,
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 ||
toolCall.State.Status == opencode.ToolPartStateStatusRunning {
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
title := renderToolTitle(toolCall, width)
title = styles.NewStyle().Width(width - 6).Render(title)
return renderContentBlock(app, title, highlight, width)
return renderContentBlock(app, title, width)
}
var result *string
@@ -334,16 +302,16 @@ func renderToolDetails(
t := theme.CurrentTheme()
backgroundColor := t.BackgroundPanel()
borderColor := t.BackgroundPanel()
if highlight {
backgroundColor = t.BackgroundElement()
borderColor = t.BorderActive()
}
defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
if toolCall.State.Metadata != nil {
metadata := toolCall.State.Metadata.(map[string]any)
switch toolCall.Tool {
case "read":
preview := metadata["preview"]
var preview any
if metadata != nil {
preview = metadata["preview"]
}
if preview != nil && toolInputMap["filePath"] != nil {
filename := toolInputMap["filePath"].(string)
body = preview.(string)
@@ -351,26 +319,34 @@ func renderToolDetails(
}
case "edit":
if filename, ok := toolInputMap["filePath"].(string); ok {
diffField := metadata["diff"]
var diffField any
if metadata != nil {
diffField = metadata["diff"]
}
if diffField != nil {
patch := diffField.(string)
var formattedDiff string
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(width-2),
)
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 highlight {
style = style.Foreground(t.Text()).Bold(true)
}
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" {
diagnostics = style.Render(diagnostics)
body += "\n" + diagnostics
}
@@ -381,7 +357,6 @@ func renderToolDetails(
content = renderContentBlock(
app,
content,
highlight,
width,
WithPadding(0),
WithBorderColor(borderColor),
@@ -393,18 +368,20 @@ func renderToolDetails(
if filename, ok := toolInputMap["filePath"].(string); ok {
if content, ok := toolInputMap["content"].(string); ok {
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
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 {
command := toolInputMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = util.ToMarkdown(body, width, backgroundColor)
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
@@ -424,7 +401,7 @@ func renderToolDetails(
body += fmt.Sprintf("- [x] %s\n", content)
case "cancelled":
// strike through cancelled todo
body += fmt.Sprintf("- [~] ~~%s~~\n", content)
body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
case "in_progress":
// highlight in progress todo
body += fmt.Sprintf("- [ ] `%s`\n", content)
@@ -439,19 +416,17 @@ func renderToolDetails(
if summary != nil {
toolcalls := summary.([]any)
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall opencode.ToolPart
_ = json.Unmarshal(data, &toolCall)
step := renderToolTitle(toolCall, width)
step = "∟ " + step
steps = append(steps, step)
}
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 := ""
@@ -459,7 +434,7 @@ func renderToolDetails(
}
body = *result
body = util.TruncateHeight(body, 10)
body = styles.NewStyle().Width(width - 6).Render(body)
body = defaultStyle(body)
}
}
@@ -479,12 +454,16 @@ func renderToolDetails(
if body == "" && error == "" && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
body = styles.NewStyle().Width(width - 6).Render(body)
body = defaultStyle(body)
}
if body == "" {
body = defaultStyle("")
}
title := renderToolTitle(toolCall, width)
content := title + "\n\n" + body
return renderContentBlock(app, content, highlight, width, WithBorderColor(borderColor))
return renderContentBlock(app, content, width, WithBorderColor(borderColor))
}
func renderToolName(name string) string {
@@ -539,10 +518,9 @@ func renderToolTitle(
toolCall opencode.ToolPart,
width int,
) string {
// TODO: handle truncate to width
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
return renderToolAction(toolCall.Tool)
title := renderToolAction(toolCall.Tool)
return styles.NewStyle().Width(width - 6).Render(title)
}
toolArgs := ""
@@ -590,13 +568,15 @@ func renderToolTitle(
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 "Searching..."
return "Planning..."
case "bash":
return "Writing command..."
case "edit":
@@ -667,7 +647,12 @@ type Diagnostic struct {
}
// renderDiagnostics formats LSP diagnostics for display in the TUI
func renderDiagnostics(metadata map[string]any, filePath string) string {
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
@@ -703,9 +688,15 @@ func renderDiagnostics(metadata map[string]any, filePath string) string {
var result strings.Builder
for _, diagnostic := range errorDiagnostics {
if result.Len() > 0 {
result.WriteString("\n")
result.WriteString("\n\n")
}
result.WriteString(styles.NewStyle().Foreground(t.Error()).Render(diagnostic))
diagnostic = ansi.WordwrapWc(diagnostic, width, " -")
result.WriteString(
styles.NewStyle().
Background(backgroundColor).
Foreground(t.Error()).
Render(diagnostic),
)
}
return result.String()
}
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ package commands
import (
"fmt"
"runtime"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -11,6 +12,7 @@ import (
"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 {
@@ -24,6 +26,7 @@ type commandsComponent struct {
width, height int
showKeybinds bool
showAll bool
showVscode bool
background *compat.AdaptiveColor
limit *int
}
@@ -73,6 +76,34 @@ func (c *commandsComponent) View() string {
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 {
@@ -196,6 +227,12 @@ func WithShowAll(showAll bool) Option {
}
}
func WithVscode(showVscode bool) Option {
return func(c *commandsComponent) {
c.showVscode = showVscode
}
}
func New(app *app.App, opts ...Option) CommandsComponent {
c := &commandsComponent{
app: app,
@@ -11,73 +11,15 @@ import (
"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 CompletionItem struct {
Title string
Value string
ProviderID string
Raw any
}
type CompletionItemI interface {
list.ListItem
GetValue() string
DisplayValue() string
GetProviderID() string
GetRaw() any
}
func (ci *CompletionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Foreground(t.Text())
truncatedStr := truncate.String(string(ci.DisplayValue()), uint(width-4))
itemStyle := baseStyle.
Background(t.BackgroundElement()).
Padding(0, 1)
if selected {
itemStyle = itemStyle.Foreground(t.Primary())
}
title := itemStyle.Render(truncatedStr)
return title
}
func (ci *CompletionItem) DisplayValue() string {
return ci.Title
}
func (ci *CompletionItem) GetValue() string {
return ci.Value
}
func (ci *CompletionItem) GetProviderID() string {
return ci.ProviderID
}
func (ci *CompletionItem) GetRaw() any {
return ci.Raw
}
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
return &completionItem
}
type CompletionProvider interface {
GetId() string
GetChildEntries(query string) ([]CompletionItemI, error)
GetEmptyMessage() string
}
type CompletionSelectedMsg struct {
Item CompletionItemI
Item completions.CompletionSuggestion
SearchString string
}
@@ -96,11 +38,11 @@ type CompletionDialog interface {
type completionDialogComponent struct {
query string
providers []CompletionProvider
providers []completions.CompletionProvider
width int
height int
pseudoSearchTextArea textarea.Model
list list.List[CompletionItemI]
list list.List[completions.CompletionSuggestion]
trigger string
}
@@ -114,7 +56,7 @@ var completionDialogKeys = completionDialogKeyMap{
key.WithKeys("tab", "enter", "right"),
),
Cancel: key.NewBinding(
key.WithKeys(" ", "esc", "backspace", "ctrl+c"),
key.WithKeys("space", " ", "esc", "backspace", "ctrl+h", "ctrl+c"),
),
}
@@ -124,7 +66,8 @@ func (c *completionDialogComponent) Init() tea.Cmd {
func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
return func() tea.Msg {
allItems := make([]CompletionItemI, 0)
allItems := make([]completions.CompletionSuggestion, 0)
providersWithResults := 0
// Collect results from all providers
for _, provider := range c.providers {
@@ -139,25 +82,27 @@ func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
)
continue
}
allItems = append(allItems, items...)
if len(items) > 0 {
providersWithResults++
allItems = append(allItems, items...)
}
}
// If there's a query, use fuzzy ranking to sort results
if query != "" && len(allItems) > 0 {
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.DisplayValue()
displayValues[i] = item.Display(baseStyle)
}
// Get fuzzy matches with ranking
matches := fuzzy.RankFindFold(query, displayValues)
// Sort by score (best matches first)
sort.Sort(matches)
// Reorder items based on fuzzy ranking
rankedItems := make([]CompletionItemI, 0, len(matches))
rankedItems := make([]completions.CompletionSuggestion, 0, len(matches))
for _, match := range matches {
rankedItems = append(rankedItems, allItems[match.OriginalIndex])
}
@@ -171,7 +116,7 @@ func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case []CompletionItemI:
case []completions.CompletionSuggestion:
c.list.SetItems(msg)
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
@@ -189,7 +134,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
u, cmd := c.list.Update(msg)
c.list = u.(list.List[CompletionItemI])
c.list = u.(list.List[completions.CompletionSuggestion])
cmds = append(cmds, cmd)
}
@@ -205,7 +150,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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" || (width <= triggerWidth && value != c.trigger) {
if (msg.String() != "backspace" && msg.String() != "ctrl+h") || (width <= triggerWidth && value != c.trigger) {
return c, c.close()
}
}
@@ -223,11 +168,11 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (c *completionDialogComponent) View() string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Foreground(t.Text())
c.list.SetMaxWidth(c.width)
return baseStyle.
Padding(0, 0).
return styles.NewStyle().
Padding(0, 1).
Foreground(t.Text()).
Background(t.BackgroundElement()).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
@@ -246,7 +191,7 @@ func (c *completionDialogComponent) IsEmpty() bool {
return c.list.IsEmpty()
}
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
func (c *completionDialogComponent) complete(item completions.CompletionSuggestion) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
@@ -265,7 +210,7 @@ func (c *completionDialogComponent) close() tea.Cmd {
func NewCompletionDialogComponent(
trigger string,
providers ...CompletionProvider,
providers ...completions.CompletionProvider,
) CompletionDialog {
ti := textarea.New()
ti.SetValue(trigger)
@@ -276,11 +221,34 @@ func NewCompletionDialogComponent(
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(
[]CompletionItemI{},
7,
emptyMessage,
false,
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{
@@ -293,7 +261,7 @@ func NewCompletionDialogComponent(
// Load initial items from all providers
go func() {
allItems := make([]CompletionItemI, 0)
allItems := make([]completions.CompletionSuggestion, 0)
for _, provider := range providers {
items, err := provider.GetChildEntries("")
if err != nil {
@@ -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
}
@@ -1,13 +1,13 @@
package dialog
import (
"github.com/charmbracelet/bubbles/v2/viewport"
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 {
@@ -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()
}
@@ -28,17 +28,26 @@ type SessionDialog interface {
type sessionItem struct {
title string
isDeleteConfirming bool
isCurrentSession bool
}
func (s sessionItem) Render(selected bool, width int) string {
func (s sessionItem) Render(
selected bool,
width int,
isFirstInViewport bool,
baseStyle styles.Style,
) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle()
var text string
if s.isDeleteConfirming {
text = "Press again to confirm delete"
} else {
text = s.title
if s.isCurrentSession {
text = "● " + s.title
} else {
text = s.title
}
}
truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
@@ -52,6 +61,14 @@ func (s sessionItem) Render(selected bool, width int) string {
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.
@@ -66,6 +83,12 @@ func (s sessionItem) Render(selected bool, width int) string {
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)
@@ -75,6 +98,10 @@ func (s sessionItem) Render(selected bool, width int) string {
return itemStyle.Render(truncatedStr)
}
func (s sessionItem) Selectable() bool {
return true
}
type sessionDialog struct {
width int
height int
@@ -110,6 +137,13 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
)
}
case "n":
s.app.Session = &opencode.Session{}
s.app.Messages = []app.Message{}
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionClearedMsg{}),
)
case "x", "delete", "backspace":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
if s.deleteConfirmation == idx {
@@ -150,10 +184,21 @@ func (s *sessionDialog) Render(background string) string {
listView := s.list.View()
t := theme.CurrentTheme()
helpStyle := styles.NewStyle().PaddingLeft(1).PaddingTop(1)
helpText := styles.NewStyle().Foreground(t.Text()).Render("x/del")
helpText = helpText + styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render(" delete session")
helpText = helpStyle.Render(helpText)
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
leftHelp := keyStyle("n") + mutedStyle(" new session")
rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
bgColor := t.BackgroundPanel()
helpText := layout.Render(layout.FlexOptions{
Direction: layout.Row,
Justify: layout.JustifySpaceBetween,
Width: layout.Current.Container.Width - 14,
Background: &bgColor,
}, layout.FlexItem{View: leftHelp}, layout.FlexItem{View: rightHelp})
helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
content := strings.Join([]string{listView, helpText}, "\n")
@@ -168,6 +213,7 @@ func (s *sessionDialog) updateListItems() {
item := sessionItem{
title: sess.Title,
isDeleteConfirming: s.deleteConfirmation == i,
isCurrentSession: s.app.Session != nil && s.app.Session.ID == sess.ID,
}
items = append(items, item)
}
@@ -203,15 +249,23 @@ func NewSessionDialog(app *app.App) SessionDialog {
items = append(items, sessionItem{
title: sess.Title,
isDeleteConfirming: false,
isCurrentSession: app.Session != nil && app.Session.ID == sess.ID,
})
}
// Create a generic list component
listComponent := list.NewListComponent(
items,
10, // maxVisibleSessions
"No sessions available",
true, // useAlphaNumericKeys
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)
@@ -5,6 +5,7 @@ import (
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"
)
@@ -24,7 +25,7 @@ type themeDialog struct {
height int
modal *modal.Modal
list list.List[list.StringItem]
list list.List[list.Item]
originalTheme string
themeApplied bool
}
@@ -42,16 +43,18 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
if item, idx := t.list.GetSelectedItem(); idx >= 0 {
selectedTheme := string(item)
if err := theme.SetTheme(selectedTheme); err != nil {
// status.Error(err.Error())
return t, nil
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}),
)
}
t.themeApplied = true
return t, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
)
}
}
@@ -61,11 +64,13 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
listModel, cmd := t.list.Update(msg)
t.list = listModel.(list.List[list.StringItem])
t.list = listModel.(list.List[list.Item])
if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx {
theme.SetTheme(string(item))
return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(item)})
if stringItem, ok := item.(list.StringItem); ok {
theme.SetTheme(string(stringItem))
return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(stringItem)})
}
}
return t, cmd
}
@@ -94,21 +99,32 @@ func NewThemeDialog() ThemeDialog {
}
}
list := list.NewStringList(
themes,
10, // maxVisibleThemes
"No themes available",
true,
// 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
list.SetSelectedIndex(selectedIdx)
listComponent.SetSelectedIndex(selectedIdx)
// Set the max width for the list to match the modal width
list.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
listComponent.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
return &themeDialog{
list: list,
list: listComponent,
modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
originalTheme: currentTheme,
themeApplied: false,
@@ -38,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
@@ -548,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
@@ -4,7 +4,6 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/app"
@@ -15,6 +14,7 @@ import (
"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
@@ -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")
}
}
@@ -95,7 +95,7 @@ func (m *Modal) Render(contentView string, background string) string {
var finalContent string
if m.title != "" {
titleStyle := baseStyle.
Foreground(t.Primary()).
Foreground(t.Text()).
Bold(true).
Padding(0, 1)
@@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string {
modalView,
background,
layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(t.Primary()),
layout.WithOverlayBorderColor(t.BorderActive()),
)
}
@@ -18,6 +18,7 @@ import (
"github.com/charmbracelet/x/ansi"
rw "github.com/mattn/go-runewidth"
"github.com/rivo/uniseg"
"github.com/sst/opencode/internal/attachment"
)
const (
@@ -32,15 +33,6 @@ const (
maxLines = 10000
)
// Attachment represents a special object within the text, distinct from regular characters.
type Attachment struct {
ID string // A unique identifier for this attachment instance
Display string // e.g., "@filename.txt"
URL string
Filename string
MediaType string
}
// Helper functions for converting between runes and any slices
// runesToInterfaces converts a slice of runes to a slice of interfaces
@@ -59,7 +51,7 @@ func interfacesToRunes(items []any) []rune {
switch val := item.(type) {
case rune:
result = append(result, val)
case *Attachment:
case *attachment.Attachment:
result = append(result, []rune(val.Display)...)
}
}
@@ -80,7 +72,7 @@ func interfacesToString(items []any) string {
switch val := item.(type) {
case rune:
s.WriteRune(val)
case *Attachment:
case *attachment.Attachment:
s.WriteString(val.Display)
}
}
@@ -90,7 +82,7 @@ func interfacesToString(items []any) string {
// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment.
// This allows for proper highlighting even when the cursor is technically at the position
// after the attachment object in the underlying slice.
func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
func (m Model) isAttachmentAtCursor() (*attachment.Attachment, int, int) {
if m.row >= len(m.value) {
return nil, -1, -1
}
@@ -104,7 +96,7 @@ func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
// Check if the cursor is at the same index as an attachment.
if col < len(row) {
if att, ok := row[col].(*Attachment); ok {
if att, ok := row[col].(*attachment.Attachment); ok {
return att, col, col
}
}
@@ -112,7 +104,7 @@ func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
// Check if the cursor is immediately after an attachment. This is a common
// state, for example, after just inserting one.
if col > 0 && col <= len(row) {
if att, ok := row[col-1].(*Attachment); ok {
if att, ok := row[col-1].(*attachment.Attachment); ok {
return att, col - 1, col - 1
}
}
@@ -132,7 +124,7 @@ func (m Model) renderLineWithAttachments(
switch val := item.(type) {
case rune:
s.WriteString(style.Render(string(val)))
case *Attachment:
case *attachment.Attachment:
// Check if this is the attachment the cursor is currently on
if currentAttachment != nil && currentAttachment.ID == val.ID {
// Cursor is on this attachment, highlight it
@@ -218,11 +210,11 @@ func DefaultKeyMap() KeyMap {
key.WithHelp("left", "character backward"),
),
WordForward: key.NewBinding(
key.WithKeys("alt+right", "alt+f"),
key.WithKeys("alt+right", "ctrl+right", "alt+f"),
key.WithHelp("alt+right", "word forward"),
),
WordBackward: key.NewBinding(
key.WithKeys("alt+left", "alt+b"),
key.WithKeys("alt+left", "ctrl+left", "alt+b"),
key.WithHelp("alt+left", "word backward"),
),
LineNext: key.NewBinding(
@@ -435,7 +427,7 @@ func (w line) Hash() string {
switch v := item.(type) {
case rune:
s.WriteRune(v)
case *Attachment:
case *attachment.Attachment:
s.WriteString(v.ID)
}
}
@@ -661,7 +653,7 @@ func (m *Model) InsertRune(r rune) {
}
// InsertAttachment inserts an attachment at the cursor position.
func (m *Model) InsertAttachment(att *Attachment) {
func (m *Model) InsertAttachment(att *attachment.Attachment) {
if m.CharLimit > 0 {
availSpace := m.CharLimit - m.Length()
// If the char limit's been reached, cancel.
@@ -716,16 +708,36 @@ func (m *Model) CurrentRowLength() int {
return len(m.value[m.row])
}
// GetAttachments returns all attachments in the textarea.
func (m Model) GetAttachments() []*Attachment {
var attachments []*Attachment
for _, row := range m.value {
// GetAttachments returns all attachments in the textarea with accurate position indices.
func (m Model) GetAttachments() []*attachment.Attachment {
var attachments []*attachment.Attachment
position := 0 // Track absolute position in the text
for rowIdx, row := range m.value {
colPosition := 0 // Track position within the current row
for _, item := range row {
if att, ok := item.(*Attachment); ok {
attachments = append(attachments, att)
switch v := item.(type) {
case *attachment.Attachment:
// Clone the attachment to avoid modifying the original
att := *v
att.StartIndex = position + colPosition
att.EndIndex = position + colPosition + len(v.Display)
attachments = append(attachments, &att)
colPosition += len(v.Display)
case rune:
colPosition++
}
}
// Add newline character position (except for last row)
if rowIdx < len(m.value)-1 {
position += colPosition + 1 // +1 for newline
} else {
position += colPosition
}
}
return attachments
}
@@ -829,7 +841,7 @@ func (m Model) Value() string {
switch val := item.(type) {
case rune:
v.WriteRune(val)
case *Attachment:
case *attachment.Attachment:
v.WriteString(val.Display)
}
}
@@ -847,7 +859,7 @@ func (m *Model) Length() int {
switch val := item.(type) {
case rune:
l += rw.RuneWidth(val)
case *Attachment:
case *attachment.Attachment:
l += uniseg.StringWidth(val.Display)
}
}
@@ -911,7 +923,7 @@ func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int {
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
@@ -952,7 +964,7 @@ func (m *Model) CursorDown() {
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
@@ -988,7 +1000,7 @@ func (m *Model) CursorDown() {
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
@@ -1034,7 +1046,7 @@ func (m *Model) CursorUp() {
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
@@ -1070,7 +1082,7 @@ func (m *Model) CursorUp() {
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
@@ -1111,6 +1123,10 @@ func (m *Model) CursorEnd() {
m.SetCursorColumn(len(m.value[m.row]))
}
func (m *Model) IsCursorAtEnd() bool {
return m.CursorColumn() == len(m.value[m.row])
}
// Focused returns the focus state on the model.
func (m Model) Focused() bool {
return m.focus
@@ -1414,14 +1430,14 @@ func (m Model) Width() int {
return m.width
}
// moveToBegin moves the cursor to the beginning of the input.
func (m *Model) moveToBegin() {
// MoveToBegin moves the cursor to the beginning of the input.
func (m *Model) MoveToBegin() {
m.row = 0
m.SetCursorColumn(0)
}
// moveToEnd moves the cursor to the end of the input.
func (m *Model) moveToEnd() {
// MoveToEnd moves the cursor to the end of the input.
func (m *Model) MoveToEnd() {
m.row = len(m.value) - 1
m.SetCursorColumn(len(m.value[m.row]))
}
@@ -1610,9 +1626,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case key.Matches(msg, m.KeyMap.WordBackward):
m.wordLeft()
case key.Matches(msg, m.KeyMap.InputBegin):
m.moveToBegin()
m.MoveToBegin()
case key.Matches(msg, m.KeyMap.InputEnd):
m.moveToEnd()
m.MoveToEnd()
case key.Matches(msg, m.KeyMap.LowercaseWordForward):
m.lowercaseRight()
case key.Matches(msg, m.KeyMap.UppercaseWordForward):
@@ -1725,7 +1741,7 @@ func (m Model) View() string {
} else if lineInfo.ColumnOffset < len(wrappedLine) {
// Render the item under the cursor
item := wrappedLine[lineInfo.ColumnOffset]
if att, ok := item.(*Attachment); ok {
if att, ok := item.(*attachment.Attachment); ok {
// Item at cursor is an attachment. Render it with the selection style.
// This becomes the "cursor" visually.
s.WriteString(m.Styles.SelectedAttachment.Render(att.Display))
@@ -2023,7 +2039,7 @@ func itemWidth(item any) int {
switch v := item.(type) {
case rune:
return rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
return uniseg.StringWidth(v.Display)
}
return 0
@@ -2052,7 +2068,7 @@ func wrapInterfaces(content []any, width int) [][]any {
isSpace = true
}
itemW = rw.RuneWidth(r)
} else if att, ok := item.(*Attachment); ok {
} else if att, ok := item.(*attachment.Attachment); ok {
itemW = uniseg.StringWidth(att.Display)
}
+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)
}
@@ -15,6 +15,11 @@ import (
"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")
@@ -272,9 +277,6 @@ func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
// getStyleAtPosition extracts the active ANSI style at a given visual position
func getStyleAtPosition(s string, targetPos int) ansiStyle {
// ANSI escape sequence regex
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
visualPos := 0
currentStyle := ansiStyle{}
@@ -284,7 +284,6 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
Table: ansi.StyleTable{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "\n",
BlockSuffix: "\n",
},
},

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