Compare commits

..

1069 Commits

Author SHA1 Message Date
Kit Langton 949ac8ed15 feat: enable type-aware no-floating-promises rule, fix all 177 violations (#22741)
publish-github-action / publish (push) Waiting to run
2026-04-15 23:27:32 -04:00
Kit Langton d2ec847f0c feat: unwrap 11 util namespaces to flat exports + barrel (#22739) 2026-04-15 23:15:58 -04:00
Kit Langton aae9495938 feat: bridge permission and provider auth routes behind OPENCODE_EXPERIMENTAL_HTTPAPI (#22736) 2026-04-15 23:02:48 -04:00
Kit Langton f53f31d33f feat: enable oxlint suspicious category, fix 24 violations (#22727)
publish-github-action / publish (push) Waiting to run
2026-04-16 02:53:10 +00:00
Kit Langton 186907ef94 feat: unwrap Archive namespace to flat exports + barrel (#22722) 2026-04-16 02:52:34 +00:00
Kit Langton 7825daef72 feat: bridge question routes from Hono to Effect HttpApi (#22718) 2026-04-15 22:50:22 -04:00
opencode-agent[bot] ed448861ed chore: generate 2026-04-16 02:46:39 +00:00
Kit Langton 7159d09978 feat: unwrap InstanceState + EffectBridge namespaces to flat exports + barrel (#22721) 2026-04-16 02:45:45 +00:00
Kit Langton 7a85583582 refactor: migrate Effect call sites from Flock to EffectFlock (#22688) 2026-04-16 02:39:59 +00:00
opencode-agent[bot] ce815ee2aa chore: update nix node_modules hashes 2026-04-16 02:39:40 +00:00
Kit Langton e9dc9d2d01 feat: unwrap Pty namespace to flat exports + barrel (#22719) 2026-04-16 02:21:46 +00:00
opencode-agent[bot] 612ed0fc18 chore: generate 2026-04-16 02:18:58 +00:00
Kit Langton 0cbf9e9199 fix: remove 10 unused type-only imports and declarations (#22696) 2026-04-16 02:17:59 +00:00
Kit Langton 5229f9f700 feat: unwrap uskill namespace to flat exports + barrel (#22714) 2026-04-16 02:17:19 +00:00
Kit Langton 21110551ee feat: unwrap ugit namespace to flat exports + barrel (#22704) 2026-04-16 02:16:42 +00:00
Kit Langton d4b2eb10f6 feat: unwrap uworktree namespace to flat exports + barrel (#22717) 2026-04-16 02:16:17 +00:00
Kit Langton cdea1a959e feat: unwrap uformat namespace to flat exports + barrel (#22703) 2026-04-16 02:16:01 +00:00
Kit Langton 7c3958e658 feat: unwrap upatch namespace to flat exports + barrel (#22709) 2026-04-16 02:15:58 +00:00
Kit Langton b78e5c1962 feat: unwrap usync namespace to flat exports + barrel (#22716) 2026-04-16 02:15:46 +00:00
Kit Langton 6578a2db95 feat: unwrap uglobal namespace to flat exports + barrel (#22705) 2026-04-16 02:15:36 +00:00
Kit Langton b6670fa42d feat: unwrap uide namespace to flat exports + barrel (#22706) 2026-04-16 02:15:21 +00:00
Kit Langton 70d454489e feat: unwrap usnapshot namespace to flat exports + barrel (#22715) 2026-04-16 02:15:20 +00:00
Kit Langton 2e1e60ca0f feat: unwrap uplugin namespace to flat exports + barrel (#22711) 2026-04-16 02:15:19 +00:00
Kit Langton 5ba13419b3 feat: unwrap usession namespace to flat exports + barrel (#22713) 2026-04-16 02:15:17 +00:00
Kit Langton c8d2ca87aa feat: unwrap upermission namespace to flat exports + barrel (#22710) 2026-04-16 02:14:59 +00:00
Kit Langton 64d4f5efe1 feat: unwrap unpm namespace to flat exports + barrel (#22708) 2026-04-16 02:14:44 +00:00
Kit Langton ea45b1a447 feat: unwrap ufile namespace to flat exports + barrel (#22702) 2026-04-16 02:14:37 +00:00
Kit Langton b7d1eab2bb feat: unwrap uinstallation namespace to flat exports + barrel (#22707) 2026-04-16 02:14:34 +00:00
Kit Langton 23fcb7fb2e feat: unwrap ucommand namespace to flat exports + barrel (#22700) 2026-04-16 02:14:18 +00:00
Kit Langton a2aa93c6bd feat: unwrap uenv namespace to flat exports + barrel (#22701) 2026-04-16 02:14:14 +00:00
Kit Langton 1461c05776 feat: unwrap uauth namespace to flat exports + barrel (#22699) 2026-04-16 02:13:56 +00:00
Kit Langton c905c43333 feat: unwrap uaccount namespace to flat exports + barrel (#22698) 2026-04-16 02:13:33 +00:00
opencode-agent[bot] 9c6585cad7 chore: generate 2026-04-16 02:08:47 +00:00
Kit Langton 773998133d add experimental provider auth HttpApi slice (#22389) 2026-04-16 02:07:42 +00:00
Kit Langton 021fe76e47 fix: clean up 49 unused variables, catch params, and stale imports (#22695) 2026-04-16 02:01:53 +00:00
Kit Langton 4a078e05b1 fix: delete 9 dead functions with zero callers (#22697) 2026-04-16 02:01:02 +00:00
opencode-agent[bot] 8a8dfd5e2f chore: generate 2026-04-16 01:57:23 +00:00
Kit Langton 1f77795ac6 fix: prefix 32 unused parameters with underscore (#22694) 2026-04-15 21:56:23 -04:00
Kit Langton 5f3ad19794 feat: unwrap MCP namespace to flat exports + barrel (#22693) 2026-04-16 01:56:02 +00:00
opencode-agent[bot] a1ae53e35d chore: generate 2026-04-16 01:45:44 +00:00
Kit Langton b047f7af36 feat: unwrap Provider namespace + improved automation script (#22690) 2026-04-15 21:44:46 -04:00
Kit Langton 228636345a fix: resolve oxlint warnings — suppress false positives, remove unused imports (#22687)
publish-github-action / publish (push) Waiting to run
2026-04-15 21:33:54 -04:00
Kit Langton b5f4d032fb feat: unwrap Config namespace to flat exports + barrel (#22689) 2026-04-15 21:26:24 -04:00
Kit Langton 5853ca2a79 refactor: remove makeRuntime facades from File and Ripgrep (#22513) 2026-04-15 21:22:18 -04:00
Kit Langton eaadd8d76b feat: namespace → flat export migration (Bus proof-of-concept) (#22685) 2026-04-16 01:18:36 +00:00
Kit Langton 261e6607bc fix: revert "core: move plugin initialisation to config layer override" (#22686) 2026-04-15 21:14:39 -04:00
Aiden Cline 7487ea03a4 fix: bash memory usage (#22660) 2026-04-15 20:09:06 -05:00
James Long 8b6f750171 feat(core): add fence to make all methods strongly consistent when syncing (#22679) 2026-04-15 21:04:37 -04:00
Kit Langton e03433df42 fix(session): retry 5xx server errors even when isRetryable is unset (#22511) 2026-04-16 00:58:48 +00:00
Kit Langton d776cd22fa feat(shared): add Effect-idiomatic file lock (EffectFlock) (#22681) 2026-04-16 00:55:14 +00:00
opencode-agent[bot] c65b6dd4e9 chore: generate 2026-04-16 00:46:18 +00:00
Kit Langton a11169b562 feat: add oxlint with correctness defaults (#22682) 2026-04-15 20:45:19 -04:00
Carlo Wood 1da8ac3eda fix(tui): Don't overwrite the agent that was specified on the command line (#20554) 2026-04-15 18:41:35 -05:00
Kit Langton 8b2c135ad9 refactor(opencode): use AppFileSystem path helpers (#22637) 2026-04-15 19:36:30 -04:00
Kit Langton 061b21d980 fix(opencode): avoid org lookup during config startup (#22670) 2026-04-15 19:20:25 -04:00
David Hill 320fd1aa5b tweak(ui): session spacing (#20839)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-04-15 18:58:05 -04:00
opencode-agent[bot] cc6b77bfed chore: generate 2026-04-15 22:45:54 +00:00
Brendan Allan 1436e4d86b core: move plugin intialisation to config layer override (#22620) 2026-04-15 22:44:55 +00:00
Ariane Emory cab36f6ef4 fix(core): Remove dead code and documentation related to the obsolete list tool. (#22672) 2026-04-15 17:44:53 -05:00
Kit Langton 69116ed7a4 feat(opencode): improve telemetry tracing and request spans (#22653) 2026-04-15 17:32:56 -04:00
opencode-agent[bot] 321ef36657 chore: generate 2026-04-15 21:29:10 +00:00
Kit Langton 39d6666dbf add experimental permission HttpApi slice (#22385) 2026-04-15 21:28:01 +00:00
Aiden Cline 16abb5fad1 tweak: ensure auto continuing compaction is tracked as agent initiated for github copilot (#22567)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-15 15:50:33 -05:00
Aiden Cline 8196bd2621 fix: ensure tool_use is always followed by tool_result (#22646)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-15 14:56:45 -05:00
opencode-agent[bot] 0efe1ab075 chore: update nix node_modules hashes 2026-04-15 17:38:21 +00:00
Kit Langton c4ddd59eb8 fix: register OTel context manager so AI SDK spans thread into Effect traces (#22645) 2026-04-15 16:35:14 +00:00
Dax 4be9af983d migrate: move flock and hash utilities to shared package (#22640) 2026-04-15 15:50:24 +00:00
Kit Langton 615c1fbc75 fix(effect): add effect bridge for callback contexts (#22504) 2026-04-15 15:22:34 +00:00
opencode-agent[bot] 7f89f4d6f5 chore: update nix node_modules hashes 2026-04-15 15:08:13 +00:00
Kit Langton 0ea98a0929 docs(opencode): add instance context migration plan (#22529) 2026-04-15 10:57:58 -04:00
Kit Langton 9d69bc8ce9 remove makeRuntime facade from Env (#22523) 2026-04-15 10:55:50 -04:00
Kit Langton 44cabe27f1 feat(opencode): trace tool execution spans (#22531) 2026-04-15 10:49:47 -04:00
Dax 780c051059 shared package (#22626) 2026-04-15 14:26:20 +00:00
James Long 4aa52779d0 feat(core): sync routes, refactor proxy, session restore, and more syncing (#22518) 2026-04-15 10:18:48 -04:00
Frank d4adc11f9e zen: better error 2026-04-15 09:19:28 -04:00
Frank 0ae48b2490 sync 2026-04-15 09:19:28 -04:00
Sebastian ddb1cad62a handle non-throwing requests (#22604) 2026-04-15 14:29:09 +02:00
Brendan Allan d755f47f31 refactor(electron): update store configuration (#22597) 2026-04-15 09:21:04 +00:00
Brendan Allan 8c93fa3475 ui: update accordion styles and session review component (#22582) 2026-04-15 07:29:36 +00:00
Brendan Allan eb6fb9faaf fix(ui): disable accordion items for binary files and improve disabled state styling (#22577) 2026-04-15 07:26:34 +00:00
opencode fdc567dee9 release: v1.4.6 2026-04-15 07:26:23 +00:00
Luke Parker 20f3898f1f fix(snapshot): avoid ENAMETOOLONG and improve staging perf via stdin pathspecs (#22560) 2026-04-15 06:43:36 +00:00
Frank 892e659128 sync 2026-04-15 02:22:47 -04:00
opencode-agent[bot] 9faa0ad5a5 chore: generate 2026-04-15 06:08:19 +00:00
Frank e5d7b01f57 Go: list model providers 2026-04-15 02:07:06 -04:00
Frank c2d0c8acea Go: qwen 3.5 & 3.6 plus 2026-04-15 02:07:06 -04:00
Frank a8e78884e9 doc: qwen3.5 & 3.6 2026-04-15 02:07:06 -04:00
Brendan Allan 855cc6bc12 fix: prevent tooltip reopen on trigger click (#22571) 2026-04-15 06:03:29 +00:00
Dax Raad 091b7ccb73 docs: use latest release for downloads instead of pinned version 2026-04-15 01:50:22 -04:00
github-actions[bot] c5c1027cb0 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/22569#issuecomment-4249515283
2026-04-15 05:36:27 +00:00
Brendan Allan 25d2096519 fix: update prompt input submit handler (#22566) 2026-04-15 05:32:52 +00:00
Dax 46d87691a5 fix(observability): handle OTEL headers with '=' in value (#22564)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-15 01:32:32 -04:00
Brendan Allan f60769628f fix: add left padding to session title input (#22556) 2026-04-15 04:35:57 +00:00
opencode-agent[bot] a14de93a39 chore: update nix node_modules hashes 2026-04-15 04:25:29 +00:00
opencode 631e7da409 release: v1.4.5 2026-04-15 04:25:18 +00:00
Brendan Allan 62e4e64a25 fix(bootstrap): await plugin initialization 2026-04-15 11:40:33 +08:00
Brendan Allan 16e4358992 core: parallelise bootstrap (#22514) 2026-04-15 03:23:28 +00:00
Dax Raad 7837ae74ff ci: ok one more time 2026-04-14 23:22:07 -04:00
Aiden Cline e25dbf7137 chore: bump ai sdk pkgs (#22539) 2026-04-14 22:14:32 -05:00
Dax Raad b061c5b683 ci: remove Tauri desktop builds from release workflow to simplify distribution 2026-04-14 23:14:08 -04:00
Dax Raad e9f4933205 delete all e2e tests (#22501)
Cherry-picked from ea463e604cdd2a3e83e1c286e39b789455f0d413
2026-04-14 23:10:25 -04:00
Brendan Allan 471030afd7 fix(desktop): chdir to homedir on macOS to fix ripgrep issues (#22537) 2026-04-15 10:56:22 +08:00
Brendan Allan fc74af964b fix(desktop): start tauri shell commands from home directory (#22535) 2026-04-15 10:51:53 +08:00
Dax Raad 0c735b747d core: pin downloads to v1.4.3 to ensure users get a tested, stable build instead of potentially unstable latest releases 2026-04-14 22:35:36 -04:00
opencode-agent[bot] 9d44c327d3 chore: generate 2026-04-15 02:31:50 +00:00
Kit Langton 97b465cc06 fix(opencode): export AI SDK telemetry spans (#22526) 2026-04-14 22:30:50 -04:00
opencode-agent[bot] 8f36d81509 chore: update nix node_modules hashes 2026-04-15 01:49:59 +00:00
opencode-agent[bot] b753cca9cf chore: generate 2026-04-15 00:57:20 +00:00
LukeParkerDev bb6aca2e5d Update test.yml 2026-04-14 20:55:42 -04:00
Shoubhit Dash d5414ac029 feat(server): extract question handler factory 2026-04-14 20:55:41 -04:00
Shoubhit Dash 1e9842de99 feat(server): extract question httpapi contract 2026-04-14 20:55:39 -04:00
opencode-agent[bot] 77fd6f6689 chore: generate 2026-04-15 00:30:27 +00:00
Frank 44c7c2a371 zen: rate limiter 2026-04-14 20:29:21 -04:00
Brendan Allan e5afe2009c fix(app): avoid bootstrap error popups during global sync init (#22426) 2026-04-15 08:24:52 +08:00
opencode 311d54dd1e release: v1.4.4 2026-04-15 00:03:43 +00:00
opencode-agent[bot] cc7bc93056 chore: update nix node_modules hashes 2026-04-14 23:13:28 +00:00
Frank 82f00e2c6c zen: support alibaba cache write 2026-04-14 18:48:00 -04:00
Frank f8d2b991c1 sync zen 2026-04-14 18:37:02 -04:00
Shoubhit Dash 4ff310a4ec feat(core): bootstrap packages/server and document extraction plan (#22492) 2026-04-15 04:01:45 +05:30
Shoubhit Dash b6d5203fbd fix: restore instance context in prompt runs (#22498) 2026-04-15 03:59:12 +05:30
Luke Parker 35097a0f1f fix(session): keep GitHub Copilot compaction requests valid (#22371) 2026-04-15 08:02:27 +10:00
Caleb Norton 86428faa94 chore(nix): remove external ripgrep (#22482) 2026-04-14 16:49:44 -05:00
Kit Langton bc769cfe97 fix(effect): preserve logger context in prompt runs (#22496) 2026-04-14 17:33:44 -04:00
Aiden Cline 25f6720b4c feat: add --sanitize flag to opencode export to strip PII or confidential info (#22489) 2026-04-14 16:24:18 -05:00
opencode-agent[bot] 46a0a03464 chore: generate 2026-04-14 20:58:35 +00:00
Kit Langton f1635f29d6 fix(question): restore flat reply sdk shape (#22487) 2026-04-14 16:57:32 -04:00
opencode-agent[bot] 20ea688b38 chore: generate 2026-04-14 19:45:10 +00:00
Kit Langton 5e6a35ba91 add experimental question HttpApi slice (#22357) 2026-04-14 19:43:49 +00:00
opencode-agent[bot] 991141b709 chore: update nix node_modules hashes 2026-04-14 19:08:59 +00:00
Sebastian 2bf70bfad2 upgrade opentui to 0.1.99 (#22283) 2026-04-14 20:29:56 +02:00
Dax Raad 09256f3622 ignore: v2 experiments 2026-04-14 14:25:38 -04:00
Sebastian 4a82fc5552 Fix diff line number contrast for built-in themes (#22464) 2026-04-14 19:59:41 +02:00
Kit Langton ea3f6be477 fix(mcp): persist immediate oauth connections (#22376) 2026-04-14 13:56:45 -04:00
Goni Zahavy 4a27172599 fix(cli): handlePluginAuth asks for api key only if authorize method exists (#22475) 2026-04-14 12:53:00 -05:00
Kit Langton 236253c405 refactor(session): remove async facade exports (#22471) 2026-04-14 13:45:13 -04:00
Kit Langton 668cb3b151 improve bash timeout retry hint (#22390) 2026-04-14 12:55:03 -04:00
Kit Langton 480a52d27f fix(project): reuse runtime in instance boot (#22470) 2026-04-14 12:53:13 -04:00
Kit Langton b019cd6822 refactor(project): remove async facade exports (#22387) 2026-04-14 12:49:20 -04:00
opencode-agent[bot] 4a56425282 chore: generate 2026-04-14 16:39:15 +00:00
Kit Langton 186c345f01 refactor(share): remove session share async facade exports (#22386) 2026-04-14 12:38:11 -04:00
Aiden Cline 87dd02777a core: prevent duplicate user messages in ACP clients (#22468) 2026-04-14 11:37:33 -05:00
RAIT-09 b81238d54f fix(acp): stop emitting user_message_chunk during session/prompt turn (#21851) 2026-04-14 11:25:00 -05:00
Aiden Cline e1d60b5a35 fix: rm effect logger from processor.ts, use old logger for now instead (#22460) 2026-04-14 10:39:01 -05:00
Shoubhit Dash 1386cd6e24 fix(opencode): resolve ripgrep worker path in builds (#22436) 2026-04-14 16:39:21 +05:30
opencode-agent[bot] ea23cb7c3d chore: update nix node_modules hashes 2026-04-14 06:58:22 +00:00
Shoubhit Dash fe416f8838 refactor(ripgrep): use embedded wasm backend (#21703) 2026-04-14 11:56:23 +05:30
Luke Parker 5e60aa52fd build(opencode): shrink single-file executable size (#22362) 2026-04-14 15:49:26 +10:00
Kit Langton a57c606e51 refactor(instance): remove state helper (#22381) 2026-04-13 22:40:12 -04:00
Kit Langton 5902701654 kit/env instance state (#22383) 2026-04-13 22:28:16 -04:00
Kit Langton 98f7765c3b refactor(tui): move config cache to InstanceState (#22378) 2026-04-13 22:24:40 -04:00
Kit Langton 0be4b81da0 refactor(snapshot): remove async facade exports (#22370) 2026-04-13 21:24:54 -04:00
Kit Langton 0ece76a789 refactor(plugin): remove async facade exports (#22367) 2026-04-13 21:24:20 -04:00
Kit Langton cf613cba34 refactor(session): remove compaction async facade exports (#22366) 2026-04-13 21:23:34 -04:00
Kit Langton 598584d608 refactor(worktree): remove async facade exports (#22369) 2026-04-13 21:23:15 -04:00
Nazar H. 1449660b04 fix(provider): guard reasoningSummary injection for @ai-sdk/openai-compatible providers (#22352)
Co-authored-by: Nazar Hnatyshen <nazar.hnatyshen@atolls.com>
2026-04-13 20:20:06 -05:00
Aiden Cline e1c846850a feat: add experimental.compaction.autocontinue hook to disable auto continuing after compaction (#22361) 2026-04-13 20:14:53 -05:00
opencode-agent[bot] 26937e199a chore: update nix node_modules hashes 2026-04-14 00:23:25 +00:00
Kit Langton 6ee000dd65 refactor(session): move llm stream into layer (#22358) 2026-04-13 19:53:30 -04:00
Kit Langton a2c81f4185 feat: add interactive burst to the TUI logo (#22098) 2026-04-13 19:36:28 -04:00
Kit Langton 3bfc06ee79 refactor(permission): remove async facade exports (#22342) 2026-04-13 19:33:58 -04:00
Kit Langton 2c57db7f0e fix grep exact file path searches (#22356) 2026-04-13 19:26:50 -04:00
Dax Raad 035954b2bf ignore: v2 thoughts 2026-04-13 17:33:34 -04:00
James Long 0ee6e47bde refactor(core): publish sync events to global event stream (#22347) 2026-04-13 16:51:59 -04:00
Kit Langton 17ad9aac9e refactor(session): remove revert async facade exports (#22339) 2026-04-13 16:16:13 -04:00
Kit Langton d7bcf152cb refactor(mcp): remove mcp auth async facade exports (#22338) 2026-04-13 15:36:12 -04:00
Kit Langton 4adfab4a36 refactor(session): remove summary async facades (#22337) 2026-04-13 15:35:38 -04:00
Kit Langton 202f585eec refactor(agent): remove async facade exports (#22341) 2026-04-13 14:54:01 -04:00
Kit Langton 90f6e3513d update effect docs (#22340) 2026-04-13 14:07:59 -04:00
opencode-agent[bot] 70905b09a3 chore: generate 2026-04-13 18:02:12 +00:00
Dax Raad acb5815c10 session entry 2026-04-13 14:00:49 -04:00
Dax 154ceb5b1f 2.0 exploration (#22335) 2026-04-13 13:47:33 -04:00
Kit Langton 40ab2c74fe refactor(mcp): remove async facade exports (#22324) 2026-04-13 13:45:34 -04:00
opencode-agent[bot] c0e2d7c40e chore: generate 2026-04-13 17:37:41 +00:00
James Long a7569f1fc3 feat(core): expose workspace adaptors to plugins (#21927) 2026-04-13 13:33:13 -04:00
Kit Langton 1813d8e056 refactor(file): remove async facade exports (#22322) 2026-04-13 13:12:02 -04:00
Kit Langton 37d470346d refactor(config): remove async facade exports (#22325) 2026-04-13 13:11:05 -04:00
github-actions[bot] 14366f3146 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/22239#issuecomment-4238224546
2026-04-13 17:06:03 +00:00
Kit Langton 5f002222c4 refactor(lsp): remove async facade exports (#22321) 2026-04-13 12:47:52 -04:00
Kit Langton 128c60aff0 refactor(provider): remove async facade exports (#22320) 2026-04-13 12:40:00 -04:00
Aiden Cline 77e6a8bf18 test: ensure project and global instructions are loaded (#22317) 2026-04-13 11:34:38 -05:00
Kit Langton d470317b3f refactor(auth): remove async auth facade exports (#22306) 2026-04-13 12:31:43 -04:00
Kit Langton 83dfb7e1ae refactor(pty): remove async facade exports (#22305) 2026-04-13 11:47:05 -04:00
Kit Langton 2a2d798e1e refactor(file): stream ripgrep search parsing (#22303) 2026-04-13 11:39:37 -04:00
Kit Langton f70946770e fix: dispose e2e app runtime (#22316) 2026-04-13 11:36:56 -04:00
Kit Langton 00088f2e77 refactor: remove vcs async facade exports (#22304) 2026-04-13 11:22:20 -04:00
Kit Langton fb40523548 refactor(skill): remove async facade exports (#22308) 2026-04-13 11:18:10 -04:00
Kit Langton e6b81503ba refactor: remove ToolRegistry runtime facade (#22307) 2026-04-13 11:09:32 -04:00
opencode-agent[bot] 13fd3f36cd chore: generate 2026-04-13 14:18:05 +00:00
Brendan Allan cc0a3eebf7 core: make InstanceBootstrap into an effect (#22274)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-04-13 10:16:40 -04:00
Kit Langton e624571111 refactor: share TUI terminal background detection (#22297) 2026-04-13 10:05:37 -04:00
Kit Langton d39a41f632 refactor(file): add ripgrep search service (#22295) 2026-04-13 10:04:32 -04:00
Kit Langton d691884399 refactor: finish small effect service adoption cleanups (#22094) 2026-04-13 09:17:13 -04:00
Brendan Allan f54a6ab5ba app: replace parsePatchFiles with parseDiffFromFile (#22270) 2026-04-13 17:19:14 +08:00
Brendan Allan 50279ca66c fix(electron): wait until ready before showing the main window (#22262) 2026-04-13 15:17:09 +08:00
opencode-agent[bot] 94f5caae4e chore: update nix node_modules hashes 2026-04-13 06:02:50 +00:00
Brendan Allan d3faaf0011 app: align workspace load more button (#22251) 2026-04-13 13:58:35 +08:00
Brendan Allan 3b1c982f4f app: fix scroll to bottom light mode style (#22250) 2026-04-13 13:55:33 +08:00
Aiden Cline 2073a298a2 fix: forgot to put alibaba case in last commit (#22249) 2026-04-13 00:39:12 -05:00
Aiden Cline 516359684a feat: add alibaba pkg and cache support (#22248) 2026-04-13 00:08:07 -05:00
Aiden Cline 574b77b0d0 tweak: adjust session getUsage function to use more up to date LanguageModelUsage instead of LanguageModelV2Usage (#22224) 2026-04-12 21:39:06 -05:00
Brendan Allan 90ca9d91c6 sdk: throw error if response has text/html content type (#21289) 2026-04-13 09:39:53 +08:00
Goni Zahavy 4a0a2a213e fix(cli): auth login now asks for api key in handlePluginAuth (#21641)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-12 20:37:57 -05:00
Aiden Cline db7ee181f1 chore: rm git ignored files (#22200) 2026-04-12 15:52:55 -05:00
Dax Raad 6a616207e8 ci: enable beta branch releases with auto-update support 2026-04-12 14:40:24 -04:00
Dax adce21c1fa fix(snapshot): complete gitignore respect for previously tracked files (#22172) 2026-04-12 14:05:46 -04:00
shafdev d4d9c038b2 fix(opencode): remove spurious scripts and randomField from package.json (#22160) 2026-04-12 13:49:24 -04:00
Dax 005e44ddd4 fix(snapshot): respect gitignore for previously tracked files (#22171) 2026-04-12 13:41:50 -04:00
Dax Raad 618dd14702 ci: enable signed Windows builds on beta branch
Allows beta releases to include properly signed Windows CLI executables, ensuring consistent security verification across all release channels.
2026-04-12 13:16:38 -04:00
Dax Raad baece3eeac ignore: exploration 2026-04-12 13:14:46 -04:00
Aiden Cline bb9c2750ee fix: ensure images read by agent dont count against quota (#22168) 2026-04-12 12:02:39 -05:00
Simon Klee 18ff7ab00f chore(github): vouch simonklee (#22127) 2026-04-12 11:33:38 +02:00
Aiden Cline 748df390f4 chore: refactor log.ts, go back to glob but add sort (#22107) 2026-04-11 23:09:19 -05:00
Aiden Cline d08638b2be feat: make gh copilot use msgs api when available (#22106) 2026-04-11 23:06:35 -05:00
Aiden Cline 2b77ff5216 fix: ensure logger cleanup properly orders list before deleting files (#22101) 2026-04-11 22:07:34 -05:00
opencode-agent[bot] a0a770588f chore: generate 2026-04-12 01:21:17 +00:00
Kit Langton 65c58554b3 refactor(tool): destroy Truncate facade, effectify Tool.define (#22093) 2026-04-11 21:20:12 -04:00
Kit Langton c0a23276e6 refactor(file): destroy FileWatcher facade (#22091) 2026-04-11 21:19:12 -04:00
opencode-agent[bot] 1b9e337dbc chore: generate 2026-04-12 00:58:05 +00:00
Kit Langton f75f3f0a9e refactor(question): destroy Question facade (#22092) 2026-04-11 20:57:01 -04:00
Kit Langton 5bbb28a6c7 refactor(file): destroy FileTime facade (#22090) 2026-04-11 20:08:55 -04:00
Kit Langton a52b5706d2 refactor(session): destroy Instruction facade (#22089) 2026-04-11 20:04:09 -04:00
opencode-agent[bot] 68f1a265a7 chore: generate 2026-04-12 00:03:01 +00:00
Kit Langton 87b789f0fc refactor(session): destroy SessionRunState facade (#22064) 2026-04-11 20:01:52 -04:00
opencode-agent[bot] 25c7b05631 chore: generate 2026-04-11 22:15:53 +00:00
Tommy D. Rossi 04559e85ab feat: allow session permission updates (#22070) 2026-04-11 17:14:30 -05:00
opencode-agent[bot] b027cc52fc chore: generate 2026-04-11 20:56:22 +00:00
Dax c5f3cc0c9d refactor(server): simplify router middleware with next() (#21720) 2026-04-11 16:55:17 -04:00
opencode-agent[bot] 6da03efa22 chore: generate 2026-04-11 20:52:52 +00:00
ryan.h.park 4d604ea369 fix: enable thinking for zhipuai-coding-plan & prevent Korean IME truncation (#22041)
Co-authored-by: claudianus <claudianus@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-11 15:51:49 -05:00
opencode-agent[bot] 5df1054d1e chore: update nix node_modules hashes 2026-04-11 19:30:50 +00:00
Aiden Cline 025bd4a529 chore: bump ai sdk deps (#22005) 2026-04-11 13:45:14 -05:00
Dax Raad e62a919749 hide download button 2026-04-11 14:18:58 -04:00
opencode-agent[bot] 95bd6b5526 chore: generate 2026-04-11 18:18:40 +00:00
Dax Raad 314d75163b ignore: exploration 2026-04-11 14:17:22 -04:00
Kit Langton fc1eb78e91 refactor(account): destroy Account facade (#22068) 2026-04-11 14:16:36 -04:00
opencode-agent[bot] 3a8ac59795 chore: generate 2026-04-11 16:53:40 +00:00
Kit Langton fca2dc9951 refactor(session): make SystemPrompt a proper Effect Service (#21992) 2026-04-11 12:52:35 -04:00
Kit Langton 0fcd20f0dd refactor(tool): make Tool.Info init effectful (#21989) 2026-04-11 12:33:17 -04:00
opencode-agent[bot] ed8f1f8af7 chore: update nix node_modules hashes 2026-04-11 04:45:55 +00:00
opencode-agent[bot] 21f82c881f chore: generate 2026-04-11 03:53:48 +00:00
Kit Langton d77d22daf3 refactor(git): remove runtime facade wrappers (#21982) 2026-04-10 23:52:48 -04:00
Kit Langton 485eb95365 refactor: collapse command facade (#21981) 2026-04-10 23:52:12 -04:00
Kit Langton 5c6e657ad9 refactor(plugin): return Effect from ToolContext.ask (#21986) 2026-04-10 23:50:50 -04:00
opencode-agent[bot] 07f4201c12 chore: update nix node_modules hashes 2026-04-11 03:39:49 +00:00
Kit Langton 5b2b825a0f docs(effect): mark SessionTodo migrated (#21987) 2026-04-10 23:35:50 -04:00
opencode-agent[bot] fb07263d47 chore: generate 2026-04-11 03:28:30 +00:00
Kit Langton f40978a84b refactor(session): remove dead updatePartDelta facade (#21985) 2026-04-10 23:27:30 -04:00
Kit Langton 5b5fc9aac1 refactor(installation): drop facade runtime wrappers (#21984) 2026-04-10 23:26:16 -04:00
Kit Langton a9a51fb28f refactor: remove ProviderAuth facade (#21983) 2026-04-10 23:25:43 -04:00
opencode-agent[bot] bd48f8f612 chore: generate 2026-04-11 03:19:50 +00:00
Kit Langton 6f9fcaa965 refactor: collapse Format facade (#21980) 2026-04-10 23:18:54 -04:00
Kit Langton f87825cf31 refactor(tool): use Session.Service directly in TaskTool (#21975) 2026-04-10 23:18:30 -04:00
Kit Langton db64dfa831 refactor(session): eliminate Effect.promise roundtrips for sync MessageV2.stream (#21973) 2026-04-10 23:18:13 -04:00
opencode-agent[bot] f5ec05d6c7 chore: generate 2026-04-11 03:13:03 +00:00
Kit Langton 0fba75a824 refactor(tool): Tool.Context.metadata returns Effect (#21972) 2026-04-10 23:12:04 -04:00
Kit Langton 0a790bc740 refactor(effect): upgrade opencode to beta.46 context APIs (#21977) 2026-04-10 23:06:28 -04:00
Kit Langton 44f256a75e refactor: make TaskPromptOps effectful (#21971) 2026-04-10 22:57:47 -04:00
opencode-agent[bot] 072b0a1411 chore: generate 2026-04-11 02:56:03 +00:00
Kit Langton 82f35ac077 fix: provide EffectLogger.layer to bare Effect.runPromise/runFork calls (#21974) 2026-04-10 22:55:08 -04:00
Brendan Allan 4d06f7d4a9 refactor(config): pass instance context to containsPath (#21882) 2026-04-11 10:43:40 +08:00
opencode-agent[bot] 5c921f902d chore: generate 2026-04-11 02:36:59 +00:00
Kit Langton f800941bcc refactor(tool): Tool.Def.execute returns Effect, rename defineEffect → define (#21961) 2026-04-10 22:36:02 -04:00
Kit Langton f47f910ae1 refactor: destroy SessionStatus facade (#21968) 2026-04-10 22:16:53 -04:00
opencode-agent[bot] b003e0d406 chore: generate 2026-04-11 02:04:02 +00:00
Kit Langton 8811669aa0 refactor: destroy ShareNext facade (#21965) 2026-04-10 22:03:06 -04:00
Kit Langton 4d49b7c8cd refactor: extract LSP diagnostic report formatter (#21964) 2026-04-10 22:00:56 -04:00
opencode-agent[bot] 62faf81122 chore: generate 2026-04-11 01:48:25 +00:00
Kit Langton c8888f5ea5 refactor: destroy Todo facade (#21962) 2026-04-10 21:47:28 -04:00
Kit Langton 2432c83668 fix: disable default Effect console logger (#21963) 2026-04-10 21:27:24 -04:00
opencode-agent[bot] ca843cbcf4 chore: generate 2026-04-11 01:22:01 +00:00
Kit Langton bf4c363d64 refactor: destroy Storage facades (#21956) 2026-04-10 21:21:02 -04:00
Kit Langton 019bf9ca96 refactor: add Effect logger for motel observability (#21954) 2026-04-10 21:10:58 -04:00
Aiden Cline 17e4384e8a chore: rm unnecessary test (now we use effect) and the test is flaky (#21959) 2026-04-10 19:59:33 -05:00
Aiden Cline 5752fb6943 tweak: rm processor .trim calls (#21958) 2026-04-10 19:47:08 -05:00
Kit Langton e029812e93 convert skill tool to Tool.defineEffect (#21936) 2026-04-10 19:49:53 -04:00
Kit Langton 437400e5ec feat: add AppRuntime for unified service composition (#21953) 2026-04-10 19:46:52 -04:00
opencode-agent[bot] 506f1b092e chore: generate 2026-04-10 23:43:20 +00:00
Kit Langton 1d96701390 refactor(tool): convert apply_patch to Tool.defineEffect (#21938) 2026-04-10 19:42:14 -04:00
Kit Langton f8b0d4064b refactor: break SessionPrompt/TaskTool cycle via ctx injection (#21948) 2026-04-10 19:36:13 -04:00
Kit Langton 0ccc297bc8 refactor(tool): convert grep tool to Tool.defineEffect (#21937) 2026-04-10 19:20:00 -04:00
opencode-agent[bot] 0415549373 chore: generate 2026-04-10 21:12:22 +00:00
Kit Langton 57b8a79d27 refactor: convert edit tool to Tool.defineEffect (#21904) 2026-04-10 17:10:28 -04:00
opencode-agent[bot] fd827655af chore: generate 2026-04-10 21:01:33 +00:00
Frank e2d0849674 zen: nemotron doc 2026-04-10 16:58:38 -04:00
Kit Langton 8e0d79bb29 refactor(tool): convert write tool to Tool.defineEffect (#21901) 2026-04-10 16:57:12 -04:00
opencode-agent[bot] 607ac7b214 chore: generate 2026-04-10 19:40:10 +00:00
Kit Langton 2f6d62165d convert list tool to Tool.defineEffect (#21899) 2026-04-10 15:38:52 -04:00
Aiden Cline e80fba93cc tweak: make it so disabling uv or ruff fmters disables both (#21921) 2026-04-10 13:30:30 -05:00
opencode-agent[bot] 44badf4578 chore: generate 2026-04-10 17:57:46 +00:00
Kit Langton e815ccf13b convert glob tool to Tool.defineEffect (#21897) 2026-04-10 13:56:42 -04:00
opencode-agent[bot] 055e0439a8 chore: generate 2026-04-10 17:27:31 +00:00
Kit Langton b8e69d52b2 refactor(tool): convert bash to defineEffect with ChildProcessSpawner (#21895) 2026-04-10 13:26:31 -04:00
James Long a37b37d00c rector(core,tui): handle workspace state in project context, add workspace status, improve ui (#21896) 2026-04-10 13:03:20 -04:00
Kit Langton d1c6cc8bbc refactor(tool): convert codesearch tool internals to Effect (#21811) 2026-04-10 11:49:20 -04:00
opencode-agent[bot] f8c45d7c74 chore: generate 2026-04-10 15:31:39 +00:00
Kit Langton 22dac66b22 refactor(tool): convert websearch tool internals to Effect (#21810) 2026-04-10 11:30:38 -04:00
opencode-agent[bot] 27c6a0a21d chore: generate 2026-04-10 14:51:27 +00:00
Kit Langton de99edef4c refactor(tool): convert webfetch tool internals to Effect (#21809) 2026-04-10 10:50:13 -04:00
James Long 10ce83d5e4 refactor(tui): switch to global events and start passing workspace param (#21719) 2026-04-10 10:47:27 -04:00
Kit Langton 5531e7de24 refactor(tool): convert plan tool internals to Effect (#21807) 2026-04-10 10:38:46 -04:00
opencode-agent[bot] fcdd8079f2 chore: generate 2026-04-10 14:08:27 +00:00
Kit Langton e7fa657119 refactor(tool): convert lsp tool internals to Effect (#21806) 2026-04-10 10:07:19 -04:00
Kit Langton 30269d8944 refactor(tool): convert question tool internals to Effect (#21808) 2026-04-10 09:42:06 -04:00
Aiden Cline d7808ebac2 fix: ts lsp (#21827) 2026-04-10 00:15:45 -05:00
Kit Langton 28a6be5a1d refactor(effect): use Git service in file and storage (#21803) 2026-04-09 22:49:36 -04:00
Kit Langton dde5ad473a refactor(effect): use SessionRevert service in prompt (#21796) 2026-04-09 22:28:11 -04:00
Kit Langton a2545c7417 refactor(effect): move tool descriptions into registry (#21795) 2026-04-09 22:20:27 -04:00
Kit Langton a92b47d66c refactor(session): extract sharing orchestration (#21759) 2026-04-09 21:47:48 -04:00
Dax Raad 9b49a0a6d3 Merge remote-tracking branch 'origin/dev' into dev 2026-04-09 21:35:09 -04:00
Dax Raad f11c20a60e core: enable prod channel to use shared production database
Ensures users on the prod channel have their data persisted to the same
database as latest and beta channels, preventing data fragmentation
across different release channels.
2026-04-09 21:34:52 -04:00
Luke Parker 67fe37cfde ci use node 24 in test workflow fixing random ECONNRESET (#21782) 2026-04-10 01:00:21 +00:00
Luke Parker 452fc6c70b fix windows e2e backend not stopping on sigterm waiting 10s for no reason (#21781) 2026-04-10 01:00:21 +00:00
opencode 7b73a59501 release: v1.4.3 2026-04-10 01:00:12 +00:00
Dax Raad 6916bd4be2 ci: prevent beta branch builds from triggering production release steps
Skip Windows and Linux code signing, along with artifact downloads for
the beta branch to ensure beta builds don't go through production
release processes.
2026-04-09 17:06:53 -04:00
Dax Raad 40d030cd51 ci: skip winget publish on beta and ensure finalize always runs
Beta releases no longer trigger unnecessary Winget submissions, and release
finalization now completes even when some build artifacts are missing.
2026-04-09 16:34:05 -04:00
opencode-agent[bot] 9149024c1d chore: generate 2026-04-09 20:29:48 +00:00
Kit Langton a55038cc62 refactor(session): inline init route orchestration (#21754) 2026-04-09 16:28:42 -04:00
Aiden Cline 8db3f197c9 fix: ensure that openai oauth works for agent create cmd, use temporary hack (#21749)
Co-authored-by: OpeOginni <brightoginni123@gmail.com>
2026-04-09 15:25:59 -05:00
opencode-agent[bot] 35f3e332e0 chore: generate 2026-04-09 20:05:05 +00:00
Kit Langton f1e2560a1a refactor(effect): extract session run state service (#21744) 2026-04-09 16:03:40 -04:00
Kit Langton db3a528899 fix: finalize interrupted bash via tool result path (#21724) 2026-04-09 15:20:28 -04:00
Kit Langton 19796cdac6 refactor: remove unused runtime facade exports (#21731) 2026-04-09 14:52:06 -04:00
Kit Langton 42b399da22 fix(effect): suspend agent default layer construction (#21732) 2026-04-09 14:32:41 -04:00
Simon Klee 15ba875b3a Revert "opencode: lazy-load top-level CLI commands" (#21726) 2026-04-09 20:03:48 +02:00
Kit Langton 9731a1edeb delete unused withALS method (#21723) 2026-04-09 13:52:58 -04:00
Simon Klee da80539ecf opencode: lazy-load top-level CLI commands
The CLI imports every top-level command before argument parsing has
decided which handler will run. This makes simple invocations pay for
the full command graph up front and slows down the default startup path.

Parse the root argv first and load only the command module that matches
the selected top-level command. Keep falling back to the default TUI
path for non-command positionals, and preserve root help, version and
completion handling
2026-04-09 19:42:25 +02:00
Aiden Cline 3d794bdc24 feat: add support for fast modes for claude and gpt models (that support it) (#21706) 2026-04-09 12:06:26 -05:00
Kit Langton 6e883aab36 fix(tui): restore hidden session scrollbar default (#20947) 2026-04-09 13:01:08 -04:00
opencode-agent[bot] cd3ab0e03d chore: generate 2026-04-09 16:47:02 +00:00
Aleksandr Lossenko 5befad3e2f feat(mcp): add OAuth redirect URI configuration for MCP servers (#21385)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-09 11:45:52 -05:00
opencode c34c30011a release: v1.4.2 2026-04-09 16:24:43 +00:00
Dax 19e24a5850 refactor: fix tool call state handling and clean up imports (#21709) 2026-04-09 11:56:19 -04:00
Kit Langton 18745d2cc6 refactor(effect): drop shell abort signals from runner (#21599) 2026-04-09 10:54:26 -04:00
Kit Langton 039bd423fa fix: preserve text part timing in session processor (#21691) 2026-04-09 10:05:14 -04:00
Kit Langton 8a63aaef6c fix: preserve interrupted bash output in tool results (#21598) 2026-04-09 10:03:26 -04:00
Brendan Allan 7d99bfdbf1 app: remove min loading duration (#21655) 2026-04-09 16:29:46 +08:00
opencode 5cd672b6f9 release: v1.4.1 2026-04-09 07:12:17 +00:00
Aiden Cline d21835e80f feat: add opencode go upsell modal when limits are hit (#21583)
Co-authored-by: Frank <frank@anoma.ly>
2026-04-09 02:16:29 -04:00
opencode-agent[bot] ac76bb2ebc chore: update nix node_modules hashes 2026-04-09 06:03:26 +00:00
opencode-agent[bot] e8dc178705 chore: generate 2026-04-09 05:19:52 +00:00
Brendan Allan 30d17b3b82 Remove CLI from electron app (#17803)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-04-09 13:18:46 +08:00
Cho HyeonJong 5be0c5b08f fix(lsp): remove CMakeLists.txt and Makefile from clangd root markers (#21466) 2026-04-08 23:10:06 -05:00
opencode-agent[bot] 99950c8838 chore: update nix node_modules hashes 2026-04-09 02:27:34 +00:00
Vladimir Glafirov bb097d4c50 feat(llm): integrate GitLab DWS tool approval with permission system (#19955)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-08 20:39:33 -05:00
Kit Langton 97c2d085d7 refactor(effect): inline session processor interrupt cleanup (#21593) 2026-04-08 21:19:01 -04:00
Kit Langton 00407ce6e2 fix: propagate abort signal to inline read tool (#21584) 2026-04-08 21:07:55 -04:00
OpeOginni 6b93f1351d fix(app): skip url password setting for same-origin server and web app (#19923) 2026-04-09 07:13:10 +08:00
Kit Langton e4d476619c refactor(effect): build task tool from agent services (#21017) 2026-04-08 19:02:19 -04:00
Aiden Cline 802a7905ec tweak: separate ModelsDev.Model and Config model schemas (#21561) 2026-04-08 15:55:14 -05:00
Aiden Cline 56df672deb fix: dont show invalid variants for BP (#21555) 2026-04-08 14:52:34 -05:00
Adam 969183a744 fix(app): diff list normalization 2026-04-08 14:02:23 -05:00
Adam 3d27a30e3b fix(app): patch tool diff rendering 2026-04-08 13:49:16 -05:00
Aiden Cline 808d35190d fix: ensure that /providers list and shell endpoints are correctly typed in sdk and openapi schema (#21543) 2026-04-08 12:56:15 -05:00
Aiden Cline 1f2458919f test: update webfetch test (#21398)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-08 12:25:02 -05:00
Brendan Allan 636603c1aa ui: fix sticky session diffs header (#21486) 2026-04-08 17:01:52 +08:00
Kit Langton 520d11d0d0 fix(tui): simplify console org display (#21339)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-07 21:03:24 -04:00
opencode-agent[bot] c92a40d97c chore: update nix node_modules hashes 2026-04-08 00:32:40 +00:00
opencode 0f0770f26e release: v1.4.0 2026-04-08 00:32:31 +00:00
opencode-agent[bot] e04429742d chore: generate 2026-04-07 23:49:25 +00:00
Dax 6cc1116f8e refactor(snapshot): store unified patches in file diffs (#21244)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-04-07 19:48:23 -04:00
Dax b2aee46183 core: refactor tool system to remove agent context from initialization (#21052) 2026-04-07 19:48:12 -04:00
Frank 3a9161b72e go: glm5.1 2026-04-07 17:55:09 -04:00
Frank aabccaab07 zen: glm5.1 doc 2026-04-07 17:46:11 -04:00
Frank 689377cd99 zen: glm5.1 doc 2026-04-07 17:21:24 -04:00
Dax 505b017e7a feat(opencode): add OTLP observability support (#21387) 2026-04-07 17:02:55 -04:00
Aiden Cline 0d2bb1aa73 fix(opencode): clear webfetch timeouts on failed fetches (#21378) 2026-04-07 20:46:02 +00:00
Kyle Altendorf 27981483ab test: disable GPG signing in test fixtures (#20386) 2026-04-07 15:26:01 -05:00
Ariane Emory 2420c17d98 feat(tui): allow variant_list keybind for the "Switch model variant" command (#21185)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-07 14:46:04 -05:00
opencode-agent[bot] c589e9683e chore: generate 2026-04-07 18:33:24 +00:00
Kit Langton 4dc9adde52 fix(opencode): improve console login transport errors (#21350) 2026-04-07 18:31:53 +00:00
Aiden Cline 4e35e466a9 fix: ensure the alibaba provider errors are retried (#21355) 2026-04-07 17:46:01 +00:00
Ariane Emory 7b51129f0c fix(tui): use sentence case for theme mode command palette items (#21192)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-07 12:25:13 -05:00
James Long 7625a7e644 refactor(core): support multiple event streams in worker and remove workspaces from plugin api (#21348) 2026-04-07 13:22:34 -04:00
Adam 2d528d4ac7 feat(app): better subagent experience (#20708) 2026-04-07 11:06:23 -05:00
Adam fe9b6d7118 chore: update web stats 2026-04-07 11:02:37 -05:00
opencode-agent[bot] ab1b1377b9 chore: generate 2026-04-07 14:14:07 +00:00
Dax b9ded6db4e fix(opencode): keep user message variants scoped to model (#21332) 2026-04-07 10:12:53 -04:00
Frank 634b2f15b5 go: support coupon 2026-04-07 10:09:00 -04:00
Shoubhit Dash 976bce3890 Move auto-accept permissions to settings (#21308) 2026-04-07 11:00:13 +00:00
Shoubhit Dash 8bb5660f4b style(app): redesign jump-to-bottom button per figma spec (#21313) 2026-04-07 10:47:53 +00:00
Shoubhit Dash f103f18f3f feat(app): show full names on composer attachment chips (#21306) 2026-04-07 10:15:22 +00:00
gitpush-gitpaid 58b633c54d feat(opencode): Add PDF attachment Drag and Drop (#16926)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-07 04:39:59 +00:00
opencode-agent[bot] 897cec3eb7 chore: update nix node_modules hashes 2026-04-07 04:38:49 +00:00
Aiden Cline cd2dacfec7 feat: add --dangerously-skip-permissions flag to opencode run (#21266) 2026-04-06 23:30:05 -05:00
Frank 72e9cb431f go: add mimo 2026-04-07 00:16:34 -04:00
James Long ef54e6f5b2 refactor(core): add full http proxy and change workspace adaptor interface (#21239) 2026-04-06 23:19:55 -04:00
Aiden Cline 5b92e6e76b chore: bump anthropic ai sdk pkg, delete patch (#21247) 2026-04-06 20:28:32 -05:00
opencode-agent[bot] 6e8ea27c8d chore: update nix node_modules hashes 2026-04-07 00:30:30 +00:00
Aiden Cline 7a5a537944 chore: remove ai-sdk/provider-utils patch and update pkg (#21245) 2026-04-06 23:53:27 +00:00
opencode-agent[bot] bd001aec46 chore: update nix node_modules hashes 2026-04-06 23:42:39 +00:00
Aiden Cline 461c134cf2 fix: bump openrouter ai sdk pkg to fix openrouter issues (#21242) 2026-04-06 17:56:11 -05:00
Aiden Cline 1648eeccd2 tweak: move the max token exclusions to plugins @rekram1-node (#21225) 2026-04-06 17:43:58 -05:00
Aiden Cline 3c67cd1d14 tweak: adjust chat.params hook to allow altering of the maxOutputTokens (#21220) 2026-04-06 18:13:30 +00:00
Aiden Cline 119a21463a tweak: ensure copilot anthropic models have same reasoning effort model as copilot cli, also fix qwen incorrectly having variants (#21212) 2026-04-06 18:12:43 +00:00
opencode-agent[bot] c3105187dd chore: update nix node_modules hashes 2026-04-06 18:08:04 +00:00
Dax 3da76ef72e refactor(server): replace Bun serve with Hono node adapters (#18335)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-04-06 13:24:55 -04:00
Sebastian 9db478dfc5 upgrade opentui to 0.1.97 (#21137) 2026-04-06 18:42:05 +02:00
Dax da77ff47b3 refactor: replace Bun.serve with Node http.createServer in OAuth handlers (#18327)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-04-06 12:17:29 -04:00
MC c27bf31167 docs: update Cloudflare provider setup to reflect /connect prompt flow (#20589) 2026-04-06 10:50:24 -05:00
opencode-agent[bot] 54f9533b7a chore: generate 2026-04-06 13:51:36 +00:00
Derek Barrera 47f39c686e fix(lsp): MEMORY LEAK: ensure typescript server uses native project config (#19953) 2026-04-06 09:50:36 -04:00
opencode e95a8da87a release: v1.3.17 2026-04-06 07:39:18 +00:00
Luke Parker 0eb56c4308 fix(tui): revert kitty keyboard events workaround on windows (#20180) 2026-04-06 07:04:50 +00:00
MC 853ca28a99 fix: show clear error when Cloudflare provider env vars are missing (#20399)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-06 05:26:04 +00:00
Frank 3d66625066 zen: normalize ipv6 2026-04-06 00:32:55 -04:00
Frank a194092969 zen: remove header check 2026-04-05 23:51:37 -04:00
opencode a851c3807a release: v1.3.16 2026-04-06 03:44:46 +00:00
Luke Parker cfa597b9a1 fix(tui): default Ctrl+Z to undo on Windows (#21138) 2026-04-06 02:38:35 +00:00
Corné Steenhuis 1777c480a4 fix: pass both 'openai' and 'azure' providerOptions keys for @ai-sdk/azure (#20272)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-06 02:34:53 +00:00
George Harker 56a9e8502c fix(core): implement proper configOptions for acp (#21134) 2026-04-05 21:29:34 -05:00
Gautier DI FOLCO 4b3bfcdb02 feat(tui): make the mouse disablable (#6824, #7926) (#13748) 2026-04-05 21:14:11 -05:00
opencode-agent[bot] 76fe8ba038 chore: update nix node_modules hashes 2026-04-06 01:18:03 +00:00
Luke Parker 4a95c9c44e fix(plugin): parse package specifiers with npm-package-arg and sanitize win32 cache paths (#21135) 2026-04-06 00:26:40 +00:00
Aiden Cline 918b7ccf7e tweak: add newline between <content> and first line of read tool output to prevent confusion (#21070) 2026-04-05 04:55:22 +00:00
Frank 8c0e16069c doc: udpate doc 2026-04-05 00:35:40 -04:00
Aiden Cline 0658bf4931 test: add regression test for double counting bug (#21053) 2026-04-04 16:40:28 -07:00
opencode-agent[bot] 8398bf933a chore: generate 2026-04-04 23:26:37 +00:00
Kit Langton 8f3de3aaad fix(tui): only show org switch affordances when useful (#21054) 2026-04-04 19:25:37 -04:00
opencode-agent[bot] b03daa9d14 chore: generate 2026-04-04 23:07:01 +00:00
Kit Langton 971151694f refactor(effect): move read tool onto defineEffect (#21016) 2026-04-04 19:06:00 -04:00
Kit Langton 94b5e89b2c feat(tui): show console-managed providers (#20956) 2026-04-04 19:05:45 -04:00
Aiden Cline 762ae4e70f fix: ensure reasoning tokens arent double counted when calculating usage (#21047) 2026-04-04 15:29:53 -07:00
opencode de0edcd25b release: v1.3.15 2026-04-04 20:54:20 +00:00
Dax 9f1a587d16 fix(npm): Arborist reify fails on compiled binary — Bun pre-resolves node-gyp path at build time (#21040) 2026-04-04 16:27:20 -04:00
Yuxin Dong 9661d82302 refactor: remove redundant Kimi skill section (#20393)
Co-authored-by: dongyuxin <dongyuxin@dev.dongyuxin.msh-dev.svc.cluster.local>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-04 18:32:26 +00:00
opencode b575e2a47a release: v1.3.14 2026-04-04 18:32:17 +00:00
Kit Langton f494fd5ea4 fix(ci): create JUnit output dirs before tests (#20959) 2026-04-03 22:47:20 -04:00
Kit Langton 0efe20d808 perf(opencode): batch snapshot diffFull blob reads (#20752)
Co-authored-by: Nate Williams <50088025+natewill@users.noreply.github.com>
2026-04-04 01:05:23 +00:00
Kit Langton 81b73e789a refactor(provider): stop custom loaders using facades (#20776)
Co-authored-by: luanweslley77 <213105503+luanweslley77@users.noreply.github.com>
2026-04-04 00:24:24 +00:00
opencode-agent[bot] 25e735d8a5 chore: generate 2026-04-03 22:55:05 +00:00
Sebastian ba02c4d28a notes on v2 (#20941) 2026-04-04 00:54:03 +02:00
Aiden Cline c696785e6a chore: rm models snapshot (#20929) 2026-04-03 19:44:50 +00:00
Kit Langton 209547f414 test(ci): publish unit reports in actions (#20547) 2026-04-03 16:12:01 +00:00
Kit Langton d613d8f4e3 refactor(effect): build todowrite tool from Todo service (#20789)
Co-authored-by: Juan Pablo Carranza Hurtado <52012198+jpcarranza94@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 12:05:40 -04:00
Adam 85e391654a chore(app): remove queued follow-ups for now 2026-04-03 10:56:40 -05:00
Adam f5215e1686 fix(app): show correct submit icon when typing follow up 2026-04-03 10:51:07 -05:00
opencode-agent[bot] e789089df2 chore: generate 2026-04-03 14:56:44 +00:00
Shoubhit Dash cf23c87965 feat: restore git-backed review modes (#20845) 2026-04-03 09:54:57 -05:00
Shoubhit Dash 1aee1475e8 fix: restore prompt focus after footer selection (#20841) 2026-04-03 14:36:33 +00:00
Kit Langton dd1f1687b5 refactor(effect): resolve built tools through the registry (#20787) 2026-04-03 10:31:00 -04:00
Shoubhit Dash 53642326b1 fix(app): hide default session timestamps (#20892) 2026-04-03 13:51:54 +00:00
Shoubhit Dash 4465a48d7e feat(ui): redesign modified files section in session turn (#20348)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-04-03 08:32:53 -05:00
Shoubhit Dash 7b3a672ec7 fix(session): disable todo dock auto-scroll (#20840) 2026-04-03 08:32:30 -05:00
Shoubhit Dash ab1b0250ed fix(prompt): unmount model controls in shell mode (#20886) 2026-04-03 13:16:26 +00:00
Shoubhit Dash e786645701 fix(session): delay jump-to-bottom button (#20853) 2026-04-03 08:12:01 -05:00
Nate Williams f0f021f47f fix(core): fix restoring earlier messages in a reverted chain (#20780) 2026-04-03 18:23:00 +05:30
Brendan Allan e4221bc7ed electron: better menus (#20878) 2026-04-03 12:41:30 +00:00
opencode-agent[bot] 198e5e4e2a chore: update nix node_modules hashes 2026-04-03 03:53:46 +00:00
Luke Parker e3743551fc fix(sdk): handle Windows opencode spawn and shutdown (#20772) 2026-04-03 13:18:50 +10:00
Kevin Flansburg 6611bcc8b5 feat: Send x-session-affinity and x-parent-session-id headers (#20744) 2026-04-02 22:09:53 -05:00
Dax 8b29acf8a3 add automatic heap snapshots for high-memory cli processes (#20788) 2026-04-03 02:34:33 +00:00
opencode-agent[bot] f0812a1f7b chore: generate 2026-04-03 02:10:50 +00:00
Juan Pablo Carranza Hurtado 4d97b32fbf fix: prevent Tool.define() wrapper accumulation on object-defined tools (#16952)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 22:09:53 -04:00
Luke Parker 4c8045066d fix: call models.dev once instead of twice on start (#20765) 2026-04-03 11:26:53 +10:00
Kit Langton d41085ce3a refactor(share): effectify share next (#20596) 2026-04-03 00:56:56 +00:00
Aiden Cline 6fa3493390 fix: rm dynamic part from bash tool description again to restore cache hits across projects (#20771) 2026-04-03 00:16:40 +00:00
Kit Langton 4f3e1ac6a5 refactor(effect): prune unused facades (#20748) 2026-04-02 20:15:09 -04:00
ykswang 71f8cc612d fix(opencode): honor model limit.input overrides (#16306)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-03 00:13:10 +00:00
Sebastian e74b820274 dialog aware prompt cursor (#20753) 2026-04-02 23:20:56 +02:00
opencode-agent[bot] 99f7530997 chore: update nix node_modules hashes 2026-04-02 20:47:24 +00:00
Sebastian b822aab5cd Adds TUI prompt traits, refs, and plugin slots (#20741) 2026-04-02 22:11:17 +02:00
Kit Langton 6a3ab9a388 refactor(todo): effectify session todo (#20595) 2026-04-02 19:11:23 +00:00
Kit Langton 2303038653 test(app): emit junit artifacts for playwright (#20732) 2026-04-02 15:07:46 -04:00
Kit Langton 0307a3f17f refactor(app): unexport internal e2e helpers (#20730) 2026-04-02 18:53:51 +00:00
Kit Langton 9f8f613190 test(opencode): remove temporary e2e url repro (#20729) 2026-04-02 14:35:21 -04:00
Kit Langton d1616c005f docs(effect): refresh migration status (#20665) 2026-04-02 14:33:58 -04:00
opencode-agent[bot] 3aea9253bf chore: update nix node_modules hashes 2026-04-02 18:31:14 +00:00
Kit Langton e4881e8d94 test(app): add a golden path for mocked e2e prompts (#20593) 2026-04-02 18:17:28 +00:00
opencode-agent[bot] 0ad5355f55 chore: generate 2026-04-02 17:58:00 +00:00
Noam Bressler b8e14248a0 feat(acp): Add messageID and emit user_message_chunk on prompt/command (#18625)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-02 12:56:56 -05:00
opencode-agent[bot] 79e4e81b92 chore: generate 2026-04-02 17:54:07 +00:00
Lenny Vaknine 46c2b0c30b feat: add macOS managed preferences support for enterprise MDM deployments (#19178)
Co-authored-by: Lenny Vaknine <lvaknine@gitlab.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-02 12:52:49 -05:00
Brendan Allan d96ca8c629 electron: add basic context menu for inspect element (#20723) 2026-04-02 17:41:26 +00:00
Frank 4a217df094 go: add mimo 2026-04-02 12:59:04 -04:00
Brendan Allan ba598a309c app: unify auto scroll ref handling (#20716) 2026-04-02 16:44:52 +00:00
Dax 15fb043349 refactor(format): update formatter interface to return command from enabled() (#20703) 2026-04-02 12:22:01 -04:00
Jack 32c7a82d6f Add MiMo-V2 models to Go UI and docs (#20709) 2026-04-02 12:01:04 -04:00
Aiden Cline 5becfc38c5 tweak: add abort signal timeout to the github copilot model fetch to prevent infinite blocking (#20705) 2026-04-02 10:53:08 -05:00
Burak Yigit Kaya 4a2b6f3157 fix(format): use biome format instead of check to prevent import removal (#20545)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-02 10:52:52 -05:00
Aiden Cline ca0014e54e ignore: fix typecheck in dev (#20702) 2026-04-02 15:38:30 +00:00
opencode-agent[bot] e5d2c2fa6d chore: generate 2026-04-02 15:19:26 +00:00
Noam Bressler fc84936a69 feat: add optional messageID to ShellInput (#20657) 2026-04-02 10:18:16 -05:00
opencode-agent[bot] 51840590c4 chore: generate 2026-04-02 15:01:45 +00:00
Aiden Cline 7cc1b528e8 refactor: split up models.dev and config model definitions to prevent coupling (#20605) 2026-04-02 10:00:43 -05:00
opencode-agent[bot] 11c99d76a8 chore: update nix node_modules hashes 2026-04-02 10:23:59 +00:00
Brendan Allan 1c97b92774 cleanup event listeners with solid-primitives/event-listener (#20619) 2026-04-02 09:40:03 +00:00
Brendan Allan b7382ab630 use solid-primitives/resize-observer across web code (#20613) 2026-04-02 17:24:10 +08:00
Shoubhit Dash decf3415af refactor: simplify solid reactivity across app and web (#20497) 2026-04-02 17:14:05 +08:00
Frank 0fd602ea74 zen: friendly trial ended message 2026-04-02 03:15:35 -04:00
Brendan Allan bb3bb3ac98 fix(node): set OPENCODE_CHANNEL during build (#20616) 2026-04-02 06:05:36 +00:00
opencode-agent[bot] ec30e8ce3c chore: update nix node_modules hashes 2026-04-02 04:47:27 +00:00
Frank e92710ab73 cli: update usage exceeded error 2026-04-02 00:25:23 -04:00
opencode-agent[bot] 4745f4f8a5 chore: generate 2026-04-02 04:06:47 +00:00
dpuyosa a2b2b97dd4 feat(opencode): Add Venice AI package as dependency (#20570) 2026-04-01 23:05:49 -05:00
Kit Langton a6e97f2cc0 fix(cli): restore colored help logo (#20592) 2026-04-02 03:21:07 +00:00
Kit Langton 1ef2b6e522 refactor(account): share token freshness helper (#20591) 2026-04-02 02:57:45 +00:00
Aaron Zhu d08c6fa5d1 fix(session): compaction agent responds in same language as conversation (#20581)
Co-authored-by: Aaron Zhu <aaron@Aarons-MacBook-Air.local>
2026-04-01 21:44:16 -05:00
Valentin Vivaldi ea1b93cc90 fix(core): prevent agent loop from stopping after tool calls with OpenAI-compatible providers (#14973)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-01 21:34:01 -05:00
Kit Langton 584107690b fix(cli): use simple logo in CLI (#20585) 2026-04-02 02:27:09 +00:00
Kit Langton d29bffd4ba refactor(instruction): migrate to Effect service pattern (#20542) 2026-04-01 22:22:51 -04:00
Kit Langton b6a3a921c1 test(app): block real llm calls in e2e prompts (#20579) 2026-04-01 22:22:43 -04:00
Aiden Cline 2d16129741 tui: add consent dialog when sharing for the first time (#20525) 2026-04-02 01:58:57 +00:00
opencode-agent[bot] a2f403f708 chore: generate 2026-04-02 01:56:34 +00:00
Kit Langton 90bed48f49 fix(build): replace require() with dynamic import() in cross-spawn-spawner (#20580) 2026-04-01 21:55:35 -04:00
MC 8d71cc78df chore: add User-Agent headers for Cloudflare providers (#20538)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-01 20:02:17 -05:00
Kit Langton fd7ff95cbd fix(account): refresh console tokens before expiry (#20558) 2026-04-02 00:25:24 +00:00
Sebastian abe10a6395 flock npm.add (#20557) 2026-04-02 00:21:26 +00:00
Kit Langton 1fe4bff157 fix: show model display name in message footer and transcript (#20539) 2026-04-02 00:17:38 +00:00
Kit Langton 9201507e5c refactor(revert): yield SessionSummary.Service directly (#20541) 2026-04-01 20:10:59 -04:00
Kit Langton b2bf8b89ec fix(test): use effect helper in snapshot race test (#20567) 2026-04-01 20:05:47 -04:00
Sebastian 077da62cde Refactor plugin/config loading, add theme-only plugin package support (#20556) 2026-04-01 23:50:22 +00:00
opencode-agent[bot] 765b93e9ea chore: generate 2026-04-01 23:49:44 +00:00
Kit Langton 0da4aa8cf2 refactor(bash): use Effect ChildProcess for bash tool execution (#20496) 2026-04-01 19:48:47 -04:00
Kit Langton 0a1efc63fe refactor: add Effect-returning versions of MessageV2 functions (#20374) 2026-04-01 19:48:36 -04:00
opencode-agent[bot] 58bdd9e14b chore: generate 2026-04-01 23:48:30 +00:00
Kit Langton 3d31c43241 fix(test): auto-acknowledge tool-result follow-ups in mock LLM server (#20528) 2026-04-01 23:47:26 +00:00
Kit Langton c5a7250c97 fix(opencode): batch snapshot revert without reordering (#20564) 2026-04-01 23:46:06 +00:00
Luke Parker 2f173e018a chore(tui): clean up scroll config follow-up (#20561) 2026-04-02 09:36:49 +10:00
Luke Parker c7fc628e19 fix(tui): apply scroll configuration uniformly across all scrollboxes (#14735) 2026-04-02 09:15:19 +10:00
Aiden Cline 2ed135358d feat: add new provider plugin hook for resolving models and sync models from github models endpoint (falls back to models.dev) (#20533) 2026-04-01 23:04:14 +00:00
Luke Parker a0f590bc64 Fix selection expansion by retaining focused input selections during global key events (#20205) 2026-04-02 08:43:40 +10:00
Sebastian 6fa4cc04f0 resolve subpath only packages for plugins (#20555) 2026-04-01 22:14:36 +00:00
Joscha Götzer cbe82e80f9 fix: normalize filepath in FileTime to prevent Windows path mismatch (#20367)
Co-authored-by: JosXa <info@josxa.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-04-02 07:45:50 +10:00
Frank aed22ae63e zen: sync 2026-04-01 17:41:04 -04:00
Dax 99a2f241f8 refactor: replace BunProc with Npm module using @npmcli/arborist (#18308)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-01 21:01:37 +00:00
Frank 7a4adf501d wip: zen 2026-04-01 14:17:31 -04:00
Kit Langton a9809cc69a test(app): fix isolated backend follow-ups (#20513) 2026-04-01 17:43:19 +00:00
Kit Langton ad16ae20dd fix(account): coalesce concurrent console token refreshes (#20503) 2026-04-01 13:16:35 -04:00
Kit Langton 8e6b0e35cd test(app): migrate more e2e suites to isolated backend (#20505) 2026-04-01 13:15:42 -04:00
github-actions[bot] f29806353d Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20482#issuecomment-4171492178
2026-04-01 16:50:21 +00:00
opencode-agent[bot] 0dbac145cc chore: update nix node_modules hashes 2026-04-01 16:47:33 +00:00
opencode-agent[bot] 4c3a97e7b3 chore: generate 2026-04-01 16:14:37 +00:00
Kit Langton 9bb7db7ef5 refactor: use Effect services instead of async facades in provider, auth, and file (#20480) 2026-04-01 16:13:13 +00:00
Kit Langton c1db6afc10 refactor(shell): use Effect ChildProcess for shell command execution (#20494) 2026-04-01 12:07:57 -04:00
Shoubhit Dash a0916847f9 refactor(init): tighten AGENTS guidance (#20422) 2026-04-01 21:37:25 +05:30
opencode-agent[bot] 20fa6be937 chore: generate 2026-04-01 15:59:28 +00:00
Kit Langton 3d4ba8b1aa test(e2e): isolate prompt tests with per-worker backend (#20464) 2026-04-01 15:58:11 +00:00
Dax Raad 42f032965e fall back to first agent if last used agent is not available 2026-04-01 11:09:29 -04:00
Kit Langton ba3b43dbd0 refactor: standardize InstanceState variable name to state (#20267) 2026-04-01 10:39:43 -04:00
Shoubhit Dash d96a37c06b perf(review): defer offscreen diff mounts (#20469) 2026-04-01 19:29:12 +05:30
Kit Langton 32bc9e0e59 test(app): route prompt e2e through mock llm (#20383) 2026-04-01 08:28:38 -04:00
Shoubhit Dash 345b76313e feat(comments): support file mentions (#20447) 2026-04-01 16:11:57 +05:30
Shoubhit Dash 7a9f4c5489 fix(session): add keyboard support to question dock (#20439) 2026-04-01 15:47:15 +05:30
Brendan Allan 80a75b5004 app: try to hide autofill popups in prompt input (#20197) 2026-04-01 08:43:03 +00:00
Brendan Allan c32c5245df electron: port mergeShellEnv logic from tauri (#20192) 2026-04-01 07:01:44 +00:00
Kit Langton 549ac73fe5 test(app): add mock llm e2e fixture (#20375) 2026-03-31 21:24:39 -04:00
opencode-agent[bot] 2362d09e3c chore: update nix node_modules hashes 2026-04-01 01:15:51 +00:00
Kit Langton 92b86bd2a0 test: finish HTTP mock processor coverage (#20372) 2026-04-01 00:45:42 +00:00
Kit Langton 0fc81f6d0b refactor(prompt): use Provider service in effect layers (#20167) 2026-04-01 00:44:15 +00:00
opencode 46feb7f777 release: v1.3.13 2026-04-01 00:44:06 +00:00
Sebastian 3ae7586aea upgrade opentui to 0.1.95 (#20369) 2026-04-01 01:53:05 +02:00
opencode-agent[bot] 07ef448abb chore: update nix node_modules hashes 2026-03-31 23:19:18 +00:00
opencode-agent[bot] e806141be0 chore: generate 2026-03-31 23:15:56 +00:00
Kit Langton 00d6a0486c refactor(session): effectify SessionRevert service (#20143) 2026-03-31 19:14:49 -04:00
Kit Langton a4a7e80c88 refactor(session): effectify SessionSummary service (#20142) 2026-03-31 19:14:45 -04:00
Kit Langton 9f35ffe300 test: migrate prompt tests to HTTP mock LLM server (#20304) 2026-03-31 19:14:32 -04:00
github-actions[bot] 1c6c935738 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20333#issuecomment-4166038038
2026-03-31 22:35:10 +00:00
opencode 766dadcb24 release: v1.3.12 2026-03-31 22:35:01 +00:00
Sebastian a481f1766b upgrade opentui to 0.1.94 (#20357) 2026-03-31 23:54:13 +02:00
github-actions[bot] 6354149e70 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20342#issuecomment-4165277636
2026-03-31 20:24:07 +00:00
Major Hayden d4c3b2944a feat: enable prompt caching and cache token tracking for google-vertex-anthropic (#20266)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-03-31 15:16:14 -05:00
Aiden Cline 63fead38a8 fix: rm exclusion of ai-sdk/azure in transform.ts, when we migrated to v6 the ai sdk changed the key for ai-sdk/azure so the exclusion is no longer needed (#20326) 2026-03-31 14:57:15 -05:00
opencode 238dcd5db1 release: v1.3.11 2026-03-31 19:55:41 +00:00
Dax 32a1c9efaa refactor(session): simplify LLM stream by replacing queue with fromAsyncIterable (#20324) 2026-03-31 15:27:51 -04:00
Aiden Cline 485d16d79a test: update model test fixture (#20182) 2026-03-31 16:20:01 +00:00
Aiden Cline 762c662c9e chore: rm harcoded model definition from codex plugin (#20294) 2026-03-31 11:13:11 -05:00
Adam 621fc80fdc chore: skip 2 tests 2026-03-31 10:56:06 -05:00
Yuxin Dong f7ec65357e feat: add a dedicated system prompt for Kimi models (#20259)
Co-authored-by: dongyuxin <dongyuxin@dev.dongyuxin.msh-dev.svc.cluster.local>
2026-03-31 17:44:17 +02:00
opencode-agent[bot] d30818e2dc chore: update nix node_modules hashes 2026-03-31 15:33:12 +00:00
Adam f53b5db603 chore: e2e model 2026-03-31 10:14:26 -05:00
Sebastian bea4bfe674 warn only and ignore plugins without entrypoints, default config via exports (#20284) 2026-03-31 17:14:03 +02:00
Adam 9583c9f627 chore: use paid zen model in e2e 2026-03-31 10:06:44 -05:00
Sebastian 089cd11b1f ensure pinned plugin versions and do not run package scripts on install (#20248) 2026-03-31 16:59:43 +02:00
Sebastian d5cd77887f upgrade opentui to 0.1.93 (#19950) 2026-03-31 16:50:23 +02:00
opencode 6572c8b70e release: v1.3.10 2026-03-31 13:31:37 +00:00
Dax 0de4cac6e7 fix(session): subagents not being clickable (#20263) 2026-03-31 08:58:46 -04:00
Frank 292b51b708 wip: zen 2026-03-31 00:07:56 -04:00
opencode-agent[bot] d0c3e93929 chore: generate 2026-03-31 01:57:41 +00:00
Kit Langton 0b7afbed8c refactor(provider): effectify Provider service (#20160) 2026-03-30 21:56:43 -04:00
Kit Langton af252ebc18 refactor(storage): effectify Storage service (#20132) 2026-03-31 01:16:02 +00:00
Kit Langton 44be3543cc fix(theme): darken muted text in catppuccin tui themes (#20161) 2026-03-30 21:06:05 -04:00
Luke Parker cbc283934f smarter changelog (#20138) 2026-03-31 00:05:46 +00:00
opencode b92339b88d release: v1.3.9 2026-03-30 23:43:58 +00:00
Luke Parker 176129b648 fix(plugin): properly resolve entrypoints without leading dot (#20140) 2026-03-31 09:21:17 +10:00
Frank d6aa8fd29b wip: zen 2026-03-30 19:04:42 -04:00
Frank 8b08b5ebec wip: zen 2026-03-30 18:50:09 -04:00
opencode-agent[bot] a62a96603e chore: generate 2026-03-30 21:49:45 +00:00
Kit Langton 54a250fb38 refactor: replace Filesystem util with AppFileSystem service (#20127) 2026-03-30 21:48:28 +00:00
opencode 316283c7e2 release: v1.3.8 2026-03-30 21:35:10 +00:00
opencode-agent[bot] 9e159205a5 chore: update nix node_modules hashes 2026-03-30 20:46:10 +00:00
Frank 9daddf0de2 zen: qwen3.6 plus 2026-03-30 16:43:55 -04:00
Frank 7326189450 zen: qwen3.6 plus 2026-03-30 16:32:22 -04:00
Kit Langton 045b8c8077 fix(session): restore busy route handling and add regression coverage (#20125) 2026-03-30 20:30:34 +00:00
Frank d107f5229e zen: add safety identifier 2026-03-30 16:28:40 -04:00
Jaaneek c504f5fc50 fix(Opencode): Bump ai-sdk/xai to 3.0.75 to fix tool calls (#20123)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-30 15:11:21 -05:00
opencode-agent[bot] 53c4a2d104 chore: generate 2026-03-30 20:08:09 +00:00
Kit Langton 9ee382c8c7 refactor(session): effectify SessionPrompt service (#19483) 2026-03-30 20:06:51 +00:00
Sebastian bf5e0a293e Refactor into plugin loader and do not enforce (#20112) 2026-03-30 20:36:21 +02:00
Aiden Cline 1613c9687e fix: agent value passed to chat.params and chat.headers hooks was not a string (#19996) 2026-03-30 16:32:27 +00:00
Adam 1f36ca7b39 chore(app): cleanup (#20062) 2026-03-30 08:50:42 -05:00
Sebastian 23214cc7e1 pluggable home footer (#20057) 2026-03-30 14:33:01 +02:00
Sebastian c5c8783ff3 update plugin themes when plugin was updated (#20052) 2026-03-30 13:51:07 +02:00
Jack 692840c93c fix: preserve image attachments when selecting slash commands (#19771) 2026-03-30 17:11:34 +08:00
opencode 8a34e1c134 release: v1.3.7 2026-03-30 06:06:12 +00:00
Luke Parker 51eb419de1 make variant modal less annoying (#19998) 2026-03-30 15:42:38 +10:00
Luke Parker 087bebe8d1 fix: stabilize release changelog generation (#19987) 2026-03-30 04:37:02 +00:00
Chris Yang 4595aa6e37 docs: rename patch tool to apply_patch and clarify apply_patch behavior (#19979) 2026-03-29 23:01:57 -05:00
opencode-agent[bot] 5b62dce2d8 chore: update nix node_modules hashes 2026-03-30 03:53:11 +00:00
Knut Zuidema 32f39df153 fix: discourage _noop tool call during LiteLLM compaction (#18539) 2026-03-29 22:48:17 -05:00
Luke Parker 24f7ef41d6 feat(windows): add first-class pwsh/powershell support (#16069) 2026-03-30 13:10:01 +10:00
Sebastian 3790ae221c theme colors for dialog textarea placeholders (#19939) 2026-03-29 21:37:46 +02:00
Sebastian 4e16f97f88 plugins installs should preserve jsonc comments (#19938) 2026-03-29 21:15:03 +02:00
Aiden Cline 25f1fa8d04 fix: ensure OPENCODE_DISABLE_CLAUDE_CODE_PROMPT is respected for project lvl CLAUDE.md (#19924) 2026-03-29 18:02:29 +00:00
opencode 2de8244b49 release: v1.3.6 2026-03-29 18:02:20 +00:00
ualtinok 0707c1d53c fix(session): fix token usage double-counting w/ anthropic & bedrock due to AI SDK v6 upgrade (#19758)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-03-29 12:40:10 -05:00
Ariane Emory d33a7984c8 docs: add question tool to available permissions list (#19922) 2026-03-29 17:35:52 +00:00
Frank 8c78a0b1f5 wip: zen 2026-03-29 13:06:00 -04:00
Frank 4d182a3551 wip: zen 2026-03-29 12:47:53 -04:00
Kit Langton 65a4a1be7e Fix variant dialog filtering (#19917) 2026-03-29 16:43:13 +00:00
Frank 05fc23013e ci: fix 2026-03-29 12:41:52 -04:00
Frank a2ae7ddf00 wip: zen 2026-03-29 12:04:20 -04:00
Luke Parker a434ed79e0 test: add regression coverage for sync plugin hooks (#19589) 2026-03-29 03:43:00 +00:00
opencode 9a85e52a7e release: v1.3.5 2026-03-29 03:42:52 +00:00
Aiden Cline e710e8256e core: fix plugin hooks to properly handle async operations ensuring plugins can execute async code without errors (#19586) 2026-03-29 03:15:41 +00:00
Aiden Cline 862207a3d7 tweak: adjust gpt prompt to be more minimal, fix file reference annoyances (#19585) 2026-03-28 21:32:40 -05:00
opencode 5805ed46a2 release: v1.3.4 2026-03-29 01:00:44 +00:00
Luke Parker d99d6c3427 ci: cancel stale nix-hashes runs (#19571) 2026-03-29 10:39:02 +10:00
Frank 5dea3e9850 zen: ZDR policy 2026-03-28 20:16:52 -04:00
Frank 2c5b1af7fd wip: zen 2026-03-28 20:16:51 -04:00
Frank ae16537b21 wip: zen 2026-03-28 20:16:51 -04:00
opencode-agent[bot] b60d11dfe4 chore: update nix node_modules hashes 2026-03-29 00:07:30 +00:00
Aiden Cline 705deab5c6 ignore: kill todo (#19566) 2026-03-29 00:06:32 +00:00
Caleb Norton 548f0f6475 fix: nix embedded web-ui support (#19561) 2026-03-28 18:39:22 -05:00
Caleb Norton 0672fd1794 fix: respect semver build identifiers for nix (#11915) 2026-03-28 18:39:03 -05:00
Sebastian 04f1ad33c6 prompt slot (#19563) 2026-03-29 00:27:27 +01:00
opencode-agent[bot] eed62b418b chore: update nix node_modules hashes 2026-03-28 20:46:03 +00:00
Vladimir Glafirov 06e72a147a fix: update opencode-gitlab-auth to 2.0.1 (#19552) 2026-03-28 20:10:06 +00:00
Sebastian afb0c87e41 use theme color for prompt placeholder (#19535) 2026-03-28 17:20:37 +01:00
Dax 5208f7106d feat: dialog variant menu and subagent improvements (#19537) 2026-03-28 16:16:00 +00:00
Kit Langton 4d4233cc49 refactor(session): effectify session processor (#19485) 2026-03-28 12:09:47 -04:00
Dax 2290890fa0 feat: open dialog for model variant selection instead of cycling (#19534) 2026-03-28 15:37:20 +00:00
Sebastian fa6beb5473 kv theme before default fallback (#19523) 2026-03-28 15:54:54 +01:00
opencode-agent[bot] 1869bf7685 chore: update nix node_modules hashes 2026-03-28 14:52:13 +00:00
opencode-agent[bot] eb26cb97d4 chore: generate 2026-03-28 14:26:45 +00:00
Kit Langton ec4ebd049f refactor(file): use AppFileSystem instead of raw Filesystem (#19458) 2026-03-28 10:25:46 -04:00
Sebastian de1729f6f2 upgrade opentui to 0.1.91 (#19440) 2026-03-28 15:10:59 +01:00
Dax Raad c02fd90926 tweak(session): add top spacing and remove obsolete docs prompt 2026-03-28 01:56:11 -04:00
Dax 41505c299c fix: restore subagent footer and fix style guide violations (#19491) 2026-03-28 01:46:29 -04:00
Dax 951195ef0f feat: add model variant selection dialog (#19488) 2026-03-28 01:21:28 -04:00
Dax d9742ee5db refactor(prompt): remove variant cycle display from footer (#19489) 2026-03-28 01:21:16 -04:00
Dax b22e3a9a8a refactor(session): move context into prompt footer (#19486) 2026-03-28 01:05:18 -04:00
Aiden Cline 83dccdbc23 tweak: adjust bash tool description to increase cache hit rates between projects (#19487) 2026-03-27 23:53:36 -05:00
opencode-agent[bot] 08aa47d365 chore: update nix node_modules hashes 2026-03-28 04:20:56 +00:00
Kit Langton bb0a7b9a14 update effect to 4.0.0-beta.42 (#19484) 2026-03-27 23:25:05 -04:00
Adam 6b6f76fc04 fix(app): more startup efficiency (#19454) 2026-03-28 01:26:57 +00:00
Luke Parker 0ec5aa84e2 feat(ci): use Azure Artifact Signing for Windows releases (#15201) 2026-03-28 11:20:19 +10:00
Kit Langton 0939be67d5 refactor(session): effectify SessionCompaction service (#19459) 2026-03-28 01:09:14 +00:00
Sebastian df9ae215ac Single target plugin entrypoints (#19467) 2026-03-27 23:44:46 +00:00
opencode-agent[bot] 67812f8daa chore: generate 2026-03-27 21:38:08 +00:00
Kit Langton 0ea1cc8061 refactor(format): use ChildProcessSpawner instead of Process.spawn (#19457) 2026-03-27 17:37:07 -04:00
opencode-agent[bot] 566768d3e6 chore: update nix node_modules hashes 2026-03-27 21:11:06 +00:00
James Long 0bc4e367c9 refactor(core): move more responsibility to workspace routing (#19455) 2026-03-27 16:33:56 -04:00
Kit Langton 6d793037f0 refactor(session): effectify Session service (#19449) 2026-03-27 16:25:47 -04:00
Aiden Cline 5dace6536b feat: AI SDK v6 support (#18433) 2026-03-27 15:24:30 -05:00
Sebastian 9ac652aceb no theme override in dev (#19456) 2026-03-27 20:21:15 +00:00
Aiden Cline 82cc46de18 tweak: add additional overflow error patterns (#19446) 2026-03-27 15:19:51 -05:00
Sebastian faed06b987 fix flaky plugin tests (no mock.module for bun) (#19445) 2026-03-27 20:55:03 +01:00
Kit Langton 40041ff74f fix(app): default file tree to closed with minimum width (#19426) 2026-03-27 14:11:50 -04:00
Kit Langton 439fe44d31 fix(desktop-electron): match dev dock icon inset on macOS (#19429) 2026-03-27 17:32:05 +00:00
Burak Yigit Kaya 61942e27b2 fix(app): resize layout viewport when mobile keyboard appears (#15841) 2026-03-27 11:39:13 -05:00
Shoubhit Dash c587f07b8e fix(ui): make streamed markdown feel more continuous (#19404) 2026-03-27 22:06:47 +05:30
Adam 292e435abb chore(app): more spacing controls 2026-03-27 11:22:28 -05:00
James Long 87933189d2 refactor(core): split out instance and route through workspaces (#19335) 2026-03-27 11:51:21 -04:00
Kit Langton 5aefdc6b1b effectify Plugin service internals (#19365) 2026-03-27 15:20:11 +00:00
opencode-agent[bot] cb541aa926 chore: update nix node_modules hashes 2026-03-27 14:45:53 +00:00
Kit Langton c6af9f1c8d effectify Skill service internals (#19364) 2026-03-27 10:15:51 -04:00
opencode-agent[bot] d8514a242d chore: generate 2026-03-27 14:01:28 +00:00
Sebastian 35ab6b6d40 tui plugins (#19347) 2026-03-27 15:00:26 +01:00
opencode-agent[bot] 2e9affc37b chore: generate 2026-03-27 13:53:59 +00:00
Kit Langton 628ea2edb1 refactor(tool-registry): yield Config/Plugin services, use Effect.forEach (#19363) 2026-03-27 09:53:00 -04:00
Shoubhit Dash f90b0cf93f fix(app): persist queued followups across project switches (#19421) 2026-03-27 12:02:09 +00:00
opencode-agent[bot] 4df1e8da53 chore: update nix node_modules hashes 2026-03-27 08:25:19 +00:00
Shoubhit Dash ed54dbb5ba fix(ui): keep partial markdown readable while responses stream (#19403) 2026-03-27 07:46:47 +00:00
Kit Langton d2986ff769 fix(opencode): ignore generated models snapshot files (#19362) 2026-03-27 02:21:17 +00:00
Kit Langton 9a18c42fe5 refactor(vcs): replace async git() with ChildProcessSpawner (#19361) 2026-03-27 02:14:46 +00:00
opencode-agent[bot] 67424c07cb chore: generate 2026-03-27 01:47:36 +00:00
Kit Langton fcfb201685 refactor(effect): yield services instead of promise facades (#19325) 2026-03-26 21:46:38 -04:00
Luke Parker c829848334 fix: web ui bundle build on windows (#19337) 2026-03-26 22:14:20 +00:00
Shoubhit Dash 535229440b fix(ui): reduce markdown jank while responses stream (#19304) 2026-03-26 14:43:30 -05:00
Adam b43642b5a3 fix(app): remove fork session button 2026-03-26 14:34:01 -05:00
Adam ebfbf1441d fix(app): default shell tool to collapsed 2026-03-26 14:20:30 -05:00
opencode-agent[bot] 192b5d4984 chore: generate 2026-03-26 19:03:22 +00:00
Adam 8c80f57cbe fix(app): don't bundle fonts (#19329) 2026-03-26 14:02:01 -05:00
opencode-agent[bot] 030f2f8214 chore: update nix node_modules hashes 2026-03-26 18:45:12 +00:00
opencode-agent[bot] da7d9e58aa chore: generate 2026-03-26 18:42:59 +00:00
Adam 6edeb30bc7 fix(app): more startup perf (#19288) 2026-03-26 13:41:22 -05:00
Kit Langton fd8c163a0f fix(mcp): close transport on failed/timed-out connections (#19200) 2026-03-26 14:41:00 -04:00
Kit Langton 3e5d76b1f8 refactor(config): use cachedInvalidateWithTTL, bump effect to beta.37 (#19322) 2026-03-26 18:05:47 +00:00
opencode-agent[bot] a782b3cdae chore: generate 2026-03-26 17:08:33 +00:00
Kit Langton d2185b44eb effectify Config service (#19139) 2026-03-26 13:07:01 -04:00
James Long e2706b7d41 feat(core): remove workspace server, WorkspaceContext, start work towards better routing (#19316) 2026-03-26 12:30:26 -04:00
Aiden Cline 332e29dcbe feat: add gpt prompt so non codex gpt models have their own system prompt modeled after codex cli (#19220) 2026-03-26 15:57:38 +00:00
opencode-agent[bot] 05fb8688fe chore: generate 2026-03-26 15:49:53 +00:00
Kit Langton de7589e425 refactor(lsp): effectify LSP service with InstanceState (#19150) 2026-03-26 11:48:36 -04:00
Dax Raad e70ab2cf7a changelog ci tweaks 2026-03-26 11:13:13 -04:00
opencode-agent[bot] 57956d8af4 chore: generate 2026-03-26 15:05:29 +00:00
opencode 6fcb906efb release: v1.3.3 2026-03-26 15:05:21 +00:00
Dax 8780f12bee feat: embed WebUI in binary with proxy flags (#19299)
Co-authored-by: BlankParticle <blankparticle@gmail.com>
2026-03-26 14:43:56 +00:00
Dax 7257eadebe tui: bypass local SSE event streaming in worker (#19183) 2026-03-26 14:41:36 +00:00
opencode-agent[bot] fbc6dd9478 chore: generate 2026-03-26 14:16:07 +00:00
Shoubhit Dash b8222bf1fb revert: roll back git-backed review modes (#19295) 2026-03-26 10:14:57 -04:00
Aiden Cline c60b0f3722 fix: ensure enterprise url is set properly during auth flow (#19212) 2026-03-25 23:59:53 -05:00
Frank 81c1967c73 go: do not respect disabled zen models 2026-03-26 00:47:23 -04:00
Frank f9364ce9a9 wip: zen 2026-03-26 00:47:23 -04:00
Frank d43a48d899 wip: zen 2026-03-26 00:47:23 -04:00
opencode-agent[bot] 25986e96a1 chore: update nix node_modules hashes 2026-03-26 02:14:16 +00:00
Luke Parker 121c8b3151 fix(opencode): image paste on Windows Terminal 1.25+ with kitty keyboard (#17674) 2026-03-26 12:00:38 +10:00
Kit Langton 8a25104177 test: restore 5 workers on Windows e2e (#19188) 2026-03-25 21:28:18 -04:00
Vladimir Glafirov fdd1aabbef fix: bump gitlab-ai-provider to 5.3.3 for DWS tool approval support (#19185) 2026-03-26 01:17:51 +00:00
opencode-agent[bot] 6b8166a777 chore: generate 2026-03-26 00:56:34 +00:00
Kit Langton 86fc2389ce skill: use Effect.cached for load deduplication (#19165) 2026-03-26 00:55:43 +00:00
Aiden Cline a0524d1dc9 ignore: update disavowed list (#19184) 2026-03-26 00:20:43 +00:00
Kit Langton 4619073179 file: use Effect.cached for scan deduplication (#19164) 2026-03-25 20:19:58 -04:00
Kit Langton 9ccef679d7 effectify Bus service: migrate to Effect PubSub + InstanceState (#18579) 2026-03-25 20:19:24 -04:00
Kit Langton 96baa14467 fix+refactor(mcp): lifecycle tests, cancelPending fix, Effect migration (#19042) 2026-03-25 20:15:05 -04:00
Kit Langton 0035f89a6b fix: Windows e2e stability (CrossSpawnSpawner, snapshot isolation, session race guards) (#19163) 2026-03-25 19:49:14 -04:00
Adam 2c8e02088f fix(app): agent normalization (#19169) 2026-03-25 20:56:10 +00:00
Ariane Emory 73d1c700d0 fix(task): respect agent permission config for todowrite tool (#19125) 2026-03-25 15:18:52 -05:00
André Cruz 8016c14695 fix(opencode): classify ZlibError from Bun fetch as retryable instead of unknown (#19104)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-25 13:08:40 -05:00
Adam 22edd61301 chore: storybook tweaks 2026-03-25 11:30:41 -05:00
Aiden Cline 78829463ec chore: remove dead code for todoread tool (#19128)
publish-github-action / publish (push) Waiting to run
2026-03-25 16:21:42 +00:00
Adam 1a233e5928 chore: cleanup 2026-03-25 11:15:34 -05:00
opencode-agent[bot] dc3ca3bd3d chore: update nix node_modules hashes 2026-03-25 16:08:30 +00:00
opencode-agent[bot] ef5b7f07b5 chore: generate 2026-03-25 15:40:53 +00:00
Shoubhit Dash 973ad436e1 feat: restore git-backed review modes with effectful git service (#18900) 2026-03-25 21:09:53 +05:30
Adam 4a7091c988 chore: storybook tweaks 2026-03-25 10:20:19 -05:00
Aiden Cline 8b0654dbd5 chore: bump modelcontextprotocol/sdk to 1.27.1 (#19064) 2026-03-25 10:00:39 -05:00
opencode-agent[bot] a53518d64b chore: generate 2026-03-25 14:48:44 +00:00
James Long 87d41cd5ac feat(core): initial implementation of syncing (#17814) 2026-03-25 10:47:40 -04:00
Adam 04da047809 chore(app): markdown playground in storyboard 2026-03-25 09:14:35 -05:00
Adam 0684cdc468 chore(app): markdown playground in storyboard 2026-03-25 09:14:35 -05:00
Adam 00c9fd819e fix(app): opencode web server url 2026-03-25 06:41:00 -05:00
Adam 870a2f8773 Reapply "fix(app): startup efficiency"
This reverts commit 24f93b627b.
2026-03-25 06:25:57 -05:00
Adam 24f93b627b Revert "fix(app): startup efficiency" 2026-03-25 06:25:05 -05:00
Adam 7957a49217 fix(app): hash inline script for csp 2026-03-25 05:59:06 -05:00
Adam c21949be00 Reapply "fix(app): more startup efficiency (#18985)"
This reverts commit e514bfd49e.
2026-03-25 05:59:06 -05:00
Adam eba2f67227 Reapply "fix(app): startup efficiency (#18854)"
This reverts commit 311a58a8c8.
2026-03-25 05:59:05 -05:00
Shoubhit Dash 66444a700a fix(app): move message navigation off cmd+arrow (#18728) 2026-03-25 05:24:55 -05:00
Brendan Allan 42b1d9f9eb app: pre-warm project globalSync state when navigate project via keybind (#19088) 2026-03-25 17:32:49 +08:00
Brendan Allan d5b2a6ac72 electron: remove file extension from electron-store wrapper (#19082) 2026-03-25 07:57:27 +00:00
Brendan Allan 0f8d7f1f5c electron: add createDirectory to open directory picker (#19071) 2026-03-25 06:25:51 +00:00
Luke Parker 81c4d85eb7 fix: unblock beta conflict recovery (#19068) 2026-03-25 06:14:38 +00:00
Luke Parker 8a987ee885 fix: beta resolver typecheck + build smoke check (#19060) 2026-03-25 05:45:30 +00:00
Aiden Cline bba2963719 tweak: only spawn lsp servers for files in current instance (or cwd if instance is global) (#19058) 2026-03-25 00:31:29 -05:00
Luke Parker 110c3bd372 fix: provide merge context to beta conflict resolver (#19055) 2026-03-25 04:45:37 +00:00
Dax 771508a2d4 fix(opencode): avoid snapshotting files over 2MB (#19043) 2026-03-25 04:43:48 +00:00
Dax Raad d01a4e4d28 Change issue close reason from not_planned to completed 2026-03-24 23:55:10 -04:00
Dax Raad 468eec0ed8 Process issues sequentially to avoid rate limits 2026-03-24 23:54:27 -04:00
Dax Raad ede2f19985 Fix close-issues workflow permissions
- Add contents: read permission for checkout
- Use github.token instead of secrets.GITHUB_TOKEN
2026-03-24 23:51:46 -04:00
Dax Raad 32c5aabdb9 Add close-issues script and GitHub Action
- Create script/github/close-issues.ts to close stale issues after 60 days
- Add GitHub Action workflow to run daily at 2 AM
- Remove old stale-issues workflow to avoid conflicts
2026-03-24 23:50:35 -04:00
Dax Raad 14d65e9c2b fix: increase operations-per-run to 1000 and pin stale action to v10.2.0
The stale-issues workflow was hitting the default 30 operations limit,
preventing it from processing all 2900+ issues/PRs. Increased to 1000
to handle the full backlog. Also pinned to exact v10.2.0 for reproducibility.
2026-03-24 23:38:15 -04:00
Kit Langton 8610313b6a effectify Worktree service (#18679) 2026-03-24 20:26:21 -04:00
Adam 311a58a8c8 Revert "fix(app): startup efficiency (#18854)"
This reverts commit 824d903e86.
2026-03-24 18:36:37 -05:00
Adam e514bfd49e Revert "fix(app): more startup efficiency (#18985)"
This reverts commit dcca493ece.
2026-03-24 18:36:25 -05:00
Kit Langton 57bc933abd fix(opencode): skip typechecking generated models snapshot (#19018) 2026-03-24 19:11:45 -04:00
opencode 84de2c4832 release: v1.3.2 2026-03-24 22:50:35 +00:00
Dax Raad 268a777129 change model for changelog 2026-03-24 18:25:52 -04:00
Dax Raad ff043c204a ci 2026-03-24 18:24:34 -04:00
Dax 16a38009ee feat(tui): add heap snapshot functionality for TUI and server (#19028) 2026-03-24 18:20:11 -04:00
opencode-agent[bot] e426ed3e9a chore: update nix node_modules hashes 2026-03-24 22:07:32 +00:00
Jay V 1d7e22d716 tweak: use theme tokens for debug bar surface 2026-03-24 22:07:32 +00:00
opencode 1158f4f6a5 release: v1.3.1 2026-03-24 22:07:24 +00:00
Dax 50ac413272 fix(core): restore SIGHUP exit handler (#16057) (#18527) 2026-03-24 17:42:58 -04:00
Kamil Jopek d858439803 feat: add Poe OAuth auth plugin (#18477) 2026-03-24 16:17:47 -05:00
Adam fbac9617ff fix(app): session timeline jumping on scroll (#18993) 2026-03-24 13:51:09 -05:00
opencode-agent[bot] c2c4a476ef chore: generate 2026-03-24 18:43:20 +00:00
Nicholas Hansen 948aad44c8 feat(bedrock): Add token caching for any amazon-bedrock provider (#18959) 2026-03-24 13:42:20 -05:00
opencode-agent[bot] 235f8771bf chore: update nix node_modules hashes 2026-03-24 18:40:28 +00:00
Adam 22e7516a47 fix(app): sidebar ux 2026-03-24 13:35:20 -05:00
Adam dcca493ece fix(app): more startup efficiency (#18985) 2026-03-24 13:23:41 -05:00
Aiden Cline 67a30121a9 chore: effectify agent.ts (#18971)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-03-24 18:15:23 +00:00
Caleb Norton 2465e8d47d fix: nix hash update parsing... again (#18989) 2026-03-24 13:06:46 -05:00
opencode-agent[bot] 156f7fdb1a chore: generate 2026-03-24 18:05:43 +00:00
Adam 70289b7236 fix(app): sidebar truncation 2026-03-24 13:04:32 -05:00
Kit Langton a6ed355ed1 effectify Project service (#18808) 2026-03-24 14:04:22 -04:00
Ryan Skidmore fbbd8c5ee8 fix: improve plugin system robustness — agent/command resolution, async errors, hook timing, two-phase init (#18280)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-24 12:50:55 -05:00
Caleb Norton 0299ab17fa chore: update flake.lock (#18976)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-24 12:50:25 -05:00
Vladimir Glafirov c5e80e7454 fix: route GitLab Duo Workflow system prompt via flowConfig (#18928) 2026-03-24 12:33:18 -05:00
Caleb Norton 2a10f0d418 fix: nix hash update parsing (#18979) 2026-03-24 12:32:48 -05:00
opencode-agent[bot] d68d4ef387 chore: generate 2026-03-24 17:05:02 +00:00
Kit Langton 9519186313 fix: remove flaky cross-spawn spawner tests (#18977) 2026-03-24 13:04:04 -04:00
Jack fb1f1b24fb fix: update Feishu community links for zh locales (#18975) 2026-03-25 01:03:01 +08:00
opencode-agent[bot] a83ebab843 chore: update nix node_modules hashes 2026-03-24 16:43:24 +00:00
Caleb Norton 973103d552 fix: better nix hash detection (#18957) 2026-03-24 11:30:39 -05:00
Kit Langton 7fcc8d6ad5 fix: restore cross-spawn behavior for effect child processes (#18798) 2026-03-24 10:35:24 -04:00
Adam 824d903e86 fix(app): startup efficiency (#18854) 2026-03-24 09:10:24 -05:00
Burak Yigit Kaya cde5e4b899 fix(ui): eliminate N+1 reactive subscriptions in SessionTurn (#18924) 2026-03-24 09:02:22 -05:00
Burak Yigit Kaya 035f9d9e6d fix(app): use optional chaining for model.current() in ProviderIcon (#18927) 2026-03-24 09:01:58 -05:00
Burak Yigit Kaya 32e89036ec fix(app): filter non-renderable part types from browser store (#18926) 2026-03-24 09:01:25 -05:00
Shoubhit Dash 751bc0a117 fix(app): stop terminal autofocus on shortcuts (#18931) 2026-03-24 11:16:16 +00:00
Sebastian 62b3869f0d manually lock/unlock theme mode (#18905) 2026-03-24 10:00:19 +01:00
Frank 4d24f97b2f sync 2026-03-24 01:00:20 -04:00
opencode-agent[bot] bc2cdbc319 chore: update nix node_modules hashes 2026-03-24 03:14:22 +00:00
Vladimir Glafirov abc6d9d2ca chore: bump gitlab-ai-provider to 5.3.1 for GPT-5.4 model support (#18849) 2026-03-23 22:00:36 -05:00
Frank f6e8d78e6d wip: zen 2026-03-23 22:24:58 -04:00
Frank 476da1afbf Zen: adjust cache tokens 2026-03-23 20:33:11 -04:00
Sebastian 4a96259b39 use renderer theme mode to switch dark/light mode (#18851) 2026-03-24 00:32:48 +01:00
opencode-agent[bot] ffa297cdeb chore: generate 2026-03-23 17:12:55 +00:00
Kit Langton 9530074d34 fix console account URL handling (#18809) 2026-03-23 13:11:38 -04:00
Frank 39c617e0a7 ci: fix 2026-03-23 12:47:42 -04:00
Aiden Cline 3484d75370 tweak: add back setting user agent in requests (#18795) 2026-03-23 15:34:59 +00:00
Abhishek Keshri 1dc24d4b38 feat(tui): add syntax highlighting for kotlin, hcl, lua, toml (#18198) 2026-03-23 16:15:24 +01:00
David Hill 72835aee86 tui: keep file tree open at its minimum resized width (#18777) 2026-03-23 20:06:43 +08:00
Brendan Allan 1bcf0e8e34 fix(app): only navigate prompt history when input is empty (#18775) 2026-03-23 11:48:34 +00:00
opencode-agent[bot] 131cca1fcd chore: update nix node_modules hashes 2026-03-23 10:52:56 +00:00
opencode-agent[bot] 988f548fc9 chore: update nix node_modules hashes 2026-03-23 10:37:23 +00:00
opencode-agent[bot] 32be4661d5 chore: generate 2026-03-23 10:35:30 +00:00
Brendan Allan bda7879e25 fix(app): handle session busy state better (#18758) 2026-03-23 10:34:32 +00:00
Brendan Allan 21ed03a5dc fix(app): lift up project hover state to layout (#18732) 2026-03-23 08:58:20 +00:00
Brendan Allan e457633e93 electron: more robust sidecar kill handling (#18742) 2026-03-23 08:44:23 +00:00
Shoubhit Dash ac936c9ee0 fix(app): prefer cmd+k for command palette (#18731) 2026-03-23 08:00:24 +00:00
Shoubhit Dash 23d37d87dc fix(app): prevent stale session hover preview on refocus (#18727) 2026-03-23 07:25:30 +00:00
Shoubhit Dash 7cc9d15cc6 fix(app): batch multi-file prompt attachments (#18722) 2026-03-23 06:44:17 +00:00
github-actions[bot] 7de12e7f29 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/18718#issuecomment-4108322776
2026-03-23 06:28:47 +00:00
Luke Parker 440a7693f4 Add 'write' role to vouch manage action (#18718) 2026-03-23 06:27:35 +00:00
James Long fa54dd86f7 fix(core): fix file watcher test (#18698) 2026-03-23 03:35:17 +00:00
opencode-agent[bot] 14c98756df chore: update nix node_modules hashes 2026-03-23 01:03:20 +00:00
Sebastian 3ab06799e4 Upgrade opentui to 0.1.90 (#18551) 2026-03-23 01:45:34 +01:00
David Hill 4e9cf57b60 tui: keep patch tool counts visible with long filenames (#18678) 2026-03-23 00:45:11 +00:00
Luke Parker 233dd46542 fix(app): restore keyboard project switching in open sidebar (#18682) 2026-03-23 00:39:46 +00:00
opencode-agent[bot] b42f24b16d chore: update nix node_modules hashes 2026-03-23 00:29:45 +00:00
David Hill c9c025d3b9 tweak(ui): fix padding bottom on the context tab (#18680) 2026-03-23 00:23:45 +00:00
David Hill c606f956f8 tui: prevent project avatar popover flicker when switching projects (#18660)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-23 10:20:49 +10:00
Luke Parker d56481b2d1 chore: bump Bun to 1.3.11 (#18144) 2026-03-23 10:19:21 +10:00
Dax Raad 08da7fd04b changelog slash command 2026-03-22 19:45:23 -04:00
opencode-agent[bot] c7d639bea8 chore: generate 2026-03-22 23:32:09 +00:00
opencode 4e248b3830 release: v1.3.0 2026-03-22 23:32:01 +00:00
Dax a65e6119ad feat: interactive update flow for non-patch releases (#18662) 2026-03-22 23:12:40 +00:00
David Hill e765ccb0e3 fix(ui): stop auto close of sidebar on resize (#18647) 2026-03-23 08:53:12 +10:00
Filip ce6f9bccb4 fix(app): terminal rename from context menu (#18263)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-03-22 04:39:51 +00:00
Kit Langton 36f76fdf16 effectify Pty service (#18572) 2026-03-22 01:17:13 +00:00
Kit Langton 22d942cba2 effectify ToolRegistry service (#18571) 2026-03-22 01:06:01 +00:00
opencode-agent[bot] 919ab3e898 chore: generate 2026-03-22 00:51:19 +00:00
Kit Langton 6fcdb273eb effectify Plugin service (#18570) 2026-03-22 00:50:22 +00:00
Kit Langton 65d4fcaf4e effectify Command service (#18568) 2026-03-22 00:21:41 +00:00
Kit Langton ccbd2936d2 effectify SessionStatus service (#18565) 2026-03-21 23:55:07 +00:00
opencode-agent[bot] 05e7b6e5a8 chore: update nix node_modules hashes 2026-03-21 19:17:12 +00:00
Sebastian f9fd9c27bd Upgrade opentui to 0.1.88 (#18079) 2026-03-21 19:00:42 +00:00
Protocol Zero 73bff070e1 fix(provider): only set thinkingConfig for models with reasoning capability (#18283) 2026-03-21 11:57:52 -05:00
opencode-agent[bot] d234f92039 chore: update nix node_modules hashes 2026-03-21 15:47:15 +00:00
opencode-agent[bot] 80342b9679 chore: generate 2026-03-21 15:34:05 +00:00
Brendan Allan 34f9f99462 app: manage mutation loading states with tanstack query (#18501) 2026-03-21 23:33:04 +08:00
Brendan Allan 9a68318aaa app: allow navigating projects with keybinds (#18502) 2026-03-21 22:13:09 +08:00
opencode-agent[bot] 69bbc9f1cd chore: generate 2026-03-21 13:12:54 +00:00
Dax Raad 2c47881bdf fix annotation 2026-03-21 09:11:15 -04:00
Frank a42b2a0ddb wip: zen 2026-03-21 04:07:51 -04:00
opencode-agent[bot] fd9a27ae04 chore: generate 2026-03-21 04:52:23 +00:00
Kit Langton b6aa472839 Move service state into InstanceState, flatten service facades (#18483) 2026-03-21 04:51:35 +00:00
opencode-agent[bot] 2a81d70eb0 chore: generate 2026-03-21 03:11:28 +00:00
Kit Langton 45a3b2ca05 fix(zen): emit cost chunk in client-facing format, not upstream format (#16817) 2026-03-20 23:10:34 -04:00
Dax Raad cb807850b5 allow customizing DB location 2026-03-20 22:49:55 -04:00
Aiden Cline 71ccf72898 ignore: add danieljoshuanazareth to disavow list (#18476) 2026-03-20 21:34:07 -04:00
github-actions[bot] 1477804b1c Update VOUCHED list
https://github.com/anomalyco/opencode/issues/18464#issuecomment-4101766628
2026-03-21 01:28:40 +00:00
Dax Raad d2273f3e88 ci 2026-03-20 17:00:05 -04:00
Kit Langton 9eafdc92ec fix: switch consumers to service imports to break bundle cycles (#18438) 2026-03-20 20:55:46 +00:00
Dax Raad f891192f86 commit and push 2026-03-20 16:36:19 -04:00
Dax Raad 1953f5cfa5 fix question cross out 2026-03-20 15:50:04 -04:00
Kit Langton 0457745593 fix: update stale account url/email on re-login (#18426) 2026-03-20 14:50:01 -04:00
opencode-agent[bot] 8dcdcdad90 chore: generate 2026-03-20 18:38:08 +00:00
Kit Langton 42a5ceb048 fix: apply Layer.fresh at instance service definition site (#18418) 2026-03-20 14:37:12 -04:00
opencode-agent[bot] cef5ab2e54 chore: update nix node_modules hashes 2026-03-20 18:16:17 +00:00
Vladimir Glafirov 1fb2ef5ec2 feat: enable GitLab Agent Platform with workflow model discovery (#18014) 2026-03-20 12:55:22 -05:00
opencode-agent[bot] d79132c5c1 chore: generate 2026-03-20 16:11:21 +00:00
Kit Langton c71771dcf2 effectify Installation service, drop Effect suffix from namespaces (#18266) 2026-03-20 12:10:33 -04:00
Aiden Cline 371aca7729 tweak: adjust codex plugin logic so that codex instruction isn't always added (oauth plan no longer enforces instruction whitelisting) (#18337) 2026-03-20 10:37:47 -05:00
MC bf916a653c docs: add Cloudflare Workers AI provider (#18322)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-20 10:10:22 -05:00
Brendan Allan bec6f54109 app: file type filter on desktop + multiple files (#18403) 2026-03-20 15:02:07 +00:00
Shoubhit Dash 884e6c4b26 fix(app): align review file comments with diff comments (#18406) 2026-03-20 14:44:18 +00:00
Luke Parker 4ccb32aca8 deslopity deslopity (#18343) 2026-03-20 05:24:27 +00:00
opencode-agent[bot] 205df45a9b chore: update nix node_modules hashes 2026-03-20 05:14:32 +00:00
Brendan Allan 6c7d5eaaab patch solid to try fix memo undefined under transition bug (#18338) 2026-03-20 14:58:35 +10:00
Luke Parker 763c116502 fix: lots of desktop stability, better e2e error logging (#18300) 2026-03-20 00:12:06 -04:00
Luke Parker ebbb4124b8 fix: avoid truncate permission import cycle (#18292) 2026-03-19 23:52:04 -04:00
opencode-agent[bot] 16a0abc970 chore: update nix node_modules hashes 2026-03-20 02:21:29 +00:00
Dax f07cdc7072 fix: miscellaneous small fixes (#18328) 2026-03-19 22:20:29 -04:00
Jaaneek 5126f77686 feat: switch xai provider to responses API (#18175)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-19 21:09:49 -05:00
opencode-agent[bot] 6875f2bad8 chore: update nix node_modules hashes 2026-03-20 01:36:22 +00:00
Dax 8158f21835 feat: add Node.js entry point and build script (#18324) 2026-03-19 21:35:07 -04:00
Dax ce01d92ef8 fix: include cache bin directory in which() lookups (#18320) 2026-03-19 21:21:55 -04:00
Dax ff945d8632 refactor: replace Bun shell execution with portable Process utilities (#18318) 2026-03-19 21:17:06 -04:00
Dax ed094f2196 refactor: abstract SQLite behind runtime-conditional #db import (#18316) 2026-03-19 21:15:35 -04:00
Dax fb1b5328d7 refactor(tui): replace Bun-specific APIs with portable alternatives (#18304) 2026-03-19 19:32:59 -04:00
Frank 8570353f0c doc: update translator to gpt model 2026-03-19 19:07:02 -04:00
Dax 0848f630b2 chore: inline tool descriptions, remove separate .txt files (#18303) 2026-03-19 19:02:42 -04:00
Frank 9d48e1e13c go: upi translation 2026-03-19 18:54:32 -04:00
Dax 8ecde3ae5f refactor: replace Bun.sleep with node:timers/promises sleep (#18301) 2026-03-19 18:50:40 -04:00
Frank 7a43e617fb go: upi payment 2026-03-19 18:44:24 -04:00
opencode-agent[bot] 922a8abb7c chore: generate 2026-03-19 20:21:10 +00:00
Kit Langton c6bc3028a6 fix(account): handle pending console login polling (#18281) 2026-03-19 16:18:28 -04:00
opencode-agent[bot] aead63e1db chore: generate 2026-03-19 20:05:33 +00:00
jorge g 8fa53ae5a5 fix: stabilize agent and skill ordering in prompt descriptions (#18261)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-19 15:04:03 -05:00
James Long b9929cc644 Fix base64Decode import in workspaces.spec.ts (#18274) 2026-03-19 15:38:54 -04:00
James Long 159096c4d5 fix(core): disable chunk timeout by default (#18264) 2026-03-19 14:30:08 -04:00
opencode-agent[bot] ccd4d75ba4 chore: generate 2026-03-19 17:52:50 +00:00
James Long 32a14ba2ea fix(core): use a queue to process events in event routes (#18259) 2026-03-19 13:51:14 -04:00
opencode-agent[bot] d2beddb249 chore: generate 2026-03-19 16:16:29 +00:00
Aiden Cline d210d67f1e ignore: revert 3 commits that broke dev branch (#18260) 2026-03-19 11:15:07 -05:00
Kit Langton 35dffce18d fix forked prompt attachments losing file parts (#17815) 2026-03-19 12:00:32 -04:00
Shoubhit Dash 7d4b36643a fix(app): show review on the empty session route (#18251) 2026-03-19 19:51:32 +05:30
opencode-agent[bot] b32c51b146 chore: generate 2026-03-19 14:00:32 +00:00
Shoubhit Dash 1725f7bb2e feat: add git-backed session review modes (#17961) 2026-03-19 19:29:29 +05:30
Brendan Allan c3ec0622de app: fix workspace flicker when switching directories (#18207)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-19 19:29:14 +05:30
Brendan Allan ec90203990 electron: lazily read updater enabled 2026-03-19 21:47:47 +08:00
OpeOginni 6d61ebeff8 fix(desktop): remote server switching (#17214)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-03-19 13:32:11 +00:00
Andrew Maguire 50ccbaa571 fix(app): ignore repeated Enter submits in prompt input (#18148)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-19 11:53:44 +00:00
opencode-agent[bot] afdc427871 chore: generate 2026-03-19 04:46:39 +00:00
Dax a84103459a anthropic legal requests (#18186) 2026-03-19 04:45:24 +00:00
Aiden Cline 6a9ff35baf feat: integrate multistep auth flows into desktop app (#18103) 2026-03-19 13:47:51 +10:00
Kit Langton ed45bd6de1 fix(session): preserve tagged error messages (#18165) 2026-03-18 20:36:53 -04:00
Frank fd5599bd87 zen: add mimo pro/omni models 2026-03-18 20:28:41 -04:00
opencode-agent[bot] a241f5ae26 chore: update nix node_modules hashes 2026-03-19 00:08:27 +00:00
opencode-agent[bot] a65004283f chore: update nix node_modules hashes 2026-03-19 00:07:30 +00:00
Luke Parker fcedc54b02 fix: restore recent test regressions and upgrade effect beta (#18158) 2026-03-19 09:54:01 +10:00
Kit Langton c325ac5b5b feat(filesystem): add AppFileSystem service, migrate Snapshot (#18138) 2026-03-18 19:52:43 -04:00
opencode-agent[bot] a8274af78b chore: generate 2026-03-18 23:45:03 +00:00
Kit Langton ae15341ffe chore: type Provider.list() as Record<ProviderID, Info>, delete dead code (#18123) 2026-03-18 19:43:12 -04:00
opencode-agent[bot] 2e4326bb36 chore: update nix node_modules hashes 2026-03-18 23:03:55 +00:00
Luke Parker 2728950929 fix(windows): use cross-spawn for shim-backed commands (#18010) 2026-03-19 08:49:16 +10:00
Aiden Cline 6d979838b6 tweak: remove unnecessary parts from the fallback system prompt (#18140) 2026-03-18 17:27:47 -05:00
Frank dd43bafeb0 docs: update go models 2026-03-18 17:48:39 -04:00
Frank 8cdff61352 fix: docs 2026-03-18 16:30:33 -04:00
Frank b8795208f0 zen: minimax m2.7 2026-03-18 14:30:55 -04:00
Frank 651e672db3 zen: minimax m2.7 2026-03-18 14:15:00 -04:00
opencode-agent[bot] 4af0e6d5d2 chore: generate 2026-03-18 17:37:40 +00:00
Kit Langton 05f98c0e42 refactor(effect): unify service namespaces and align naming (#18093) 2026-03-18 13:34:36 -04:00
Aiden Cline 5ece9ea6a1 feat: integrate support for multi step auth flows for providers that require additional questions (#18035) 2026-03-18 11:36:19 -05:00
Aiden Cline 3a89645257 tweak: update gpt subscription model list (#18101) 2026-03-18 10:51:39 -05:00
Frank 5c21abc6c4 zen: fix routing non OC traffic 2026-03-18 10:59:56 -04:00
OpeOginni 55cb0a2ab0 fix(desktop): fix error handling by adding errorName function to identify NotFoundError rather than statusCode (#17591) 2026-03-18 19:27:56 +05:30
David Hill 2952da2736 tweak(ui): theme overrides (#17958) 2026-03-18 19:26:32 +05:30
David Hill 40792c96c7 tui: make the title bar search easier to scan without a redundant icon 2026-03-18 13:52:57 +00:00
David Hill ab8bd02a48 Revert "tui: clean up search button in session header by removing magnifying glass icon and excess padding"
This reverts commit b22cc3eac2.
2026-03-18 13:52:36 +00:00
David Hill b22cc3eac2 tui: clean up search button in session header by removing magnifying glass icon and excess padding 2026-03-18 13:49:58 +00:00
opencode-agent[bot] 486e797f81 chore: generate 2026-03-18 12:58:46 +00:00
OpeOginni 2499e69b88 docs(cli): update experimental TY LSP flag description for clarity across multiple languages (#14770) 2026-03-18 18:27:42 +05:30
Brendan Allan 3a1f2f7905 app: remove debug text 2026-03-18 17:02:23 +08:00
Brendan Allan d8199b91d7 app: replace autoselect effects with single resource 2026-03-18 17:01:38 +08:00
Brendan Allan a1353899bc app: initialize command catalog more efficiently
cuts down load times by like 30-50%
2026-03-18 14:46:53 +08:00
Frank b5503f1c86 zen: gpt 5.4 mini and nano 2026-03-18 02:12:30 -04:00
Johannes Loher ca7cb89cb0 fix(core): detect vLLM context overflow errors (#17763)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-17 23:52:16 -05:00
Frank 5cc809edcd wip: zen 2026-03-17 23:31:14 -04:00
Frank d4c69eec62 wip: zen 2026-03-17 22:50:54 -04:00
Ryan Vogel 23791c6657 feat(enterprise): contact form now pushes to salesforce 🙄 (#17964)
Co-authored-by: slickstef11 <stefan@wundergraph.com>
Co-authored-by: Frank <frank@anoma.ly>
2026-03-17 22:43:43 -04:00
opencode-agent[bot] 855a0d72a2 chore: generate 2026-03-18 02:01:02 +00:00
Kit Langton 2a6909cd07 refactor(truncation): effectify TruncateService, delete Scheduler (#17957) 2026-03-17 21:59:54 -04:00
Ariane Emory 8f3cb8b609 fix: Add apply_patch to EDIT_TOOLS filter (#18009) 2026-03-17 20:11:42 -05:00
opencode-agent[bot] 4cee904375 chore: generate 2026-03-18 01:05:16 +00:00
Kit Langton 0782c757be refactor(snapshot): effectify SnapshotService (#17878) 2026-03-17 21:04:16 -04:00
Kit Langton 90f66d356d add prompt schema validation debug logs (#17812) 2026-03-17 19:18:16 -04:00
Frank 7afb3e3de5 wip: zen 2026-03-17 19:06:22 -04:00
Frank edd365a417 zen: add missing model lab names 2026-03-17 18:41:38 -04:00
Frank ea856eb472 zen: error logging 2026-03-17 16:53:10 -04:00
David Hill 96ecc010bc tweak(ui): add an empty state to the sidebar when no projects (#17971)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-17 19:54:14 +00:00
Frank eca94bcba5 wip: zen 2026-03-17 15:00:44 -04:00
Frank 7cb5dc27eb zen: deprecate gemini 3 pro 2026-03-17 12:31:54 -04:00
OpeOginni a5f0b7be4e docs: mark tools config as deprecated (#17951) 2026-03-17 10:07:35 -05:00
Shoubhit Dash b21adc16ee fix: inline review comment submit and layout (#17948) 2026-03-17 19:54:20 +05:30
Brendan Allan 728d8c72df app: inherit owner when creating prompt session 2026-03-17 19:47:06 +08:00
opencode-agent[bot] 8e8276dfca chore: generate 2026-03-17 11:15:35 +00:00
Luke Parker 1ed580cef1 fix(app): avoid prompt tooltip Switch on startup (#17857) 2026-03-17 06:14:30 -05:00
opencode-agent[bot] 666ad5b9b2 chore: generate 2026-03-17 03:47:33 +00:00
Kyle Altendorf 8d8c2d645c docs: add describe annotation to snapshot config field (#17861)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-16 22:46:09 -05:00
Aiden Cline 2dd6e169a1 chore: denounce ai spammer (#17901) 2026-03-16 22:38:15 -05:00
Luke Parker 80358a80f7 test(app): deflake slash terminal toggle flow (#17881) 2026-03-17 12:55:58 +10:00
Luke Parker 1a7c45c770 test(opencode): deflake file and tool timing (#17859) 2026-03-17 00:49:04 +00:00
Kyle Altendorf 31f5c3eb93 fix(tui): check for selected text instead of any selection in dialog escape handler (#16779) 2026-03-17 10:25:03 +10:00
opencode-agent[bot] 47e83d74a9 chore: update nix node_modules hashes 2026-03-17 00:05:19 +00:00
opencode-agent[bot] 9cdef4e301 chore: update nix node_modules hashes 2026-03-16 23:54:30 +00:00
opencode-agent[bot] 508f5def47 chore: generate 2026-03-16 23:52:10 +00:00
Kit Langton cbb2e9b0f4 refactor(skill): effectify SkillService as scoped service (#17849) 2026-03-16 23:51:07 +00:00
AbigailJixiangyuyu 4c056c026d fix(windows): restore /editor support on Windows (#17146) 2026-03-17 08:11:02 +10:00
Aiden Cline fb33a71539 fix: github copilot enterprise integration (#17847) 2026-03-16 17:05:14 -05:00
opencode-agent[bot] 7256de81d3 chore: generate 2026-03-16 20:19:50 +00:00
Kit Langton 3355d71a20 refactor(file): effectify FileService as scoped service (#17845) 2026-03-16 16:18:39 -04:00
opencode-agent[bot] 036f3dc22e chore: generate 2026-03-16 19:59:49 +00:00
Kit Langton c3de254735 refactor(format): effectify FormatService as scoped service (#17675) 2026-03-16 15:58:36 -04:00
opencode-agent[bot] febd77140d chore: generate 2026-03-16 18:24:21 +00:00
Kit Langton 184fc0b796 refactor(file-time): effectify FileTimeService with Semaphore locks (#17835) 2026-03-16 18:23:13 +00:00
opencode-agent[bot] 3831fdaac4 chore: generate 2026-03-16 18:00:18 +00:00
Kit Langton 9edca88956 fix+refactor(vcs): fix HEAD filter bug and effectify VcsService (#17829) 2026-03-16 13:59:11 -04:00
opencode-agent[bot] 387cb0d2d2 chore: generate 2026-03-16 17:19:44 +00:00
Kit Langton 6696517665 stack: effectify-file-watcher-service (#17827) 2026-03-16 13:18:40 -04:00
opencode-agent[bot] fbfb2fc514 chore: generate 2026-03-16 16:56:12 +00:00
Kit Langton c4ae100b70 refactor(instance): move scoped services to LayerMap (#17544) 2026-03-16 12:55:14 -04:00
DS 0d9fe65a58 fix(opencode): apply message transforms during compaction (#17823) 2026-03-16 11:32:53 -05:00
Johannes Loher 3202268123 fix(core): consider code: context_length_exceeded as context overflow in API call errors (#17748) 2026-03-16 10:25:24 -05:00
Michal Šlesár 0a292ad568 fix(app): agent switch should not reset thinking level (#17470) 2026-03-16 19:43:29 +05:30
Kit Langton 70d3e316b3 fix(cli): scope active org labels to the active account (#16957) 2026-03-16 10:01:42 -04:00
Shoubhit Dash d8dc149251 Wrap question option descriptions instead of truncating (#17782) 2026-03-16 11:29:18 +00:00
Luke Parker 3a91561593 fix(ci): workaround by using hoisted Bun linker on Windows (#17751) 2026-03-16 08:39:06 +00:00
Luke Parker 157f5755ac fix(opencode): teach Kit's test what an ID is (#17745) 2026-03-16 18:08:27 +10:00
Jason Quense b04934aa25 fix(opencode): preserve prompt tool enables with empty agent permissions (#17064)
Co-authored-by: jquense <jquense@ramp.com>
2026-03-15 23:01:46 -05:00
opencode 575159f225 release: v1.2.27 2026-03-16 02:33:48 +00:00
Aiden Cline 02f3ba0600 fix: vcs watcher if statement (#17673) 2026-03-15 19:20:39 -05:00
Erik Engervall 393aa80cae docs: Add opencode-firecrawl to ecosystem documentation (#17672) 2026-03-15 18:56:24 -05:00
Frank 57f814c579 zen: update claude prices 2026-03-15 10:54:40 -04:00
David Hill 382c63bb3f tweak(ui): remove open label (#17512) 2026-03-15 08:25:40 -05:00
Orlando Ascanio 5f32c5f8a0 docs(es): fix Spanish intro page translation, grammar, and terminology (#17563) 2026-03-15 08:22:32 -05:00
Kit Langton b216fd0062 chore(permission): delete legacy permission module (#17534) 2026-03-14 20:56:52 -05:00
Kit Langton 7c49041c37 fix(question): clean up pending entry on abort (#17533) 2026-03-15 00:49:49 +00:00
Sebastian ba8ff3a7b8 remove sighup exit (#17254) 2026-03-15 00:52:28 +01:00
opencode-agent[bot] 8e49012eec chore: generate 2026-03-14 18:29:06 +00:00
Kit Langton 90618eda13 refactor(permission): effectify PermissionNext + fix InstanceState ALS bug (#17511) 2026-03-14 18:28:00 +00:00
Shoubhit Dash 72fed20caa fix(app): handle multiline web paste in prompt composer (#17509) 2026-03-14 22:51:45 +05:30
Kit Langton 641b426f7b refactor(schema): inline branded ID schemas (#17504) 2026-03-14 16:14:46 +00:00
opencode-agent[bot] cab828c262 chore: generate 2026-03-14 15:59:01 +00:00
Kit Langton 4df2577034 refactor(question): effectify QuestionService (#17432) 2026-03-14 11:58:00 -04:00
Aiden Cline a2f522c8c5 tweak: ensure that compaction message is tracked as agent initiated (#17431) 2026-03-14 10:46:24 -05:00
James Long 726caa9bd2 fix(core): increase default chunk timeout from 2 min to 5 min (#17490) 2026-03-14 14:27:06 +00:00
Marcus Schiesser 53e8eccbf2 fix(ui): prevent long filenames from overlapping actions (#17151) 2026-03-13 16:17:15 -05:00
Michael Dwan 3ee10d49a5 fix(opencode): lost sessions across worktrees and orphan branches (#16389)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-13 15:51:55 -04:00
David Hill 3e4f676789 tui: update compaction status message to use Session instead of History across all languages
The compaction message now correctly indicates the current session was compacted rather than the entire history, making it clearer to users which conversation data was optimized.
2026-03-13 16:33:01 +00:00
opencode bc499c9aeb release: v1.2.26 2026-03-13 16:32:53 +00:00
Adam a65fb9a7a9 fix(app): model selection persist by session (#17348) 2026-03-13 11:05:08 -05:00
David Hill 4a931d8a4f fix(app): polish prompt composer controls (#17388) 2026-03-13 10:48:10 -05:00
Adam 9146b837b1 fix(app): sidebar sync 2026-03-13 10:47:45 -05:00
David Hill ee7a5685bd tweak(ui): use new-session icon in sidebar buttons 2026-03-13 10:18:08 -05:00
David Hill e67b88cca2 fix(app): avoid clipping new session during sidebar anim 2026-03-13 10:18:03 -05:00
David Hill 170e5777c2 fix(app): animate titlebar controls on sidebar open 2026-03-13 10:17:56 -05:00
David Hill 524f5de7c2 fix(app): restore sidebar dash and sync session spinner colors (#17384) 2026-03-13 10:08:23 -05:00
Kit Langton 39cee82010 feat(schema): scaffold effect-to-zod bridge (#17273) 2026-03-13 10:59:57 -04:00
Adam 1fefd34c54 fix(app): fork should copy prompt into new session (#17375) 2026-03-13 09:59:11 -05:00
Jack fd986f015a fix(ui): force wasm highlighter for markdown code blocks (#17373) 2026-03-13 09:57:14 -05:00
David Hill 03069399bc fix(app): sidebar spacing + session list spinner transition (#17355) 2026-03-13 09:19:02 -05:00
Adam 4b5fd4683a fix(app): terminal cloning without retry (#17354) 2026-03-13 08:56:48 -05:00
Adam 7e0a596cf0 fix(app): scroll falls behind prompt input 2026-03-13 08:39:42 -05:00
Adam e43f0ff375 fix(app): tooltip quirks 2026-03-13 08:38:32 -05:00
Adam 052e3fecdf fix(app): todo spacing 2026-03-13 07:43:50 -05:00
Adam a559ef332b fix(app): better optimistic prompt submit (#17337) 2026-03-13 07:38:03 -05:00
Shoubhit Dash e7bccd6d12 fix(opencode): serialize config bun installs (#17342) 2026-03-13 17:58:00 +05:30
Adam 9b5acb0919 fix(app): oc-2 went too dark 2026-03-13 07:25:42 -05:00
Adam ac0640f4ca fix(app): support text attachments (#17335) 2026-03-13 06:58:24 -05:00
Adam 01dea97657 chore(app): i18n sync (#17283) 2026-03-13 06:48:38 -05:00
Adam cff676ae63 chore: cleanup (#17284) 2026-03-13 06:27:58 -05:00
Shoubhit Dash 0e3cb695d5 perf(app): use cursor session history loading (#17329) 2026-03-13 16:43:41 +05:30
David Hill 440a8d9290 Remove prompt mode toggle (#17216) 2026-03-13 05:38:34 -05:00
opencode-agent[bot] 864e46215e chore: generate 2026-03-13 10:19:52 +00:00
Shoubhit Dash 984e0b66ad perf(server): paginate session history (#17134) 2026-03-13 15:48:43 +05:30
Adam d62c4c41fe fix(app): simplify themes (#17274) 2026-03-13 10:12:58 +00:00
Frank 26c12a7898 go: upi pay 2026-03-13 03:24:33 -04:00
Aiden Cline b9fc95af48 chore: cleanup migrate from global code (#17292) 2026-03-12 23:54:11 -05:00
Michael Dwan f02ea4d115 fix(opencode): sessions lost after git init in existing project (#16814)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-12 23:18:59 -05:00
Luke Parker bf6b51e4aa fix(e2e): fail fast on config dependency installs (#17280) 2026-03-13 14:08:51 +10:00
Adam 11a0607d93 fix(app): list item background colors 2026-03-12 22:23:51 -05:00
Adam 11ef4651a4 feat(app): missing themes (#17275) 2026-03-12 22:21:02 -05:00
Tom Ryder 16edfe662d fix: filter empty content blocks for Bedrock provider (#14586) 2026-03-12 22:13:09 -05:00
opencode-agent[bot] 2327da1c18 chore: generate 2026-03-13 02:33:45 +00:00
Kit Langton d3b47c2390 fix(cli): hide console command from help output (#17277) 2026-03-12 22:22:16 -04:00
Luke Parker 8ea7713f54 fix(app): stabilize todo dock e2e with composer probe (#17267) 2026-03-13 12:21:50 +10:00
opencode-agent[bot] 2ebfc38700 chore: generate 2026-03-13 02:10:27 +00:00
Kit Langton 35f8e9ca55 refactor(provider): effectify ProviderAuthService (#17227) 2026-03-13 02:08:37 +00:00
Brendan Allan 9175eeca8f desktop: multi-window support in electron (#17155) 2026-03-13 09:18:27 +08:00
Kit Langton 9077726a47 feat(cli): add console account subcommands (#17265) 2026-03-13 00:56:40 +00:00
Kit Langton 4a4b0edc48 refactor(auth): effectify AuthService (#17212) 2026-03-12 20:43:24 -04:00
Aiden Cline d318add4a0 tweak: rm openrouter warning (#17259) 2026-03-12 19:42:31 -05:00
Luke Parker 0ae4558ce9 fix(app): increase CI e2e workers (#17263) 2026-03-13 10:15:34 +10:00
Kit Langton a14e43828f reorder provider list in providers login (#17262) 2026-03-13 00:09:30 +00:00
Adam 68fc806548 feat(app): interruption state 2026-03-12 19:07:23 -05:00
Luke Parker f6c30b85cf fix(ci): keep test runs on dev (#17260) 2026-03-13 09:54:34 +10:00
1466 changed files with 183016 additions and 87666 deletions
+11
View File
@@ -10,6 +10,10 @@
adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-borealbytes
-danieljoshuanazareth
-danieljoshuanazareth
edemaine
-florianleibert
fwang
@@ -17,7 +21,14 @@ iamdavidhill
jayair
kitlangton
kommander
-opencode2026
-opencodeengineer bot that spams issues
r44vc0rp
rekram1-node
-ricardo-m-l
-robinmordasiewicz
shantur
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
-toastythebot
+9 -1
View File
@@ -41,5 +41,13 @@ runs:
shell: bash
- name: Install dependencies
run: bun install
run: |
# Workaround for patched peer variants
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --linker hoisted
else
bun install
fi
shell: bash
+24
View File
@@ -0,0 +1,24 @@
name: close-issues
on:
schedule:
- cron: "0 2 * * *" # Daily at 2:00 AM
workflow_dispatch:
jobs:
close:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Close stale issues
env:
GITHUB_TOKEN: ${{ github.token }}
run: bun script/github/close-issues.ts
+1 -1
View File
@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
deploy:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
+3 -2
View File
@@ -9,7 +9,8 @@ on:
jobs:
sync-locales:
if: github.actor != 'opencode-agent[bot]'
if: false
#if: github.actor != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
@@ -34,7 +35,7 @@ jobs:
- name: Compute changed English docs
id: changes
run: |
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true)
if [ -z "$FILES" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No English docs changed in push range"
+5 -1
View File
@@ -17,6 +17,10 @@ on:
- "patches/**"
- ".github/workflows/nix-hashes.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Native runners required: bun install cross-compilation flags (--os/--cpu)
# do not produce byte-identical node_modules as native installs.
@@ -56,7 +60,7 @@ jobs:
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
# Extract hash from build log with portability
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM}"
+185 -2
View File
@@ -98,15 +98,129 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: opencode-cli
path: packages/opencode/dist
path: |
packages/opencode/dist/opencode-darwin*
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-windows:
needs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
steps:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Azure login
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: azure/artifact-signing-action@v1
with:
endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
files: |
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
exclude-environment-credential: true
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
exclude-shared-token-cache-credential: true
exclude-visual-studio-credential: true
exclude-visual-studio-code-credential: true
exclude-azure-cli-credential: false
exclude-azure-powershell-credential: true
exclude-azure-developer-cli-credential: true
exclude-interactive-browser-credential: true
- name: Verify Windows CLI signatures
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
)
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- name: Repack Windows CLI archives
working-directory: packages/opencode/dist
shell: pwsh
run: |
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
- name: Upload signed Windows CLI release assets
if: needs.version.outputs.release != ''
shell: pwsh
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
gh release upload "v${{ needs.version.outputs.version }}" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
--clobber `
--repo "${{ needs.version.outputs.repo }}"
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-signed-windows
path: |
packages/opencode/dist/opencode-windows-arm64
packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline
build-tauri:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
@@ -152,6 +266,14 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
@@ -190,6 +312,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -246,11 +369,35 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
- name: Verify signed Windows desktop artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
)
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
build-electron:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
@@ -275,7 +422,6 @@ jobs:
target: aarch64-unknown-linux-gnu
platform_flag: --linux
runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps:
- uses: actions/checkout@v3
@@ -292,6 +438,14 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
@@ -326,6 +480,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -358,6 +513,22 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @()
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName
foreach ($file in $files | Select-Object -Unique) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- uses: actions/upload-artifact@v4
with:
name: opencode-electron-${{ matrix.settings.target }}
@@ -373,8 +544,10 @@ jobs:
needs:
- version
- build-cli
- sign-cli-windows
- build-tauri
- build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
@@ -411,6 +584,16 @@ jobs:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release
with:
-54
View File
@@ -1,54 +0,0 @@
name: sign-cli
on:
push:
branches:
- brendan/desktop-signpath
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
sign-cli:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: ./.github/actions/setup-bun
- name: Build
run: |
./packages/opencode/script/build.ts
- name: Upload unsigned Windows CLI
id: upload_unsigned_windows_cli
uses: actions/upload-artifact@v4
with:
name: unsigned-opencode-windows-cli
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
if-no-files-found: error
- name: Submit SignPath signing request
id: submit_signpath_signing_request
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_KEY }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed-opencode-cli
- name: Upload signed Windows CLI
uses: actions/upload-artifact@v4
with:
name: signed-opencode-windows-cli
path: signed-opencode-cli/*.exe
if-no-files-found: error
-33
View File
@@ -1,33 +0,0 @@
name: stale-issues
on:
schedule:
- cron: "30 1 * * *" # Daily at 1:30 AM
workflow_dispatch:
env:
DAYS_BEFORE_STALE: 90
DAYS_BEFORE_CLOSE: 7
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v10
with:
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
stale-issue-label: "stale"
close-issue-message: |
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
Feel free to reopen if you still need this!
stale-issue-message: |
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
remove-stale-when-updated: true
exempt-issue-labels: "pinned,security,feature-request,on-hold"
start-date: "2025-12-27"
+74 -9
View File
@@ -8,11 +8,17 @@ on:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Keep every run on dev so cancelled checks do not pollute the default branch
# commit history. PRs and other branches still share a group and cancel stale runs.
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
cancel-in-progress: true
permissions:
contents: read
checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
unit:
@@ -35,6 +41,11 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
@@ -43,25 +54,53 @@ jobs:
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests
run: bun turbo test
run: bun turbo test:ci
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e:
name: e2e (${{ matrix.settings.name }})
needs: unit
strategy:
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows
host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }}
env:
PLAYWRIGHT_BROWSERS_PATH: 0
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
@@ -71,26 +110,52 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install Playwright browsers
- name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
working-directory: packages/app
run: ${{ matrix.settings.playwright }}
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local
env:
CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30
- name: Upload Playwright artifacts
if: failure()
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
if-no-files-found: ignore
retention-days: 7
path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results
packages/app/e2e/playwright-report
+1 -1
View File
@@ -33,6 +33,6 @@ jobs:
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
+2 -1
View File
@@ -17,7 +17,7 @@ ts-dist
/result
refs
Session.vim
opencode.json
/opencode.json
a.out
target
.scripts
@@ -25,6 +25,7 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
logs/
*.bun-build
tsconfig.tsbuildinfo
+5 -2
View File
@@ -1,4 +1,7 @@
plans/
bun.lock
node_modules
plans
package.json
bun.lock
.gitignore
package-lock.json
references/
-34
View File
@@ -1,34 +0,0 @@
---
description: ALWAYS use this when writing docs
color: "#38A3EE"
---
You are an expert technical documentation writer
You are not verbose
Use a relaxed and friendly tone
The title of the page should be a word or a 2-3 word phrase
The description should be one short line, should not start with "The", should
avoid repeating the title of the page, should be 5-10 words long
Chunks of text should not be more than 2 sentences long
Each section is separated by a divider of 3 dashes
The section titles are short with only the first letter of the word capitalized
The section titles are in the imperative mood
The section titles should not repeat the term used in the page title, for
example, if the page title is "Models", avoid using a section title like "Add
new models". This might be unavoidable in some cases, but try to avoid it.
Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
For JS or TS code snippets remove trailing semicolons and any trailing commas
that might not be needed.
If you are making a commit prefix the commit message with `docs:`
+1 -1
View File
@@ -1,7 +1,7 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gemini-3-pro
model: opencode/gpt-5.4
---
You are a professional translator and localization specialist.
+46
View File
@@ -0,0 +1,46 @@
---
model: opencode/gpt-5.4
---
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
Do not preserve, merge, or reuse text from the existing file.
The input already contains the exact commit range since the last non-draft release.
The commits are already filtered to the release-relevant packages and grouped into
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
The input may also include a `## Community Contributors Input` section.
Before writing any entry you keep, inspect the real diff with
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
understand the actual code changes and not just the commit message (they may be misleading).
Do not use `git log` or author metadata when deciding attribution.
Rules:
- Write the final file with sections in this order:
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry
- Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter
- Prefer what changed for users over what code changed internally
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
- If an input bullet has no `(@username)` suffix, do not add one
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
- Do not derive the thank-you section from the main summary bullets
- Do not include the heading `## Community Contributors Input` in the final file
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
<changelog_input>
!`bun script/raw-changelog.ts $ARGUMENTS`
</changelog_input>
+223
View File
@@ -0,0 +1,223 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"nord0": "#2E3440",
"nord1": "#3B4252",
"nord2": "#434C5E",
"nord3": "#4C566A",
"nord4": "#D8DEE9",
"nord5": "#E5E9F0",
"nord6": "#ECEFF4",
"nord7": "#8FBCBB",
"nord8": "#88C0D0",
"nord9": "#81A1C1",
"nord10": "#5E81AC",
"nord11": "#BF616A",
"nord12": "#D08770",
"nord13": "#EBCB8B",
"nord14": "#A3BE8C",
"nord15": "#B48EAD"
},
"theme": {
"primary": {
"dark": "nord10",
"light": "nord9"
},
"secondary": {
"dark": "nord9",
"light": "nord9"
},
"accent": {
"dark": "nord7",
"light": "nord7"
},
"error": {
"dark": "nord11",
"light": "nord11"
},
"warning": {
"dark": "nord12",
"light": "nord12"
},
"success": {
"dark": "nord14",
"light": "nord14"
},
"info": {
"dark": "nord8",
"light": "nord10"
},
"text": {
"dark": "nord6",
"light": "nord0"
},
"textMuted": {
"dark": "#8B95A7",
"light": "nord1"
},
"background": {
"dark": "nord0",
"light": "nord6"
},
"backgroundPanel": {
"dark": "nord1",
"light": "nord5"
},
"backgroundElement": {
"dark": "nord2",
"light": "nord4"
},
"border": {
"dark": "nord2",
"light": "nord3"
},
"borderActive": {
"dark": "nord3",
"light": "nord2"
},
"borderSubtle": {
"dark": "nord2",
"light": "nord3"
},
"diffAdded": {
"dark": "nord14",
"light": "nord14"
},
"diffRemoved": {
"dark": "nord11",
"light": "nord11"
},
"diffContext": {
"dark": "#8B95A7",
"light": "nord3"
},
"diffHunkHeader": {
"dark": "#8B95A7",
"light": "nord3"
},
"diffHighlightAdded": {
"dark": "nord14",
"light": "nord14"
},
"diffHighlightRemoved": {
"dark": "nord11",
"light": "nord11"
},
"diffAddedBg": {
"dark": "#36413C",
"light": "#E6EBE7"
},
"diffRemovedBg": {
"dark": "#43393D",
"light": "#ECE6E8"
},
"diffContextBg": {
"dark": "nord1",
"light": "nord5"
},
"diffLineNumber": {
"dark": "nord2",
"light": "nord4"
},
"diffAddedLineNumberBg": {
"dark": "#303A35",
"light": "#DDE4DF"
},
"diffRemovedLineNumberBg": {
"dark": "#3C3336",
"light": "#E4DDE0"
},
"markdownText": {
"dark": "nord4",
"light": "nord0"
},
"markdownHeading": {
"dark": "nord8",
"light": "nord10"
},
"markdownLink": {
"dark": "nord9",
"light": "nord9"
},
"markdownLinkText": {
"dark": "nord7",
"light": "nord7"
},
"markdownCode": {
"dark": "nord14",
"light": "nord14"
},
"markdownBlockQuote": {
"dark": "#8B95A7",
"light": "nord3"
},
"markdownEmph": {
"dark": "nord12",
"light": "nord12"
},
"markdownStrong": {
"dark": "nord13",
"light": "nord13"
},
"markdownHorizontalRule": {
"dark": "#8B95A7",
"light": "nord3"
},
"markdownListItem": {
"dark": "nord8",
"light": "nord10"
},
"markdownListEnumeration": {
"dark": "nord7",
"light": "nord7"
},
"markdownImage": {
"dark": "nord9",
"light": "nord9"
},
"markdownImageText": {
"dark": "nord7",
"light": "nord7"
},
"markdownCodeBlock": {
"dark": "nord4",
"light": "nord0"
},
"syntaxComment": {
"dark": "#8B95A7",
"light": "nord3"
},
"syntaxKeyword": {
"dark": "nord9",
"light": "nord9"
},
"syntaxFunction": {
"dark": "nord8",
"light": "nord8"
},
"syntaxVariable": {
"dark": "nord7",
"light": "nord7"
},
"syntaxString": {
"dark": "nord14",
"light": "nord14"
},
"syntaxNumber": {
"dark": "nord15",
"light": "nord15"
},
"syntaxType": {
"dark": "nord7",
"light": "nord7"
},
"syntaxOperator": {
"dark": "nord9",
"light": "nord9"
},
"syntaxPunctuation": {
"dark": "nord4",
"light": "nord0"
}
}
}
+937
View File
@@ -0,0 +1,937 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type {
TuiKeybindSet,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
modal: "ctrl+shift+m",
screen: "ctrl+shift+o",
home: "escape,ctrl+h",
left: "left,h",
right: "right,l",
up: "up,k",
down: "down,j",
alert: "a",
confirm: "c",
prompt: "p",
select: "s",
modal_accept: "enter,return",
modal_close: "escape",
dialog_close: "escape",
local: "x",
local_push: "enter,return",
local_close: "q,backspace",
host: "z",
}
const pick = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback
if (!value.trim()) return fallback
return value
}
const num = (value: unknown, fallback: number) => {
if (typeof value !== "number") return fallback
return value
}
const rec = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return
return Object.fromEntries(Object.entries(value))
}
type Cfg = {
label: string
route: string
vignette: number
keybinds: Record<string, unknown> | undefined
}
type Route = {
modal: string
screen: string
}
type State = {
tab: number
count: number
source: string
note: string
selected: string
local: number
}
const cfg = (options: Record<string, unknown> | undefined) => {
return {
label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: rec(options?.keybinds),
}
}
const names = (input: Cfg) => {
return {
modal: `${input.route}.modal`,
screen: `${input.route}.screen`,
}
}
type Keys = TuiKeybindSet
const ui = {
panel: "#1d1d1d",
border: "#4a4a4a",
text: "#f0f0f0",
muted: "#a5a5a5",
accent: "#5f87ff",
}
type Color = RGBA | string
const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
const value = map[name]
if (typeof value === "string") return value
if (value instanceof RGBA) return value
return fallback
}
const look = (map: Record<string, unknown>) => {
return {
panel: ink(map, "backgroundPanel", ui.panel),
border: ink(map, "border", ui.border),
text: ink(map, "text", ui.text),
muted: ink(map, "textMuted", ui.muted),
accent: ink(map, "primary", ui.accent),
selected: ink(map, "selectedListItemText", ui.text),
}
}
const tone = (api: TuiPluginApi) => {
return look(api.theme.current)
}
type Skin = {
panel: Color
border: Color
text: Color
muted: Color
accent: Color
selected: Color
}
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
return (
<box
onMouseUp={() => {
props.run()
}}
backgroundColor={props.on ? props.skin.accent : props.skin.border}
paddingLeft={1}
paddingRight={1}
>
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
</box>
)
}
const parse = (params: Record<string, unknown> | undefined) => {
const tab = typeof params?.tab === "number" ? params.tab : 0
const count = typeof params?.count === "number" ? params.count : 0
const source = typeof params?.source === "string" ? params.source : "unknown"
const note = typeof params?.note === "string" ? params.note : ""
const selected = typeof params?.selected === "string" ? params.selected : ""
const local = typeof params?.local === "number" ? params.local : 0
return {
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
count,
source,
note,
selected,
local: Math.max(0, local),
}
}
const current = (api: TuiPluginApi, route: Route) => {
const value = api.route.current
const ok = Object.values(route).includes(value.name)
if (!ok) return parse(undefined)
if (!("params" in value)) return parse(undefined)
return parse(value.params)
}
const opts = [
{
title: "Overview",
value: 0,
description: "Switch to overview tab",
},
{
title: "Counter",
value: 1,
description: "Switch to counter tab",
},
{
title: "Help",
value: 2,
description: "Switch to help tab",
},
]
const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{input.label} host overlay</b>
</text>
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
<box flexDirection="row" gap={1}>
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
</box>
</box>
))
}
const warn = (api: TuiPluginApi, route: Route, value: State) => {
const DialogAlert = api.ui.DialogAlert
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogAlert
title="Smoke alert"
message="Testing built-in alert dialog"
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
/>
))
}
const check = (api: TuiPluginApi, route: Route, value: State) => {
const DialogConfirm = api.ui.DialogConfirm
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogConfirm
title="Smoke confirm"
message="Apply +1 to counter?"
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
/>
))
}
const entry = (api: TuiPluginApi, route: Route, value: State) => {
const DialogPrompt = api.ui.DialogPrompt
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogPrompt
title="Smoke prompt"
value={value.note}
onConfirm={(note) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
}}
onCancel={() => {
api.ui.dialog.clear()
api.route.navigate(route.screen, value)
}}
/>
))
}
const picker = (api: TuiPluginApi, route: Route, value: State) => {
const DialogSelect = api.ui.DialogSelect
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogSelect
title="Smoke select"
options={opts}
current={value.tab}
onSelect={(item) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, {
...value,
tab: typeof item.value === "number" ? item.value : value.tab,
selected: item.title,
source: "select",
})
}}
/>
))
}
const Screen = (props: {
api: TuiPluginApi
input: Cfg
route: Route
keys: Keys
meta: TuiPluginMeta
params?: Record<string, unknown>
}) => {
const dim = useTerminalDimensions()
const value = parse(props.params)
const skin = tone(props.api)
const set = (local: number, base?: State) => {
const next = base ?? current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
}
const push = (base?: State) => {
const next = base ?? current(props.api, props.route)
set(next.local + 1, next)
}
const open = () => {
const next = current(props.api, props.route)
if (next.local > 0) return
set(1, next)
}
const pop = (base?: State) => {
const next = base ?? current(props.api, props.route)
const local = Math.max(0, next.local - 1)
set(local, next)
}
const show = () => {
setTimeout(() => {
open()
}, 0)
}
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (props.api.ui.dialog.open) {
if (props.keys.match("dialog_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.ui.dialog.clear()
return
}
return
}
if (next.local > 0) {
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
pop(next)
return
}
if (props.keys.match("local_push", evt)) {
evt.preventDefault()
evt.stopPropagation()
push(next)
return
}
return
}
if (props.keys.match("home", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return
}
if (props.keys.match("left", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
return
}
if (props.keys.match("right", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
return
}
if (props.keys.match("up", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (props.keys.match("down", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (props.keys.match("modal", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (props.keys.match("local", evt)) {
evt.preventDefault()
evt.stopPropagation()
open()
return
}
if (props.keys.match("host", evt)) {
evt.preventDefault()
evt.stopPropagation()
host(props.api, props.input, skin)
return
}
if (props.keys.match("alert", evt)) {
evt.preventDefault()
evt.stopPropagation()
warn(props.api, props.route, next)
return
}
if (props.keys.match("confirm", evt)) {
evt.preventDefault()
evt.stopPropagation()
check(props.api, props.route, next)
return
}
if (props.keys.match("prompt", evt)) {
evt.preventDefault()
evt.stopPropagation()
entry(props.api, props.route, next)
return
}
if (props.keys.match("select", evt)) {
evt.preventDefault()
evt.stopPropagation()
picker(props.api, props.route, next)
}
})
return (
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
<box
flexDirection="column"
width="100%"
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
>
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
<text fg={skin.text}>
<b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span>
</text>
<text fg={skin.muted}>{props.keys.print("home")} home</text>
</box>
<box flexDirection="row" gap={1} paddingBottom={1}>
{tabs.map((item, i) => {
const on = value.tab === i
return (
<Btn
txt={item}
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
skin={skin}
on={on}
/>
)
})}
</box>
<box
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexGrow={1}
>
{value.tab === 0 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Route: {props.route.screen}</text>
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
<text fg={skin.muted}>
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
</text>
<text fg={skin.muted}>plugin source: {props.meta.source}</text>
<text fg={skin.muted}>source: {value.source}</text>
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
<text fg={skin.muted}>local stack depth: {value.local}</text>
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
</box>
) : null}
{value.tab === 1 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}>
{props.keys.print("up")} / {props.keys.print("down")} change value
</text>
</box>
) : null}
{value.tab === 2 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.muted}>
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
</text>
<text fg={skin.muted}>
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
</text>
<text fg={skin.muted}>
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
close
</text>
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
</box>
) : null}
</box>
<box flexDirection="row" gap={1} paddingTop={1}>
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
<Btn txt="local overlay" run={show} skin={skin} />
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
</box>
</box>
<box
visible={value.local > 0}
width={dim().width}
height={dim().height}
alignItems="center"
position="absolute"
zIndex={3000}
paddingTop={dim().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
onMouseUp={() => {
pop()
}}
>
<box
onMouseUp={(evt) => {
evt.stopPropagation()
}}
width={60}
maxWidth={dim().width - 2}
backgroundColor={skin.panel}
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
flexDirection="column"
>
<text fg={skin.text}>
<b>{props.input.label} local overlay</b>
</text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<text fg={skin.muted}>
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
</text>
<box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on />
<Btn txt="pop" run={pop} skin={skin} />
</box>
</box>
</box>
</box>
)
}
const Modal = (props: {
api: TuiPluginApi
input: Cfg
route: Route
keys: Keys
params?: Record<string, unknown>
}) => {
const Dialog = props.api.ui.Dialog
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.modal) return
if (props.keys.match("modal_accept", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
return
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
}
})
return (
<box width="100%" height="100%" backgroundColor={skin.panel}>
<Dialog onClose={() => props.api.route.navigate("home")}>
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{props.input.label} modal</b>
</text>
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
<text fg={skin.muted}>
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
</text>
<box flexDirection="row" gap={1}>
<Btn
txt="open screen"
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
skin={skin}
on
/>
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
</box>
</box>
</Dialog>
</box>
)
}
const home = (api: TuiPluginApi, input: Cfg) => ({
slots: {
home_logo(ctx) {
const map = ctx.theme.current
const skin = look(map)
const art = [
" $$\\",
" $$ |",
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
]
const fill = [
skin.accent,
skin.muted,
ink(map, "info", ui.accent),
skin.text,
ink(map, "success", ui.accent),
ink(map, "warning", ui.accent),
ink(map, "secondary", ui.accent),
ink(map, "error", ui.accent),
]
return (
<box flexDirection="column">
{art.map((line, i) => (
<text fg={fill[i]}>{line}</text>
))}
</box>
)
},
home_prompt(ctx, value) {
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
type Slot = (
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
) => JSX.Element | null
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
const Prompt = ui.Prompt
const Slot = ui.Slot
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify prompt-right slot passthrough",
]
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
const hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}></span> smoke home prompt
</text>
</box>
)
return (
<Prompt
workspaceID={value.workspace_id}
hint={hint}
right={
<box flexDirection="row" gap={1}>
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
</box>
}
placeholders={{ normal, shell }}
/>
)
},
home_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = value.workspace_id?.slice(0, 8) ?? "none"
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
</text>
)
},
session_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
</text>
)
},
smoke_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
const label = typeof value.label === "string" ? value.label : input.label
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
</text>
)
},
home_bottom(ctx) {
const skin = look(ctx.theme.current)
const text = "extra content in the unified home bottom slot"
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
width="100%"
>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> {text}
</text>
</box>
</box>
)
},
},
})
const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
order,
slots: {
sidebar_content(ctx, value) {
const skin = look(ctx.theme.current)
return (
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="column"
gap={1}
>
<text fg={skin.accent}>
<b>{title}</b>
</text>
<text fg={skin.text}>{text}</text>
<text fg={skin.muted}>
{input.label} order {order} · session {value.session_id.slice(0, 8)}
</text>
</box>
)
},
},
})
const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
home(api, input),
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
]
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
const route = names(input)
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: keys.get("modal"),
category: "Plugin",
slash: {
name: "smoke",
},
onSelect: () => {
api.route.navigate(route.modal, { source: "command" })
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: keys.get("screen"),
category: "Plugin",
slash: {
name: "smoke-screen",
},
onSelect: () => {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
},
},
{
title: `${input.label} alert dialog`,
value: "plugin.smoke.alert",
category: "Plugin",
slash: {
name: "smoke-alert",
},
onSelect: () => {
warn(api, route, current(api, route))
},
},
{
title: `${input.label} confirm dialog`,
value: "plugin.smoke.confirm",
category: "Plugin",
slash: {
name: "smoke-confirm",
},
onSelect: () => {
check(api, route, current(api, route))
},
},
{
title: `${input.label} prompt dialog`,
value: "plugin.smoke.prompt",
category: "Plugin",
slash: {
name: "smoke-prompt",
},
onSelect: () => {
entry(api, route, current(api, route))
},
},
{
title: `${input.label} select dialog`,
value: "plugin.smoke.select",
category: "Plugin",
slash: {
name: "smoke-select",
},
onSelect: () => {
picker(api, route, current(api, route))
},
},
{
title: `${input.label} host overlay`,
value: "plugin.smoke.host",
category: "Plugin",
slash: {
name: "smoke-host",
},
onSelect: () => {
host(api, input, tone(api))
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: api.route.current.name !== "home",
onSelect: () => {
api.route.navigate("home")
},
},
{
title: `${input.label} toast`,
value: "plugin.smoke.toast",
category: "Plugin",
onSelect: () => {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
])
}
const tui: TuiPlugin = async (api, options, meta) => {
if (options?.enabled === false) return
await api.theme.install("./smoke-theme.json")
api.theme.set("smoke-theme")
const value = cfg(options ?? undefined)
const route = names(value)
const keys = api.keybind.create(bind, value.keybinds)
const fx = new VignetteEffect(value.vignette)
const post = fx.apply.bind(fx)
api.renderer.addPostProcessFn(post)
api.lifecycle.onDispose(() => {
api.renderer.removePostProcessFn(post)
})
api.route.register([
{
name: route.screen,
render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
},
{
name: route.modal,
render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
},
])
reg(api, value, keys)
for (const item of slot(api, value)) {
api.slots.register(item)
}
}
const plugin: TuiPluginModule & { id: string } = {
id: "tui-smoke",
tui,
}
export default plugin
+21
View File
@@ -0,0 +1,21 @@
---
name: effect
description: Answer questions about the Effect framework
---
# Effect
This codebase uses Effect, a framework for writing typescript.
## How to Answer Effect Questions
1. Clone the Effect repository: `https://github.com/Effect-TS/effect-smol` to
`.opencode/references/effect-smol` in this project NOT the skill folder.
2. Use the explore agent to search the codebase for answers about Effect patterns, APIs, and concepts
3. Provide responses based on the actual Effect source code and documentation
## Guidelines
- Always use the explore agent with the cloned repository when answering Effect-related questions
- Reference specific files and patterns found in the Effect codebase
- Do not answer from memory - always verify against the source
+1
View File
@@ -0,0 +1 @@
smoke-theme.json
+2 -2
View File
@@ -116,8 +116,8 @@
"light": "nord5"
},
"diffLineNumber": {
"dark": "nord2",
"light": "nord4"
"dark": "#abafb7",
"light": "textMuted"
},
"diffAddedLineNumberBg": {
"dark": "#3B4252",
+10 -3
View File
@@ -1,7 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
@@ -24,7 +22,16 @@ interface PR {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
-10
View File
@@ -1,10 +0,0 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
+6 -3
View File
@@ -1,7 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
@@ -40,7 +38,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
-6
View File
@@ -1,6 +0,0 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": false,
"label": "workspace",
"keybinds": {
"modal": "ctrl+alt+m",
"screen": "ctrl+alt+o",
"home": "escape,ctrl+shift+h",
"dialog_close": "escape,q"
}
}
]
]
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
"categories": {
"suspicious": "warn"
},
"rules": {
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
"require-yield": "off",
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
"no-unassigned-vars": "off",
// SolidJS tracks reactive deps by reading properties inside createEffect
"no-unused-expressions": "off",
// Intentional control char matching (ANSI escapes, null byte sanitization)
"no-control-regex": "off",
// SST and plugin tools require triple-slash references
"triple-slash-reference": "off",
// Suspicious category: suppress noisy rules
// Effect's nested function* closures inherently shadow outer scope
"no-shadow": "off",
// Namespace-heavy codebase makes this too noisy
"unicorn/consistent-function-scoping": "off",
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
"unicorn/no-array-sort": "off",
"unicorn/no-array-reverse": "off",
// Not relevant — this isn't a DOM event handler codebase
"unicorn/prefer-add-event-listener": "off",
// Bundler handles module resolution
"unicorn/require-module-specifiers": "off",
// postMessage target origin not relevant for this codebase
"unicorn/require-post-message-target-origin": "off",
// Side-effectful constructors are intentional in some places
"no-new": "off",
// Type-aware: catch unhandled promises
"typescript/no-floating-promises": "warn"
},
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"]
}
@@ -1,5 +0,0 @@
github-policies:
runners:
allowed_groups:
- "GitHub Actions"
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
-26
View File
@@ -11,36 +11,10 @@
- Keep things in one function unless composable or reusable
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
### Naming
Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts
// Good
const foo = 1
function journal(dir: string) {}
// Bad
const fooBar = 1
function prepareJournal(dir: string) {}
```
Reduce total variable count by inlining when a value is only used once.
```ts
+1 -1
View File
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
+1 -1
View File
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
+1179 -722
View File
File diff suppressed because it is too large Load Diff
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1772091128,
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
"lastModified": 1773909469,
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3f0336406035444b4a24b942788334af5f906259",
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
"type": "github"
},
"original": {
+6 -7
View File
@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
})
connected = true
break
} catch (e) {}
} catch {}
await sleep(300)
} while (retry++ < 30)
@@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
todoread: ["Todo", "\x1b[33m\x1b[1m"],
bash: ["Bash", "\x1b[31m\x1b[1m"],
edit: ["Edit", "\x1b[32m\x1b[1m"],
glob: ["Glob", "\x1b[34m\x1b[1m"],
@@ -514,7 +513,7 @@ async function subscribeSessionEvents() {
const decoder = new TextDecoder()
let text = ""
;(async () => {
void (async () => {
while (true) {
try {
const { done, value } = await reader.read()
@@ -543,7 +542,7 @@ async function subscribeSessionEvents() {
? JSON.stringify(part.state.input)
: "Unknown"
console.log()
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
console.log(`${color}|`, `\x1b[0m\x1b[2m ${tool.padEnd(7, " ")}`, "", `\x1b[0m${title}`)
}
if (part.type === "text") {
@@ -562,7 +561,7 @@ async function subscribeSessionEvents() {
if (evt.properties.info.id !== session.id) continue
session = evt.properties.info
}
} catch (e) {
} catch {
// Ignore parse errors
}
}
@@ -577,7 +576,7 @@ async function subscribeSessionEvents() {
async function summarize(response: string) {
try {
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
} catch (e) {
} catch {
if (isScheduleEvent()) {
return "Scheduled task changes"
}
@@ -777,7 +776,7 @@ async function assertPermissions() {
console.log(` permission: ${permission}`)
} catch (error) {
console.error(`Failed to check permissions: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error })
}
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
+16
View File
@@ -109,6 +109,12 @@ const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50",
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
name: "First month 100% off",
percentOff: 100,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id,
currency: "usd",
@@ -122,7 +128,9 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
product: zenLiteProduct.id,
price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
},
})
@@ -201,6 +209,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")],
@@ -219,8 +231,12 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE,
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,
+2 -2
View File
@@ -1,9 +1,9 @@
import { SECRET } from "./secret"
import { domain, shortDomain } from "./stage"
import { shortDomain } from "./stage"
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
const teams = new sst.cloudflare.x.SolidStart("Teams", {
new sst.cloudflare.x.SolidStart("Teams", {
domain: shortDomain,
path: "packages/enterprise",
buildCommand: "bun run build:cloudflare",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
"x86_64-linux": "sha256-VIgTxIjmZ4Bfwwdj/YFmRJdBpPHYhJSY31kh06EXX+0=",
"aarch64-linux": "sha256-9118AS1ED0nrliURgZYBRuF/18RqXpUouhYJRlZ6jeA=",
"aarch64-darwin": "sha256-ppo3MfSIGKQHJCdYEZiLFRc61PtcJ9J0kAXH1pNIonA=",
"x86_64-darwin": "sha256-m+CZSOglBCTfNzbdBX6hXdDqqOzHNMzAddVp6BZVDtU="
}
}
+2 -1
View File
@@ -20,7 +20,7 @@ let
in
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
version = "${packageJson.version}-${rev}";
version = "${packageJson.version}+${lib.replaceString "-" "." rev}";
src = lib.fileset.toSource {
root = ../.;
@@ -54,6 +54,7 @@ stdenvNoCC.mkDerivation {
--filter '!./' \
--filter './packages/opencode' \
--filter './packages/desktop' \
--filter './packages/app' \
--frozen-lockfile \
--ignore-scripts \
--no-progress
+21 -18
View File
@@ -3,10 +3,10 @@
stdenvNoCC,
callPackage,
bun,
nodejs,
sysctl,
makeBinaryWrapper,
models-dev,
ripgrep,
installShellFiles,
versionCheckHook,
writableTmpDirAsHomeHook,
@@ -19,6 +19,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
nativeBuildInputs = [
bun
nodejs # for patchShebangs node_modules
installShellFiles
makeBinaryWrapper
models-dev
@@ -29,6 +30,8 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
patchShebangs node_modules
patchShebangs packages/*/node_modules
runHook postConfigure
'';
@@ -48,25 +51,25 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook postBuild
'';
installPhase = ''
runHook preInstall
installPhase =
''
runHook preInstall
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath (
[
ripgrep
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
''
# bun runs sysctl to detect if dunning on rosetta2
+ lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath [
sysctl
]
# bun runs sysctl to detect if dunning on rosetta2
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
)
}
runHook postInstall
'';
}
''
+ ''
runHook postInstall
'';
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions
+25 -9
View File
@@ -4,13 +4,16 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.10",
"packageManager": "bun@1.3.11",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
@@ -24,7 +27,11 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.9",
"@effect/opentelemetry": "4.0.0-beta.48",
"@effect/platform-node": "4.0.0-beta.48",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.11",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -41,17 +48,20 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.31",
"ai": "5.0.124",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.48",
"ai": "6.0.158",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"@playwright/test": "1.51.0",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
"semver": "7.7.4",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
@@ -65,7 +75,8 @@
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10"
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10"
}
},
"devDependencies": {
@@ -75,6 +86,8 @@
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.18.10",
@@ -85,6 +98,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
"repository": {
@@ -98,9 +112,11 @@
},
"trustedDependencies": [
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],
@@ -110,6 +126,6 @@
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
}
}
+2 -3
View File
@@ -31,11 +31,10 @@ Your app is ready to be deployed!
## E2E Testing
Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`).
Use the local runner to create a temp sandbox, seed data, and run the tests.
Playwright starts the Vite dev server automatically via `webServer`, and UI tests expect an opencode backend at `localhost:4096` by default.
```bash
bunx playwright install
bunx playwright install chromium
bun run test:e2e:local
bun run test:e2e:local -- --grep "settings"
```
-197
View File
@@ -1,197 +0,0 @@
# E2E Testing Guide
## Build/Lint/Test Commands
```bash
# Run all e2e tests
bun test:e2e
# Run specific test file
bun test:e2e -- app/home.spec.ts
# Run single test by title
bun test:e2e -- -g "home renders and shows core entrypoints"
# Run tests with UI mode (for debugging)
bun test:e2e:ui
# Run tests locally with full server setup
bun test:e2e:local
# View test report
bun test:e2e:report
# Typecheck
bun typecheck
```
## Test Structure
All tests live in `packages/app/e2e/`:
```
e2e/
├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk)
├── actions.ts # Reusable action helpers
├── selectors.ts # DOM selectors
├── utils.ts # Utilities (serverUrl, modKey, path helpers)
└── [feature]/
└── *.spec.ts # Test files
```
## Test Patterns
### Basic Test Structure
```typescript
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
test("test description", async ({ page, sdk, gotoSession }) => {
await gotoSession() // or gotoSession(sessionID)
// Your test code
await expect(page.locator(promptSelector)).toBeVisible()
})
```
### Using Fixtures
- `page` - Playwright page
- `sdk` - OpenCode SDK client for API calls
- `gotoSession(sessionID?)` - Navigate to session
### Helper Functions
**Actions** (`actions.ts`):
- `openPalette(page)` - Open command palette
- `openSettings(page)` - Open settings dialog
- `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
- `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL
- `slugFromUrl(url)` - Read workspace slug from URL
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
- `trackDirectory(directory)` - Register directory for fixture cleanup
- `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`):
- `promptSelector` - Prompt input
- `terminalSelector` - Terminal panel
- `sessionItemSelector(id)` - Session in sidebar
- `listItemSelector` - Generic list items
**Utils** (`utils.ts`):
- `modKey` - Meta (Mac) or Control (Linux/Win)
- `serverUrl` - Backend server URL
- `sessionPath(dir, id?)` - Build session URL
## Code Style Guidelines
### Imports
Always import from `../fixtures`, not `@playwright/test`:
```typescript
// ✅ Good
import { test, expect } from "../fixtures"
// ❌ Bad
import { test, expect } from "@playwright/test"
```
### Naming Conventions
- Test files: `feature-name.spec.ts`
- Test names: lowercase, descriptive: `"sidebar can be toggled"`
- Variables: camelCase
- Constants: SCREAMING_SNAKE_CASE
### Error Handling
Tests should clean up after themselves. Prefer fixture-managed cleanup:
```typescript
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "test session", async (session) => {
await gotoSession(session.id)
// Test code...
}) // Auto-deletes session
})
```
- Prefer `withSession(...)` for temp sessions
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
- Avoid calling `sdk.session.delete(...)` directly
### Timeouts
Default: 60s per test, 10s per assertion. Override when needed:
```typescript
test.setTimeout(120_000) // For long LLM operations
test("slow test", async () => {
await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
})
```
### Selectors
Use `data-component`, `data-action`, or semantic roles:
```typescript
// ✅ Good
await page.locator('[data-component="prompt-input"]').click()
await page.getByRole("button", { name: "Open settings" }).click()
// ❌ Bad
await page.locator(".css-class-name").click()
await page.locator("#id-name").click()
```
### Keyboard Shortcuts
Use `modKey` for cross-platform compatibility:
```typescript
import { modKey } from "../utils"
await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
await page.keyboard.press(`${modKey}+Comma`) // Open settings
```
### Terminal Tests
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
## Writing New Tests
1. Choose appropriate folder or create new one
2. Import from `../fixtures`
3. Use helper functions from `../actions` and `../selectors`
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
5. Clean up any created resources
6. Use specific selectors (avoid CSS classes)
7. Test one feature per test file
## Local Development
For UI debugging, use:
```bash
bun test:e2e:ui
```
This opens Playwright's interactive UI for step-through debugging.
-762
View File
@@ -1,762 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
titlebarRightSelector,
popoverBodySelector,
listItemSelector,
listItemKeySelector,
listItemKeyStartsWithSelector,
terminalSelector,
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
export async function defocus(page: Page) {
await page
.evaluate(() => {
const el = document.activeElement
if (el instanceof HTMLElement) el.blur()
})
.catch(() => undefined)
}
async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr)
if (id) return id
throw new Error(`Active terminal missing ${terminalAttr}`)
}
async function terminalReady(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return !!state?.connected && (state.settled ?? 0) > 0
}, id)
}
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate(
(input) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
return state?.rendered.includes(input.token) ?? false
},
{ id, token: input.token },
)
}
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await expect(term).toBeVisible()
await expect(term.locator("textarea")).toHaveCount(1)
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
}
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
const textarea = term.locator("textarea")
await term.click()
await expect(textarea).toBeFocused()
await page.keyboard.type(input.cmd)
await page.keyboard.press("Enter")
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
return dialog
}
export async function closeDialog(page: Page, dialog: Locator) {
await page.keyboard.press("Escape")
const closed = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closed) return
await page.keyboard.press("Escape")
const closedSecond = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closedSecond) return
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
await expect(dialog).toHaveCount(0)
}
export async function isSidebarClosed(page: Page) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toBeVisible()
return (await button.getAttribute("aria-expanded")) !== "true"
}
export async function toggleSidebar(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+B`)
}
export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click()
const opened = await expect(button)
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) return
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "true")
}
export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click()
const closed = await expect(button)
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closed) return
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false")
}
export async function openSettings(page: Page) {
await defocus(page)
const dialog = page.getByRole("dialog")
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
const opened = await dialog
.waitFor({ state: "visible", timeout: 3000 })
.then(() => true)
.catch(() => false)
if (opened) return dialog
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
return dialog
}
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
await page.addInitScript(
(args: { directory: string; serverUrl: string; extra: string[] }) => {
const key = "opencode.global.dat:server"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
try {
return JSON.parse(raw) as unknown
} catch {
return undefined
}
})()
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
const list = Array.isArray(store.list) ? store.list : []
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
const nextProjects = { ...(projects as Record<string, unknown>) }
const add = (origin: string, directory: string) => {
const current = nextProjects[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)
if (existing.some((p) => p.worktree === directory)) return
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
}
const directories = [args.directory, ...args.extra]
for (const directory of directories) {
add("local", directory)
add(args.serverUrl, directory)
}
localStorage.setItem(
key,
JSON.stringify({
list,
projects: nextProjects,
lastProject,
}),
)
},
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
)
}
export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" })
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
cwd: root,
stdio: "ignore",
})
return resolveDirectory(root)
}
export async function cleanupTestProject(directory: string) {
try {
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
} catch {}
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
}
export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
}
export async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
let next = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
next = ""
return ""
}
next = slug
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return next
}
export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
}
export async function hoverSessionItem(page: Page, sessionID: string) {
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
await expect(sessionEl).toBeVisible()
await sessionEl.hover()
return sessionEl
}
export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
.filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
.filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
.filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
.first()
const opened = await menu
.isVisible()
.then((x) => x)
.catch(() => false)
if (opened) return menu
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()
await expect(menu).toBeVisible()
return menu
}
export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
await expect(item).toBeVisible()
await item.click({ force: options?.force })
}
export async function confirmDialog(page: Page, buttonName: string | RegExp) {
const dialog = page.getByRole("dialog").first()
await expect(dialog).toBeVisible()
const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
await expect(button).toBeVisible()
await button.click()
}
export async function openSharePopover(page: Page) {
const rightSection = page.locator(titlebarRightSelector)
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
await expect(shareButton).toBeVisible()
const popoverBody = page
.locator(popoverBodySelector)
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
.first()
const opened = await popoverBody
.isVisible()
.then((x) => x)
.catch(() => false)
if (!opened) {
await shareButton.click()
await expect(popoverBody).toBeVisible()
}
return { rightSection, popoverBody }
}
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
await expect(button).toBeVisible()
await button.click()
}
export async function clickListItem(
container: Locator | Page,
filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
): Promise<Locator> {
let item: Locator
if (typeof filter === "string" || filter instanceof RegExp) {
item = container.locator(listItemSelector).filter({ hasText: filter }).first()
} else if (filter.keyStartsWith) {
item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
} else if (filter.key) {
item = container.locator(listItemKeySelector(filter.key)).first()
} else if (filter.text) {
item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
} else {
throw new Error("Invalid filter provided to clickListItem")
}
await expect(item).toBeVisible()
await item.click()
return item
}
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const data = await sdk.session
.status()
.then((x) => x.data ?? {})
.catch(() => undefined)
return data?.[sessionID]
}
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
let prev = ""
await expect
.poll(
async () => {
const info = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!info) return true
const next = `${info.title}:${info.time.updated ?? info.time.created}`
if (next !== prev) {
prev = next
return false
}
return true
},
{ timeout },
)
.toBe(true)
}
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
}
export async function cleanupSession(input: {
sessionID: string
directory?: string
sdk?: ReturnType<typeof createSdk>
}) {
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
const current = await status(sdk, input.sessionID).catch(() => undefined)
if (current && current.type !== "idle") {
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
}
await stable(sdk, input.sessionID).catch(() => undefined)
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
}
export async function withSession<T>(
sdk: ReturnType<typeof createSdk>,
title: string,
callback: (session: { id: string; title: string }) => Promise<T>,
): Promise<T> {
const session = await sdk.session.create({ title }).then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
try {
return await callback(session)
} finally {
await cleanupSession({ sdk, sessionID: session.id })
}
}
const seedSystem = [
"You are seeding deterministic e2e UI state.",
"Follow the user's instruction exactly.",
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
"Do not call any extra tools.",
].join(" ")
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
const timeout = input.timeout ?? 30_000
const end = Date.now() + timeout
while (Date.now() < end) {
const value = await input.probe()
if (value !== undefined) return value
await new Promise((resolve) => setTimeout(resolve, 250))
}
}
const seed = async <T>(input: {
sessionID: string
prompt: string
sdk: ReturnType<typeof createSdk>
probe: () => Promise<T | undefined>
timeout?: number
attempts?: number
}) => {
for (let i = 0; i < (input.attempts ?? 2); i++) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
agent: "build",
system: seedSystem,
parts: [{ type: "text", text: input.prompt }],
})
const value = await wait({ probe: input.probe, timeout: input.timeout })
if (value !== undefined) return value
}
}
export async function seedSessionQuestion(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
questions: Array<{
header: string
question: string
options: Array<{ label: string; description: string }>
multiple?: boolean
custom?: boolean
}>
},
) {
const first = input.questions[0]
if (!first) throw new Error("Question seed requires at least one question")
const text = [
"Your only valid response is one question tool call.",
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
"Do not output plain text.",
"After calling the tool, wait for the user response.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.question.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
},
})
if (!result) throw new Error("Timed out seeding question request")
return { id: result.id }
}
export async function seedSessionPermission(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
permission: string
patterns: string[]
description?: string
},
) {
const text = [
"Your only valid response is one bash tool call.",
`Use this JSON input: ${JSON.stringify({
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
workdir: "/",
description: input.description ?? `seed ${input.permission} permission request`,
})}`,
"Do not output plain text.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.permission.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID)
},
})
if (!result) throw new Error("Timed out seeding permission request")
return { id: result.id }
}
export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
description: string
prompt: string
subagentType?: string
},
) {
const text = [
"Your only valid response is one task tool call.",
`Use this JSON input: ${JSON.stringify({
description: input.description,
prompt: input.prompt,
subagent_type: input.subagentType ?? "general",
})}`,
"Do not output plain text.",
"Wait for the task to start and return the child session id.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 90_000,
probe: async () => {
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
const part = messages
.flatMap((message) => message.parts)
.find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false
if (part.state.input?.description !== input.description) return false
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
})
if (!part) return
const id = part.state.metadata?.sessionId
if (typeof id !== "string" || !id) return
const child = await sdk.session
.get({ sessionID: id })
.then((x) => x.data)
.catch(() => undefined)
if (!child?.id) return
return { sessionID: id }
},
})
if (!result) throw new Error("Timed out seeding task tool")
return result
}
export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
todos: Array<{ content: string; status: string; priority: string }>
},
) {
const text = [
"Your only valid response is one todowrite tool call.",
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
"Do not output plain text.",
].join("\n")
const target = JSON.stringify(input.todos)
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
if (JSON.stringify(todos) !== target) return
return true
},
})
if (!result) throw new Error("Timed out seeding todos")
return true
}
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const [questions, permissions] = await Promise.all([
sdk.question.list().then((x) => x.data ?? []),
sdk.permission.list().then((x) => x.data ?? []),
])
await Promise.all([
...questions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
...permissions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
])
return true
}
export async function openStatusPopover(page: Page) {
await defocus(page)
const rightSection = page.locator(titlebarRightSelector)
const trigger = rightSection.getByRole("button", { name: /status/i }).first()
const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
const opened = await popoverBody
.isVisible()
.then((x) => x)
.catch(() => false)
if (!opened) {
await expect(trigger).toBeVisible()
await trigger.click()
await expect(popoverBody).toBeVisible()
}
return { rightSection, popoverBody }
}
export async function openProjectMenu(page: Page, projectSlug: string) {
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1)
const menu = page
.locator(dropdownMenuContentSelector)
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
.first()
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger
.click({ timeout: 1500 })
.then(() => true)
.catch(() => false)
if (clicked) {
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
await expect(close).toBeVisible()
return menu
}
}
await trigger.focus()
await page.keyboard.press("Enter")
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
await expect(close).toBeVisible()
return menu
}
throw new Error(`Failed to open project menu: ${projectSlug}`)
}
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
const current = await page
.getByRole("button", { name: "New workspace" })
.first()
.isVisible()
.then((x) => x)
.catch(() => false)
if (current === enabled) return
const flip = async (timeout?: number) => {
const menu = await openProjectMenu(page, projectSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
return toggle.click({ force: true, timeout })
}
const flipped = await flip(1500)
.then(() => true)
.catch(() => false)
if (!flipped) await flip()
const expected = enabled ? "New workspace" : "New session"
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
}
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
return menu
}
-21
View File
@@ -1,21 +0,0 @@
import { test, expect } from "../fixtures"
import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
})
test("server picker dialog opens from home", async ({ page }) => {
await page.goto("/")
const trigger = page.getByRole("button", { name: serverNamePattern })
await expect(trigger).toBeVisible()
await trigger.click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
})
-10
View File
@@ -1,10 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { dirPath } from "../utils"
test("project route redirects to /session", async ({ page, directory, slug }) => {
await page.goto(dirPath(directory))
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await expect(page.locator(promptSelector)).toBeVisible()
})
-11
View File
@@ -1,11 +0,0 @@
import { test, expect } from "../fixtures"
import { openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page)
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
@@ -1,58 +0,0 @@
import { test, expect } from "../fixtures"
import { serverNamePattern, serverUrls } from "../utils"
import { closeDialog, clickMenuItem } from "../actions"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
test("can set a default server on web", async ({ page, gotoSession }) => {
await page.addInitScript((key: string) => {
try {
localStorage.removeItem(key)
} catch {
return
}
}, DEFAULT_SERVER_URL_KEY)
await gotoSession()
const status = page.getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
const popover = page.locator('[data-component="popover-content"]').filter({ hasText: "Manage servers" })
const ensurePopoverOpen = async () => {
if (await popover.isVisible()) return
await status.click()
await expect(popover).toBeVisible()
}
await ensurePopoverOpen()
await popover.getByRole("button", { name: "Manage servers" }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click({ force: true })
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible()
await clickMenuItem(menu, /set as default/i)
await expect
.poll(async () =>
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
)
.toBe(true)
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
await closeDialog(page, dialog)
await ensurePopoverOpen()
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
await expect(serverRow).toBeVisible()
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
})
-16
View File
@@ -1,16 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke ${Date.now()}`
await withSession(sdk, title, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("hello from e2e")
await expect(prompt).toContainText("hello from e2e")
})
})
@@ -1,120 +0,0 @@
import { test, expect } from "../fixtures"
import { defocus, openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
await gotoSession(one.id)
await openSidebar(page)
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
const back = page.getByRole("button", { name: "Back" })
const forward = page.getByRole("button", { name: "Forward" })
await expect(back).toBeVisible()
await expect(back).toBeEnabled()
await back.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(forward).toBeVisible()
await expect(forward).toBeEnabled()
await forward.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => {
await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => {
await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => {
await gotoSession(a.id)
await openSidebar(page)
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
await expect(second).toBeVisible()
await second.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
const back = page.getByRole("button", { name: "Back" })
const forward = page.getByRole("button", { name: "Forward" })
await expect(back).toBeVisible()
await expect(back).toBeEnabled()
await back.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${a.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page)
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
await expect(third).toBeVisible()
await third.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(forward).toBeVisible()
await expect(forward).toBeDisabled()
})
})
})
})
test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => {
await withSession(sdk, `e2e titlebar shortcuts 2 ${stamp}`, async (two) => {
await gotoSession(one.id)
await openSidebar(page)
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await defocus(page)
await page.keyboard.press(`${modKey}+[`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await defocus(page)
await page.keyboard.press(`${modKey}+]`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
@@ -1,15 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await page.locator("main").click({ position: { x: 5, y: 5 } })
await expect(prompt).not.toBeFocused()
await page.keyboard.press("Control+L")
await expect(prompt).toBeFocused()
})
-33
View File
@@ -1,33 +0,0 @@
import { test, expect } from "../fixtures"
import { modKey } from "../utils"
const expanded = async (el: { getAttribute: (name: string) => Promise<string | null> }) => {
const value = await el.getAttribute("aria-expanded")
if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`)
return value === "true"
}
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
await gotoSession()
const reviewPanel = page.locator("#review-panel")
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
await expect(treeToggle).toBeVisible()
if (await expanded(treeToggle)) await treeToggle.click()
await expect(treeToggle).toHaveAttribute("aria-expanded", "false")
const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first()
await expect(reviewToggle).toBeVisible()
if (await expanded(reviewToggle)) await reviewToggle.click()
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
})
@@ -1,32 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("mod+w closes the active file tab", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await dialog.getByRole("textbox").first().fill("package.json")
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" }).first()
await expect(tab).toBeVisible()
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await page.keyboard.press(`${modKey}+W`)
await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0)
})
-31
View File
@@ -1,31 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0)
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible()
})
-56
View File
@@ -1,56 +0,0 @@
import { test, expect } from "../fixtures"
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
await gotoSession()
const toggle = page.getByRole("button", { name: "Toggle file tree" })
const panel = page.locator("#file-tree-panel")
const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
await expect(toggle).toBeVisible()
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(panel).toBeVisible()
await expect(treeTabs).toBeVisible()
const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
await expect(allTab).toBeVisible()
await allTab.click()
await expect(allTab).toHaveAttribute("aria-selected", "true")
const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
await expect(tree).toBeVisible()
const expand = async (name: string) => {
const folder = tree.getByRole("button", { name, exact: true }).first()
await expect(folder).toBeVisible()
await expect(folder).toHaveAttribute("aria-expanded", /true|false/)
if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
await expect(folder).toHaveAttribute("aria-expanded", "true")
}
await expand("packages")
await expand("app")
await expand("src")
await expand("components")
const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
await expect(file).toBeVisible()
await file.click()
const tab = page.getByRole("tab", { name: "file-tree.tsx" })
await expect(tab).toBeVisible()
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(allTab).toHaveAttribute("aria-selected", "true")
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer).toContainText("export default function FileTree")
})
-156
View File
@@ -1,156 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
})
test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await page.locator(promptSelector).click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
})
test("cmd+f opens text viewer search while prompt is not focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await viewer.click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
})
-114
View File
@@ -1,114 +0,0 @@
import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3"
type TestFixtures = {
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
withProject: <T>(
callback: (project: {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void
}) => Promise<T>,
options?: { extra?: string[] },
) => Promise<T>
}
type WorkerFixtures = {
directory: string
slug: string
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
directory: [
async ({}, use) => {
const directory = await getWorktree()
await use(directory)
},
{ scope: "worker" },
],
slug: [
async ({ directory }, use) => {
await use(dirSlug(directory))
},
{ scope: "worker" },
],
sdk: async ({ directory }, use) => {
await use(createSdk(directory))
},
gotoSession: async ({ page, directory }, use) => {
await seedStorage(page, { directory })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
}
await use(gotoSession)
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const root = await createTestProject()
const slug = dirSlug(root)
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
const trackSession = (sessionID: string, directory?: string) => {
sessions.set(sessionID, directory ?? root)
}
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await gotoSession()
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally {
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
}
})
},
})
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
terminal: {
enabled: true,
terminals: {},
},
}
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
user: [],
variant: {},
}),
)
})
}
export { expect }
@@ -1,48 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { clickListItem } from "../actions"
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
const selected = dialog.locator('[data-slot="list-item"][data-selected="true"]').first()
await expect(selected).toBeVisible()
const other = dialog.locator('[data-slot="list-item"]:not([data-selected="true"])').first()
const target = (await other.count()) > 0 ? other : selected
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const model = key.split(":").slice(1).join(":")
await input.fill(model)
await clickListItem(dialog, { key })
await expect(dialog).toHaveCount(0)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialogAgain = page.getByRole("dialog")
await expect(dialogAgain).toBeVisible()
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
})
@@ -1,61 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { closeDialog, openSettings, clickListItem } from "../actions"
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const picker = page.getByRole("dialog")
await expect(picker).toBeVisible()
const target = picker.locator('[data-slot="list-item"]').first()
await expect(target).toBeVisible()
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
if (!name) throw new Error("Failed to resolve model name from list item")
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
await expect(search).toBeVisible()
await search.fill(name)
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
const input = toggle.locator('[data-slot="switch-input"]')
await expect(toggle).toBeVisible()
await expect(input).toHaveAttribute("aria-checked", "true")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const pickerAgain = page.getByRole("dialog")
await expect(pickerAgain).toBeVisible()
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(pickerAgain).toHaveCount(0)
})
@@ -1,43 +0,0 @@
import { test, expect } from "../fixtures"
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await openSidebar(page)
const open = async () => {
const menu = await openProjectMenu(page, slug)
await clickMenuItem(menu, /^Edit$/i, { force: true })
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
return dialog
}
const name = `e2e project ${Date.now()}`
const startup = `echo e2e_${Date.now()}`
const dialog = await open()
const nameInput = dialog.getByLabel("Name")
await nameInput.fill(name)
const startupInput = dialog.getByLabel("Workspace startup script")
await startupInput.fill(startup)
await dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0)
const header = page.locator(".group\\/project").first()
await expect(header).toContainText(name)
const reopened = await open()
await expect(reopened.getByLabel("Name")).toHaveValue(name)
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
await reopened.getByRole("button", { name: "Cancel" }).click()
await expect(reopened).toHaveCount(0)
})
})
@@ -1,54 +0,0 @@
import { test, expect } from "../fixtures"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("closing active project navigates to another open project", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async ({ slug }) => {
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const menu = await openProjectMenu(page, otherSlug)
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect
.poll(
() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
},
{ timeout: 15_000 },
)
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
await expect
.poll(
async () => {
return await page.locator(projectSwitchSelector(otherSlug)).count()
},
{ timeout: 15_000 },
)
.toBe(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
@@ -1,143 +0,0 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils"
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async ({ directory }) => {
await defocus(page)
const currentSlug = dirSlug(directory)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
await expect(currentButton).toBeVisible()
await currentButton.click()
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page)
await workspaces(page, directory, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
const raw = await waitSlug(page, [slug])
const dir = base64Decode(raw)
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
const space = await resolveDirectory(dir)
const next = dirSlug(space)
trackDirectory(space)
await openSidebar(page)
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
await expect(item).toBeVisible()
await item.hover()
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
await expect(btn).toBeVisible()
await btn.click({ force: true })
// A new workspace can be discovered via a transient slug before the route and sidebar
// settle to the canonical workspace path on Windows, so interact with either and assert
// against the resolved workspace slug.
await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill("test")
await page.keyboard.press("Enter")
// Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click()
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
@@ -1,109 +0,0 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [root, ...seen])
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}
async function openWorkspaceNewSession(page: Page, slug: string) {
await waitWorkspaceReady(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
return next
}
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
const next = await openWorkspaceNewSession(page, slug)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.fill(text)
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next }
}
async function sessionDirectory(directory: string, sessionID: string) {
const info = await createSdk(directory)
.session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!info) return ""
return info.directory
}
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, root, [])
trackDirectory(first.directory)
await waitWorkspaceReady(page, first.slug)
const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory)
await waitWorkspaceReady(page, second.slug)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, first.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
})
})
@@ -1,373 +0,0 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
test.describe.configure({ mode: "serial" })
import {
cleanupTestProject,
clickMenuItem,
confirmDialog,
openSidebar,
openWorkspaceMenu,
setWorkspacesEnabled,
slugFromUrl,
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug])
const dir = base64Decode(slug)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
return { rootSlug, slug, directory: dir }
}
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await openSidebar(page)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
await setWorkspacesEnabled(page, slug, false)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
})
})
test("can create a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
const workspaceSlug = await waitSlug(page, [slug])
const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
await cleanupTestProject(workspaceDir)
})
})
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
const nonGitSlug = dirSlug(nonGit)
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try {
await withProject(async () => {
await page.goto(`/${nonGitSlug}/session`)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = base64Decode(slugFromUrl(page.url()))
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
const trigger = page.locator('[data-action="project-menu"]').first()
const hasMenu = await trigger
.isVisible()
.then((x) => x)
.catch(() => false)
if (!hasMenu) return
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
await expect(toggle).toBeVisible()
await expect(toggle).toBeDisabled()
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
})
} finally {
await cleanupTestProject(nonGit)
}
})
test("can rename a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const { slug } = await setupWorkspaceTest(page, project)
const rename = `e2e workspace ${Date.now()}`
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Rename$/i, { force: true })
await expect(menu).toHaveCount(0)
const item = page.locator(workspaceItemSelector(slug)).first()
await expect(item).toBeVisible()
const input = item.locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(item).toContainText(rename)
})
})
test("can reset a workspace", async ({ page, sdk, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
const readme = path.join(createdDir, "README.md")
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
const original = await fs.readFile(readme, "utf8")
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
await fs.writeFile(readme, dirty, "utf8")
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(true)
await expect
.poll(async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
})
.toBeGreaterThan(0)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Reset$/i, { force: true })
await confirmDialog(page, /^Reset workspace$/i)
await expect
.poll(
async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
},
{ timeout: 60_000 },
)
.toBe(0)
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(false)
})
})
test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await project.gotoSession()
await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
})
})
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: rootSlug }) => {
const workspaces = [] as { directory: string; slug: string }[]
const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const waitReady = async (slug: string) => {
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
const drag = async (from: string, to: string) => {
const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first()
const a = await src.boundingBox()
const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
await page.mouse.down()
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
await page.mouse.up()
}
try {
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug, prev])
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })
await openSidebar(page)
}
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
}
})
})
-95
View File
@@ -1,95 +0,0 @@
import { test, expect } from "../fixtures"
import type { Page } from "@playwright/test"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
function contextButton(page: Page) {
return page
.locator('[data-component="button"]')
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()
}
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
await expect
.poll(async () => {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 1 })
.then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
}
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}`
await withSession(sdk, title, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
})
})
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
const context = tabs.getByRole("tab", { name: "Context" })
await expect(context).toBeVisible()
await page.getByRole("button", { name: "Close tab" }).first().click()
await expect(context).toHaveCount(0)
})
})
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
await page.getByRole("button", { name: "Open file" }).first().click()
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
})
@@ -1,76 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
await gotoSession()
const token = `E2E_ASYNC_${Date.now()}`
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
try {
// Agent response arrives via SSE despite sync endpoint being dead
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
await cleanupSession({ sdk, sessionID })
}
})
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
const value = `restore ${Date.now()}`
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "e2e prompt failure" }),
}),
)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type(value)
await page.keyboard.press("Enter")
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 15_000 },
)
.toBe(0)
})
})
@@ -1,22 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
const dt = await page.evaluateHandle((text) => {
const dt = new DataTransfer()
dt.setData("text/plain", text)
return dt
}, `file:${path}`)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
await expect(pill).toBeVisible()
await expect(pill).toHaveAttribute("data-path", path)
})
@@ -1,30 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
const dt = await page.evaluateHandle((b64) => {
const dt = new DataTransfer()
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
const file = new File([bytes], "drop.png", { type: "image/png" })
dt.items.add(file)
return dt
}, png)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const img = page.locator('img[alt="drop.png"]').first()
await expect(img).toBeVisible()
const remove = page.getByRole("button", { name: "Remove attachment" }).first()
await expect(remove).toBeVisible()
await img.hover()
await remove.click()
await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
})
@@ -1,181 +0,0 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { promptSelector } from "../selectors"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
}
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
}
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
const part = messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
},
{ timeout: 90_000 },
)
.toContain(token)
}
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await prompt.click()
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, firstToken)
await prompt.click()
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, secondToken)
await prompt.click()
await page.keyboard.type(draft)
await wait(page, draft)
await edge(page, "start")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, draft)
})
})
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, first, firstToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, second, secondToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, normalToken)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
})
})
@@ -1,26 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
const sep = process.platform === "win32" ? "\\" : "/"
const file = ["packages", "app", "package.json"].join(sep)
const filePattern = /packages[\\/]+app[\\/]+\s*package\.json/
await page.keyboard.type(`@${file}`)
const suggestion = page.getByRole("button", { name: filePattern }).first()
await expect(suggestion).toBeVisible()
await suggestion.hover()
await page.keyboard.press("Tab")
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
await expect(pill).toBeVisible()
await expect(pill).toHaveAttribute("data-path", filePattern)
await page.keyboard.type(" ok")
await expect(page.locator(promptSelector)).toContainText("ok")
})
@@ -1,18 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => {
await gotoSession()
await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("line one")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("line two")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toContainText("line one")
await expect(prompt).toContainText("line two")
})
@@ -1,62 +0,0 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
import { sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
test.setTimeout(120_000)
await withProject(async ({ directory, gotoSession, trackSession }) => {
const sdk = createSdk(directory)
const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "ls"
await gotoSession()
await prompt.click()
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
trackSession(id, directory)
await expect
.poll(
async () => {
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
const msg = list.findLast(
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
)
if (!msg) return
const part = msg.parts
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
const output =
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!output.includes("README.md")) return
return { cwd: directory, output }
},
{ timeout: 90_000 },
)
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
await expect(prompt).toHaveText("")
})
})
@@ -1,22 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
@@ -1,64 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e share seed" }],
})
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0)
}
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
await seed(sdk, session.id)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await prompt.click()
await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
})
})
@@ -1,35 +0,0 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector)
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
await expect(terminal).not.toBeVisible()
await prompt.fill("/terminal")
await expect(slash).toBeVisible()
await page.keyboard.press("Enter")
await waitTerminalReady(page, { term: terminal })
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
// which can steal focus from the prompt and prevent fill() from triggering
// the slash popover. Re-attempt click+fill until all retries are exhausted
// and the popover appears.
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill("/terminal").catch(() => false)
return slash.isVisible().catch(() => false)
},
{ timeout: 10_000 },
)
.toBe(true)
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible()
})
-55
View File
@@ -1,55 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const pageErrors: string[] = []
const onPageError = (err: Error) => {
pageErrors.push(err.message)
}
page.on("pageerror", onPageError)
await gotoSession()
const token = `E2E_OK_${Date.now()}`
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = (() => {
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id
})()
try {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
page.off("pageerror", onPageError)
await cleanupSession({ sdk, sessionID })
}
if (pageErrors.length > 0) {
throw new Error(`Page error(s):\n${pageErrors.join("\n")}`)
}
})
-72
View File
@@ -1,72 +0,0 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
export const settingsThemeSelector = '[data-action="settings-theme"]'
export const settingsFontSelector = '[data-action="settings-font"]'
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
export const projectSwitchSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
export const projectMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectClearNotificationsSelector = (slug: string) =>
`[data-action="project-clear-notifications"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
export const titlebarRightSelector = "#opencode-titlebar-right"
export const popoverBodySelector = '[data-slot="popover-body"]'
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>
`${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
export const workspaceMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
export const workspaceNewSessionSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`
export const listItemSelector = '[data-slot="list-item"]'
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]`
@@ -1,37 +0,0 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const errs: string[] = []
const onError = (err: Error) => {
errs.push(err.message)
}
page.on("pageerror", onError)
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
})
try {
await gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000)
expect(errs).toEqual([])
} finally {
page.off("pageerror", onError)
}
})
})
@@ -1,425 +0,0 @@
import { test, expect } from "../fixtures"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
permissionDockSelector,
promptSelector,
questionDockSelector,
sessionComposerDockSelector,
sessionTodoDockSelector,
sessionTodoListSelector,
sessionTodoToggleButtonSelector,
} from "../selectors"
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
async function withDockSession<T>(
sdk: Sdk,
title: string,
fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[] },
) {
const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
try {
return await fn(session)
} finally {
await cleanupSession({ sdk, sessionID: session.id })
}
}
test.setTimeout(120_000)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
try {
return await fn()
} finally {
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
}
}
async function clearPermissionDock(page: any, label: RegExp) {
const dock = page.locator(permissionDockSelector)
for (let i = 0; i < 3; i++) {
const count = await dock.count()
if (count === 0) return
await dock.getByRole("button", { name: label }).click()
await page.waitForTimeout(150)
}
}
async function setAutoAccept(page: any, enabled: boolean) {
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
const pressed = (await button.getAttribute("aria-pressed")) === "true"
if (pressed === enabled) return
await button.click()
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
}
async function withMockPermission<T>(
page: any,
request: {
id: string
sessionID: string
permission: string
patterns: string[]
metadata?: Record<string, unknown>
always?: string[]
},
opts: { child?: any } | undefined,
fn: () => Promise<T>,
) {
let pending = [
{
...request,
always: request.always ?? ["*"],
metadata: request.metadata ?? {},
},
]
const list = async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(pending),
})
}
const reply = async (route: any) => {
const url = new URL(route.request().url())
const id = url.pathname.split("/").pop()
pending = pending.filter((item) => item.id !== id)
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(true),
})
}
await page.route("**/permission", list)
await page.route("**/session/*/permissions/*", reply)
const sessionList = opts?.child
? async (route: any) => {
const res = await route.fetch()
const json = await res.json()
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
await route.fulfill({
status: res.status(),
headers: res.headers(),
contentType: "application/json",
body: JSON.stringify(json),
})
}
: undefined
if (sessionList) await page.route("**/session?*", sessionList)
try {
return await fn()
} finally {
await page.unroute("**/permission", list)
await page.unroute("**/session/*/permissions/*", reply)
if (sessionList) await page.unroute("**/session?*", sessionList)
}
}
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => {
await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
})
})
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
await gotoSession()
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_once",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" },
},
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow once/i)
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
})
})
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_reject",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /deny/i)
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
})
})
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_always",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" },
},
undefined,
async () => {
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow always/i)
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
})
})
test("child session question request blocks parent dock and unblocks after submit", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
await gotoSession(session.id)
const child = await sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withDockSeed(sdk, child.id, async () => {
await seedSessionQuestion(sdk, {
sessionID: child.id,
questions: [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
})
const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
} finally {
await cleanupSession({ sdk, sessionID: child.id })
}
})
})
test("child session permission request blocks parent dock and supports allow once", async ({
page,
sdk,
gotoSession,
}) => {
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
await gotoSession(session.id)
await setAutoAccept(page, false)
const child = await sdk.session
.create({
title: "e2e composer dock child permission",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withMockPermission(
page,
{
id: "per_e2e_child",
sessionID: child.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" },
},
{ child },
async () => {
await page.goto(page.url())
const dock = page.locator(permissionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow once/i)
await page.goto(page.url())
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
} finally {
await cleanupSession({ sdk, sessionID: child.id })
}
})
})
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
})
})
})
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
],
})
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
})
})
@@ -1,217 +0,0 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { createSdk } from "../utils"
const count = 14
function body(mark: string) {
return [
`title ${mark}`,
`mark ${mark}`,
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
]
}
function files(tag: string) {
return Array.from({ length: count }, (_, i) => {
const id = String(i).padStart(2, "0")
return {
file: `review-scroll-${id}.txt`,
mark: `${tag}-${id}`,
}
})
}
function seed(list: ReturnType<typeof files>) {
const out = ["*** Begin Patch"]
for (const item of list) {
out.push(`*** Add File: ${item.file}`)
for (const line of body(item.mark)) out.push(`+${line}`)
}
out.push("*** End Patch")
return out.join("\n")
}
function edit(file: string, prev: string, next: string) {
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
"\n",
)
}
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
await sdk.session.promptAsync({
sessionID,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Your only valid response is one apply_patch tool call.",
`Use this JSON input: ${JSON.stringify({ patchText })}`,
"Do not call any other tools.",
"Do not output plain text.",
].join("\n"),
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})
await waitSessionIdle(sdk, sessionID, 120_000)
}
async function show(page: Parameters<typeof test>[0]["page"]) {
const btn = page.getByRole("button", { name: "Toggle review" }).first()
await expect(btn).toBeVisible()
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
await expect(btn).toHaveAttribute("aria-expanded", "true")
}
async function expand(page: Parameters<typeof test>[0]["page"]) {
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
const open = await close
.isVisible()
.then((value) => value)
.catch(() => false)
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
if (open) {
await close.click()
await expect(btn).toBeVisible()
}
await expect(btn).toBeVisible()
await btn.click()
await expect(close).toBeVisible()
}
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
await page.waitForFunction(
({ file, mark }) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return false
const head = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(head instanceof HTMLElement)) return false
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
if (!(host instanceof HTMLElement)) return false
const root = host.shadowRoot
return root?.textContent?.includes(`mark ${mark}`) ?? false
})
},
{ file, mark },
{ timeout: 60_000 },
)
}
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
return page.evaluate((file) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return null
const row = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(row instanceof HTMLElement)) return null
const a = row.getBoundingClientRect()
const b = view.getBoundingClientRect()
return {
top: a.top - b.top,
y: view.scrollTop,
}
}, file)
}
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-${Date.now()}`
const list = files(tag)
const hit = list[list.length - 4]!
const next = `${tag}-live`
await page.setViewportSize({ width: 1600, height: 1000 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await expect
.poll(
async () => {
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0
},
{ timeout: 60_000 },
)
.toBe(list.length)
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(list.length)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, {
timeout: 60_000,
})
await expand(page)
await waitMark(page, hit.file, hit.mark)
const row = page
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
.first()
await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : ""
},
{ timeout: 60_000 },
)
.toContain(`mark ${next}`)
await waitMark(page, hit.file, next)
await expect
.poll(
async () => {
const next = await spot(page, hit.file)
if (!next) return Number.POSITIVE_INFINITY
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
},
{ timeout: 60_000 },
)
.toBeLessThanOrEqual(32)
})
})
})
@@ -1,233 +0,0 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { createSdk, modKey } from "../utils"
import { promptSelector } from "../selectors"
async function seedConversation(input: {
page: Page
sdk: ReturnType<typeof createSdk>
sessionID: string
token: string
}) {
const messages = async () =>
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
const seeded = await messages()
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [{ type: "text", text: input.token }],
})
let userMessageID: string | undefined
await expect
.poll(
async () => {
const users = (await messages()).filter(
(m) =>
!userIDs.has(m.info.id) &&
m.info.role === "user" &&
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
)
if (users.length === 0) return false
const user = users[users.length - 1]
if (!user) return false
userMessageID = user.info.id
return true
},
{ timeout: 90_000, intervals: [250, 500, 1_000] },
)
.toBe(true)
if (!userMessageID) throw new Error("Expected a user message id")
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
return { prompt, userMessageID }
}
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `undo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await expect(seeded.prompt).toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
})
})
})
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `redo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await seeded.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(seeded.prompt).not.toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
})
})
})
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const first = await seedConversation({
page,
sdk,
sessionID: session.id,
token: firstToken,
})
const second = await seedConversation({
page,
sdk,
sessionID: session.id,
token: secondToken,
})
expect(first.userMessageID).not.toBe(second.userMessageID)
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(first.userMessageID)
await expect(firstMessage).toHaveCount(0)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
})
})
})
-174
View File
@@ -1,174 +0,0 @@
import { test, expect } from "../fixtures"
import {
openSidebar,
openSessionMoreMenu,
clickMenuItem,
confirmDialog,
openSharePopover,
withSession,
} from "../actions"
import { sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
type Sdk = Parameters<typeof withSession>[0]
async function seedMessage(sdk: Sdk, sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e seed" }],
})
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0)
}
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}`
const renamedTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle)
await input.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title
},
{ timeout: 30_000 },
)
.toBe(renamedTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
})
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const title = `e2e archive test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /archive/i)
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const title = `e2e delete test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /delete/i)
await confirmDialog(page, /delete/i)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID: session.id })
.then((r) => r.data)
.catch(() => undefined)
return data?.id
},
{ timeout: 30_000 },
)
.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
const stamp = Date.now()
const title = `e2e share test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
})
})
@@ -1,389 +0,0 @@
import { test, expect } from "../fixtures"
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils"
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")).first()
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyH`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("H")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h")
await closeDialog(page, dialog)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
await page.keyboard.press(`${modKey}+Shift+H`)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
expect(afterToggleClosed).toBe(!initiallyClosed)
await page.keyboard.press(`${modKey}+Shift+H`)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
expect(finalClosed).toBe(initiallyClosed)
})
test("sidebar toggle keybind guards against shortcut conflicts", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyP`)
await page.waitForTimeout(100)
const toast = page.locator('[data-component="toast"]').last()
await expect(toast).toBeVisible()
await expect(toast).toContainText(/already/i)
await keybindButton.click()
await expect(keybindButton).toContainText("B")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
await closeDialog(page, dialog)
})
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
})
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const customKeybind = await keybindButton.textContent()
expect(customKeybind).toContain("X")
const resetButton = dialog.getByRole("button", { name: "Reset to defaults" })
await expect(resetButton).toBeVisible()
await expect(resetButton).toBeEnabled()
await resetButton.click()
await page.waitForTimeout(100)
const restoredKeybind = await keybindButton.textContent()
expect(restoredKeybind).toContain("B")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
await closeDialog(page, dialog)
})
test("clearing a keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press("Delete")
await page.waitForTimeout(100)
const clearedKeybind = await keybindButton.textContent()
expect(clearedKeybind).toMatch(/unassigned|press/i)
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none")
await closeDialog(page, dialog)
await page.keyboard.press(`${modKey}+B`)
await page.waitForTimeout(100)
const stillOnSession = page.url().includes("/session")
expect(stillOnSession).toBe(true)
})
test("changing settings open keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("settings.open"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain(",")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Slash`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("/")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["settings.open"]).toBe("mod+/")
await closeDialog(page, dialog)
const settingsDialog = page.getByRole("dialog")
await expect(settingsDialog).toHaveCount(0)
await page.keyboard.press(`${modKey}+Slash`)
await page.waitForTimeout(100)
await expect(settingsDialog).toBeVisible()
await closeDialog(page, settingsDialog)
})
test("changing new session keybind works", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "test session for keybind", async (session) => {
await gotoSession(session.id)
const initialUrl = page.url()
expect(initialUrl).toContain(`/session/${session.id}`)
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("session.new"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyN`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("N")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n")
await closeDialog(page, dialog)
await page.keyboard.press(`${modKey}+Shift+N`)
await page.waitForTimeout(200)
const newUrl = page.url()
expect(newUrl).toMatch(/\/session\/?$/)
expect(newUrl).not.toContain(session.id)
})
})
test("changing file open keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("file.open"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyF`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("F")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f")
await closeDialog(page, dialog)
const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) })
await expect(filePickerDialog).toHaveCount(0)
await page.keyboard.press(`${modKey}+Shift+F`)
await page.waitForTimeout(100)
await expect(filePickerDialog).toBeVisible()
await page.keyboard.press("Escape")
await expect(filePickerDialog).toHaveCount(0)
})
test("changing terminal toggle keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+KeyY`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("Y")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y")
await closeDialog(page, dialog)
const terminal = page.locator(terminalSelector)
await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await waitTerminalReady(page, { term: terminal })
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible()
})
test("terminal toggle keybind persists after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyY`)
await page.waitForTimeout(100)
await expect(keybindButton).toContainText("Y")
await closeDialog(page, dialog)
await page.reload()
await expect
.poll(async () => {
return await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
if (!raw) return
const parsed = JSON.parse(raw)
return parsed?.keybinds?.["terminal.toggle"]
})
})
.toBe("mod+shift+y")
const reloaded = await openSettings(page)
await reloaded.getByRole("tab", { name: "Shortcuts" }).click()
const reloadedKeybind = reloaded.locator(keybindButtonSelector("terminal.toggle")).first()
await expect(reloadedKeybind).toContainText("Y")
await closeDialog(page, reloaded)
})
test("changing command palette keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("command.palette"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyK`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("K")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k")
await closeDialog(page, dialog)
const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() })
await expect(palette).toHaveCount(0)
await page.keyboard.press(`${modKey}+Shift+K`)
await page.waitForTimeout(100)
await expect(palette).toBeVisible()
await expect(palette.getByRole("textbox").first()).toBeVisible()
await page.keyboard.press("Escape")
await expect(palette).toHaveCount(0)
})
@@ -1,122 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { closeDialog, openSettings } from "../actions"
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const picker = page.getByRole("dialog")
await expect(picker).toBeVisible()
const target = picker.locator('[data-slot="list-item"]').first()
await expect(target).toBeVisible()
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
if (!name) throw new Error("Failed to resolve model name from list item")
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
await expect(search).toBeVisible()
await search.fill(name)
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
const input = toggle.locator('[data-slot="switch-input"]')
await expect(toggle).toBeVisible()
await expect(input).toHaveAttribute("aria-checked", "true")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const pickerAgain = page.getByRole("dialog")
await expect(pickerAgain).toBeVisible()
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(pickerAgain).toHaveCount(0)
})
test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const picker = page.getByRole("dialog")
await expect(picker).toBeVisible()
const target = picker.locator('[data-slot="list-item"]').first()
await expect(target).toBeVisible()
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
if (!name) throw new Error("Failed to resolve model name from list item")
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
await expect(search).toBeVisible()
await search.fill(name)
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
const input = toggle.locator('[data-slot="switch-input"]')
await expect(toggle).toBeVisible()
await expect(input).toHaveAttribute("aria-checked", "true")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "true")
await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const pickerAgain = page.getByRole("dialog")
await expect(pickerAgain).toBeVisible()
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible()
await page.keyboard.press("Escape")
await expect(pickerAgain).toHaveCount(0)
})
@@ -1,136 +0,0 @@
import { test, expect } from "../fixtures"
import { closeDialog, openSettings } from "../actions"
test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await expect(customProviderSection).toBeVisible()
const connectButton = customProviderSection.getByRole("button", { name: "Connect" })
await connectButton.click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("test-provider")
await providerDialog.getByLabel("Display name").fill("Test Provider")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake")
await providerDialog.getByLabel("API key").fill("fake-key")
await providerDialog.getByPlaceholder("model-id").first().fill("test-model")
await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model")
await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider")
await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider")
await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake")
await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key")
await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model")
await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model")
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
test("custom provider form shows validation errors", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("invalid provider id")
await providerDialog.getByLabel("Base URL").fill("not-a-url")
await providerDialog.getByRole("button", { name: /submit|save/i }).click()
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible()
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible()
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
test("custom provider form can add and remove models", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("multi-model-test")
await providerDialog.getByLabel("Display name").fill("Multi Model Test")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi")
await providerDialog.getByPlaceholder("model-id").first().fill("model-1")
await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1")
const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count()
await providerDialog.getByRole("button", { name: "Add model" }).click()
const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count()
expect(idInputsAfter).toBe(idInputsBefore + 1)
await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2")
await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2")
await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2")
await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2")
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
test("custom provider form can add and remove headers", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("header-test")
await providerDialog.getByLabel("Display name").fill("Header Test")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers")
await providerDialog.getByPlaceholder("model-id").first().fill("model-x")
await providerDialog.getByPlaceholder("Display Name").first().fill("Model X")
const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count()
await providerDialog.getByRole("button", { name: "Add header" }).click()
const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count()
expect(headerInputsAfter).toBe(headerInputsBefore + 1)
await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization")
await providerDialog.getByPlaceholder("value").first().fill("Bearer token123")
await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization")
await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123")
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
-519
View File
@@ -1,519 +0,0 @@
import { test, expect, settingsKey } from "../fixtures"
import { closeDialog, openSettings } from "../actions"
import {
settingsColorSchemeSelector,
settingsFontSelector,
settingsLanguageSelectSelector,
settingsNotificationsAgentSelector,
settingsNotificationsErrorsSelector,
settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector,
settingsSoundsAgentSelector,
settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector,
settingsThemeSelector,
settingsUpdatesStartupSelector,
} from "../selectors"
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
await closeDialog(page, dialog)
})
test("changing language updates settings labels", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
})
await gotoSession()
const dialog = await openSettings(page)
const heading = dialog.getByRole("heading", { level: 2 })
await expect(heading).toHaveText("General")
const select = dialog.locator(settingsLanguageSelectSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
await expect(heading).toHaveText("Allgemein")
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
await expect(heading).toHaveText("General")
})
test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsColorSchemeSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
const colorScheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-color-scheme")
})
expect(colorScheme).toBe("dark")
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click()
const lightColorScheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-color-scheme")
})
expect(lightColorScheme).toBe("light")
})
test("changing theme persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsThemeSelector)
await expect(select).toBeVisible()
const currentThemeId = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
})
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
.map((x) => x.trim())
.find((x) => x && x !== currentTheme)
expect(nextTheme).toBeTruthy()
await items.filter({ hasText: nextTheme! }).first().click()
await page.keyboard.press("Escape")
const storedThemeId = await page.evaluate(() => {
return localStorage.getItem("opencode-theme-id")
})
expect(storedThemeId).not.toBeNull()
expect(storedThemeId).not.toBe(currentThemeId)
const dataTheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
})
expect(dataTheme).toBe(storedThemeId)
})
test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("opencode-theme-id", "oc-1")
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
})
await gotoSession()
await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-id")
})
})
.toBe("oc-2")
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-css-light")
})
})
.toBeNull()
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-css-dark")
})
})
.toBeNull()
})
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsFontSelector)
await expect(select).toBeVisible()
const initialFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
})
expect(initialFontFamily).toContain("IBM Plex Mono")
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
await items.nth(2).click()
await page.waitForTimeout(100)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
const newFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
})
expect(newFontFamily).not.toBe(initialFontFamily)
})
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
await expect(colorSchemeSelect).toBeVisible()
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const fontSelect = dialog.locator(settingsFontSelector)
await expect(fontSelect).toBeVisible()
const initialFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
const initialSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const currentFont =
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
const fontItems = page.locator('[data-slot="select-select-item"]')
expect(await fontItems.count()).toBeGreaterThan(1)
if (currentFont) {
await fontItems.filter({ hasNotText: currentFont }).first().click()
}
if (!currentFont) {
await fontItems.nth(1).click()
}
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
font: expect.any(String),
},
})
const updatedSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const updatedFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
expect(updatedFontFamily).not.toBe(initialFontFamily)
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
await closeDialog(page, dialog)
await page.reload()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
font: updatedSettings?.appearance?.font,
},
})
const rehydratedSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
await expect
.poll(async () => {
return await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
})
.not.toBe(initialFontFamily)
const rehydratedFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
})
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsAgentSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.agent).toBe(false)
})
test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.permissions).toBe(false)
})
test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsErrorsSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(false)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(true)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.errors).toBe(true)
})
test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
await items.nth(2).click()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
})
test("selecting none disables agent sound", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
const trigger = select.locator('[data-slot="select-select-trigger"]')
await expect(select).toBeVisible()
await expect(trigger).toBeEnabled()
await trigger.click()
const items = page.locator('[data-slot="select-select-item"]')
await expect(items.first()).toBeVisible()
await items.first().click()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agentEnabled).toBe(false)
})
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const permissionsSelect = dialog.locator(settingsSoundsPermissionsSelector)
const errorsSelect = dialog.locator(settingsSoundsErrorsSelector)
await expect(permissionsSelect).toBeVisible()
await expect(errorsSelect).toBeVisible()
const initial = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const permissionsCurrent =
(await permissionsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await permissionsSelect.locator('[data-slot="select-select-trigger"]').click()
const permissionItems = page.locator('[data-slot="select-select-item"]')
expect(await permissionItems.count()).toBeGreaterThan(1)
if (permissionsCurrent) {
await permissionItems.filter({ hasNotText: permissionsCurrent }).first().click()
}
if (!permissionsCurrent) {
await permissionItems.nth(1).click()
}
const errorsCurrent =
(await errorsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await errorsSelect.locator('[data-slot="select-select-trigger"]').click()
const errorItems = page.locator('[data-slot="select-select-item"]')
expect(await errorItems.count()).toBeGreaterThan(1)
if (errorsCurrent) {
await errorItems.filter({ hasNotText: errorsCurrent }).first().click()
}
if (!errorsCurrent) {
await errorItems.nth(1).click()
}
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
sounds: {
permissions: expect.any(String),
errors: expect.any(String),
},
})
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.permissions).not.toBe(initial?.sounds?.permissions)
expect(stored?.sounds?.errors).not.toBe(initial?.sounds?.errors)
})
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsUpdatesStartupSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled)
if (isDisabled) {
test.skip()
return
}
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.updates?.startup).toBe(false)
})
test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsReleaseNotesSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.general?.releaseNotes).toBe(false)
})
@@ -1,39 +0,0 @@
import { test, expect } from "../fixtures"
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector } from "../selectors"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e sidebar popover archive 1 ${stamp}` }).then((r) => r.data)
const two = await sdk.session.create({ title: `e2e sidebar popover archive 2 ${stamp}` }).then((r) => r.data)
if (!one?.id) throw new Error("Session create did not return an id")
if (!two?.id) throw new Error("Session create did not return an id")
try {
await gotoSession(one.id)
await closeSidebar(page)
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
await project.hover()
await expect(oneItem).toBeVisible()
await expect(twoItem).toBeVisible()
const item = await hoverSessionItem(page, one.id)
await item
.getByRole("button", { name: /archive/i })
.first()
.click()
await expect(twoItem).toBeVisible()
} finally {
await cleanupSession({ sdk, sessionID: one.id })
await cleanupSession({ sdk, sessionID: two.id })
}
})
@@ -1,30 +0,0 @@
import { test, expect } from "../fixtures"
import { cleanupSession, openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data)
const two = await sdk.session.create({ title: `e2e sidebar nav 2 ${stamp}` }).then((r) => r.data)
if (!one?.id) throw new Error("Session create did not return an id")
if (!two?.id) throw new Error("Session create did not return an id")
try {
await gotoSession(one.id)
await openSidebar(page)
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
} finally {
await cleanupSession({ sdk, sessionID: one.id })
await cleanupSession({ sdk, sessionID: two.id })
}
})
-40
View File
@@ -1,40 +0,0 @@
import { test, expect } from "../fixtures"
import { openSidebar, toggleSidebar, withSession } from "../actions"
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession()
await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toHaveAttribute("aria-expanded", "true")
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false")
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "true")
})
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "sidebar persist session 1", async (session1) => {
await withSession(sdk, "sidebar persist session 2", async (session2) => {
await gotoSession(session1.id)
await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false")
await gotoSession(session2.id)
await expect(button).toHaveAttribute("aria-expanded", "false")
await page.reload()
await expect(button).toHaveAttribute("aria-expanded", "false")
const opened = await page.evaluate(
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
)
await expect(opened).toBe(false)
})
})
})
@@ -1,94 +0,0 @@
import { test, expect } from "../fixtures"
import { openStatusPopover } from "../actions"
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible()
await page.keyboard.press("Escape")
await expect(popoverBody).toHaveCount(0)
})
test("status popover servers tab shows current server", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const serversTab = popoverBody.getByRole("tab", { name: /servers/i })
await expect(serversTab).toHaveAttribute("aria-selected", "true")
const serverList = popoverBody.locator('[role="tabpanel"]').first()
await expect(serverList.locator("button").first()).toBeVisible()
})
test("status popover can switch to mcp tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i })
await mcpTab.click()
const ariaSelected = await mcpTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(mcpContent).toBeVisible()
})
test("status popover can switch to lsp tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const lspTab = popoverBody.getByRole("tab", { name: /lsp/i })
await lspTab.click()
const ariaSelected = await lspTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(lspContent).toBeVisible()
})
test("status popover can switch to plugins tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i })
await pluginsTab.click()
const ariaSelected = await pluginsTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(pluginsContent).toBeVisible()
})
test("status popover closes on escape", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody).toBeVisible()
await page.keyboard.press("Escape")
await expect(popoverBody).toHaveCount(0)
})
test("status popover closes when clicking outside", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody).toBeVisible()
await page.getByRole("main").click({ position: { x: 5, y: 5 } })
await expect(popoverBody).toHaveCount(0)
})
@@ -1,28 +0,0 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
await gotoSession()
const terminals = page.locator(terminalSelector)
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const opened = await terminals.first().isVisible()
if (!opened) {
await page.keyboard.press(terminalToggleKey)
}
await waitTerminalReady(page, { term: terminals.first() })
await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back
// to the app shell before triggering `terminal.new`.
await page.locator(promptSelector).click()
await page.keyboard.press("Control+Alt+T")
await expect(tabs).toHaveCount(2)
await expect(terminals).toHaveCount(1)
await waitTerminalReady(page, { term: terminals.first() })
})
@@ -1,132 +0,0 @@
import type { Page } from "@playwright/test"
import { runTerminal, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = {
active?: string
all: Array<{
id: string
title: string
titleNumber: number
buffer?: string
}>
}
async function open(page: Page) {
const terminal = page.locator(terminalSelector)
const visible = await terminal.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal })
}
async function store(page: Page, key: string) {
return page.evaluate((key) => {
const raw = localStorage.getItem(key)
if (raw) return JSON.parse(raw) as State
for (let i = 0; i < localStorage.length; i++) {
const next = localStorage.key(i)
if (!next?.endsWith(":workspace:terminal")) continue
const value = localStorage.getItem(next)
if (!value) continue
return JSON.parse(value) as State
}
}, key)
}
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const one = `E2E_TERM_ONE_${Date.now()}`
const two = `E2E_TERM_TWO_${Date.now()}`
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await gotoSession()
await open(page)
await runTerminal(page, { cmd: `echo ${one}`, token: one })
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
await runTerminal(page, { cmd: `echo ${two}`, token: two })
await first.click()
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 5_000 },
)
.toEqual({ first: false, second: true })
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return {
first: first.includes(one),
second: second.includes(two),
}
},
{ timeout: 5_000 },
)
.toEqual({ first: true, second: false })
})
})
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
await gotoSession()
await open(page)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await second.hover()
await page
.getByRole("button", { name: /close terminal/i })
.nth(1)
.click({ force: true })
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
await expect(tabs).toHaveCount(1)
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
return {
count: state?.all.length ?? 0,
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
}
},
{ timeout: 15_000 },
)
.toEqual({ count: 1, first: true })
})
})
@@ -1,18 +0,0 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
await gotoSession()
const terminal = page.locator(terminalSelector)
const initiallyOpen = await terminal.isVisible()
if (initiallyOpen) {
await page.keyboard.press(terminalToggleKey)
await expect(terminal).toHaveCount(0)
}
await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal })
})
-25
View File
@@ -1,25 +0,0 @@
import { test, expect } from "./fixtures"
import { modelVariantCycleSelector } from "./selectors"
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
await gotoSession()
await page.addStyleTag({
content: `${modelVariantCycleSelector} { display: inline-block !important; }`,
})
const button = page.locator(modelVariantCycleSelector)
const exists = (await button.count()) > 0
test.skip(!exists, "current model has no variants")
if (!exists) return
await expect(button).toBeVisible()
const before = (await button.innerText()).trim()
await button.click()
await expect(button).not.toHaveText(before)
const after = (await button.innerText()).trim()
await button.click()
await expect(button).not.toHaveText(after)
})
+11
View File
@@ -0,0 +1,11 @@
import { test } from "@playwright/test"
test(
"test something cool",
{
annotation: { type: "todo" },
},
async () => {
test.fixme()
},
)
+1
View File
@@ -2,6 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts"]
-63
View File
@@ -1,63 +0,0 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
export const serverUrl = `http://${serverHost}:${serverPort}`
export const serverName = `${serverHost}:${serverPort}`
const localHosts = ["127.0.0.1", "localhost"]
const serverLabels = (() => {
const url = new URL(serverUrl)
if (!localHosts.includes(url.hostname)) return [serverName]
return localHosts.map((host) => `${host}:${url.port}`)
})()
export const serverNames = [...new Set(serverLabels)]
export const serverUrls = serverNames.map((name) => `http://${name}`)
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`)
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
export const terminalToggleKey = "Control+Backquote"
export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
}
export async function resolveDirectory(directory: string) {
return createSdk(directory)
.path.get()
.then((x) => x.data?.directory ?? directory)
}
export async function getWorktree() {
const sdk = createSdk()
const result = await sdk.path.get()
const data = result.data
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
return data.worktree
}
export function dirSlug(directory: string) {
return base64Encode(directory)
}
export function dirPath(directory: string) {
return `/${dirSlug(directory)}`
}
export function sessionPath(directory: string, sessionID?: string) {
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
}
export function workspacePersistKey(directory: string, key: string) {
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(directory) ?? "0"
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en" style="background-color: var(--background-base)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
+10 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.25",
"version": "1.4.6",
"description": "",
"type": "module",
"exports": {
@@ -15,17 +15,18 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "bun script/e2e-local.ts",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report"
},
"license": "MIT",
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "1.57.0",
"@playwright/test": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
@@ -41,22 +42,25 @@
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opencode-ai/shared": "workspace:*",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "4.0.0-beta.31",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",
+8 -1
View File
@@ -6,6 +6,12 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
}
export default defineConfig({
testDir: "./e2e",
@@ -17,7 +23,8 @@ export default defineConfig({
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
workers,
reporter,
webServer: {
command,
url: baseURL,
-179
View File
@@ -1,179 +0,0 @@
import fs from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
async function freePort() {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.once("error", reject)
server.listen(0, () => {
const address = server.address()
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire a free port")))
return
}
server.close((err) => {
if (err) {
reject(err)
return
}
resolve(address.port)
})
})
})
}
async function waitForHealth(url: string) {
const timeout = Date.now() + 120_000
const errors: string[] = []
while (Date.now() < timeout) {
const result = await fetch(url)
.then((r) => ({ ok: r.ok, error: undefined }))
.catch((error) => ({
ok: false,
error: error instanceof Error ? error.message : String(error),
}))
if (result.ok) return
if (result.error) errors.push(result.error)
await new Promise((r) => setTimeout(r, 250))
}
const last = errors.length ? ` (last error: ${errors[errors.length - 1]})` : ""
throw new Error(`Timed out waiting for server health: ${url}${last}`)
}
const appDir = process.cwd()
const repoDir = path.resolve(appDir, "../..")
const opencodeDir = path.join(repoDir, "packages", "opencode")
const extraArgs = (() => {
const args = process.argv.slice(2)
if (args[0] === "--") return args.slice(1)
return args
})()
const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1"
const serverEnv = {
...process.env,
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
XDG_DATA_HOME: path.join(sandbox, "share"),
XDG_CACHE_HOME: path.join(sandbox, "cache"),
XDG_CONFIG_HOME: path.join(sandbox, "config"),
XDG_STATE_HOME: path.join(sandbox, "state"),
OPENCODE_E2E_PROJECT_DIR: repoDir,
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app",
} satisfies Record<string, string>
const runnerEnv = {
...serverEnv,
PLAYWRIGHT_SERVER_HOST: "127.0.0.1",
PLAYWRIGHT_SERVER_PORT: String(serverPort),
VITE_OPENCODE_SERVER_HOST: "127.0.0.1",
VITE_OPENCODE_SERVER_PORT: String(serverPort),
PLAYWRIGHT_PORT: String(webPort),
} satisfies Record<string, string>
let seed: ReturnType<typeof Bun.spawn> | undefined
let runner: ReturnType<typeof Bun.spawn> | undefined
let server: { stop: () => Promise<void> | void } | undefined
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
let cleaned = false
const cleanup = async () => {
if (cleaned) return
cleaned = true
if (seed && seed.exitCode === null) seed.kill("SIGTERM")
if (runner && runner.exitCode === null) runner.kill("SIGTERM")
const jobs = [
inst?.Instance.disposeAll(),
server?.stop(),
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
].filter(Boolean)
await Promise.allSettled(jobs)
}
const shutdown = (code: number, reason: string) => {
process.exitCode = code
void cleanup().finally(() => {
console.error(`e2e-local shutdown: ${reason}`)
process.exit(code)
})
}
const reportInternalError = (reason: string, error: unknown) => {
console.warn(`e2e-local ignored server error: ${reason}`)
console.warn(error)
}
process.once("SIGINT", () => shutdown(130, "SIGINT"))
process.once("SIGTERM", () => shutdown(143, "SIGTERM"))
process.once("SIGHUP", () => shutdown(129, "SIGHUP"))
process.once("uncaughtException", (error) => {
reportInternalError("uncaughtException", error)
})
process.once("unhandledRejection", (error) => {
reportInternalError("unhandledRejection", error)
})
let code = 1
try {
seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
cwd: opencodeDir,
env: serverEnv,
stdout: "inherit",
stderr: "inherit",
})
const seedExit = await seed.exited
if (seedExit !== 0) {
code = seedExit
} else {
Object.assign(process.env, serverEnv)
process.env.AGENT = "1"
process.env.OPENCODE = "1"
process.env.OPENCODE_PID = String(process.pid)
const log = await import("../../opencode/src/util/log")
const install = await import("../../opencode/src/installation")
await log.Log.init({
print: true,
dev: install.Installation.isLocal(),
level: "WARN",
})
const servermod = await import("../../opencode/src/server/server")
inst = await import("../../opencode/src/project/instance")
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
cwd: appDir,
env: runnerEnv,
stdout: "inherit",
stderr: "inherit",
})
code = await runner.exited
}
} catch (error) {
console.error(error)
code = 1
} finally {
await cleanup()
}
process.exit(code)

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