Compare commits

...
Author SHA1 Message Date
Aiden Cline e6a3be31de merge dev
format / format (push) Has been cancelled
test / test (push) Has been cancelled
2025-11-21 01:44:45 -06:00
Aiden Cline d16c8c9f0f ignore: update sdk 2025-11-21 01:25:06 -06:00
Aiden Cline fffe20cbe5 add provider whitelist 2025-11-21 01:24:44 -06:00
Aiden Cline f6da3c467b ignore: sync sdk 2025-11-21 01:04:02 -06:00
geril07andAiden Cline c0d9f21c0f feat: whitelist/blacklist config options for provider (#3416)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-21 01:01:30 -06:00
GitHub Action a67b616139 chore: format code 2025-11-21 06:03:38 +00:00
opencode 2991547974 release: v1.0.85
Update Nix Hashes / update (push) Has been cancelled
2025-11-21 06:03:38 +00:00
Dax Raad b59def2e4a hide gpt5 nano 2025-11-21 00:58:02 -05:00
opencode d842353f39 release: v1.0.84
Update Nix Hashes / update (push) Has been cancelled
2025-11-21 05:55:03 +00:00
Dax Raad d20ef569de tui: replace text shimmer with animated progress bar during model processing 2025-11-21 00:47:27 -05:00
Dax Raad ca2b871810 tui: simplify popular providers list to show only top 6 instead of applying provider priority sorting 2025-11-21 00:32:21 -05:00
DaxandGitHub Action 23ea8ba1ce Tui onboarding (#4569)
Co-authored-by: GitHub Action <action@github.com>
2025-11-21 00:21:06 -05:00
Zakopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-nodeAiden Cline
c417fec246 tweak: adjust invalid directory error message (#4567)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-20 22:36:07 -06:00
Aiden Cline 5413b16b57 fix: split not a function error 2025-11-20 22:33:59 -06:00
SpoonGitHub Actionopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
43c021ed80 feat: add field to allow user only messages (#4554)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-20 19:13:42 -06:00
GitHub Action 3b005d29d7 chore: format code 2025-11-21 00:49:39 +00:00
opencode 635f70f477 release: v1.0.83
Update Nix Hashes / update (push) Has been cancelled
2025-11-21 00:49:39 +00:00
Aiden Cline adbb6037ac fix: undefined err 2025-11-20 18:43:58 -06:00
opencode 598d6d00e4 release: v1.0.82
Update Nix Hashes / update (push) Has been cancelled
2025-11-20 22:44:37 +00:00
Aiden Cline cf934357c9 fix: make use openrouter ai sdk package to fix interlevened thinking models 2025-11-20 16:32:44 -06:00
Jay 8063e645c7 docs: Clarify model provider recommendations in README
Updated the README to clarify model provider recommendations.
2025-11-20 17:21:19 -05:00
Aiden Cline 8ab206b443 tweak: navigate to child session if it is asking for permissions 2025-11-20 15:46:49 -06:00
Aiden Cline ec5c96e10d Revert "Image tag fix for real this time (#4540)"
This reverts commit eb975bb89c.
2025-11-20 14:14:02 -06:00
Aiden Cline d2a61290b9 Revert "ignore: write todo"
This reverts commit 5aa2078852.
2025-11-20 14:13:56 -06:00
Aiden Cline 10faf9e717 ignore: fix mdx 2025-11-20 14:07:25 -06:00
Aiden Cline cba239bc8f change default child session cycle keybind to <leader>right, and <leader>left 2025-11-20 14:01:02 -06:00
jian 6f5e3ddfb3 chore: rm empty file (#4552) 2025-11-20 13:33:09 -06:00
GitHub Action d412ba264a chore: format code 2025-11-20 19:17:06 +00:00
Aiden Cline 9780f2b792 docs: update model list 2025-11-20 13:16:27 -06:00
Aiden Cline 5aa2078852 ignore: write todo 2025-11-20 12:35:34 -06:00
eb975bb89c Image tag fix for real this time (#4540)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-20 12:34:39 -06:00
GitHub Action 9479fe3ce6 chore: format code 2025-11-20 17:35:43 +00:00
William TanandAiden Cline 4393cf8dbe tweak: Prefer VISUAL environment variable over EDITOR per Unix convention (#4549)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-20 11:27:16 -06:00
opencode 447a4ca8c3 release: v1.0.81
Update Nix Hashes / update (push) Has been cancelled
2025-11-20 17:16:31 +00:00
Aiden Cline 40ac2549ff fix: aur build 2025-11-20 11:04:09 -06:00
cc-bjojacBjörn Jacobsopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
a9c56b813a Make the MAX_OUTPUT_LENGTH in bash.ts overridable by OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH (#4497)
Co-authored-by: Björn Jacobs <bjoern.jacobs.ext@bundesanzeiger.de>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-20 10:45:13 -06:00
Haris GušićandGitHub Action b1b73c9deb fix: Autocomplete with existing space after trigger (#4121)
Co-authored-by: GitHub Action <action@github.com>
2025-11-20 10:37:45 -06:00
Aiden Cline 774377330b fix: lockfile 2025-11-20 10:16:34 -06:00
processtrader e7a157ef8f fix: replace ruby-lsp with rubocop for better LSP performance (#4543) 2025-11-20 09:54:47 -06:00
Frank 3989b9fc7f wip: zen 2025-11-20 10:31:35 -05:00
GitHub Action 8bfcdf4831 chore: format code 2025-11-20 14:55:06 +00:00
Frank 3632ba3785 zen: show token breakdown 2025-11-20 09:54:22 -05:00
Albert O'SheaandGithub Action b7b3824d76 nix: update flake.lock as part of the github workflow (#4535)
Co-authored-by: Github Action <action@github.com>
2025-11-20 08:43:13 -06:00
Tommy D. RossiandCopilot b12efb2023 fix: do not print ai sdk warnings (#4541)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-20 08:42:24 -06:00
Roberto Gongora bd91cf220c docs: typo in PHP Intelephense documentation (#4545) 2025-11-20 08:41:31 -06:00
Asmae_ELAZRAK 9eb6731c21 docs: add cortecs docs (#4547) 2025-11-20 08:34:04 -06:00
GitHub Action 11373755d9 ignore: update download stats 2025-11-20 2025-11-20 12:04:55 +00:00
Aiden Cline 00b5e9f6ca chore: rm unused code 2025-11-20 00:10:51 -06:00
Aiden Cline 6b3f424e4d tweak: let user name export file 2025-11-19 23:58:40 -06:00
Aiden Cline e7dfeec9c4 fix: make toast wrap 2025-11-19 22:50:27 -06:00
Aiden Cline 97893bd7e6 tweak: make /export go to cwd 2025-11-19 22:45:40 -06:00
Frank bfefdb3752 zen: add gemini icon 2025-11-19 21:14:31 -05:00
Frank 12b79c581e zen: update gemini provider name 2025-11-19 20:58:22 -05:00
Frank ac9b4c7ebf docs: add gemini 3 pricing 2025-11-19 20:50:43 -05:00
GitHub Action 208af232ff chore: format code 2025-11-20 01:27:50 +00:00
opencode 600c6b4973 release: v1.0.80
Update Nix Hashes / update (push) Has been cancelled
2025-11-20 01:27:49 +00:00
Dax Raad 61007a9b94 refactor: switch to Switch/Match pattern for assistant message status rendering 2025-11-19 20:18:15 -05:00
opencode 52fe1a5ac5 release: v1.0.79
Update Nix Hashes / update (push) Has been cancelled
2025-11-20 01:11:20 +00:00
468927e06a chore: bump ai-sdk to v5.0.97 (#4518)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-19 18:44:33 -06:00
Aiden Cline 61562dd9f0 make aur build check if u are glibc system or a musl system (#4519) 2025-11-19 18:36:33 -06:00
Github Action c86dd91310 Update Nix hashes 2025-11-19 23:30:37 +00:00
Sebastian Herrlinger 9c85a37811 bump opentui version to v0.1.47
- fixing cursor issues with some graphemes in textarea
- proper suspend/resume
2025-11-20 00:28:25 +01:00
Aiden Cline 51bba6e634 tweak: default to disabling fetch timeout in provider options 2025-11-19 16:20:29 -06:00
Daniel PolitoandAiden Cline e1089bc5de Adding LSP: PHP Intelephense (#4504)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-19 16:01:18 -06:00
Aiden Cline 618c654aa0 ignore: todo fix test case 2025-11-19 15:18:21 -06:00
IljoandAiden Cline 4703e859bd Add YAML language server support (#4508)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-19 14:47:04 -06:00
Aiden Cline a1dc4ebbe4 ignore: flaky test be a lil less flaky plz 2025-11-19 14:46:06 -06:00
Aiden Cline e4e6096510 ignore: fix hanging test 2025-11-19 14:38:12 -06:00
Aiden Cline c472734933 tweak: make getUsage function handle missing usage data 2025-11-19 14:29:19 -06:00
Aiden Cline 9d068c20bb fix: openrouter ai sdk package support 2025-11-19 14:22:51 -06:00
Aiden Cline 48e4f2f45d tweak: add bun install retries 2025-11-19 13:04:20 -06:00
Aiden Cline bbf4574476 fix: make external_directory permission wildcarding more sane 2025-11-19 12:55:02 -06:00
Adam 8bad513140 Revert "feat(cli): better install script output"
This reverts commit 24bb293136.
2025-11-19 12:44:35 -06:00
Aiden Cline 1ff5d888c2 fix: make bash tool use external_directory perm 2025-11-19 12:31:34 -06:00
Dax Raad 5d25758400 use bash description as task title 2025-11-19 13:23:29 -05:00
Dax 16fdc90976 fix: resolve issue 4475 (#4505) 2025-11-19 13:10:09 -05:00
Aiden Cline 793542230f tweak: bash description 2025-11-19 11:31:12 -06:00
Tommy D. RossiandCopilot 9de1242d9b fix: show reasoning summaries for gemini models (#4491)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-19 11:25:18 -06:00
Aiden Cline b3afa84058 Revert "Added subagents to agents modal, non-selectable (#4460)"
This reverts commit 90044196bf.
2025-11-19 11:00:38 -06:00
Aiden Cline 024a10bbb5 ci: auto label nix 2025-11-19 10:51:11 -06:00
Adam bef9ac96e2 fix(web): stats 2025-11-19 10:05:39 -06:00
Adam 24bb293136 feat(cli): better install script output 2025-11-19 09:30:41 -06:00
Adam 45180104fe fix(desktop): message animation 2025-11-19 06:04:20 -06:00
Adam edd86e3fb7 fix(desktop): text part styling 2025-11-19 06:04:20 -06:00
Adam 4a72d57534 fix(desktop): pre styling 2025-11-19 06:04:19 -06:00
Aiden Cline 0068cb305f tweak: toast 2025-11-19 00:51:07 -06:00
opencode-agent[bot]andAiden Cline 90044196bf Added subagents to agents modal, non-selectable (#4460)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-19 00:40:47 -06:00
963a926db2 allow task tool to have resume capabilities (#4204)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-19 00:17:26 -06:00
Frank 0d3d48bb59 zen: fix cost in graph 2025-11-18 23:43:26 -05:00
GitHub Action 66eaba4bdc chore: format code 2025-11-19 01:02:57 +00:00
Dax Raad 21b6e5404e feat: add @opencode-ai/util package with utility functions 2025-11-18 20:02:10 -05:00
GitHub Action a0fe59ab75 chore: format code 2025-11-18 23:49:29 +00:00
Aiden Cline 81ebf56cf1 feat: add top level lsp: false and formatter: false to allow disabling all formatters or lsps at once 2025-11-18 17:48:40 -06:00
opencode 429708e3d5 release: v1.0.78
Update Nix Hashes / update (push) Has been cancelled
2025-11-18 23:36:35 +00:00
shuvandGitHub Action d50f825c6d fix: pass model info to ReadTool to enable image support check (#4473)
Co-authored-by: GitHub Action <action@github.com>
2025-11-18 17:20:03 -06:00
47bfae52c0 fix: permission checks for external_directory and doom_loop (#4433)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: AerionDyseti <AerionDyseti@users.noreply.github.com>
2025-11-18 17:18:23 -06:00
Frank 52cf9e3423 wip: zen 2025-11-18 17:42:48 -05:00
GitHub Action a9b6debfa2 chore: format code 2025-11-18 22:24:29 +00:00
Eric Juden d6bf475749 docs: Improving Plugin Documentation - Adding Events (#4438) 2025-11-18 16:23:46 -06:00
opencode f22580e943 release: v1.0.77
Update Nix Hashes / update (push) Has been cancelled
2025-11-18 22:17:39 +00:00
Dax Raad 6d98db57c7 better gemini retry errors 2025-11-18 17:11:29 -05:00
OpeOginni 59f127a250 fix: allow for theme references (#4450) 2025-11-18 14:26:42 -06:00
Adam 3068e7dcf7 fix(desktop): animating too much 2025-11-18 14:24:26 -06:00
GitHub Action f83d62191a chore: format code 2025-11-18 20:23:29 +00:00
Longloneopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
3b72857124 fix: update reasoningEffort logic for gpt-5.1 models in SessionPrompt-ensureTitle (#4456)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-18 14:22:44 -06:00
opencode 68cd105d9d release: v1.0.76
Update Nix Hashes / update (push) Has been cancelled
2025-11-18 20:12:38 +00:00
Aiden Cline e09af2cb4b fix windows bash tool issue 2025-11-18 14:06:45 -06:00
Adam 14bd3b1d30 chore(desktop): remove logging 2025-11-18 13:52:29 -06:00
Adam 3a9c2152f7 fix(desktop): reactivity issue on route change 2025-11-18 13:45:27 -06:00
Frank 7283bfa480 zen: gemini 2025-11-18 14:28:31 -05:00
opencode-agent[bot]opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-nodeAiden Cline
37d5099728 Added opencode agent list command to show all available agents with details. (#4446)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-18 13:25:53 -06:00
GitHub Action d45fc030b2 chore: format code 2025-11-18 18:35:26 +00:00
Adam c7042c807f fix(desktop): only animate response once 2025-11-18 12:34:34 -06:00
Ian Maurer 715265de4b Update .gitignore to remove '*.bun-build'
Remove '*.bun-build' from the .gitignore file
2025-11-12 11:33:59 -05:00
Ian Maurer 2048f32491 Merge branch 'dev' into fix/cli-clean-exit-on-model-errors 2025-11-12 11:30:36 -05:00
GitHub Action 2d7ba43a21 chore: format code 2025-11-12 16:14:12 +00:00
Ian Maurer bd198d8550 fix(cli): robust suggestions for unknown provider and model-only input; ignore bun build artifacts 2025-11-12 11:13:34 -05:00
GitHub Action cbffbcdd3d chore: format code 2025-11-12 15:43:30 +00:00
Ian Maurer 2be8b2269f feat(cli): suggest closest provider/model on not found ("Did you mean…")\n\nSummary\n- Add fuzzy suggestions to ProviderModelNotFoundError with up to 3 candidates\n- Normalize punctuation (e.g., 4.5 vs 4-5) and case to better match common typos\n- Support model-only input (no provider) by searching across all providers\n- Enhance CLI error formatter to display suggestions when present\n\nImplementation\n- provider.ts: use fuzzysort; add normalization by stripping non-alphanumerics; search by key for robust matches\n- provider.ts: when provider is unknown and model is empty, treat token as unqualified model and search across all providers' models; otherwise suggest provider matches\n- error.ts: print "Did you mean: <provider/model>, …" when suggestions exist\n\nExamples\n1) Typo in model ID\n $ bun run ./src/index.ts run --model anthropic/claude-haiu-4-5 "hi"\n Error: Model not found: anthropic/claude-haiu-4-5\n Did you mean: anthropic/claude-haiku-4-5, anthropic/claude-haiku-4-5-20251001\n Try: zai-coding-plan/glm-4.5-flash
zai-coding-plan/glm-4.5
zai-coding-plan/glm-4.5-air
zai-coding-plan/glm-4.5v
zai-coding-plan/glm-4.6
opencode/big-pickle
opencode/grok-code
anthropic/claude-opus-4-0
anthropic/claude-3-5-sonnet-20241022
anthropic/claude-opus-4-1
anthropic/claude-haiku-4-5
anthropic/claude-3-5-sonnet-20240620
anthropic/claude-3-5-haiku-latest
anthropic/claude-3-opus-20240229
anthropic/claude-sonnet-4-5
anthropic/claude-sonnet-4-5-20250929
anthropic/claude-sonnet-4-20250514
anthropic/claude-opus-4-20250514
anthropic/claude-3-5-haiku-20241022
anthropic/claude-3-haiku-20240307
anthropic/claude-3-7-sonnet-20250219
anthropic/claude-3-7-sonnet-latest
anthropic/claude-sonnet-4-0
anthropic/claude-opus-4-1-20250805
anthropic/claude-3-sonnet-20240229
anthropic/claude-haiku-4-5-20251001
openai/gpt-4.1-nano
openai/text-embedding-3-small
openai/gpt-4
openai/o1-pro
openai/gpt-4o-2024-05-13
openai/gpt-4o-2024-08-06
openai/gpt-4.1-mini
openai/o3-deep-research
openai/gpt-3.5-turbo
openai/text-embedding-3-large
openai/gpt-4-turbo
openai/o1-preview
openai/o3-mini
openai/codex-mini-latest
openai/gpt-5-nano
openai/gpt-5-codex
openai/gpt-4o
openai/gpt-4.1
openai/o4-mini
openai/o1
openai/gpt-5-mini
openai/o1-mini
openai/text-embedding-ada-002
openai/o3-pro
openai/gpt-4o-2024-11-20
openai/o3
openai/o4-mini-deep-research
openai/gpt-4o-mini
openai/gpt-5
openai/gpt-5-pro to list available models\n   Or check your config (opencode.json) provider/model names\n\n2) Dot vs dash (punctuation normalization)\n   $ bun run ./src/index.ts run --model anthropic/claude-haiku-4.5 "hi"\n   Error: Model not found: anthropic/claude-haiku-4.5\n   Did you mean: anthropic/claude-haiku-4-5, anthropic/claude-haiku-4-5-20251001\n   Try: zai-coding-plan/glm-4.5-flash
zai-coding-plan/glm-4.5
zai-coding-plan/glm-4.5-air
zai-coding-plan/glm-4.5v
zai-coding-plan/glm-4.6
opencode/big-pickle
opencode/grok-code
anthropic/claude-opus-4-0
anthropic/claude-3-5-sonnet-20241022
anthropic/claude-opus-4-1
anthropic/claude-haiku-4-5
anthropic/claude-3-5-sonnet-20240620
anthropic/claude-3-5-haiku-latest
anthropic/claude-3-opus-20240229
anthropic/claude-sonnet-4-5
anthropic/claude-sonnet-4-5-20250929
anthropic/claude-sonnet-4-20250514
anthropic/claude-opus-4-20250514
anthropic/claude-3-5-haiku-20241022
anthropic/claude-3-haiku-20240307
anthropic/claude-3-7-sonnet-20250219
anthropic/claude-3-7-sonnet-latest
anthropic/claude-sonnet-4-0
anthropic/claude-opus-4-1-20250805
anthropic/claude-3-sonnet-20240229
anthropic/claude-haiku-4-5-20251001
openai/gpt-4.1-nano
openai/text-embedding-3-small
openai/gpt-4
openai/o1-pro
openai/gpt-4o-2024-05-13
openai/gpt-4o-2024-08-06
openai/gpt-4.1-mini
openai/o3-deep-research
openai/gpt-3.5-turbo
openai/text-embedding-3-large
openai/gpt-4-turbo
openai/o1-preview
openai/o3-mini
openai/codex-mini-latest
openai/gpt-5-nano
openai/gpt-5-codex
openai/gpt-4o
openai/gpt-4.1
openai/o4-mini
openai/o1
openai/gpt-5-mini
openai/o1-mini
openai/text-embedding-ada-002
openai/o3-pro
openai/gpt-4o-2024-11-20
openai/o3
openai/o4-mini-deep-research
openai/gpt-4o-mini
openai/gpt-5
openai/gpt-5-pro to list available models\n   Or check your config (opencode.json) provider/model names\n\n3) Missing provider (model-only input)\n   $ bun run ./src/index.ts run --model big-pickle "hi"\n   Error: Model not found: big-pickle/\n   Did you mean: opencode/big-pickle\n\n4) Correct model after suggestion\n   $ bun run ./src/index.ts run --model opencode/big-pickle "hi"\n   Hi! How can I help you with your opencode project today?\n\nNotes\n- Suggestions are hints only; behavior is unchanged (no auto-selection).\n- This runs locally as part of the CLI error path; performance impact is negligible (small in-memory scans).
2025-11-12 10:42:18 -05:00
Ian Maurer c1fa257a92 Merge branch 'dev' into fix/cli-clean-exit-on-model-errors 2025-11-11 17:18:23 -05:00
Ian Maurer 7fd81dd93e chore(cli): resolve merge conflict with dev in bootstrap; keep try/finally with explicit return 2025-11-11 17:16:47 -05:00
Ian Maurer d554e7aaef fix(cli): always dispose instance on error to prevent hanging; add friendly ProviderModelNotFoundError/InitError messages\n\n- Wrap bootstrap callback in try/finally to guarantee Instance.dispose()\n- Format provider/model errors into actionable guidance (opencode models, config)\n\nRepro: running opencode run --model typo/claude-haiku-4-5 prints stack and hangs until SIGINT due to lingering watchers.\nFix: disposing Instance tears down watchers/subscriptions, allowing process to exit.\n\nNotes: Prior attempt (#3083) explicitly exited; this approach addresses root cause without forcing exit and improves UX for common misconfigurations. 2025-11-11 17:13:55 -05:00
114 changed files with 2722 additions and 675 deletions
+16 -8
View File
@@ -28,14 +28,14 @@ jobs:
const versionPattern = /[v]?1\.0\./i; const versionPattern = /[v]?1\.0\./i;
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description); const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
// Check for "nix" keyword
const nixPattern = /\bnix\b/i;
const isNixRelated = nixPattern.test(title) || nixPattern.test(description);
const labels = [];
if (isWebRelated) { if (isWebRelated) {
// Add web label labels.push('web');
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['web']
});
// Assign to adamdotdevin // Assign to adamdotdevin
await github.rest.issues.addAssignees({ await github.rest.issues.addAssignees({
@@ -46,10 +46,18 @@ jobs:
}); });
} else if (isVersionRelated) { } else if (isVersionRelated) {
// Only add opentui if NOT web-related // Only add opentui if NOT web-related
labels.push('opentui');
}
if (isNixRelated) {
labels.push('nix');
}
if (labels.length > 0) {
await github.rest.issues.addLabels({ await github.rest.issues.addLabels({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: issue.number, issue_number: issue.number,
labels: ['opentui'] labels: labels
}); });
} }
+7 -2
View File
@@ -37,6 +37,11 @@ jobs:
git config --global user.email "action@github.com" git config --global user.email "action@github.com"
git config --global user.name "Github Action" git config --global user.name "Github Action"
- name: Update flake.lock
run: |
set -euo pipefail
nix flake update
- name: Update node_modules hash - name: Update node_modules hash
run: | run: |
set -euo pipefail set -euo pipefail
@@ -62,7 +67,7 @@ jobs:
echo "" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY"
} }
FILES=(flake.nix nix/node-modules.nix nix/hashes.json) FILES=(flake.lock flake.nix nix/node-modules.nix nix/hashes.json)
STATUS="$(git status --short -- "${FILES[@]}" || true)" STATUS="$(git status --short -- "${FILES[@]}" || true)"
if [ -z "$STATUS" ]; then if [ -z "$STATUS" ]; then
summarize "no changes" summarize "no changes"
@@ -71,7 +76,7 @@ jobs:
fi fi
git add "${FILES[@]}" git add "${FILES[@]}"
git commit -m "Update Nix hashes" git commit -m "Update Nix flake.lock and hashes"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git push origin HEAD:"$BRANCH" git push origin HEAD:"$BRANCH"
+1
View File
@@ -17,3 +17,4 @@ dist
refs refs
Session.vim Session.vim
opencode.json opencode.json
a.out
+1 -1
View File
@@ -87,7 +87,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
It's very similar to Claude Code in terms of capability. Here are the key differences: It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source - 100% open source
- Not coupled to any provider. Although Anthropic is recommended, OpenCode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important. - Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
- Out of the box LSP support - Out of the box LSP support
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. - A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients. - A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
+2
View File
@@ -144,3 +144,5 @@
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) | | 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) | | 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) | | 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
View File
+45 -26
View File
@@ -41,7 +41,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.75", "version": "1.0.85",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -68,7 +68,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.0.75", "version": "1.0.85",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -92,7 +92,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.75", "version": "1.0.85",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -116,7 +116,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.0.75", "version": "1.0.85",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -156,7 +156,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.75", "version": "1.0.85",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
@@ -172,7 +172,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.0.75", "version": "1.0.85",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -180,6 +180,7 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1", "@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/mcp": "0.0.8",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
@@ -190,8 +191,8 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.46", "@opentui/core": "0.1.47",
"@opentui/solid": "0.1.46", "@opentui/solid": "0.1.47",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:", "@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -250,7 +251,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.0.75", "version": "1.0.85",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -270,7 +271,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.0.75", "version": "1.0.85",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.81.0", "@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -281,7 +282,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.0.75", "version": "1.0.85",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -294,7 +295,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.0.75", "version": "1.0.85",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -322,9 +323,19 @@
"vite-plugin-solid": "catalog:", "vite-plugin-solid": "catalog:",
}, },
}, },
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.85",
"dependencies": {
"zod": "catalog:",
},
"devDependencies": {
"typescript": "catalog:",
},
},
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.0.75", "version": "1.0.85",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -378,7 +389,7 @@
"@types/bun": "1.3.0", "@types/bun": "1.3.0",
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@typescript/native-preview": "7.0.0-dev.20251014.1", "@typescript/native-preview": "7.0.0-dev.20251014.1",
"ai": "5.0.8", "ai": "5.0.97",
"diff": "8.0.2", "diff": "8.0.2",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.7.10", "hono": "4.7.10",
@@ -413,12 +424,14 @@
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.4", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-1roLdgMbFU3Nr4MC97/te7w6OqxsWBkDUkpbCcvxF3jz/ku91WVaJldn/PKU8feMKNyI5W9wnqhbjb1BqbExOQ=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.12", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W+cB1sOWvPcz9qiIsNtD+HxUrBUva2vWv2K1EFukuImX+HA0uZx3EyyOjhYQ9gtf/teqEG80M6OvJ7xx/VLV2A=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.11", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.7" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-dnVIgSz1DZD/0gVau6ifYN3HZFN15HZwC9VjevTFfvrfSfbEvpXj5x/k/zk/0XuQrlQ5g8JiwJtxc9bx24x2xw=="], "@ai-sdk/google": ["@ai-sdk/google@2.0.11", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.7" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-dnVIgSz1DZD/0gVau6ifYN3HZFN15HZwC9VjevTFfvrfSfbEvpXj5x/k/zk/0XuQrlQ5g8JiwJtxc9bx24x2xw=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.16", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.9", "@ai-sdk/google": "2.0.11", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.7", "google-auth-library": "^9.15.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-tStlnOCRGRqKKJSCOtXhijX4r9kYVK2v+Vs7miJnfvr3sZfO8nRS0xnNhfgu17xuNi5LMMufeCYURTz4lKxzUQ=="], "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.16", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.9", "@ai-sdk/google": "2.0.11", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.7", "google-auth-library": "^9.15.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-tStlnOCRGRqKKJSCOtXhijX4r9kYVK2v+Vs7miJnfvr3sZfO8nRS0xnNhfgu17xuNi5LMMufeCYURTz4lKxzUQ=="],
"@ai-sdk/mcp": ["@ai-sdk/mcp@0.0.8", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "pkce-challenge": "^5.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9y9GuGcZ9/+pMIHfpOCJgZVp+AZMv6TkjX2NVT17SQZvTF2N8LXuCXyoUPyi1PxIxzxl0n463LxxaB2O6olC+Q=="],
"@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="], "@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.1", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-luHVcU+yKzwv3ekKgbP3v+elUVxb2Rt+8c6w9qi7g2NYG2/pEL21oIrnaEnc6UtTZLLZX9EFBcpq2N1FQKDIMw=="], "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.1", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-luHVcU+yKzwv3ekKgbP3v+elUVxb2Rt+8c6w9qi7g2NYG2/pEL21oIrnaEnc6UtTZLLZX9EFBcpq2N1FQKDIMw=="],
@@ -965,25 +978,27 @@
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"], "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.46", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.46", "@opentui/core-darwin-x64": "0.1.46", "@opentui/core-linux-arm64": "0.1.46", "@opentui/core-linux-x64": "0.1.46", "@opentui/core-win32-arm64": "0.1.46", "@opentui/core-win32-x64": "0.1.46", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-9682jrr65vYP0hPHfrZRK3xymlCSLVBMrRKNtclFasDi6bRvACUrtziFOIIyMIvPHRJCFWPbtz0MppARmN4zvQ=="], "@opentui/core": ["@opentui/core@0.1.47", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.47", "@opentui/core-darwin-x64": "0.1.47", "@opentui/core-linux-arm64": "0.1.47", "@opentui/core-linux-x64": "0.1.47", "@opentui/core-win32-arm64": "0.1.47", "@opentui/core-win32-x64": "0.1.47", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-gKcYX9EJ/e5VLEwBH2kalDr5xoI9MEanzQV7uV3Sb2Z9+ndwEUShKKna3odN8g4E20c4sX2VpwmB9hhl3Tsd9w=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.46", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sp/uFS8J/1yVfhgkTJ43OZfy64hv1/9sdT+oC5yb8XTNPI3QGtg6ixjr3nRoD/Lkxuj2i5SJ30RZufqH6rkCpA=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.47", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0/u4VkJJPvW24cZzMaKf6Dm+VzeO1a94l6NV3AQ1Wb+pPTEyOmNWkRvj03ZrRLMCyQduaFVtlnor8DVCk6OHuQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.46", "", { "os": "darwin", "cpu": "x64" }, "sha512-JtxEruRyLQRK8ByPmBm1nYYSvnX6mXNC+mngvd5RDiCzLzkM6qVBQBd/m3Hxp2/s6MO5Z2+iVBzZ8XFH5T4IZw=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.47", "", { "os": "darwin", "cpu": "x64" }, "sha512-y1+c/e+IaZAj5N02GnD+oaubbb5JiW5eKgF0h58kw73iXDMfynuoGOpREz58i1rUFYOMYJGdrSjEHtXk2pD2XA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.46", "", { "os": "linux", "cpu": "arm64" }, "sha512-pN8nR4CwBlkZ5uh5KvoytiXXav2GhkP9cB2d3gPe49c7MBz2XrjGexgb47xjaq0hAVbytv9XUifqdPTcFQdPaQ=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.47", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZESHmqILtfb6FFEfi40JGKl8z0+LhOSoHgfOK1PPyuyRT9Mk8uXeQgPMF5W6Ac0pp4w+uWVC4TrFjijCCSiaUQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.46", "", { "os": "linux", "cpu": "x64" }, "sha512-oH4/FEYZYce9qMQVqGl4+Btw+Mfsf6ybpWIIJUJjXMWWZlAgsTMAWM8m195Oe6WstfFLF+nRH7NUcm/YOsCHnw=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.47", "", { "os": "linux", "cpu": "x64" }, "sha512-qfvy1qshgnZMcAHQ3MS093IBjxM2pPx+kEnW7icsyud60zoJgoUugdN2kjgJiIJiYX3f3PgE68J6CVW2MCtYfQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.46", "", { "os": "win32", "cpu": "arm64" }, "sha512-C/rTBJ9bzBcZJRCLIxi9Ka/DANe2SaHtryotseWPk9RDydw7LTHGoi3VtRW0RFijQGqmvFg+31MeNhvY1YZ65Q=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.47", "", { "os": "win32", "cpu": "arm64" }, "sha512-f6OoPnaz303H6fudi8blS+iEcJtlFlcqdBoWnWnJQfN9rLmajW3Yf7RfpNOoLUlDcwxQLyTL/5EHwbcG8D4r7A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.46", "", { "os": "win32", "cpu": "x64" }, "sha512-d2DXSlA93LbSriX+pDDZ5sMwkcW1+eVoeykxeW4UParSb4/3ceBCD4aSARaZ6yoq0rR1IWOdgKdiihZH4mwdJQ=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.47", "", { "os": "win32", "cpu": "x64" }, "sha512-lQnJg7FucyyTbN/ybTj5FZ7S8OAfT5KxXDR5l9Sla7R5MIDY6nBXYM3GWeF81jzDd4K4Z/0hxNFtWSopEXRFYg=="],
"@opentui/solid": ["@opentui/solid@0.1.46", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.46", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-t+LDXS+FBT+fjQHOxuL44Bx1jGuXuLouyB25BZuypQKKT8sOhi8rJ5+Q4UwH5lrI4OoRBXmzrgsWj7DD58sHDw=="], "@opentui/solid": ["@opentui/solid@0.1.47", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.47", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-azN2sf8X/6HiLkz8ip2lcY532ApNEkl+BHd+wml/HdwdgLE7nthgA6x8Pgvi7f4qkRmpeYATU+danIzB6K6B8A=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1495,6 +1510,8 @@
"@vercel/nft": ["@vercel/nft@0.30.3", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^10.4.5", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-UEq+eF0ocEf9WQCV1gktxKhha36KDs7jln5qii6UpPf5clMqDc0p3E7d9l2Smx0i9Pm1qpq4S4lLfNl97bbv6w=="], "@vercel/nft": ["@vercel/nft@0.30.3", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^10.4.5", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-UEq+eF0ocEf9WQCV1gktxKhha36KDs7jln5qii6UpPf5clMqDc0p3E7d9l2Smx0i9Pm1qpq4S4lLfNl97bbv6w=="],
"@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
"@vinxi/listhen": ["@vinxi/listhen@1.5.6", "", { "dependencies": { "@parcel/watcher": "^2.3.0", "@parcel/watcher-wasm": "2.3.0", "citty": "^0.1.5", "clipboardy": "^4.0.0", "consola": "^3.2.3", "defu": "^6.1.4", "get-port-please": "^3.1.2", "h3": "^1.10.0", "http-shutdown": "^1.2.2", "jiti": "^1.21.0", "mlly": "^1.5.0", "node-forge": "^1.3.1", "pathe": "^1.1.2", "std-env": "^3.7.0", "ufo": "^1.3.2", "untun": "^0.1.3", "uqr": "^0.1.2" }, "bin": { "listen": "bin/listhen.mjs", "listhen": "bin/listhen.mjs" } }, "sha512-WSN1z931BtasZJlgPp704zJFnQFRg7yzSjkm3MzAWQYe4uXFXlFr1hc5Ac2zae5/HDOz5x1/zDM5Cb54vTCnWw=="], "@vinxi/listhen": ["@vinxi/listhen@1.5.6", "", { "dependencies": { "@parcel/watcher": "^2.3.0", "@parcel/watcher-wasm": "2.3.0", "citty": "^0.1.5", "clipboardy": "^4.0.0", "consola": "^3.2.3", "defu": "^6.1.4", "get-port-please": "^3.1.2", "h3": "^1.10.0", "http-shutdown": "^1.2.2", "jiti": "^1.21.0", "mlly": "^1.5.0", "node-forge": "^1.3.1", "pathe": "^1.1.2", "std-env": "^3.7.0", "ufo": "^1.3.2", "untun": "^0.1.3", "uqr": "^0.1.2" }, "bin": { "listen": "bin/listhen.mjs", "listhen": "bin/listhen.mjs" } }, "sha512-WSN1z931BtasZJlgPp704zJFnQFRg7yzSjkm3MzAWQYe4uXFXlFr1hc5Ac2zae5/HDOz5x1/zDM5Cb54vTCnWw=="],
"@vinxi/plugin-directives": ["@vinxi/plugin-directives@0.5.1", "", { "dependencies": { "@babel/parser": "^7.23.5", "acorn": "^8.10.0", "acorn-jsx": "^5.3.2", "acorn-loose": "^8.3.0", "acorn-typescript": "^1.4.3", "astring": "^1.8.6", "magicast": "^0.2.10", "recast": "^0.23.4", "tslib": "^2.6.2" }, "peerDependencies": { "vinxi": "^0.5.5" } }, "sha512-pH/KIVBvBt7z7cXrUH/9uaqcdxjegFC7+zvkZkdOyWzs+kQD5KPf3cl8kC+5ayzXHT+OMlhGhyitytqN3cGmHg=="], "@vinxi/plugin-directives": ["@vinxi/plugin-directives@0.5.1", "", { "dependencies": { "@babel/parser": "^7.23.5", "acorn": "^8.10.0", "acorn-jsx": "^5.3.2", "acorn-loose": "^8.3.0", "acorn-typescript": "^1.4.3", "astring": "^1.8.6", "magicast": "^0.2.10", "recast": "^0.23.4", "tslib": "^2.6.2" }, "peerDependencies": { "vinxi": "^0.5.5" } }, "sha512-pH/KIVBvBt7z7cXrUH/9uaqcdxjegFC7+zvkZkdOyWzs+kQD5KPf3cl8kC+5ayzXHT+OMlhGhyitytqN3cGmHg=="],
@@ -1529,7 +1546,7 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@5.0.8", "", { "dependencies": { "@ai-sdk/gateway": "1.0.4", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.1", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-qbnhj046UvG30V1S5WhjBn+RBGEAmi8PSZWqMhRsE3EPxvO5BcePXTZFA23e9MYyWS9zr4Vm8Mv3wQXwLmtIBw=="], "ai": ["ai@5.0.97", "", { "dependencies": { "@ai-sdk/gateway": "2.0.12", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8zBx0b/owis4eJI2tAlV8a1Rv0BANmLxontcAelkLNwEHhgfgXeKpDkhNB6OgV+BJSwboIUDkgd9312DdJnCOQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
@@ -3603,7 +3620,7 @@
"@ai-sdk/amazon-bedrock/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@ai-sdk/amazon-bedrock/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.1", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-/iP1sKc6UdJgGH98OCly7sWJKv+J9G47PnTjIj40IJMUQKwDrUMyf7zOOfRtPwSuNifYhSoJQ4s1WltI65gJ/g=="], "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.7", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-o3BS5/t8KnBL3ubP8k3w77AByOypLm+pkIL/DCw0qKkhDbvhCy+L3hRTGPikpdb8WHcylAeKsjgwOxhj4cqTUA=="], "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.7", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-o3BS5/t8KnBL3ubP8k3w77AByOypLm+pkIL/DCw0qKkhDbvhCy+L3hRTGPikpdb8WHcylAeKsjgwOxhj4cqTUA=="],
@@ -3611,6 +3628,8 @@
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.7", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-o3BS5/t8KnBL3ubP8k3w77AByOypLm+pkIL/DCw0qKkhDbvhCy+L3hRTGPikpdb8WHcylAeKsjgwOxhj4cqTUA=="], "@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.7", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-o3BS5/t8KnBL3ubP8k3w77AByOypLm+pkIL/DCw0qKkhDbvhCy+L3hRTGPikpdb8WHcylAeKsjgwOxhj4cqTUA=="],
"@ai-sdk/mcp/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], "@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], "@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
@@ -3819,7 +3838,7 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.1", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-/iP1sKc6UdJgGH98OCly7sWJKv+J9G47PnTjIj40IJMUQKwDrUMyf7zOOfRtPwSuNifYhSoJQ4s1WltI65gJ/g=="], "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1762156382, "lastModified": 1763618868,
"narHash": "sha256-Yg7Ag7ov5+36jEFC1DaZh/12SEXo6OO3/8rqADRxiqs=", "narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "7241bcbb4f099a66aafca120d37c65e8dda32717", "rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"nodeModules": "sha256-lAU5G92UJ998pKOd0EAt9gUa/HUXXvu6xAUdH2STmDo=" "nodeModules": "sha256-bPiUpHGtgwVxHQHXBprpc6fFeJqW6/x7dwtQZBq29oU="
} }
+1 -1
View File
@@ -32,7 +32,7 @@
"@solidjs/meta": "0.29.4", "@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"ai": "5.0.8", "ai": "5.0.97",
"hono": "4.7.10", "hono": "4.7.10",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"luxon": "3.6.1", "luxon": "3.6.1",
+1 -1
View File
@@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev", "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json", "build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start", "start": "vinxi start",
"version": "1.0.75" "version": "1.0.85"
}, },
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -202,6 +202,14 @@ export function IconZai(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
) )
} }
export function IconGoogle(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 50 50" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M49.04,24.001l-1.082-0.043h-0.001C36.134,23.492,26.508,13.866,26.042,2.043L25.999,0.96C25.978,0.424,25.537,0,25,0 s-0.978,0.424-0.999,0.96l-0.043,1.083C23.492,13.866,13.866,23.492,2.042,23.958L0.96,24.001C0.424,24.022,0,24.463,0,25 c0,0.537,0.424,0.978,0.961,0.999l1.082,0.042c11.823,0.467,21.449,10.093,21.915,21.916l0.043,1.083C24.022,49.576,24.463,50,25,50 s0.978-0.424,0.999-0.96l0.043-1.083c0.466-11.823,10.092-21.449,21.915-21.916l1.082-0.042C49.576,25.978,50,25.537,50,25 C50,24.463,49.576,24.022,49.04,24.001z"></path>
</svg>
)
}
export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) { export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return ( return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none"> <svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none">
@@ -228,3 +236,14 @@ export function IconChevronRight(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</svg> </svg>
) )
} }
export function IconBreakdown(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 12L2 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M6 12L6 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M10 12L10 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M14 12L14 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
)
}
+2 -2
View File
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page) // Static stats (used on landing page)
stats: { stats: {
contributors: "250", contributors: "300",
commits: "3,500", commits: "4,000",
monthlyUsers: "300,000", monthlyUsers: "300,000",
}, },
} as const } as const
@@ -1,4 +1,4 @@
import { and, Database, eq, gte, inArray, isNull, lte, or, sql } from "@opencode-ai/console-core/drizzle/index.js" import { and, Database, eq, gte, inArray, isNull, lte, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js"
import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js" import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
@@ -35,7 +35,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
.select({ .select({
date: sql<string>`DATE(${UsageTable.timeCreated})`, date: sql<string>`DATE(${UsageTable.timeCreated})`,
model: UsageTable.model, model: UsageTable.model,
totalCost: sql<number>`SUM(${UsageTable.cost})`, totalCost: sum(UsageTable.cost),
keyId: UsageTable.keyID, keyId: UsageTable.keyID,
}) })
.from(UsageTable) .from(UsageTable)
@@ -46,7 +46,13 @@ async function getCosts(workspaceID: string, year: number, month: number) {
lte(UsageTable.timeCreated, endDate), lte(UsageTable.timeCreated, endDate),
), ),
) )
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID), .groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
.then((x) =>
x.map((r) => ({
...r,
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
})),
),
) )
// Get unique key IDs from usage // Get unique key IDs from usage
@@ -204,18 +210,16 @@ export function GraphSection() {
const dates = getDates() const dates = getDates()
if (!data?.usage?.length) return null if (!data?.usage?.length) return null
const filteredUsageResults = store.key ? data.usage.filter((row) => row.keyId === store.key) : data.usage
const dailyData = new Map<string, Map<string, number>>() const dailyData = new Map<string, Map<string, number>>()
for (const dateKey of dates) dailyData.set(dateKey, new Map()) for (const dateKey of dates) dailyData.set(dateKey, new Map())
for (const row of filteredUsageResults) { data.usage
const dayMap = dailyData.get(row.date) .filter((row) => (store.key ? row.keyId === store.key : true))
if (dayMap) { .forEach((row) => {
const existing = dayMap.get(row.model) || 0 const dayMap = dailyData.get(row.date)
dayMap.set(row.model, existing + row.totalCost) if (!dayMap) return
} dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
} })
const filteredModels = store.model === null ? getModels() : [store.model] const filteredModels = store.model === null ? getModels() : [store.model]
@@ -223,7 +227,7 @@ export function GraphSection() {
const color = getModelColor(model) const color = getModelColor(model)
return { return {
label: model, label: model,
data: dates.map((date) => (dailyData.get(date)?.get(model) || 0) / 100000000), data: dates.map((date) => (dailyData.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: color, backgroundColor: color,
hoverBackgroundColor: color, hoverBackgroundColor: color,
borderWidth: 0, borderWidth: 0,
@@ -5,11 +5,21 @@ import { withActor } from "~/context/auth.withActor"
import { ZenData } from "@opencode-ai/console-core/model.js" import { ZenData } from "@opencode-ai/console-core/model.js"
import styles from "./model-section.module.css" import styles from "./model-section.module.css"
import { querySessionInfo } from "../common" import { querySessionInfo } from "../common"
import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon" import {
IconAlibaba,
IconAnthropic,
IconGoogle,
IconMoonshotAI,
IconOpenAI,
IconStealth,
IconXai,
IconZai,
} from "~/component/icon"
const getModelLab = (modelId: string) => { const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic" if (modelId.startsWith("claude")) return "Anthropic"
if (modelId.startsWith("gpt")) return "OpenAI" if (modelId.startsWith("gpt")) return "OpenAI"
if (modelId.startsWith("gemini")) return "Google"
if (modelId.startsWith("kimi")) return "Moonshot AI" if (modelId.startsWith("kimi")) return "Moonshot AI"
if (modelId.startsWith("glm")) return "Z.ai" if (modelId.startsWith("glm")) return "Z.ai"
if (modelId.startsWith("qwen")) return "Alibaba" if (modelId.startsWith("qwen")) return "Alibaba"
@@ -24,7 +34,17 @@ const getModelsInfo = query(async (workspaceID: string) => {
all: Object.entries(ZenData.list().models) all: Object.entries(ZenData.list().models)
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id)) .filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
.filter(([id, _model]) => !id.startsWith("alpha-")) .filter(([id, _model]) => !id.startsWith("alpha-"))
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name)) .sort(([idA, modelA], [idB, modelB]) => {
const priority = ["big-pickle", "grok", "claude", "gpt", "gemini"]
const getPriority = (id: string) => {
const index = priority.findIndex((p) => id.startsWith(p))
return index === -1 ? Infinity : index
}
const pA = getPriority(idA)
const pB = getPriority(idB)
if (pA !== pB) return pA - pB
return modelA.name.localeCompare(modelB.name)
})
.map(([id, model]) => ({ id, name: model.name })), .map(([id, model]) => ({ id, name: model.name })),
disabled: await Model.listDisabled(), disabled: await Model.listDisabled(),
} }
@@ -96,6 +116,8 @@ export function ModelSection() {
return <IconOpenAI width={16} height={16} /> return <IconOpenAI width={16} height={16} />
case "Anthropic": case "Anthropic":
return <IconAnthropic width={16} height={16} /> return <IconAnthropic width={16} height={16} />
case "Google":
return <IconGoogle width={16} height={16} />
case "Moonshot AI": case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} /> return <IconMoonshotAI width={16} height={16} />
case "Z.ai": case "Z.ai":
@@ -56,6 +56,53 @@
color: var(--color-text); color: var(--color-text);
font-weight: 500; font-weight: 500;
} }
[data-slot="tokens-with-breakdown"] {
position: relative;
display: flex;
align-items: center;
gap: var(--space-2);
}
[data-slot="breakdown-button"] {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: var(--color-text);
}
svg {
width: 16px;
height: 16px;
}
}
[data-slot="breakdown-popup"] {
position: absolute;
left: 0;
top: 100%;
margin-top: var(--space-2);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
z-index: 10;
min-width: 180px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: var(--font-size-xs);
@media (prefers-color-scheme: dark) {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
} }
tbody tr:last-child td { tbody tr:last-child td {
@@ -116,4 +163,24 @@
} }
} }
} }
/* Breakdown popup content */
[data-slot="breakdown-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-1) 0;
}
[data-slot="breakdown-label"] {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
[data-slot="breakdown-value"] {
color: var(--color-text);
font-weight: 500;
font-size: var(--font-size-xs);
}
} }
@@ -1,9 +1,9 @@
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import { createAsync, query, useParams } from "@solidjs/router" import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, For, Show, createEffect } from "solid-js" import { createMemo, For, Show, createEffect, createSignal } from "solid-js"
import { formatDateUTC, formatDateForTable } from "../common" import { formatDateUTC, formatDateForTable } from "../common"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
import { IconChevronLeft, IconChevronRight } from "~/component/icon" import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
import styles from "./usage-section.module.css" import styles from "./usage-section.module.css"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -22,15 +22,34 @@ export function UsageSection() {
const params = useParams() const params = useParams()
const usage = createAsync(() => queryUsageInfo(params.id!, 0)) const usage = createAsync(() => queryUsageInfo(params.id!, 0))
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> }) const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
createEffect(() => { createEffect(() => {
setStore({ usage: usage() }) setStore({ usage: usage() })
}, [usage]) }, [usage])
createEffect(() => {
if (!openBreakdownId()) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('[data-slot="tokens-with-breakdown"]')) {
setOpenBreakdownId(null)
}
}
document.addEventListener("click", handleClickOutside)
return () => document.removeEventListener("click", handleClickOutside)
})
const hasResults = createMemo(() => store.usage && store.usage.length > 0) const hasResults = createMemo(() => store.usage && store.usage.length > 0)
const canGoPrev = createMemo(() => store.page > 0) const canGoPrev = createMemo(() => store.page > 0)
const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE) const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
const calculateTotalInputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
}
const goPrev = async () => { const goPrev = async () => {
const usage = await getUsageInfo(params.id!, store.page - 1) const usage = await getUsageInfo(params.id!, store.page - 1)
setStore({ setStore({
@@ -73,15 +92,50 @@ export function UsageSection() {
</thead> </thead>
<tbody> <tbody>
<For each={store.usage}> <For each={store.usage}>
{(usage) => { {(usage, index) => {
const date = createMemo(() => new Date(usage.timeCreated)) const date = createMemo(() => new Date(usage.timeCreated))
const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage))
const breakdownId = `breakdown-${index()}`
const isOpen = createMemo(() => openBreakdownId() === breakdownId)
const isClaude = usage.model.toLowerCase().includes("claude")
return ( return (
<tr> <tr>
<td data-slot="usage-date" title={formatDateUTC(date())}> <td data-slot="usage-date" title={formatDateUTC(date())}>
{formatDateForTable(date())} {formatDateForTable(date())}
</td> </td>
<td data-slot="usage-model">{usage.model}</td> <td data-slot="usage-model">{usage.model}</td>
<td data-slot="usage-tokens">{usage.inputTokens}</td> <td data-slot="usage-tokens">
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
<button
data-slot="breakdown-button"
onClick={(e) => {
e.stopPropagation()
setOpenBreakdownId(isOpen() ? null : breakdownId)
}}
>
<IconBreakdown />
</button>
<span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
<Show when={isOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Input</span>
<span data-slot="breakdown-value">{usage.inputTokens}</span>
</div>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Cache Read</span>
<span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
</div>
<Show when={isClaude}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Cache Write</span>
<span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
</div>
</Show>
</div>
</Show>
</div>
</td>
<td data-slot="usage-tokens">{usage.outputTokens}</td> <td data-slot="usage-tokens">{usage.outputTokens}</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td> <td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
</tr> </tr>
@@ -15,6 +15,7 @@ import { logger } from "./logger"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error" import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider" import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic" import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai" import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible" import { oaCompatHelper } from "./provider/openai-compatible"
import { createRateLimiter } from "./rateLimiter" import { createRateLimiter } from "./rateLimiter"
@@ -30,6 +31,8 @@ export async function handler(
opts: { opts: {
format: ZenData.Format format: ZenData.Format
parseApiKey: (headers: Headers) => string | undefined parseApiKey: (headers: Headers) => string | undefined
parseModel: (url: string, body: any) => string
parseIsStream: (url: string, body: any) => boolean
}, },
) { ) {
type AuthInfo = Awaited<ReturnType<typeof authenticate>> type AuthInfo = Awaited<ReturnType<typeof authenticate>>
@@ -43,15 +46,18 @@ export async function handler(
] ]
try { try {
const url = input.request.url
const body = await input.request.json() const body = await input.request.json()
const ip = input.request.headers.get("x-real-ip") ?? "" const ip = input.request.headers.get("x-real-ip") ?? ""
const model = opts.parseModel(url, body)
const isStream = opts.parseIsStream(url, body)
logger.metric({ logger.metric({
is_tream: !!body.stream, is_tream: isStream,
session: input.request.headers.get("x-opencode-session"), session: input.request.headers.get("x-opencode-session"),
request: input.request.headers.get("x-opencode-request"), request: input.request.headers.get("x-opencode-request"),
}) })
const zenData = ZenData.list() const zenData = ZenData.list()
const modelInfo = validateModel(zenData, body.model) const modelInfo = validateModel(zenData, model)
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip) const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
await rateLimiter?.check() await rateLimiter?.check()
@@ -64,7 +70,7 @@ export async function handler(
logger.metric({ provider: providerInfo.id }) logger.metric({ provider: providerInfo.id })
const startTimestamp = Date.now() const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api) const reqUrl = providerInfo.modifyUrl(providerInfo.api, providerInfo.model, isStream)
const reqBody = JSON.stringify( const reqBody = JSON.stringify(
providerInfo.modifyBody({ providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body), ...createBodyConverter(opts.format, providerInfo.format)(body),
@@ -114,7 +120,7 @@ export async function handler(
logger.debug("STATUS: " + res.status + " " + res.statusText) logger.debug("STATUS: " + res.status + " " + res.statusText)
// Handle non-streaming response // Handle non-streaming response
if (!body.stream) { if (!isStream) {
const responseConverter = createResponseConverter(providerInfo.format, opts.format) const responseConverter = createResponseConverter(providerInfo.format, opts.format)
const json = await res.json() const json = await res.json()
const body = JSON.stringify(responseConverter(json)) const body = JSON.stringify(responseConverter(json))
@@ -169,7 +175,7 @@ export async function handler(
responseLength += value.length responseLength += value.length
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
const parts = buffer.split("\n\n") const parts = buffer.split(providerInfo.streamSeparator)
buffer = parts.pop() ?? "" buffer = parts.pop() ?? ""
for (let part of parts) { for (let part of parts) {
@@ -283,6 +289,7 @@ export async function handler(
...(() => { ...(() => {
const format = zenData.providers[provider.id].format const format = zenData.providers[provider.id].format
if (format === "anthropic") return anthropicHelper if (format === "anthropic") return anthropicHelper
if (format === "google") return googleHelper
if (format === "openai") return openaiHelper if (format === "openai") return openaiHelper
return oaCompatHelper return oaCompatHelper
})(), })(),
@@ -30,6 +30,7 @@ export const anthropicHelper = {
service_tier: "standard_only", service_tier: "standard_only",
} }
}, },
streamSeparator: "\n\n",
createUsageParser: () => { createUsageParser: () => {
let usage: Usage let usage: Usage
@@ -0,0 +1,74 @@
import { ProviderHelper } from "./provider"
/*
{
promptTokenCount: 11453,
candidatesTokenCount: 71,
totalTokenCount: 11625,
cachedContentTokenCount: 8100,
promptTokensDetails: [
{modality: "TEXT",tokenCount: 11453}
],
cacheTokensDetails: [
{modality: "TEXT",tokenCount: 8100}
],
thoughtsTokenCount: 101
}
*/
type Usage = {
promptTokenCount?: number
candidatesTokenCount?: number
totalTokenCount?: number
cachedContentTokenCount?: number
promptTokensDetails?: { modality: string; tokenCount: number }[]
cacheTokensDetails?: { modality: string; tokenCount: number }[]
thoughtsTokenCount?: number
}
export const googleHelper = {
format: "google",
modifyUrl: (providerApi: string, model?: string, isStream?: boolean) =>
`${providerApi}/models/${model}:${isStream ? "streamGenerateContent?alt=sse" : "generateContent"}`,
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("x-goog-api-key", apiKey)
},
modifyBody: (body: Record<string, any>) => {
return body
},
streamSeparator: "\r\n\r\n",
createUsageParser: () => {
let usage: Usage
return {
parse: (chunk: string) => {
if (!chunk.startsWith("data: ")) return
let json
try {
json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage }
} catch (e) {
return
}
if (!json.usageMetadata) return
usage = json.usageMetadata
},
retrieve: () => usage,
}
},
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.promptTokenCount ?? 0
const outputTokens = usage.candidatesTokenCount ?? 0
const reasoningTokens = usage.thoughtsTokenCount ?? 0
const cacheReadTokens = usage.cachedContentTokenCount ?? 0
return {
inputTokens: inputTokens - cacheReadTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens: undefined,
cacheWrite1hTokens: undefined,
}
},
} satisfies ProviderHelper
@@ -33,6 +33,7 @@ export const oaCompatHelper = {
...(body.stream ? { stream_options: { include_usage: true } } : {}), ...(body.stream ? { stream_options: { include_usage: true } } : {}),
} }
}, },
streamSeparator: "\n\n",
createUsageParser: () => { createUsageParser: () => {
let usage: Usage let usage: Usage
@@ -21,6 +21,7 @@ export const openaiHelper = {
modifyBody: (body: Record<string, any>) => { modifyBody: (body: Record<string, any>) => {
return body return body
}, },
streamSeparator: "\n\n",
createUsageParser: () => { createUsageParser: () => {
let usage: Usage let usage: Usage
@@ -26,9 +26,10 @@ import {
export type ProviderHelper = { export type ProviderHelper = {
format: ZenData.Format format: ZenData.Format
modifyUrl: (providerApi: string) => string modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any> modifyBody: (body: Record<string, any>) => Record<string, any>
streamSeparator: string
createUsageParser: () => { createUsageParser: () => {
parse: (chunk: string) => void parse: (chunk: string) => void
retrieve: () => any retrieve: () => any
@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
return handler(input, { return handler(input, {
format: "oa-compat", format: "oa-compat",
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
parseModel: (url: string, body: any) => body.model,
parseIsStream: (url: string, body: any) => !!body.stream,
}) })
} }
@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
return handler(input, { return handler(input, {
format: "anthropic", format: "anthropic",
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
parseModel: (url: string, body: any) => body.model,
parseIsStream: (url: string, body: any) => !!body.stream,
}) })
} }
@@ -0,0 +1,13 @@
import type { APIEvent } from "@solidjs/start/server"
import { handler } from "~/routes/zen/util/handler"
export function POST(input: APIEvent) {
return handler(input, {
format: "google",
parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined,
parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
parseIsStream: (url: string, body: any) =>
// ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse'
url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false,
})
}
@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
return handler(input, { return handler(input, {
format: "openai", format: "openai",
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
parseModel: (url: string, body: any) => body.model,
parseIsStream: (url: string, body: any) => !!body.stream,
}) })
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.75", "version": "1.0.85",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -8,7 +8,7 @@ import { Actor } from "./actor"
import { Resource } from "@opencode-ai/console-resource" import { Resource } from "@opencode-ai/console-resource"
export namespace ZenData { export namespace ZenData {
const FormatSchema = z.enum(["anthropic", "openai", "oa-compat"]) const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
export type Format = z.infer<typeof FormatSchema> export type Format = z.infer<typeof FormatSchema>
const ModelCostSchema = z.object({ const ModelCostSchema = z.object({
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.0.75", "version": "1.0.85",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -12,7 +12,8 @@ export default {
if ( if (
url.pathname !== "/zen/v1/chat/completions" && url.pathname !== "/zen/v1/chat/completions" &&
url.pathname !== "/zen/v1/messages" && url.pathname !== "/zen/v1/messages" &&
url.pathname !== "/zen/v1/responses" url.pathname !== "/zen/v1/responses" &&
!url.pathname.startsWith("/zen/v1/models/")
) )
return return
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.75", "version": "1.0.85",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.0.75", "version": "1.0.85",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -2
View File
@@ -18,8 +18,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
sdk.global.event().then(async (events) => { sdk.global.event().then(async (events) => {
for await (const event of events.stream) { for await (const event of events.stream) {
console.log("event", event) // console.log("event", event)
// console.log("event", event.payload.type)
emitter.emit(event.directory, event.payload) emitter.emit(event.directory, event.payload)
} }
}) })
+1 -1
View File
@@ -15,7 +15,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const name = createMemo( const name = createMemo(
() => `${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`, () => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
) )
const [store, setStore] = makePersisted( const [store, setStore] = makePersisted(
@@ -0,0 +1,20 @@
import { createSignal, onCleanup, onMount } from "solid-js"
import { isServer } from "solid-js/web"
export function createSessionSeen(key: string, delay = 1000) {
// 1. Initialize state based on storage (default to true on server to avoid flash)
const storageKey = `app:seen:${key}`
const [hasSeen] = createSignal(!isServer && sessionStorage.getItem(storageKey) === "true")
onMount(() => {
// 2. If we haven't seen it, mark it as seen for NEXT time
if (!hasSeen()) {
const timer = setTimeout(() => {
sessionStorage.setItem(storageKey, "true")
}, delay)
onCleanup(() => clearTimeout(timer))
}
})
return hasSeen
}
+7 -5
View File
@@ -12,7 +12,7 @@ import { LayoutProvider } from "./context/layout"
import { GlobalSDKProvider } from "./context/global-sdk" import { GlobalSDKProvider } from "./context/global-sdk"
import { SessionProvider } from "./context/session" import { SessionProvider } from "./context/session"
import { base64Encode } from "./utils" import { base64Encode } from "./utils"
import { createMemo } from "solid-js" import { createMemo, Show } from "solid-js"
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1" const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096" const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
@@ -51,10 +51,12 @@ render(
<Route path="/" component={() => <Navigate href="session" />} /> <Route path="/" component={() => <Navigate href="session" />} />
<Route <Route
path="/session/:id?" path="/session/:id?"
component={() => ( component={(p) => (
<SessionProvider> <Show when={p.params.id || true} keyed>
<Session /> <SessionProvider>
</SessionProvider> <Session />
</SessionProvider>
</Show>
)} )}
/> />
</Route> </Route>
@@ -1,4 +1,4 @@
import { createMemo, Show, type ParentProps } from "solid-js" import { createMemo, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { SyncProvider } from "@/context/sync" import { SyncProvider } from "@/context/sync"
+1 -1
View File
@@ -122,7 +122,7 @@ export default function Layout(props: ParentProps) {
?.replace(" hr.", "h")} ?.replace(" hr.", "h")}
</span> </span>
</div> </div>
<div class="hidden flex justify-between items-center self-stretch"> <div class="hidden _flex justify-between items-center self-stretch">
<span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span> <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
<Show when={session.summary}> <Show when={session.summary}>
{(summary) => <DiffChanges changes={summary()} />} {(summary) => <DiffChanges changes={summary()} />}
+11 -7
View File
@@ -52,6 +52,7 @@ import { useSession } from "@/context/session"
import { StickyAccordionHeader } from "@/components/sticky-accordion-header" import { StickyAccordionHeader } from "@/components/sticky-accordion-header"
import { SessionReview } from "@/components/session-review" import { SessionReview } from "@/components/session-review"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { createSessionSeen } from "@/hooks/create-session-seen"
export default function Page() { export default function Page() {
const layout = useLayout() const layout = useLayout()
@@ -451,7 +452,9 @@ export default function Page() {
<For each={session.messages.user()}> <For each={session.messages.user()}>
{(message) => { {(message) => {
const isActive = createMemo(() => session.messages.active()?.id === message.id) const isActive = createMemo(() => session.messages.active()?.id === message.id)
const [titled, setTitled] = createSignal(!!message.summary?.title) const titleSeen = createSessionSeen(`message-title-${message.id}`)
const contentSeen = createSessionSeen(`message-content-${message.id}`)
const [titled, setTitled] = createSignal(titleSeen())
const assistantMessages = createMemo(() => { const assistantMessages = createMemo(() => {
if (!session.id) return [] if (!session.id) return []
return sync.data.message[session.id]?.filter( return sync.data.message[session.id]?.filter(
@@ -459,7 +462,6 @@ export default function Page() {
) as AssistantMessageType[] ) as AssistantMessageType[]
}) })
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
const [detailsExpanded, setDetailsExpanded] = createSignal(false) const [detailsExpanded, setDetailsExpanded] = createSignal(false)
const parts = createMemo(() => sync.data.part[message.id]) const parts = createMemo(() => sync.data.part[message.id])
const hasToolPart = createMemo(() => const hasToolPart = createMemo(() =>
@@ -470,16 +472,18 @@ export default function Page() {
const working = createMemo( const working = createMemo(
() => message.id === session.messages.last()?.id && session.working(), () => message.id === session.messages.last()?.id && session.working(),
) )
const initialCompleted = !(message.id === session.messages.last()?.id && session.working())
const [completed, setCompleted] = createSignal(initialCompleted)
// allowing time for the animations to finish // allowing time for the animations to finish
createEffect(() => { createEffect(() => {
if (titleSeen()) return
const title = message.summary?.title const title = message.summary?.title
setTimeout(() => setTitled(!!title), 10_000) if (title) setTimeout(() => setTitled(true), 10_000)
}) })
createEffect(() => { createEffect(() => {
const summary = message.summary?.body const completed = !working()
const complete = !!summary || !!error() setTimeout(() => setCompleted(completed), 1200)
setTimeout(() => setCompleted(complete), 1200)
}) })
return ( return (
@@ -523,7 +527,7 @@ export default function Page() {
<Markdown <Markdown
classList={{ classList={{
"text-14-regular": !!message.summary?.diffs?.length, "text-14-regular": !!message.summary?.diffs?.length,
"[&>*]:fade-up-text": !message.summary?.diffs?.length, "[&>*]:fade-up-text": !message.summary?.diffs?.length && !contentSeen(),
}} }}
text={summary()} text={summary()}
/> />
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The AI coding agent built for the terminal" description = "The AI coding agent built for the terminal"
version = "1.0.75" version = "1.0.85"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/sst/opencode" repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.75/opencode-darwin-arm64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.75/opencode-darwin-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.75/opencode-linux-arm64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-linux-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.75/opencode-linux-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-linux-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.75/opencode-windows-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.75", "version": "1.0.85",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.0.75", "version": "1.0.85",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -44,6 +44,7 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1", "@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/mcp": "0.0.8",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
@@ -54,8 +55,8 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.46", "@opentui/core": "0.1.47",
"@opentui/solid": "0.1.46", "@opentui/solid": "0.1.47",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:", "@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
+9
View File
@@ -167,6 +167,15 @@ export default {
], ],
}, },
}, },
{
filetype: "yaml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-yaml/releases/download/v0.7.2/tree-sitter-yaml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/yaml/highlights.scm",
],
},
},
{ {
filetype: "haskell", filetype: "haskell",
wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm", wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
+28 -1
View File
@@ -131,7 +131,34 @@ if (!Script.preview) {
"", "",
"package() {", "package() {",
` cd "opencode-\${pkgver}/packages/opencode"`, ` cd "opencode-\${pkgver}/packages/opencode"`,
' install -Dm755 $(find dist/*/bin/opencode) "${pkgdir}/usr/bin/opencode"', ' mkdir -p "${pkgdir}/usr/bin"',
' target_arch="x64"',
' case "$CARCH" in',
' x86_64) target_arch="x64" ;;',
' aarch64) target_arch="arm64" ;;',
' *) printf "unsupported architecture: %s\\n" "$CARCH" >&2 ; return 1 ;;',
" esac",
' libc=""',
" if command -v ldd >/dev/null 2>&1; then",
" if ldd --version 2>&1 | grep -qi musl; then",
' libc="-musl"',
" fi",
" fi",
' if [ -z "$libc" ] && ls /lib/ld-musl-* >/dev/null 2>&1; then',
' libc="-musl"',
" fi",
' base=""',
' if [ "$target_arch" = "x64" ]; then',
" if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then",
' base="-baseline"',
" fi",
" fi",
' bin="dist/opencode-linux-${target_arch}${base}${libc}/bin/opencode"',
' if [ ! -f "$bin" ]; then',
' printf "unable to find binary for %s%s%s\\n" "$target_arch" "$base" "$libc" >&2',
" return 1",
" fi",
' install -Dm755 "$bin" "${pkgdir}/usr/bin/opencode"',
"}", "}",
"", "",
].join("\n") ].join("\n")
+42 -10
View File
@@ -79,16 +79,48 @@ export namespace BunProc {
version, version,
}) })
await BunProc.run(args, { const total = 3
cwd: Global.Path.cache, const wait = 500
}).catch((e) => {
throw new InstallFailedError( const runInstall = async (count: number = 1): Promise<void> => {
{ pkg, version }, log.info("bun install attempt", {
{ pkg,
cause: e, version,
}, attempt: count,
) total,
}) })
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch(async (error) => {
log.warn("bun install failed", {
pkg,
version,
attempt: count,
total,
error,
})
if (count >= total) {
throw new InstallFailedError(
{ pkg, version },
{
cause: error,
},
)
}
const delay = wait * count
log.info("bun install retrying", {
pkg,
version,
next: count + 1,
delay,
})
await Bun.sleep(delay)
return runInstall(count + 1)
})
}
await runInstall()
parsed.dependencies[pkg] = version parsed.dependencies[pkg] = version
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2)) await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
return mod return mod
+25 -1
View File
@@ -6,6 +6,7 @@ import { Agent } from "../../agent/agent"
import path from "path" import path from "path"
import matter from "gray-matter" import matter from "gray-matter"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { EOL } from "os"
const AgentCreateCommand = cmd({ const AgentCreateCommand = cmd({
command: "create", command: "create",
@@ -133,9 +134,32 @@ const AgentCreateCommand = cmd({
}, },
}) })
const AgentListCommand = cmd({
command: "list",
describe: "list all available agents",
async handler() {
await Instance.provide({
directory: process.cwd(),
async fn() {
const agents = await Agent.list()
const sortedAgents = agents.sort((a, b) => {
if (a.builtIn !== b.builtIn) {
return a.builtIn ? -1 : 1
}
return a.name.localeCompare(b.name)
})
for (const agent of sortedAgents) {
process.stdout.write(`${agent.name} (${agent.mode})${EOL}`)
}
},
})
},
})
export const AgentCommand = cmd({ export const AgentCommand = cmd({
command: "agent", command: "agent",
describe: "manage agents", describe: "manage agents",
builder: (yargs) => yargs.command(AgentCreateCommand).demandCommand(), builder: (yargs) => yargs.command(AgentCreateCommand).command(AgentListCommand).demandCommand(),
async handler() {}, async handler() {},
}) })
+22 -11
View File
@@ -2,10 +2,11 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu
import { Clipboard } from "@tui/util/clipboard" import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route" import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch } from "solid-js" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show } from "solid-js"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { Global } from "@/global" import { Global } from "@/global"
import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { SDKProvider, useSDK } from "@tui/context/sdk" import { SDKProvider, useSDK } from "@tui/context/sdk"
import { SyncProvider, useSync } from "@tui/context/sync" import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local" import { LocalProvider, useLocal } from "@tui/context/local"
@@ -293,6 +294,14 @@ function App() {
}, },
category: "System", category: "System",
}, },
{
title: "Connect provider",
value: "provider.connect",
onSelect: () => {
dialog.replace(() => <DialogProviderList />)
},
category: "System",
},
{ {
title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`, title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
value: "theme.switch_mode", value: "theme.switch_mode",
@@ -451,16 +460,18 @@ function App() {
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text> <text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box> </box>
</box> </box>
<box flexDirection="row" flexShrink={0}> <Show when={false}>
<text fg={theme.textMuted} paddingRight={1}> <box flexDirection="row" flexShrink={0}>
tab <text fg={theme.textMuted} paddingRight={1}>
</text> tab
<text fg={local.agent.color(local.agent.current().name)}>{""}</text> </text>
<text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}> <text fg={local.agent.color(local.agent.current().name)}>{""}</text>
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span> <text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}>
<span> AGENT </span> <span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
</text> <span> AGENT </span>
</box> </text>
</box>
</Show>
</box> </box>
</box> </box>
) )
@@ -1,16 +1,21 @@
export const EmptyBorder = {
topLeft: "",
bottomLeft: "",
vertical: "",
topRight: "",
bottomRight: "",
horizontal: " ",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}
export const SplitBorder = { export const SplitBorder = {
border: ["left" as const, "right" as const], border: ["left" as const, "right" as const],
customBorderChars: { customBorderChars: {
topLeft: "", ...EmptyBorder,
bottomLeft: "",
vertical: "┃", vertical: "┃",
topRight: "",
bottomRight: "",
horizontal: "",
bottomT: "",
topT: "",
cross: "",
leftT: "",
rightT: "",
}, },
} }
@@ -1,15 +1,10 @@
import { createMemo, createSignal } from "solid-js" import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda" import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy, take } from "remeda"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog" import { useDialog } from "@tui/ui/dialog"
import { useTheme } from "../context/theme" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
function Free() {
const { theme } = useTheme()
return <span style={{ fg: theme.secondary }}>Free</span>
}
export function DialogModel() { export function DialogModel() {
const local = useLocal() const local = useLocal()
@@ -17,9 +12,16 @@ export function DialogModel() {
const dialog = useDialog() const dialog = useDialog()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>() const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const connected = createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected())
const providers = createDialogProviderOptions()
const options = createMemo(() => { const options = createMemo(() => {
return [ return [
...(!ref()?.filter ...(showRecent()
? local.model.recent().flatMap((item) => { ? local.model.recent().flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)! const provider = sync.data.provider.find((x) => x.id === item.providerID)!
if (!provider) return [] if (!provider) return []
@@ -35,7 +37,17 @@ export function DialogModel() {
title: model.name ?? item.modelID, title: model.name ?? item.modelID,
description: provider.name, description: provider.name,
category: "Recent", category: "Recent",
footer: model.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined, footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
}, },
] ]
}) })
@@ -56,28 +68,56 @@ export function DialogModel() {
modelID: model, modelID: model,
}, },
title: info.name ?? model, title: info.name ?? model,
description: provider.name, description: connected() ? provider.name : undefined,
category: provider.name, category: connected() ? provider.name : undefined,
footer: info.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined, disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model,
},
{ recent: true },
)
},
})), })),
filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))), filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
sortBy((x) => x.title), sortBy((x) => x.title),
), ),
), ),
), ),
...(!connected()
? pipe(
providers(),
map((option) => {
return {
...option,
category: "Popular providers",
}
}),
take(6),
)
: []),
] ]
}) })
return ( return (
<DialogSelect <DialogSelect
keybind={[
{
keybind: { ctrl: true, name: "a", meta: false, shift: false, leader: false },
title: connected() ? "Connect provider" : "More providers",
onTrigger() {
dialog.replace(() => <DialogProvider />)
},
},
]}
ref={setRef} ref={setRef}
title="Select model" title="Select model"
current={local.model.current()} current={local.model.current()}
options={options()} options={options()}
onSelect={(option) => {
dialog.clear()
local.model.set(option.value, { recent: true })
}}
/> />
) )
} }
@@ -0,0 +1,222 @@
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { useSync } from "@tui/context/sync"
import { map, pipe, sortBy } from "remeda"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "../context/sdk"
import { DialogPrompt } from "../ui/dialog-prompt"
import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk"
import { DialogModel } from "./dialog-model"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
}
export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
map((provider) => ({
title: provider.name,
value: provider.id,
footer: {
opencode: "Recommended",
anthropic: "Claude Max or API key",
}[provider.id],
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
path: {
id: provider.id,
},
body: {
method: index,
},
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
}
}
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
})),
sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99),
)
})
return options
}
export function DialogProvider() {
const options = createDialogProviderOptions()
return <DialogSelect title="Connect a provider" options={options()} />
}
interface AutoMethodProps {
index: number
providerID: string
title: string
authorization: ProviderAuthAuthorization
}
function AutoMethod(props: AutoMethodProps) {
const { theme } = useTheme()
const sdk = useSDK()
const dialog = useDialog()
const sync = useSync()
onMount(async () => {
const result = await sdk.client.provider.oauth.callback({
path: {
id: props.providerID,
},
body: {
method: props.index,
},
})
if (result.error) {
dialog.clear()
return
}
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
})
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box gap={1}>
<text fg={theme.primary}>{props.authorization.url}</text>
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
</box>
<text fg={theme.textMuted}>Waiting for authorization...</text>
</box>
)
}
interface CodeMethodProps {
index: number
title: string
providerID: string
authorization: ProviderAuthAuthorization
}
function CodeMethod(props: CodeMethodProps) {
const { theme } = useTheme()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const [error, setError] = createSignal(false)
return (
<DialogPrompt
title={props.title}
placeholder="Authorization code"
onConfirm={async (value) => {
const { error } = await sdk.client.provider.oauth.callback({
path: {
id: props.providerID,
},
body: {
method: props.index,
code: value,
},
})
if (!error) {
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
return
}
setError(true)
}}
description={() => (
<box gap={1}>
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
<text fg={theme.primary}>{props.authorization.url}</text>
<Show when={error()}>
<text fg={theme.error}>Invalid code</text>
</Show>
</box>
)}
/>
)
}
interface ApiMethodProps {
providerID: string
title: string
}
function ApiMethod(props: ApiMethodProps) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
return (
<DialogPrompt
title={props.title}
placeholder="API key"
onConfirm={async (value) => {
if (!value) return
sdk.client.auth.set({
path: {
id: props.providerID,
},
body: {
type: "api",
key: value,
},
})
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel />)
}}
/>
)
}
@@ -53,15 +53,7 @@ export function Autocomplete(props: {
// Track props.value to make memo reactive to text changes // Track props.value to make memo reactive to text changes
props.value // <- there surely is a better way to do this, like making .input() reactive props.value // <- there surely is a better way to do this, like making .input() reactive
const val = props.input().getTextRange(store.index + 1, props.input().cursorOffset + 1) return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
// If the filter contains a space, hide the autocomplete
if (val.includes(" ")) {
hide()
return undefined
}
return val
}) })
function insertPart(text: string, part: PromptInfo["parts"][number]) { function insertPart(text: string, part: PromptInfo["parts"][number]) {
@@ -387,17 +379,19 @@ export function Autocomplete(props: {
get visible() { get visible() {
return store.visible return store.visible
}, },
onInput() { onInput(value) {
if (store.visible) { if (store.visible) {
if (props.input().cursorOffset <= store.index) { if (
// Typed text before the trigger
props.input().cursorOffset <= store.index ||
// There is a space between the trigger and the cursor
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
// "/<command>" is not the sole content
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
) {
hide() hide()
return return
} }
// Check if a space was typed after the trigger character
const currentText = props.input().getTextRange(store.index + 1, props.input().cursorOffset + 1)
if (currentText.includes(" ")) {
hide()
}
} }
}, },
onKeyDown(e: KeyEvent) { onKeyDown(e: KeyEvent) {
@@ -1,18 +1,8 @@
import { import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
TextAttributes, import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
BoxRenderable,
TextareaRenderable,
MouseEvent,
PasteEvent,
t,
dim,
fg,
type KeyBinding,
} from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX, onMount, batch } from "solid-js"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border" import { EmptyBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
@@ -29,6 +19,8 @@ import { Clipboard } from "../../util/clipboard"
import type { FilePart } from "@opencode-ai/sdk" import type { FilePart } from "@opencode-ai/sdk"
import { TuiEvent } from "../../event" import { TuiEvent } from "../../event"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { Shimmer } from "../../ui/shimmer"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@@ -57,7 +49,7 @@ export function Prompt(props: PromptProps) {
const sdk = useSDK() const sdk = useSDK()
const route = useRoute() const route = useRoute()
const sync = useSync() const sync = useSync()
const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle")) const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory() const history = usePromptHistory()
const command = useCommandDialog() const command = useCommandDialog()
const renderer = useRenderer() const renderer = useRenderer()
@@ -222,12 +214,17 @@ export function Prompt(props: PromptProps) {
title: "Interrupt session", title: "Interrupt session",
value: "session.interrupt", value: "session.interrupt",
keybind: "session_interrupt", keybind: "session_interrupt",
disabled: status() !== "working", disabled: status().type === "idle",
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: (dialog) => {
if (!props.sessionID) return
if (autocomplete.visible) return if (autocomplete.visible) return
if (!input.focused) return if (!input.focused) return
// TODO: this should be its own command
if (store.mode === "shell") {
setStore("mode", "normal")
return
}
if (!props.sessionID) return
setStore("interrupt", store.interrupt + 1) setStore("interrupt", store.interrupt + 1)
@@ -425,6 +422,10 @@ export function Prompt(props: PromptProps) {
}, },
body: { body: {
agent: local.agent.current().name, agent: local.agent.current().name,
model: {
providerID: local.model.current().providerID,
modelID: local.model.current().modelID,
},
command: inputText, command: inputText,
}, },
}) })
@@ -538,6 +539,16 @@ export function Prompt(props: PromptProps) {
return return
} }
const highlight = createMemo(() => {
if (keybind.leader) return theme.border
if (store.mode === "shell") return theme.primary
return local.agent.color(local.agent.current().name)
})
createEffect(() => {
renderer.setCursorColor(highlight())
})
return ( return (
<> <>
<Autocomplete <Autocomplete
@@ -562,17 +573,22 @@ export function Prompt(props: PromptProps) {
/> />
<box ref={(r) => (anchor = r)}> <box ref={(r) => (anchor = r)}>
<box <box
flexDirection="row" border={["left"]}
{...SplitBorder} borderColor={highlight()}
borderColor={keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border} customBorderChars={{
justifyContent="space-evenly" ...EmptyBorder,
vertical: "┃",
bottomLeft: "╹",
}}
> >
<box backgroundColor={theme.backgroundElement} width={3} height="100%" alignItems="center" paddingTop={1}> <box
<text attributes={TextAttributes.BOLD} fg={theme.primary}> paddingLeft={2}
{store.mode === "normal" ? ">" : "!"} paddingRight={1}
</text> paddingTop={1}
</box> flexShrink={0}
<box paddingTop={1} paddingBottom={1} backgroundColor={theme.backgroundElement} flexGrow={1}> backgroundColor={theme.backgroundElement}
flexGrow={1}
>
<textarea <textarea
placeholder={ placeholder={
props.showPlaceholder props.showPlaceholder
@@ -747,37 +763,151 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.primary} cursorColor={theme.primary}
syntaxStyle={syntax()} syntaxStyle={syntax()}
/> />
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<text flexShrink={0} fg={theme.text}>
{local.model.parsed().model}
</text>
</box>
</Show>
</box>
</box> </box>
<box backgroundColor={theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box> </box>
<box
height={1}
border={["left"]}
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: "╹",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={{
...EmptyBorder,
horizontal: "▀",
}}
/>
</box> </box>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
<text flexShrink={0} wrapMode="none" fg={theme.text}> <Show when={status().type !== "idle"} fallback={<text />}>
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "} <box
<span style={{ bold: true }}>{local.model.parsed().model}</span> flexDirection="row"
</text> gap={1}
<Switch> flexGrow={1}
<Match when={status() === "compacting"}> justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
<text fg={theme.textMuted}>compacting...</text> >
</Match> <box flexShrink={0} flexDirection="row" gap={1}>
<Match when={status() === "working"}> <Loader />
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1} flexShrink={0}>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}> {(() => {
esc{" "} const retry = createMemo(() => {
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}> const s = status()
{store.interrupt > 0 ? "again to interrupt" : "interrupt"} if (s.type !== "retry") return
</span> return s
</text> })
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini 3 way too hot right now"
if (r.message.length > 50) return r.message.slice(0, 50) + "..."
return r.message
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
return (
<Show when={retry()}>
<text fg={theme.error}>
{message()} [retrying {seconds() > 0 ? `in ${seconds()}s ` : ""}
attempt #{retry()!.attempt}]
</text>
</Show>
)
})()}
</box>
</box> </box>
</Match> <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
<Match when={props.hint}>{props.hint!}</Match> esc{" "}
<Match when={true}> <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
<text fg={theme.text}> {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span> </span>
</text> </text>
</Match> </box>
</Switch> </Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span>
</text>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</Show>
</box> </box>
</box> </box>
</> </>
) )
} }
function Loader() {
const FRAMES = [
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▰▱▱▱▱▱▱",
"▰▰▱▱▱▱▱",
"▰▰▰▱▱▱▱",
"▱▰▰▰▱▱▱",
"▱▱▰▰▰▱▱",
"▱▱▱▰▰▰▱",
"▱▱▱▱▰▰▰",
"▱▱▱▱▱▰▰",
"▱▱▱▱▱▱▰",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
]
const [frame, setFrame] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
setFrame((frame() + 1) % FRAMES.length)
}, 100)
onCleanup(() => {
clearInterval(timer)
})
})
const { theme } = useTheme()
return <text fg={theme.diffAdded}>{FRAMES[frame()]}</text>
}
@@ -10,6 +10,7 @@ import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { useArgs } from "./args" import { useArgs } from "./args"
import { RGBA } from "@opentui/core"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
@@ -91,7 +92,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}, },
color(name: string) { color(name: string) {
const agent = agents().find((x) => x.name === name) const agent = agents().find((x) => x.name === name)
if (agent?.color) return agent.color if (agent?.color) return RGBA.fromHex(agent.color)
const index = agents().findIndex((x) => x.name === name) const index = agents().findIndex((x) => x.name === name)
return colors()[index % colors().length] return colors()[index % colors().length]
}, },
@@ -18,7 +18,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
sdk.event.subscribe().then(async (events) => { sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) { for await (const event of events.stream) {
console.log("event", event.type)
emitter.emit(event.type, event) emitter.emit(event.type, event)
} }
}) })
@@ -12,6 +12,8 @@ import type {
McpStatus, McpStatus,
FormatterStatus, FormatterStatus,
SessionStatus, SessionStatus,
ProviderListResponse,
ProviderAuthMethod,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
@@ -28,6 +30,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
status: "loading" | "partial" | "complete" status: "loading" | "partial" | "complete"
provider: Provider[] provider: Provider[]
provider_default: Record<string, string> provider_default: Record<string, string>
provider_next: ProviderListResponse
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[] agent: Agent[]
command: Command[] command: Command[]
permission: { permission: {
@@ -56,6 +60,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
} }
formatter: FormatterStatus[] formatter: FormatterStatus[]
}>({ }>({
provider_next: {
all: [],
default: {},
connected: [],
},
provider_auth: {},
config: {}, config: {},
status: "loading", status: "loading",
agent: [], agent: [],
@@ -232,20 +242,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const exit = useExit() const exit = useExit()
onMount(() => { async function bootstrap() {
// blocking // blocking
Promise.all([ await Promise.all([
sdk.client.config.providers({ throwOnError: true }).then((x) => { sdk.client.config.providers({ throwOnError: true }).then((x) => {
batch(() => { batch(() => {
setStore("provider", x.data!.providers) setStore("provider", x.data!.providers)
setStore("provider_default", x.data!.default) setStore("provider_default", x.data!.default)
}) })
}), }),
sdk.client.provider.list({ throwOnError: true }).then((x) => {
batch(() => {
setStore("provider_next", x.data!)
})
}),
sdk.client.app.agents({ throwOnError: true }).then((x) => setStore("agent", x.data ?? [])), sdk.client.app.agents({ throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)), sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)),
]) ])
.then(() => { .then(() => {
setStore("status", "partial") if (store.status !== "complete") setStore("status", "partial")
// non-blocking // non-blocking
Promise.all([ Promise.all([
sdk.client.session.list().then((x) => sdk.client.session.list().then((x) =>
@@ -259,6 +274,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)), sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)), sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
sdk.client.session.status().then((x) => setStore("session_status", x.data!)), sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
]).then(() => { ]).then(() => {
setStore("status", "complete") setStore("status", "complete")
}) })
@@ -266,6 +282,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.catch(async (e) => { .catch(async (e) => {
await exit(e) await exit(e)
}) })
}
onMount(() => {
bootstrap()
}) })
const result = { const result = {
@@ -320,6 +340,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
console.log("synced in " + (Date.now() - now), sessionID) console.log("synced in " + (Date.now() - now), sessionID)
}, },
}, },
bootstrap,
} }
return result return result
}, },
@@ -132,7 +132,16 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
if (c instanceof RGBA) return c if (c instanceof RGBA) return c
if (typeof c === "string") { if (typeof c === "string") {
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0) if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
if (c.startsWith("#")) return RGBA.fromHex(c)
if (defs[c]) {
return resolveColor(defs[c])
} else if (theme.theme[c as keyof Theme]) {
return resolveColor(theme.theme[c as keyof Theme])
} else {
throw new Error(`Color reference "${c}" not found in defs or theme`)
}
} }
return resolveColor(c[mode]) return resolveColor(c[mode])
} }
@@ -54,6 +54,7 @@ import { DialogMessage } from "./dialog-message"
import type { PromptInfo } from "../../component/prompt/history" import type { PromptInfo } from "../../component/prompt/history"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogConfirm } from "@tui/ui/dialog-confirm"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { DialogTimeline } from "./dialog-timeline" import { DialogTimeline } from "./dialog-timeline"
import { DialogSessionRename } from "../../component/dialog-session-rename" import { DialogSessionRename } from "../../component/dialog-session-rename"
import { Sidebar } from "./sidebar" import { Sidebar } from "./sidebar"
@@ -105,11 +106,6 @@ export function Session() {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
}) })
const lastUserMessage = createMemo(() => {
const p = pending()
return messages().findLast((x) => x.role === "user" && (!p || x.id < p)) as UserMessage
})
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto")) const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
const [conceal, setConceal] = createSignal(true) const [conceal, setConceal] = createSignal(true)
@@ -146,9 +142,30 @@ export function Session() {
}) })
const toast = useToast() const toast = useToast()
const sdk = useSDK() const sdk = useSDK()
// Auto-navigate to whichever session currently needs permission input
createEffect(() => {
const currentSession = session()
if (!currentSession) return
const currentPermissions = permissions()
let targetID = currentPermissions.length > 0 ? currentSession.id : undefined
if (!targetID) {
const child = sync.data.session.find(
(x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0,
)
if (child) targetID = child.id
}
if (targetID && targetID !== currentSession.id) {
navigate({
type: "session",
sessionID: targetID,
})
}
})
let scroll: ScrollBoxRenderable let scroll: ScrollBoxRenderable
let prompt: PromptRef let prompt: PromptRef
const keybind = useKeybind() const keybind = useKeybind()
@@ -583,12 +600,17 @@ export function Session() {
transcript += `---\n\n` transcript += `---\n\n`
} }
// Save to file in data directory // Prompt for optional filename
const exportDir = path.join(Global.Path.data, "exports") const customFilename = await DialogPrompt.show(dialog, "Export filename", {
await fs.mkdir(exportDir, { recursive: true }) value: `session-${sessionData.id.slice(0, 8)}.md`,
})
const timestamp = new Date().toISOString().replace(/[:.]/g, "-") // Cancel if user pressed escape
const filename = `session-${sessionData.id.slice(0, 8)}-${timestamp}.md` if (customFilename === null) return
// Save to file in current working directory
const exportDir = process.cwd()
const filename = customFilename.trim()
const filepath = path.join(exportDir, filename) const filepath = path.join(exportDir, filename)
await Bun.write(filepath, transcript) await Bun.write(filepath, transcript)
@@ -880,52 +902,55 @@ function UserMessage(props: {
<Show when={text()}> <Show when={text()}>
<box <box
id={props.message.id} id={props.message.id}
onMouseOver={() => {
setHover(true)
}}
onMouseOut={() => {
setHover(false)
}}
onMouseUp={props.onMouseUp}
border={["left"]} border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
marginTop={props.index === 0 ? 0 : 1}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={color()} borderColor={color()}
flexShrink={0} customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
> >
<text fg={theme.text}>{text()?.text}</text> <box
<Show when={files().length}> onMouseOver={() => {
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap"> setHover(true)
<For each={files()}> }}
{(file) => { onMouseOut={() => {
const bg = createMemo(() => { setHover(false)
if (file.mime.startsWith("image/")) return theme.accent }}
if (file.mime === "application/pdf") return theme.primary onMouseUp={props.onMouseUp}
return theme.secondary paddingTop={1}
}) paddingBottom={1}
return ( paddingLeft={1}
<text fg={theme.text}> backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span> flexShrink={0}
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span> >
</text> <text fg={theme.text}>{text()?.text}</text>
) <Show when={files().length}>
}} <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
</For> <For each={files()}>
</box> {(file) => {
</Show> const bg = createMemo(() => {
<text fg={theme.text}> if (file.mime.startsWith("image/")) return theme.accent
{sync.data.config.username ?? "You"}{" "} if (file.mime === "application/pdf") return theme.primary
<Show return theme.secondary
when={queued()} })
fallback={<span style={{ fg: theme.textMuted }}>({Locale.time(props.message.time.created)})</span>} return (
> <text fg={theme.text}>
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span> <span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
</text>
)
}}
</For>
</box>
</Show> </Show>
</text> <text fg={theme.textMuted}>
{sync.data.config.username ?? "You"}{" "}
<Show
when={queued()}
fallback={<span style={{ fg: theme.textMuted }}>{Locale.time(props.message.time.created)}</span>}
>
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
</Show>
</text>
</box>
</box> </box>
</Show> </Show>
<Show when={compaction()}> <Show when={compaction()}>
@@ -982,51 +1007,62 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<text fg={theme.textMuted}>{props.message.error?.data.message}</text> <text fg={theme.textMuted}>{props.message.error?.data.message}</text>
</box> </box>
</Show> </Show>
<Show when={props.last && status().type !== "idle"}> <Switch>
<box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}> <Match when={props.last && status().type !== "idle" && false}>
<text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text> <box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}>
<Shimmer text={props.message.modelID} color={theme.text} /> <text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
{(() => { <Shimmer text={props.message.modelID} color={theme.text} />
const retry = createMemo(() => { {(() => {
const s = status() const retry = createMemo(() => {
if (s.type !== "retry") return const s = status()
return s if (s.type !== "retry") return
}) return s
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
}) })
}) const message = createMemo(() => {
return ( const r = retry()
<Show when={retry()}> if (!r) return
<text fg={theme.error}> if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
{retry()!.message} [attempt #{retry()!.attempt} return "gemini 3 way too hot right now"
{seconds() > 0 ? `, retrying in ${seconds()}s` : ""}] if (r.message.length > 50) return r.message.slice(0, 50) + "..."
</text> return r.message
</Show> })
) const [seconds, setSeconds] = createSignal(0)
})()} onMount(() => {
</box> const timer = setInterval(() => {
</Show> const next = retry()?.next
<Show if (next) setSeconds(Math.round((next - Date.now()) / 1000))
when={ }, 1000)
props.message.time.completed &&
props.parts.some((item) => item.type === "step-finish" && item.reason !== "tool-calls") onCleanup(() => {
} clearInterval(timer)
> })
<box paddingLeft={3}> })
<text marginTop={1}> return (
<span style={{ fg: local.agent.color(props.message.mode) }}>{Locale.titlecase(props.message.mode)}</span>{" "} <Show when={retry()}>
<span style={{ fg: theme.textMuted }}>{props.message.modelID}</span> <text fg={theme.error}>
</text> {message()} [retrying {seconds() > 0 ? `in ${seconds()}s ` : ""}
</box> attempt #{retry()!.attempt}]
</Show> </text>
</Show>
)
})()}
</box>
</Match>
<Match
when={
(props.message.time.completed &&
props.parts.some((item) => item.type === "step-finish" && item.reason !== "tool-calls")) ||
props.last
}
>
<box paddingLeft={3}>
<text marginTop={1}>
<span style={{ fg: local.agent.color(props.message.mode) }}>{Locale.titlecase(props.message.mode)}</span>{" "}
<span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
</text>
</box>
</Match>
</Switch>
</> </>
) )
} }
@@ -1275,7 +1311,10 @@ ToolRegistry.register<typeof WriteTool>({
container: "block", container: "block",
render(props) { render(props) {
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const lines = createMemo(() => props.input.content?.split("\n") ?? [], [] as string[]) const lines = createMemo(
() => (typeof props.input.content === "string" ? props.input.content.split("\n") : []),
[] as string[],
)
const code = createMemo(() => { const code = createMemo(() => {
if (!props.input.content) return "" if (!props.input.content) return ""
const text = props.input.content const text = props.input.content
@@ -1,11 +1,13 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core" import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { onMount } from "solid-js" import { onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
export type DialogPromptProps = { export type DialogPromptProps = {
title: string title: string
description?: () => JSX.Element
placeholder?: string
value?: string value?: string
onConfirm?: (value: string) => void onConfirm?: (value: string) => void
onCancel?: () => void onCancel?: () => void
@@ -19,12 +21,11 @@ export function DialogPrompt(props: DialogPromptProps) {
useKeyboard((evt) => { useKeyboard((evt) => {
if (evt.name === "return") { if (evt.name === "return") {
props.onConfirm?.(textarea.plainText) props.onConfirm?.(textarea.plainText)
dialog.clear()
} }
}) })
onMount(() => { onMount(() => {
dialog.setSize("large") dialog.setSize("medium")
setTimeout(() => { setTimeout(() => {
textarea.focus() textarea.focus()
}, 1) }, 1)
@@ -37,35 +38,36 @@ export function DialogPrompt(props: DialogPromptProps) {
<text attributes={TextAttributes.BOLD}>{props.title}</text> <text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text> <text fg={theme.textMuted}>esc</text>
</box> </box>
<box> <box gap={1}>
{props.description}
<textarea <textarea
onSubmit={() => { onSubmit={() => {
props.onConfirm?.(textarea.plainText) props.onConfirm?.(textarea.plainText)
dialog.clear()
}} }}
height={3}
keyBindings={[{ name: "return", action: "submit" }]} keyBindings={[{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)} ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.value} initialValue={props.value}
placeholder="Enter text" placeholder={props.placeholder ?? "Enter text"}
/> />
</box> </box>
<box paddingBottom={1}> <box paddingBottom={1} gap={1} flexDirection="row">
<text fg={theme.textMuted}>Press enter to confirm, esc to cancel</text> <text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>cancel</span>
</text>
</box> </box>
</box> </box>
) )
} }
DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => { DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => {
return new Promise<string | null>((resolve) => { return new Promise<string | null>((resolve) => {
dialog.replace( dialog.replace(
() => ( () => (
<DialogPrompt <DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
title={title}
value={value}
onConfirm={(value) => resolve(value)}
onCancel={() => resolve(null)}
/>
), ),
() => resolve(null), () => resolve(null),
) )
@@ -157,7 +157,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return ( return (
<box gap={1}> <box gap={1}>
<box paddingLeft={3} paddingRight={2}> <box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}> <text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.title} {props.title}
@@ -184,8 +184,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</box> </box>
</box> </box>
<scrollbox <scrollbox
paddingLeft={2} paddingLeft={1}
paddingRight={2} paddingRight={1}
scrollbarOptions={{ visible: false }} scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (scroll = r)} ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()} maxHeight={height()}
@@ -194,7 +194,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
{([category, options], index) => ( {([category, options], index) => (
<> <>
<Show when={category}> <Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}> <box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}> <text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category} {category}
</text> </text>
@@ -203,6 +203,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<For each={options}> <For each={options}>
{(option) => { {(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
const current = createMemo(() => isDeepEqual(option.value, props.current))
return ( return (
<box <box
id={JSON.stringify(option.value)} id={JSON.stringify(option.value)}
@@ -217,8 +218,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
moveTo(index) moveTo(index)
}} }}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={1} paddingLeft={current() ? 1 : 3}
paddingRight={1} paddingRight={3}
gap={1} gap={1}
> >
<Option <Option
@@ -226,7 +227,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
footer={option.footer} footer={option.footer}
description={option.description !== category ? option.description : undefined} description={option.description !== category ? option.description : undefined}
active={active()} active={active()}
current={isDeepEqual(option.value, props.current)} current={current()}
/> />
</box> </box>
) )
@@ -236,12 +237,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)} )}
</For> </For>
</scrollbox> </scrollbox>
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1} gap={1}> <box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={1}>
<For each={props.keybind ?? []}> <For each={props.keybind ?? []}>
{(item) => ( {(item) => (
<text> <text>
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span> <span style={{ fg: theme.text }}>
<span style={{ fg: theme.textMuted }}> {item.title}</span> <b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text> </text>
)} )}
</For> </For>
@@ -268,7 +271,7 @@ function Option(props: {
fg={props.active ? theme.background : props.current ? theme.primary : theme.text} fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
marginRight={0.5} marginRight={0.5}
> >
</text> </text>
</Show> </Show>
<text <text
@@ -277,6 +280,7 @@ function Option(props: {
attributes={props.active ? TextAttributes.BOLD : undefined} attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden" overflow="hidden"
wrapMode="none" wrapMode="none"
paddingLeft={3}
> >
{Locale.truncate(props.title, 62)} {Locale.truncate(props.title, 62)}
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span> <span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
@@ -3,6 +3,8 @@ import { batch, createContext, Show, useContext, type JSX, type ParentProps } fr
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core" import { Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "./toast"
export function Dialog( export function Dialog(
props: ParentProps<{ props: ParentProps<{
@@ -12,10 +14,12 @@ export function Dialog(
) { ) {
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const { theme } = useTheme() const { theme } = useTheme()
const renderer = useRenderer()
return ( return (
<box <box
onMouseUp={async () => { onMouseUp={async () => {
if (renderer.getSelection()) return
props.onClose?.() props.onClose?.()
}} }}
width={dimensions().width} width={dimensions().width}
@@ -29,6 +33,7 @@ export function Dialog(
> >
<box <box
onMouseUp={async (e) => { onMouseUp={async (e) => {
if (renderer.getSelection()) return
e.stopPropagation() e.stopPropagation()
}} }}
width={props.size === "large" ? 80 : 60} width={props.size === "large" ? 80 : 60}
@@ -124,10 +129,28 @@ const ctx = createContext<DialogContext>()
export function DialogProvider(props: ParentProps) { export function DialogProvider(props: ParentProps) {
const value = init() const value = init()
const renderer = useRenderer()
const toast = useToast()
return ( return (
<ctx.Provider value={value}> <ctx.Provider value={value}>
{props.children} {props.children}
<box position="absolute"> <box
position="absolute"
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
}}
>
<Show when={value.stack.length}> <Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}> <Dialog onClose={() => value.clear()} size={value.size}>
{value.stack.at(-1)!.element} {value.stack.at(-1)!.element}
@@ -23,7 +23,6 @@ export function Toast() {
alignItems="flex-start" alignItems="flex-start"
top={2} top={2}
right={2} right={2}
width={Math.min(60, dimensions().width - 6)}
maxWidth={Math.min(60, dimensions().width - 6)} maxWidth={Math.min(60, dimensions().width - 6)}
paddingLeft={2} paddingLeft={2}
paddingRight={2} paddingRight={2}
@@ -39,7 +38,9 @@ export function Toast() {
{current().title} {current().title}
</text> </text>
</Show> </Show>
<text fg={theme.text}>{current().message}</text> <text fg={theme.text} wrapMode="word" width="100%">
{current().message}
</text>
</box> </box>
)} )}
</Show> </Show>
@@ -6,7 +6,7 @@ import { CliRenderer } from "@opentui/core"
export namespace Editor { export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> { export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
const editor = process.env["EDITOR"] const editor = process.env["VISUAL"] || process.env["EDITOR"]
if (!editor) return if (!editor) return
const filepath = join(tmpdir(), `${Date.now()}.md`) const filepath = join(tmpdir(), `${Date.now()}.md`)
+14 -1
View File
@@ -1,18 +1,31 @@
import { ConfigMarkdown } from "@/config/markdown" import { ConfigMarkdown } from "@/config/markdown"
import { Config } from "../config/config" import { Config } from "../config/config"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { Provider } from "../provider/provider"
import { UI } from "./ui" import { UI } from "./ui"
export function FormatError(input: unknown) { export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input)) if (MCP.Failed.isInstance(input))
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (Provider.ModelNotFoundError.isInstance(input)) {
const { providerID, modelID, suggestions } = input.data
return [
`Model not found: ${providerID}/${modelID}`,
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
].join("\n")
}
if (Provider.InitError.isInstance(input)) {
return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.`
}
if (Config.JsonError.isInstance(input)) { if (Config.JsonError.isInstance(input)) {
return ( return (
`Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "") `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
) )
} }
if (Config.ConfigDirectoryTypoError.isInstance(input)) { if (Config.ConfigDirectoryTypoError.isInstance(input)) {
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Use "${input.data.suggestion}" instead. This is a common typo.` return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
} }
if (ConfigMarkdown.FrontmatterError.isInstance(input)) { if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}` return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
+39 -26
View File
@@ -428,8 +428,8 @@ export namespace Config {
input_newline: z.string().optional().default("shift+return,ctrl+j").describe("Insert newline in input"), input_newline: z.string().optional().default("shift+return,ctrl+j").describe("Insert newline in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"), history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Next history item"), history_next: z.string().optional().default("down").describe("Next history item"),
session_child_cycle: z.string().optional().default("ctrl+right").describe("Next child session"), session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"),
session_child_cycle_reverse: z.string().optional().default("ctrl+left").describe("Previous child session"), session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"),
}) })
.strict() .strict()
.meta({ .meta({
@@ -480,6 +480,10 @@ export namespace Config {
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"), .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"), autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"), disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
enabled_providers: z
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: z small_model: z
.string() .string()
@@ -511,6 +515,8 @@ export namespace Config {
z.string(), z.string(),
ModelsDev.Provider.partial() ModelsDev.Provider.partial()
.extend({ .extend({
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
models: z.record(z.string(), ModelsDev.Model.partial()).optional(), models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
options: z options: z
.object({ .object({
@@ -542,36 +548,43 @@ export namespace Config {
.describe("Custom provider configurations and model overrides"), .describe("Custom provider configurations and model overrides"),
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"), mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
formatter: z formatter: z
.record( .union([
z.string(), z.literal(false),
z.object({ z.record(
disabled: z.boolean().optional(), z.string(),
command: z.array(z.string()).optional(), z.object({
environment: z.record(z.string(), z.string()).optional(), disabled: z.boolean().optional(),
extensions: z.array(z.string()).optional(), command: z.array(z.string()).optional(),
}), environment: z.record(z.string(), z.string()).optional(),
) extensions: z.array(z.string()).optional(),
}),
),
])
.optional(), .optional(),
lsp: z lsp: z
.record( .union([
z.string(), z.literal(false),
z.union([ z.record(
z.object({ z.string(),
disabled: z.literal(true), z.union([
}), z.object({
z.object({ disabled: z.literal(true),
command: z.array(z.string()), }),
extensions: z.array(z.string()).optional(), z.object({
disabled: z.boolean().optional(), command: z.array(z.string()),
env: z.record(z.string(), z.string()).optional(), extensions: z.array(z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(), disabled: z.boolean().optional(),
}), env: z.record(z.string(), z.string()).optional(),
]), initialization: z.record(z.string(), z.any()).optional(),
) }),
]),
),
])
.optional() .optional()
.refine( .refine(
(data) => { (data) => {
if (!data) return true if (!data) return true
if (typeof data === "boolean") return true
const serverIds = new Set(Object.values(LSPServer).map((s) => s.id)) const serverIds = new Set(Object.values(LSPServer).map((s) => s.id))
return Object.entries(data).every(([id, config]) => { return Object.entries(data).every(([id, config]) => {
+2
View File
@@ -11,6 +11,8 @@ export namespace Flag {
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS") export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT") export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export const OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH =
process.env["OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH"]
// Experimental // Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
+8
View File
@@ -28,6 +28,14 @@ export namespace Format {
const cfg = await Config.get() const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {} const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) {
log.info("all formatters are disabled")
return {
enabled,
formatters,
}
}
for (const item of Object.values(Formatter)) { for (const item of Object.values(Formatter)) {
formatters[item.name] = item formatters[item.name] = item
} }
+12 -1
View File
@@ -62,10 +62,21 @@ export namespace LSP {
async () => { async () => {
const clients: LSPClient.Info[] = [] const clients: LSPClient.Info[] = []
const servers: Record<string, LSPServer.Info> = {} const servers: Record<string, LSPServer.Info> = {}
const cfg = await Config.get()
if (cfg.lsp === false) {
log.info("all LSPs are disabled")
return {
broken: new Set<string>(),
servers,
clients,
spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
}
}
for (const server of Object.values(LSPServer)) { for (const server of Object.values(LSPServer)) {
servers[server.id] = server servers[server.id] = server
} }
const cfg = await Config.get()
for (const [name, item] of Object.entries(cfg.lsp ?? {})) { for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
const existing = servers[name] const existing = servers[name]
if (item.disabled) { if (item.disabled) {
+96 -8
View File
@@ -250,12 +250,12 @@ export namespace LSPServer {
}, },
} }
export const RubyLsp: Info = { export const Rubocop: Info = {
id: "ruby-lsp", id: "ruby-lsp",
root: NearestRoot(["Gemfile"]), root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"], extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) { async spawn(root) {
let bin = Bun.which("ruby-lsp", { let bin = Bun.which("rubocop", {
PATH: process.env["PATH"] + ":" + Global.Path.bin, PATH: process.env["PATH"] + ":" + Global.Path.bin,
}) })
if (!bin) { if (!bin) {
@@ -266,25 +266,25 @@ export namespace LSPServer {
return return
} }
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing ruby-lsp") log.info("installing rubocop")
const proc = Bun.spawn({ const proc = Bun.spawn({
cmd: ["gem", "install", "ruby-lsp", "--bindir", Global.Path.bin], cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin],
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
stdin: "pipe", stdin: "pipe",
}) })
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) { if (exit !== 0) {
log.error("Failed to install ruby-lsp") log.error("Failed to install rubocop")
return return
} }
bin = path.join(Global.Path.bin, "ruby-lsp" + (process.platform === "win32" ? ".exe" : "")) bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed ruby-lsp`, { log.info(`installed rubocop`, {
bin, bin,
}) })
} }
return { return {
process: spawn(bin!, ["--stdio"], { process: spawn(bin!, ["--lsp"], {
cwd: root, cwd: root,
}), }),
} }
@@ -945,6 +945,54 @@ export namespace LSPServer {
}, },
} }
export const YamlLS: Info = {
id: "yaml-ls",
extensions: [".yaml", ".yml"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("yaml-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"yaml-language-server",
"out",
"server",
"src",
"server.js",
)
const exists = await Bun.file(js).exists()
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
process: proc,
}
},
}
export const LuaLS: Info = { export const LuaLS: Info = {
id: "lua-ls", id: "lua-ls",
root: NearestRoot([ root: NearestRoot([
@@ -1077,4 +1125,44 @@ export namespace LSPServer {
} }
}, },
} }
export const PHPIntelephense: Info = {
id: "php intelephense",
extensions: [".php"],
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
async spawn(root) {
let binary = Bun.which("intelephense")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Bun.file(js).exists())) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
process: proc,
initialization: {},
}
},
}
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { experimental_createMCPClient, type Tool } from "ai" import { type Tool } from "ai"
import { experimental_createMCPClient } from "@ai-sdk/mcp"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
+6 -1
View File
@@ -186,8 +186,13 @@ export namespace Permission {
public readonly permissionID: string, public readonly permissionID: string,
public readonly toolCallID?: string, public readonly toolCallID?: string,
public readonly metadata?: Record<string, any>, public readonly metadata?: Record<string, any>,
public readonly reason?: string,
) { ) {
super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`) super(
reason !== undefined
? reason
: `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
)
} }
} }
} }
@@ -51,6 +51,7 @@ export const Instance = {
async dispose() { async dispose() {
Log.Default.info("disposing instance", { directory: Instance.directory }) Log.Default.info("disposing instance", { directory: Instance.directory })
await State.dispose(Instance.directory) await State.dispose(Instance.directory)
cache.delete(Instance.directory)
}, },
async disposeAll() { async disposeAll() {
Log.Default.info("disposing all instances") Log.Default.info("disposing all instances")
+1 -1
View File
@@ -57,7 +57,7 @@ export namespace State {
tasks.push(task) tasks.push(task)
} }
entries.delete(key) entries.clear()
await Promise.all(tasks) await Promise.all(tasks)
disposalFinished = true disposalFinished = true
log.info("state disposal completed", { key }) log.info("state disposal completed", { key })
+143
View File
@@ -0,0 +1,143 @@
import { Instance } from "@/project/instance"
import { Plugin } from "../plugin"
import { map, filter, pipe, fromEntries, mapValues } from "remeda"
import z from "zod"
import { fn } from "@/util/fn"
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@/util/error"
import { Auth } from "@/auth"
export namespace ProviderAuth {
const state = Instance.state(async () => {
const methods = pipe(
await Plugin.list(),
filter((x) => x.auth?.provider !== undefined),
map((x) => [x.auth!.provider, x.auth!] as const),
fromEntries(),
)
return { methods, pending: {} as Record<string, AuthOuathResult> }
})
export const Method = z
.object({
type: z.union([z.literal("oauth"), z.literal("api")]),
label: z.string(),
})
.meta({
ref: "ProviderAuthMethod",
})
export type Method = z.infer<typeof Method>
export async function methods() {
const s = await state().then((x) => x.methods)
return mapValues(s, (x) =>
x.methods.map(
(y): Method => ({
type: y.type,
label: y.label,
}),
),
)
}
export const Authorization = z
.object({
url: z.string(),
method: z.union([z.literal("auto"), z.literal("code")]),
instructions: z.string(),
})
.meta({
ref: "ProviderAuthAuthorization",
})
export type Authorization = z.infer<typeof Authorization>
export const authorize = fn(
z.object({
providerID: z.string(),
method: z.number(),
}),
async (input): Promise<Authorization | undefined> => {
const auth = await state().then((s) => s.methods[input.providerID])
const method = auth.methods[input.method]
if (method.type === "oauth") {
const result = await method.authorize()
await state().then((s) => (s.pending[input.providerID] = result))
return {
url: result.url,
method: result.method,
instructions: result.instructions,
}
}
},
)
export const callback = fn(
z.object({
providerID: z.string(),
method: z.number(),
code: z.string().optional(),
}),
async (input) => {
const match = await state().then((s) => s.pending[input.providerID])
if (!match) throw new OauthMissing({ providerID: input.providerID })
let result
if (match.method === "code") {
if (!input.code) throw new OauthCodeMissing({ providerID: input.providerID })
result = await match.callback(input.code)
}
if (match.method === "auto") {
result = await match.callback()
}
if (result?.type === "success") {
if ("key" in result) {
await Auth.set(input.providerID, {
type: "api",
key: result.key,
})
}
if ("refresh" in result) {
await Auth.set(input.providerID, {
type: "oauth",
access: result.access,
refresh: result.refresh,
expires: result.expires,
})
}
return
}
throw new OauthCallbackFailed({})
},
)
export const api = fn(
z.object({
providerID: z.string(),
key: z.string(),
}),
async (input) => {
await Auth.set(input.providerID, {
type: "api",
key: input.key,
})
},
)
export const OauthMissing = NamedError.create(
"ProviderAuthOauthMissing",
z.object({
providerID: z.string(),
}),
)
export const OauthCodeMissing = NamedError.create(
"ProviderAuthOauthCodeMissing",
z.object({
providerID: z.string(),
}),
)
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
}
+125 -20
View File
@@ -1,5 +1,5 @@
import z from "zod" import z from "zod"
import path from "path" import fuzzysort from "fuzzysort"
import { Config } from "../config/config" import { Config } from "../config/config"
import { mergeDeep, sortBy } from "remeda" import { mergeDeep, sortBy } from "remeda"
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai" import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
@@ -10,7 +10,6 @@ import { ModelsDev } from "./models"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { Auth } from "../auth" import { Auth } from "../auth"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Global } from "../global"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
@@ -243,6 +242,15 @@ export namespace Provider {
const config = await Config.get() const config = await Config.get()
const database = await ModelsDev.get() const database = await ModelsDev.get()
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
function isProviderAllowed(providerID: string): boolean {
if (enabled && !enabled.has(providerID)) return false
if (disabled.has(providerID)) return false
return true
}
const providers: { const providers: {
[providerID: string]: { [providerID: string]: {
source: Source source: Source
@@ -367,10 +375,10 @@ export namespace Provider {
} }
parsed.models[modelID] = parsedModel parsed.models[modelID] = parsedModel
} }
database[providerID] = parsed database[providerID] = parsed
} }
const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))
// load env // load env
for (const [providerID, provider] of Object.entries(database)) { for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue if (disabled.has(providerID)) continue
@@ -448,6 +456,12 @@ export namespace Provider {
} }
for (const [providerID, provider] of Object.entries(providers)) { for (const [providerID, provider] of Object.entries(providers)) {
if (!isProviderAllowed(providerID)) {
delete providers[providerID]
continue
}
const configProvider = config.provider?.[providerID]
const filteredModels = Object.fromEntries( const filteredModels = Object.fromEntries(
Object.entries(provider.info.models) Object.entries(provider.info.models)
// Filter out blacklisted models // Filter out blacklisted models
@@ -460,15 +474,32 @@ export namespace Provider {
([, model]) => ([, model]) =>
((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) && ((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) &&
model.status !== "deprecated", model.status !== "deprecated",
), )
// Filter by provider's whitelist/blacklist from config
.filter(([modelID]) => {
if (!configProvider) return true
return (
(!configProvider.blacklist || !configProvider.blacklist.includes(modelID)) &&
(!configProvider.whitelist || configProvider.whitelist.includes(modelID))
)
}),
) )
provider.info.models = filteredModels provider.info.models = filteredModels
if (Object.keys(provider.info.models).length === 0) { if (Object.keys(provider.info.models).length === 0) {
delete providers[providerID] delete providers[providerID]
continue continue
} }
log.info("found", { providerID })
// TODO: set this in models.dev, not set due to breaking issues on older OC versions
// u have to set include usage to true w/ this provider, setting in models.dev would cause undefined issue when accessing usage in older versions
if (providerID === "openrouter") {
provider.info.npm = "@openrouter/ai-sdk-provider"
}
log.info("found", { providerID, npm: provider.info.npm })
} }
return { return {
@@ -494,6 +525,7 @@ export namespace Provider {
if (pkg.includes("@ai-sdk/openai-compatible") && options["includeUsage"] === undefined) { if (pkg.includes("@ai-sdk/openai-compatible") && options["includeUsage"] === undefined) {
options["includeUsage"] = true options["includeUsage"] = true
} }
const key = Bun.hash.xxHash32(JSON.stringify({ pkg, options })) const key = Bun.hash.xxHash32(JSON.stringify({ pkg, options }))
const existing = s.sdk.get(key) const existing = s.sdk.get(key)
if (existing) return existing if (existing) return existing
@@ -515,26 +547,29 @@ export namespace Provider {
const modPath = const modPath =
provider.id === "google-vertex-anthropic" ? `${installedPath}/dist/anthropic/index.mjs` : installedPath provider.id === "google-vertex-anthropic" ? `${installedPath}/dist/anthropic/index.mjs` : installedPath
const mod = await import(modPath) const mod = await import(modPath)
if (options["timeout"] !== undefined && options["timeout"] !== null) {
// Preserve custom fetch if it exists, wrap it with timeout logic
const customFetch = options["fetch"]
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
const { signal, ...rest } = init ?? {}
const customFetch = options["fetch"]
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
// Preserve custom fetch if it exists, wrap it with timeout logic
const fetchFn = customFetch ?? fetch
const opts = init ?? {}
if (options["timeout"] !== undefined && options["timeout"] !== null) {
const signals: AbortSignal[] = [] const signals: AbortSignal[] = []
if (signal) signals.push(signal) if (opts.signal) signals.push(opts.signal)
if (options["timeout"] !== false) signals.push(AbortSignal.timeout(options["timeout"])) if (options["timeout"] !== false) signals.push(AbortSignal.timeout(options["timeout"]))
const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0] const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0]
const fetchFn = customFetch ?? fetch opts.signal = combined
return fetchFn(input, {
...rest,
signal: combined,
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
timeout: false,
})
} }
return fetchFn(input, {
...opts,
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
timeout: false,
})
} }
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn({ const loaded = fn({
@@ -563,9 +598,78 @@ export namespace Provider {
}) })
const provider = s.providers[providerID] const provider = s.providers[providerID]
if (!provider) throw new ModelNotFoundError({ providerID, modelID }) if (!provider) {
let suggestions: string[] = []
const normalize = (str: string) => str.toLowerCase().replace(/[^a-z0-9]/g, "")
const levenshtein = (a: string, b: string) => {
const m = a.length,
n = b.length
const dp = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0))
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost)
}
}
return dp[m][n]
}
if (!modelID || modelID.trim() === "") {
// Treat single-token input as an unqualified model; search across all providers' models.
const q = normalize(providerID)
const entries: { combo: string; norm: string }[] = []
for (const [pid, prov] of Object.entries(s.providers)) {
for (const mid of Object.keys(prov.info.models)) {
entries.push({ combo: pid + "/" + mid, norm: normalize(mid) })
}
}
const byNorm = fuzzysort.go(q, entries as any, { limit: 5, key: "norm" }).map((r: any) => r.obj.combo)
const combos = entries.map((e) => e.combo)
const byRaw = fuzzysort.go(providerID, combos, { limit: 5 }).map((r) => r.target)
let merged = Array.from(new Set([...byNorm, ...byRaw]))
if (merged.length === 0) {
// fallback to edit distance on normalized mid
const scored = entries
.map((e) => ({ combo: e.combo, d: levenshtein(q, e.norm) }))
.sort((a, b) => a.d - b.d)
.slice(0, 3)
.map((x) => x.combo)
merged = scored
}
suggestions = merged.slice(0, 3)
} else {
const pcands = Object.keys(s.providers)
const corpus = pcands.map((raw) => ({ raw, norm: normalize(raw) }))
const q = normalize(providerID)
const hits = fuzzysort.go(q, corpus as any, { limit: 5, key: "norm" })
let ranked = hits.map((r: any) => r.obj.raw)
if (ranked.length === 0) {
ranked = pcands
.map((p) => ({ p, d: levenshtein(q, normalize(p)) }))
.sort((a, b) => a.d - b.d)
.slice(0, 3)
.map((x) => x.p)
}
const providerSuggestions = ranked.map((r) => r + "/" + modelID)
suggestions = providerSuggestions
}
throw new ModelNotFoundError({ providerID, modelID, suggestions })
}
const info = provider.info.models[modelID] const info = provider.info.models[modelID]
if (!info) throw new ModelNotFoundError({ providerID, modelID }) if (!info) {
const candidates = Object.keys(provider.info.models)
// Normalize punctuation differences like '-' vs '.' by stripping non-alphanumerics
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
const corpus = candidates.map((raw) => ({ raw, norm: normalize(raw) }))
const query = normalize(modelID)
const results = fuzzysort.go(query, corpus as any, { limit: 5, key: "norm" })
const ranked = results.map((r) => ("obj" in r ? (r as any).obj.raw : (r as any).target)) as string[]
const fallback = fuzzysort.go(modelID, candidates, { limit: 5 }).map((r) => r.target)
const merged = Array.from(new Set([...ranked, ...fallback]))
const suggestions = merged.slice(0, 3).map((m) => providerID + "/" + m)
throw new ModelNotFoundError({ providerID, modelID, suggestions })
}
const sdk = await getSDK(provider.info, info) const sdk = await getSDK(provider.info, info)
try { try {
@@ -666,6 +770,7 @@ export namespace Provider {
z.object({ z.object({
providerID: z.string(), providerID: z.string(),
modelID: z.string(), modelID: z.string(),
suggestions: z.array(z.string()).optional(),
}), }),
) )
+21 -1
View File
@@ -24,7 +24,6 @@ export namespace ProviderTransform {
const result: ModelMessage[] = [] const result: ModelMessage[] = []
for (let i = 0; i < msgs.length; i++) { for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i] const msg = msgs[i]
const prevMsg = msgs[i - 1]
const nextMsg = msgs[i + 1] const nextMsg = msgs[i + 1]
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
@@ -137,10 +136,23 @@ export namespace ProviderTransform {
): Record<string, any> | undefined { ): Record<string, any> | undefined {
const result: Record<string, any> = {} const result: Record<string, any> = {}
// switch to providerID later, for now use this
if (npm === "@openrouter/ai-sdk-provider") {
result["usage"] = {
include: true,
}
}
if (providerID === "openai") { if (providerID === "openai") {
result["promptCacheKey"] = sessionID result["promptCacheKey"] = sessionID
} }
if (providerID === "google" || (providerID === "opencode" && modelID.includes("gemini-3"))) {
result["thinkingConfig"] = {
includeThoughts: true,
}
}
if (modelID.includes("gpt-5") && !modelID.includes("gpt-5-chat")) { if (modelID.includes("gpt-5") && !modelID.includes("gpt-5-chat")) {
if (modelID.includes("codex")) { if (modelID.includes("codex")) {
result["store"] = false result["store"] = false
@@ -178,10 +190,18 @@ export namespace ProviderTransform {
return { return {
["anthropic" as string]: options, ["anthropic" as string]: options,
} }
case "@ai-sdk/google":
return {
["google" as string]: options,
}
case "@ai-sdk/gateway": case "@ai-sdk/gateway":
return { return {
["gateway" as string]: options, ["gateway" as string]: options,
} }
case "@openrouter/ai-sdk-provider":
return {
["openrouter" as string]: options,
}
default: default:
return { return {
[providerID]: options, [providerID]: options,
+157
View File
@@ -23,6 +23,7 @@ import { Instance } from "../project/instance"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Auth } from "../auth" import { Auth } from "../auth"
import { Command } from "../command" import { Command } from "../command"
import { ProviderAuth } from "../provider/auth"
import { Global } from "../global" import { Global } from "../global"
import { ProjectRoute } from "./project" import { ProjectRoute } from "./project"
import { ToolRegistry } from "../tool/registry" import { ToolRegistry } from "../tool/registry"
@@ -42,6 +43,9 @@ import { SessionSummary } from "@/session/summary"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { SessionStatus } from "@/session/status" import { SessionStatus } from "@/session/status"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
const ERRORS = { const ERRORS = {
400: { 400: {
description: "Bad request", description: "Bad request",
@@ -303,6 +307,27 @@ export namespace Server {
) )
}, },
) )
.post(
"/instance/dispose",
describeRoute({
description: "Dispose the current instance",
operationId: "instance.dispose",
responses: {
200: {
description: "Instance disposed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Instance.dispose()
return c.json(true)
},
)
.get( .get(
"/path", "/path",
describeRoute({ describeRoute({
@@ -1160,6 +1185,138 @@ export namespace Server {
}) })
}, },
) )
.get(
"/provider",
describeRoute({
description: "List all providers",
operationId: "provider.list",
responses: {
200: {
description: "List of providers",
content: {
"application/json": {
schema: resolver(
z.object({
all: ModelsDev.Provider.array(),
default: z.record(z.string(), z.string()),
connected: z.array(z.string()),
}),
),
},
},
},
},
}),
async (c) => {
const providers = await ModelsDev.get()
const connected = await Provider.list().then((x) => Object.keys(x))
return c.json({
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected,
})
},
)
.get(
"/provider/auth",
describeRoute({
description: "Get provider authentication methods",
operationId: "provider.auth",
responses: {
200: {
description: "Provider auth methods",
content: {
"application/json": {
schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))),
},
},
},
},
}),
async (c) => {
return c.json(await ProviderAuth.methods())
},
)
.post(
"/provider/:id/oauth/authorize",
describeRoute({
description: "Authorize a provider using OAuth",
operationId: "provider.oauth.authorize",
responses: {
200: {
description: "Authorization URL and method",
content: {
"application/json": {
schema: resolver(ProviderAuth.Authorization.optional()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
id: z.string().meta({ description: "Provider ID" }),
}),
),
validator(
"json",
z.object({
method: z.number().meta({ description: "Auth method index" }),
}),
),
async (c) => {
const id = c.req.valid("param").id
const { method } = c.req.valid("json")
const result = await ProviderAuth.authorize({
providerID: id,
method,
})
return c.json(result)
},
)
.post(
"/provider/:id/oauth/callback",
describeRoute({
description: "Handle OAuth callback for a provider",
operationId: "provider.oauth.callback",
responses: {
200: {
description: "OAuth callback processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
id: z.string().meta({ description: "Provider ID" }),
}),
),
validator(
"json",
z.object({
method: z.number().meta({ description: "Auth method index" }),
code: z.string().optional().meta({ description: "OAuth authorization code" }),
}),
),
async (c) => {
const id = c.req.valid("param").id
const { method, code } = c.req.valid("json")
await ProviderAuth.callback({
providerID: id,
method,
code,
})
return c.json(true)
},
)
.get( .get(
"/find", "/find",
describeRoute({ describeRoute({
+25 -17
View File
@@ -382,17 +382,23 @@ export namespace Session {
const adjustedInputTokens = excludesCachedTokens const adjustedInputTokens = excludesCachedTokens
? (input.usage.inputTokens ?? 0) ? (input.usage.inputTokens ?? 0)
: (input.usage.inputTokens ?? 0) - cachedInputTokens : (input.usage.inputTokens ?? 0) - cachedInputTokens
const safe = (value: number) => {
if (!Number.isFinite(value)) return 0
return value
}
const tokens = { const tokens = {
input: adjustedInputTokens, input: safe(adjustedInputTokens),
output: input.usage.outputTokens ?? 0, output: safe(input.usage.outputTokens ?? 0),
reasoning: input.usage?.reasoningTokens ?? 0, reasoning: safe(input.usage?.reasoningTokens ?? 0),
cache: { cache: {
write: (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? write: safe(
// @ts-expect-error (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? // @ts-expect-error
0) as number, input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
read: cachedInputTokens, 0) as number,
),
read: safe(cachedInputTokens),
}, },
} }
@@ -401,15 +407,17 @@ export namespace Session {
? input.model.cost.context_over_200k ? input.model.cost.context_over_200k
: input.model.cost : input.model.cost
return { return {
cost: new Decimal(0) cost: safe(
.add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000)) new Decimal(0)
.add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000)) .add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(costInfo?.cache_read ?? 0).div(1_000_000)) .add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(costInfo?.cache_write ?? 0).div(1_000_000)) .add(new Decimal(tokens.cache.read).mul(costInfo?.cache_read ?? 0).div(1_000_000))
// TODO: update models.dev to have better pricing model, for now: .add(new Decimal(tokens.cache.write).mul(costInfo?.cache_write ?? 0).div(1_000_000))
// charge reasoning tokens at the same rate as output tokens // TODO: update models.dev to have better pricing model, for now:
.add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000)) // charge reasoning tokens at the same rate as output tokens
.toNumber(), .add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000))
.toNumber(),
),
tokens, tokens,
} }
}, },
+2 -1
View File
@@ -58,6 +58,7 @@ export namespace MessageV2 {
type: z.literal("text"), type: z.literal("text"),
text: z.string(), text: z.string(),
synthetic: z.boolean().optional(), synthetic: z.boolean().optional(),
ignored: z.boolean().optional(),
time: z time: z
.object({ .object({
start: z.number(), start: z.number(),
@@ -566,7 +567,7 @@ export namespace MessageV2 {
} }
result.push(userMessage) result.push(userMessage)
for (const part of msg.parts) { for (const part of msg.parts) {
if (part.type === "text") if (part.type === "text" && !part.ignored)
userMessage.parts.push({ userMessage.parts.push({
type: "text", type: "text",
text: part.text, text: part.text,
@@ -136,6 +136,7 @@ export namespace SessionProcessor {
const parts = await MessageV2.parts(input.assistantMessage.id) const parts = await MessageV2.parts(input.assistantMessage.id)
const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD) const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD)
if ( if (
lastThree.length === DOOM_LOOP_THRESHOLD && lastThree.length === DOOM_LOOP_THRESHOLD &&
lastThree.every( lastThree.every(
@@ -160,6 +161,17 @@ export namespace SessionProcessor {
input: value.input, input: value.input,
}, },
}) })
} else if (permission.doom_loop === "deny") {
throw new Permission.RejectedError(
input.assistantMessage.sessionID,
"doom_loop",
value.toolCallId,
{
tool: value.toolName,
input: value.input,
},
`You seem to be stuck in a doom loop, please stop repeating the same action`,
)
} }
} }
} }
+23 -17
View File
@@ -49,6 +49,9 @@ import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task" import { TaskTool } from "@/tool/task"
import { SessionStatus } from "./status" import { SessionStatus } from "./status"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
export namespace SessionPrompt { export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" }) const log = Log.create({ service: "session.prompt" })
export const OUTPUT_TOKEN_MAX = 32_000 export const OUTPUT_TOKEN_MAX = 32_000
@@ -239,6 +242,7 @@ export namespace SessionPrompt {
let step = 0 let step = 0
while (true) { while (true) {
SessionStatus.set(sessionID, { type: "busy" })
log.info("loop", { step, sessionID }) log.info("loop", { step, sessionID })
if (abort.aborted) break if (abort.aborted) break
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
@@ -600,12 +604,9 @@ export namespace SessionPrompt {
throw new Error("Impossible") throw new Error("Impossible")
}) })
async function resolveModel(input: { model: PromptInput["model"]; agent: Agent.Info }) { async function lastModel(sessionID: string) {
if (input.model) { for await (const item of MessageV2.stream(sessionID)) {
return input.model if (item.info.role === "user" && item.info.model) return item.info.model
}
if (input.agent.model) {
return input.agent.model
} }
return Provider.defaultModel() return Provider.defaultModel()
} }
@@ -794,10 +795,7 @@ export namespace SessionPrompt {
tools: input.tools, tools: input.tools,
system: input.system, system: input.system,
agent: agent.name, agent: agent.name,
model: await resolveModel({ model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
model: input.model,
agent,
}),
} }
const parts = await Promise.all( const parts = await Promise.all(
@@ -899,7 +897,7 @@ export namespace SessionPrompt {
abort: new AbortController().signal, abort: new AbortController().signal,
agent: input.agent!, agent: input.agent!,
messageID: info.id, messageID: info.id,
extra: { bypassCwdCheck: true }, extra: { bypassCwdCheck: true, ...info.model },
metadata: async () => {}, metadata: async () => {},
}) })
pieces.push( pieces.push(
@@ -1091,6 +1089,12 @@ export namespace SessionPrompt {
export const ShellInput = z.object({ export const ShellInput = z.object({
sessionID: Identifier.schema("session"), sessionID: Identifier.schema("session"),
agent: z.string(), agent: z.string(),
model: z
.object({
providerID: z.string(),
modelID: z.string(),
})
.optional(),
command: z.string(), command: z.string(),
}) })
export type ShellInput = z.infer<typeof ShellInput> export type ShellInput = z.infer<typeof ShellInput>
@@ -1100,7 +1104,7 @@ export namespace SessionPrompt {
SessionRevert.cleanup(session) SessionRevert.cleanup(session)
} }
const agent = await Agent.get(input.agent) const agent = await Agent.get(input.agent)
const model = await resolveModel({ agent, model: undefined }) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const userMsg: MessageV2.User = { const userMsg: MessageV2.User = {
id: Identifier.ascending("message"), id: Identifier.ascending("message"),
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -1338,10 +1342,8 @@ export namespace SessionPrompt {
return cmdAgent.model return cmdAgent.model
} }
} }
if (input.model) { if (input.model) return Provider.parseModel(input.model)
return Provider.parseModel(input.model) return await lastModel(input.sessionID)
}
return await Provider.defaultModel()
})() })()
const agent = await Agent.get(agentName) const agent = await Agent.get(agentName)
@@ -1397,7 +1399,11 @@ export namespace SessionPrompt {
...small.info.options, ...small.info.options,
} }
if (small.providerID === "openai" || small.modelID.includes("gpt-5")) { if (small.providerID === "openai" || small.modelID.includes("gpt-5")) {
options["reasoningEffort"] = "minimal" if (small.modelID.includes("5.1")) {
options["reasoningEffort"] = "low"
} else {
options["reasoningEffort"] = "minimal"
}
} }
if (small.providerID === "google") { if (small.providerID === "google") {
options["thinkingConfig"] = { options["thinkingConfig"] = {
+35 -7
View File
@@ -12,8 +12,14 @@ import { Filesystem } from "@/util/filesystem"
import { Wildcard } from "@/util/wildcard" import { Wildcard } from "@/util/wildcard"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
import path from "path"
const MAX_OUTPUT_LENGTH = 30_000 const DEFAULT_MAX_OUTPUT_LENGTH = 30_000
const MAX_OUTPUT_LENGTH = (() => {
const parsed = Number(Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH)
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_OUTPUT_LENGTH
})()
const DEFAULT_TIMEOUT = 1 * 60 * 1000 const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000 const MAX_TIMEOUT = 10 * 60 * 1000
const SIGKILL_TIMEOUT_MS = 200 const SIGKILL_TIMEOUT_MS = 200
@@ -22,7 +28,7 @@ export const log = Log.create({ service: "bash-tool" })
const resolveWasm = (asset: string) => { const resolveWasm = (asset: string) => {
if (asset.startsWith("file://")) return fileURLToPath(asset) if (asset.startsWith("file://")) return fileURLToPath(asset)
if (asset.startsWith("/")) return asset if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset
const url = new URL(asset, import.meta.url) const url = new URL(asset, import.meta.url)
return fileURLToPath(url) return fileURLToPath(url)
} }
@@ -68,7 +74,8 @@ export const BashTool = Tool.define("bash", {
if (!tree) { if (!tree) {
throw new Error("Failed to parse command") throw new Error("Failed to parse command")
} }
const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash) const agent = await Agent.get(ctx.agent)
const permissions = agent.permission.bash
const askPatterns = new Set<string>() const askPatterns = new Set<string>()
for (const node of tree.rootNode.descendantsOfType("command")) { for (const node of tree.rootNode.descendantsOfType("command")) {
@@ -107,9 +114,30 @@ export const BashTool = Tool.define("bash", {
: resolved : resolved
if (!Filesystem.contains(Instance.directory, normalized)) { if (!Filesystem.contains(Instance.directory, normalized)) {
throw new Error( const parentDir = path.dirname(normalized)
`This command references paths outside of ${Instance.directory} so it is not allowed to be executed.`, if (agent.permission.external_directory === "ask") {
) await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `This command references paths outside of ${Instance.directory}`,
metadata: {
command: params.command,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
command: params.command,
},
`This command references paths outside of ${Instance.directory} so it is not allowed to be executed.`,
)
}
} }
} }
} }
@@ -271,7 +299,7 @@ export const BashTool = Tool.define("bash", {
} }
return { return {
title: params.command, title: params.description,
metadata: { metadata: {
output, output,
exit: proc.exitCode, exit: proc.exitCode,
+1 -1
View File
@@ -35,7 +35,7 @@ Usage notes:
# Committing changes with git # Committing changes with git
When the user asks you to create a new git commit, follow these steps carefully: If and only if the user asks you to create a new git commit, follow these steps carefully:
1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following bash commands in parallel, each using the Bash tool: 1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following bash commands in parallel, each using the Bash tool:
- Run a git status command to see all untracked files. - Run a git status command to see all untracked files.
+12 -1
View File
@@ -47,7 +47,7 @@ export const EditTool = Tool.define("edit", {
if (agent.permission.external_directory === "ask") { if (agent.permission.external_directory === "ask") {
await Permission.ask({ await Permission.ask({
type: "external_directory", type: "external_directory",
pattern: parentDir, pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
messageID: ctx.messageID, messageID: ctx.messageID,
callID: ctx.callID, callID: ctx.callID,
@@ -57,6 +57,17 @@ export const EditTool = Tool.define("edit", {
parentDir, parentDir,
}, },
}) })
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filePath,
parentDir,
},
`File ${filePath} is not in the current working directory`,
)
} }
} }
+12 -1
View File
@@ -58,7 +58,7 @@ export const PatchTool = Tool.define("patch", {
if (agent.permission.external_directory === "ask") { if (agent.permission.external_directory === "ask") {
await Permission.ask({ await Permission.ask({
type: "external_directory", type: "external_directory",
pattern: parentDir, pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
messageID: ctx.messageID, messageID: ctx.messageID,
callID: ctx.callID, callID: ctx.callID,
@@ -68,6 +68,17 @@ export const PatchTool = Tool.define("patch", {
parentDir, parentDir,
}, },
}) })
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filePath,
parentDir,
},
`File ${filePath} is not in the current working directory`,
)
} }
} }
+12 -1
View File
@@ -36,7 +36,7 @@ export const ReadTool = Tool.define("read", {
if (agent.permission.external_directory === "ask") { if (agent.permission.external_directory === "ask") {
await Permission.ask({ await Permission.ask({
type: "external_directory", type: "external_directory",
pattern: parentDir, pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
messageID: ctx.messageID, messageID: ctx.messageID,
callID: ctx.callID, callID: ctx.callID,
@@ -46,6 +46,17 @@ export const ReadTool = Tool.define("read", {
parentDir, parentDir,
}, },
}) })
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filepath,
parentDir,
},
`File ${filepath} is not in the current working directory`,
)
} }
} }
+17 -4
View File
@@ -7,6 +7,7 @@ import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id" import { Identifier } from "../id/id"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt" import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer" import { defer } from "@/util/defer"
export const TaskTool = Tool.define("task", async () => { export const TaskTool = Tool.define("task", async () => {
@@ -23,13 +24,21 @@ export const TaskTool = Tool.define("task", async () => {
description: z.string().describe("A short (3-5 words) description of the task"), description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"), prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"), subagent_type: z.string().describe("The type of specialized agent to use for this task"),
session_id: z.string().describe("Existing Task session to continue").optional(),
}), }),
async execute(params, ctx) { async execute(params, ctx) {
const agent = await Agent.get(params.subagent_type) const agent = await Agent.get(params.subagent_type)
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
const session = await Session.create({ const session = await iife(async () => {
parentID: ctx.sessionID, if (params.session_id) {
title: params.description + ` (@${agent.name} subagent)`, const found = await Session.get(params.session_id).catch(() => {})
if (found) return found
}
return await Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${agent.name} subagent)`,
})
}) })
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
if (msg.info.role !== "assistant") throw new Error("Not an assistant message") if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
@@ -89,13 +98,17 @@ export const TaskTool = Tool.define("task", async () => {
all = await Session.messages({ sessionID: session.id }) all = await Session.messages({ sessionID: session.id })
all = all.filter((x) => x.info.role === "assistant") all = all.filter((x) => x.info.role === "assistant")
all = all.flatMap((msg) => msg.parts.filter((x: any) => x.type === "tool") as MessageV2.ToolPart[]) all = all.flatMap((msg) => msg.parts.filter((x: any) => x.type === "tool") as MessageV2.ToolPart[])
const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
const output = text + "\n\n" + ["<task_metadata>", `session_id: ${session.id}`, "</task_metadata>"].join("\n")
return { return {
title: params.description, title: params.description,
metadata: { metadata: {
summary: all, summary: all,
sessionId: session.id, sessionId: session.id,
}, },
output: (result.parts.findLast((x: any) => x.type === "text") as any)?.text ?? "", output,
} }
}, },
} }
+1 -1
View File
@@ -18,7 +18,7 @@ When NOT to use the Task tool:
Usage notes: Usage notes:
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses 1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. 2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. 3. Each agent invocation is stateless unless you provide a session_id. Your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
4. The agent's outputs should generally be trusted 4. The agent's outputs should generally be trusted
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent 5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. 6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
+12 -1
View File
@@ -26,7 +26,7 @@ export const WriteTool = Tool.define("write", {
if (agent.permission.external_directory === "ask") { if (agent.permission.external_directory === "ask") {
await Permission.ask({ await Permission.ask({
type: "external_directory", type: "external_directory",
pattern: parentDir, pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
messageID: ctx.messageID, messageID: ctx.messageID,
callID: ctx.callID, callID: ctx.callID,
@@ -36,6 +36,17 @@ export const WriteTool = Tool.define("write", {
parentDir, parentDir,
}, },
}) })
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filepath,
parentDir,
},
`File ${filepath} is not in the current working directory`,
)
} }
} }
+19 -16
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { BashTool } from "../../src/tool/bash" import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Permission } from "../../src/permission"
const ctx = { const ctx = {
sessionID: "test", sessionID: "test",
@@ -33,20 +34,22 @@ describe("tool.bash", () => {
}) })
}) })
test("cd ../ should fail outside of project root", async () => { // TODO: better test
await Instance.provide({ // test("cd ../ should ask for permission for external directory", async () => {
directory: projectRoot, // await Instance.provide({
fn: async () => { // directory: projectRoot,
expect( // fn: async () => {
bash.execute( // bash.execute(
{ // {
command: "cd ../", // command: "cd ../",
description: "Try to cd to parent directory", // description: "Try to cd to parent directory",
}, // },
ctx, // ctx,
), // )
).rejects.toThrow("This command references paths outside of") // // Give time for permission to be asked
}, // await new Promise((resolve) => setTimeout(resolve, 1000))
}) // expect(Permission.pending()[ctx.sessionID]).toBeDefined()
}) // },
// })
// })
}) })
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.0.75", "version": "1.0.85",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
@@ -24,4 +24,4 @@
"typescript": "catalog:", "typescript": "catalog:",
"@typescript/native-preview": "catalog:" "@typescript/native-preview": "catalog:"
} }
} }
+110 -108
View File
@@ -26,120 +26,122 @@ export type PluginInput = {
export type Plugin = (input: PluginInput) => Promise<Hooks> export type Plugin = (input: PluginInput) => Promise<Hooks>
export type AuthHook = {
provider: string
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
methods: (
| {
type: "oauth"
label: string
prompts?: Array<
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
condition?: (inputs: Record<string, string>) => boolean
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
condition?: (inputs: Record<string, string>) => boolean
}
>
authorize(inputs?: Record<string, string>): Promise<AuthOuathResult>
}
| {
type: "api"
label: string
prompts?: Array<
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
condition?: (inputs: Record<string, string>) => boolean
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
condition?: (inputs: Record<string, string>) => boolean
}
>
authorize?(inputs?: Record<string, string>): Promise<
| {
type: "success"
key: string
provider?: string
}
| {
type: "failed"
}
>
}
)[]
}
export type AuthOuathResult = { url: string; instructions: string } & (
| {
method: "auto"
callback(): Promise<
| ({
type: "success"
provider?: string
} & (
| {
refresh: string
access: string
expires: number
}
| { key: string }
))
| {
type: "failed"
}
>
}
| {
method: "code"
callback(code: string): Promise<
| ({
type: "success"
provider?: string
} & (
| {
refresh: string
access: string
expires: number
}
| { key: string }
))
| {
type: "failed"
}
>
}
)
export interface Hooks { export interface Hooks {
event?: (input: { event: Event }) => Promise<void> event?: (input: { event: Event }) => Promise<void>
config?: (input: Config) => Promise<void> config?: (input: Config) => Promise<void>
tool?: { tool?: {
[key: string]: ToolDefinition [key: string]: ToolDefinition
} }
auth?: { auth?: AuthHook
provider: string
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
methods: (
| {
type: "oauth"
label: string
prompts?: Array<
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
condition?: (inputs: Record<string, string>) => boolean
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
condition?: (inputs: Record<string, string>) => boolean
}
>
authorize(inputs?: Record<string, string>): Promise<
{ url: string; instructions: string } & (
| {
method: "auto"
callback(): Promise<
| ({
type: "success"
provider?: string
} & (
| {
refresh: string
access: string
expires: number
}
| { key: string }
))
| {
type: "failed"
}
>
}
| {
method: "code"
callback(code: string): Promise<
| ({
type: "success"
provider?: string
} & (
| {
refresh: string
access: string
expires: number
}
| { key: string }
))
| {
type: "failed"
}
>
}
)
>
}
| {
type: "api"
label: string
prompts?: Array<
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
condition?: (inputs: Record<string, string>) => boolean
}
| {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
condition?: (inputs: Record<string, string>) => boolean
}
>
authorize?(inputs?: Record<string, string>): Promise<
| {
type: "success"
key: string
provider?: string
}
| {
type: "failed"
}
>
}
)[]
}
/** /**
* Called when a new message is received * Called when a new message is received
*/ */
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.0.75", "version": "1.0.85",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
@@ -26,4 +26,4 @@
"publishConfig": { "publishConfig": {
"directory": "dist" "directory": "dist"
} }
} }
+87
View File
@@ -19,6 +19,8 @@ import type {
ToolListData, ToolListData,
ToolListResponses, ToolListResponses,
ToolListErrors, ToolListErrors,
InstanceDisposeData,
InstanceDisposeResponses,
PathGetData, PathGetData,
PathGetResponses, PathGetResponses,
SessionListData, SessionListData,
@@ -92,6 +94,16 @@ import type {
CommandListResponses, CommandListResponses,
ConfigProvidersData, ConfigProvidersData,
ConfigProvidersResponses, ConfigProvidersResponses,
ProviderListData,
ProviderListResponses,
ProviderAuthData,
ProviderAuthResponses,
ProviderOauthAuthorizeData,
ProviderOauthAuthorizeResponses,
ProviderOauthAuthorizeErrors,
ProviderOauthCallbackData,
ProviderOauthCallbackResponses,
ProviderOauthCallbackErrors,
FindTextData, FindTextData,
FindTextResponses, FindTextResponses,
FindFilesData, FindFilesData,
@@ -272,6 +284,18 @@ class Tool extends _HeyApiClient {
} }
} }
class Instance extends _HeyApiClient {
/**
* Dispose the current instance
*/
public dispose<ThrowOnError extends boolean = false>(options?: Options<InstanceDisposeData, ThrowOnError>) {
return (options?.client ?? this._client).post<InstanceDisposeResponses, unknown, ThrowOnError>({
url: "/instance/dispose",
...options,
})
}
}
class Path extends _HeyApiClient { class Path extends _HeyApiClient {
/** /**
* Get the current path * Get the current path
@@ -554,6 +578,67 @@ class Command extends _HeyApiClient {
} }
} }
class Oauth extends _HeyApiClient {
/**
* Authorize a provider using OAuth
*/
public authorize<ThrowOnError extends boolean = false>(options: Options<ProviderOauthAuthorizeData, ThrowOnError>) {
return (options.client ?? this._client).post<
ProviderOauthAuthorizeResponses,
ProviderOauthAuthorizeErrors,
ThrowOnError
>({
url: "/provider/{id}/oauth/authorize",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
})
}
/**
* Handle OAuth callback for a provider
*/
public callback<ThrowOnError extends boolean = false>(options: Options<ProviderOauthCallbackData, ThrowOnError>) {
return (options.client ?? this._client).post<
ProviderOauthCallbackResponses,
ProviderOauthCallbackErrors,
ThrowOnError
>({
url: "/provider/{id}/oauth/callback",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
})
}
}
class Provider extends _HeyApiClient {
/**
* List all providers
*/
public list<ThrowOnError extends boolean = false>(options?: Options<ProviderListData, ThrowOnError>) {
return (options?.client ?? this._client).get<ProviderListResponses, unknown, ThrowOnError>({
url: "/provider",
...options,
})
}
/**
* Get provider authentication methods
*/
public auth<ThrowOnError extends boolean = false>(options?: Options<ProviderAuthData, ThrowOnError>) {
return (options?.client ?? this._client).get<ProviderAuthResponses, unknown, ThrowOnError>({
url: "/provider/auth",
...options,
})
}
oauth = new Oauth({ client: this._client })
}
class Find extends _HeyApiClient { class Find extends _HeyApiClient {
/** /**
* Find text in files * Find text in files
@@ -891,9 +976,11 @@ export class OpencodeClient extends _HeyApiClient {
project = new Project({ client: this._client }) project = new Project({ client: this._client })
config = new Config({ client: this._client }) config = new Config({ client: this._client })
tool = new Tool({ client: this._client }) tool = new Tool({ client: this._client })
instance = new Instance({ client: this._client })
path = new Path({ client: this._client }) path = new Path({ client: this._client })
session = new Session({ client: this._client }) session = new Session({ client: this._client })
command = new Command({ client: this._client }) command = new Command({ client: this._client })
provider = new Provider({ client: this._client })
find = new Find({ client: this._client }) find = new Find({ client: this._client })
file = new File({ client: this._client }) file = new File({ client: this._client })
app = new App({ client: this._client }) app = new App({ client: this._client })
+190 -23
View File
@@ -150,6 +150,7 @@ export type TextPart = {
type: "text" type: "text"
text: string text: string
synthetic?: boolean synthetic?: boolean
ignored?: boolean
time?: { time?: {
start: number start: number
end?: number end?: number
@@ -1015,6 +1016,10 @@ export type Config = {
* Disable providers that are loaded automatically * Disable providers that are loaded automatically
*/ */
disabled_providers?: Array<string> disabled_providers?: Array<string>
/**
* When set, ONLY these providers will be enabled. All other providers will be ignored
*/
enabled_providers?: Array<string>
/** /**
* Model to use in the format of provider/model, eg anthropic/claude-2 * Model to use in the format of provider/model, eg anthropic/claude-2
*/ */
@@ -1096,6 +1101,8 @@ export type Config = {
} }
} }
} }
whitelist?: Array<string>
blacklist?: Array<string>
options?: { options?: {
apiKey?: string apiKey?: string
baseURL?: string baseURL?: string
@@ -1117,33 +1124,37 @@ export type Config = {
mcp?: { mcp?: {
[key: string]: McpLocalConfig | McpRemoteConfig [key: string]: McpLocalConfig | McpRemoteConfig
} }
formatter?: { formatter?:
[key: string]: { | false
disabled?: boolean | {
command?: Array<string> [key: string]: {
environment?: {
[key: string]: string
}
extensions?: Array<string>
}
}
lsp?: {
[key: string]:
| {
disabled: true
}
| {
command: Array<string>
extensions?: Array<string>
disabled?: boolean disabled?: boolean
env?: { command?: Array<string>
environment?: {
[key: string]: string [key: string]: string
} }
initialization?: { extensions?: Array<string>
[key: string]: unknown
}
} }
} }
lsp?:
| false
| {
[key: string]:
| {
disabled: true
}
| {
command: Array<string>
extensions?: Array<string>
disabled?: boolean
env?: {
[key: string]: string
}
initialization?: {
[key: string]: unknown
}
}
}
/** /**
* Additional instruction files or patterns to include * Additional instruction files or patterns to include
*/ */
@@ -1229,6 +1240,7 @@ export type TextPartInput = {
type: "text" type: "text"
text: string text: string
synthetic?: boolean synthetic?: boolean
ignored?: boolean
time?: { time?: {
start: number start: number
end?: number end?: number
@@ -1327,6 +1339,17 @@ export type Provider = {
} }
} }
export type ProviderAuthMethod = {
type: "oauth" | "api"
label: string
}
export type ProviderAuthAuthorization = {
url: string
method: "auto" | "code"
instructions: string
}
export type Symbol = { export type Symbol = {
name: string name: string
kind: number kind: number
@@ -1605,6 +1628,24 @@ export type ToolListResponses = {
export type ToolListResponse = ToolListResponses[keyof ToolListResponses] export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
export type InstanceDisposeData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/instance/dispose"
}
export type InstanceDisposeResponses = {
/**
* Instance disposed
*/
200: boolean
}
export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses]
export type PathGetData = { export type PathGetData = {
body?: never body?: never
path?: never path?: never
@@ -2290,6 +2331,10 @@ export type SessionCommandResponse = SessionCommandResponses[keyof SessionComman
export type SessionShellData = { export type SessionShellData = {
body?: { body?: {
agent: string agent: string
model?: {
providerID: string
modelID: string
}
command: string command: string
} }
path: { path: {
@@ -2474,6 +2519,128 @@ export type ConfigProvidersResponses = {
export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]
export type ProviderListData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/provider"
}
export type ProviderListResponses = {
/**
* List of providers
*/
200: {
all: Array<Provider>
default: {
[key: string]: string
}
connected: Array<string>
}
}
export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses]
export type ProviderAuthData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/provider/auth"
}
export type ProviderAuthResponses = {
/**
* Provider auth methods
*/
200: {
[key: string]: Array<ProviderAuthMethod>
}
}
export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses]
export type ProviderOauthAuthorizeData = {
body?: {
/**
* Auth method index
*/
method: number
}
path: {
/**
* Provider ID
*/
id: string
}
query?: {
directory?: string
}
url: "/provider/{id}/oauth/authorize"
}
export type ProviderOauthAuthorizeErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors]
export type ProviderOauthAuthorizeResponses = {
/**
* Authorization URL and method
*/
200: ProviderAuthAuthorization
}
export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses]
export type ProviderOauthCallbackData = {
body?: {
/**
* Auth method index
*/
method: number
/**
* OAuth authorization code
*/
code?: string
}
path: {
/**
* Provider ID
*/
id: string
}
query?: {
directory?: string
}
url: "/provider/{id}/oauth/callback"
}
export type ProviderOauthCallbackErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors]
export type ProviderOauthCallbackResponses = {
/**
* OAuth callback processed successfully
*/
200: boolean
}
export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
export type FindTextData = { export type FindTextData = {
body?: never body?: never
path?: never path?: never
@@ -81,8 +81,8 @@ class KeybindsConfig:
session_unshare: Union[Unset, str] = "none" session_unshare: Union[Unset, str] = "none"
session_interrupt: Union[Unset, str] = "esc" session_interrupt: Union[Unset, str] = "esc"
session_compact: Union[Unset, str] = "<leader>c" session_compact: Union[Unset, str] = "<leader>c"
session_child_cycle: Union[Unset, str] = "ctrl+right" session_child_cycle: Union[Unset, str] = "<leader>right"
session_child_cycle_reverse: Union[Unset, str] = "ctrl+left" session_child_cycle_reverse: Union[Unset, str] = "<leader>left"
messages_page_up: Union[Unset, str] = "pgup" messages_page_up: Union[Unset, str] = "pgup"
messages_page_down: Union[Unset, str] = "pgdown" messages_page_down: Union[Unset, str] = "pgdown"
messages_half_page_up: Union[Unset, str] = "ctrl+alt+u" messages_half_page_up: Union[Unset, str] = "ctrl+alt+u"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.0.75", "version": "1.0.85",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun run src/index.ts", "dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.0.75", "version": "1.0.85",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/components/index.ts", ".": "./src/components/index.ts",
+12 -6
View File
@@ -1,8 +1,7 @@
[data-component="markdown"] { [data-component="markdown"] {
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
overflow: auto; overflow: hidden;
scrollbar-width: none;
color: var(--text-base); color: var(--text-base);
text-wrap: pretty; text-wrap: pretty;
@@ -14,10 +13,6 @@
line-height: var(--line-height-large); /* 166.667% */ line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
&::-webkit-scrollbar {
display: none;
}
h1, h1,
h2, h2,
h3 { h3 {
@@ -41,4 +36,15 @@
margin-bottom: 16px; margin-bottom: 16px;
border-color: var(--border-weaker-base); border-color: var(--border-weaker-base);
} }
pre {
margin-top: 2rem;
margin-bottom: 2rem;
overflow: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
} }
@@ -22,6 +22,8 @@
} }
[data-component="text-part"] { [data-component="text-part"] {
width: 100%;
[data-component="markdown"] { [data-component="markdown"] {
margin-top: 32px; margin-top: 32px;
} }

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