Compare commits

..

454 Commits

Author SHA1 Message Date
Aiden Cline 6714f74433 feat(plugin): flat tool draft with namespace
Effect draft.add accepts flat tool objects matching Promise ergonomics.
Migrate the tool grouping concept fully to namespace: rename MCP's group
helper/registration to namespace and thread namespace through the flat
Effect and Promise draft declarations.
2026-07-13 00:14:06 -05:00
Aiden Cline 64ca9b8d77 refactor(core): replace deferred tool option with codemode (#36560) 2026-07-12 21:21:07 -05:00
Kit Langton a4b91d33d9 fix(tui): align skills dialog copy (#36592) 2026-07-12 22:02:24 -04:00
Kit Langton 397993e898 fix(client): preserve compatible background service (#36583) 2026-07-12 21:32:34 -04:00
Kit Langton c0ed0106b1 fix(tui): show background shell completion (#36534) 2026-07-12 21:01:36 -04:00
opencode-agent[bot] f112a73c06 fix(core): load config across git boundaries (#36577)
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 00:05:41 +00:00
Dax Raad 7e9b9cb0fd docs: document TUI config migration 2026-07-12 19:28:26 -04:00
Dax Raad 09a6cecf23 refactor(tui): remove code concealment toggle 2026-07-12 18:12:36 -04:00
opencode-agent[bot] 6e55ddd078 fix(config): load configs across git boundaries (#36568)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-12 22:07:20 +00:00
Dax Raad f8f8cc9546 refactor(tui): simplify config context 2026-07-12 17:12:48 -04:00
Aiden Cline 3c60470269 refactor(codemode): remove tool concurrency cap (#36545) 2026-07-12 16:07:53 -05:00
Dax Raad b2bdab24e6 fix(tui): preserve legacy host compatibility 2026-07-12 17:01:02 -04:00
Dax Raad 3568dd1b99 feat(tui): add managed config interface 2026-07-12 17:01:02 -04:00
opencode-agent[bot] 1e17202413 chore: merge dev into v2 (#36556)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
2026-07-12 15:16:08 -05:00
Aiden Cline 49cb73b348 refactor(codemode): split runtime into focused interpreter modules (#36540) 2026-07-12 12:47:56 -05:00
opencode-agent[bot] 430750e2f8 fix(tui): remove unsupported MCP toggle (#36531)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-12 10:56:24 -05:00
Kit Langton 7913c4a490 refactor(server): share event stream encoding (#36484) 2026-07-12 11:48:52 -04:00
Kit Langton 5414697bd1 fix(tui): show background subagent completion (#36530)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-07-12 11:43:13 -04:00
Kit Langton e22e3b8f2c fix(cli): keep update preflight through TUI loading (#36475) 2026-07-11 22:19:13 -04:00
Kit Langton 8e657c7db5 fix(core): settle malformed tool input on failure (#36477) 2026-07-11 21:41:57 -04:00
Kit Langton 02e2277057 fix(tui): lower durable event log level (#36479) 2026-07-11 21:37:08 -04:00
Dax Raad ac1ddc3f83 refactor(tui): simplify config and tool display 2026-07-11 19:51:19 -04:00
opencode-agent[bot] 8f904c8e9a fix(cli): load v2 tui config (#36466)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-11 19:13:29 -04:00
Dax Raad ec07ee56f4 feat(tui): add plugin route api 2026-07-11 18:52:04 -04:00
Dax Raad de86d73d19 feat(tui): add v2 plugin context 2026-07-11 17:56:25 -04:00
Dax Raad 0fa9e5039e refactor(tui): reduce legacy sync usage 2026-07-11 17:05:23 -04:00
Dax Raad c073387723 refactor(tui): use default client export 2026-07-11 16:44:55 -04:00
Dax Raad 1956497f42 fix(client): loosen opaque promise outputs 2026-07-11 16:38:02 -04:00
Dax Raad 2a7e32c416 refactor(tui): remove legacy sdk surfaces 2026-07-11 16:38:02 -04:00
Kit Langton 56a7c06a80 fix(cli): hand update completion directly to the TUI (#36455) 2026-07-11 15:42:31 -04:00
Aiden Cline 75e8fd4da2 test(codemode): drop leftover $codemode references (#36451) 2026-07-11 14:15:11 -05:00
opencode-agent[bot] b9f39dd751 fix(core): disable unused fff content caches (#36452)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-11 15:03:54 -04:00
Aiden Cline 6eeeb4bfcf feat(codemode): make search a global built-in and rewrite README (#36450) 2026-07-11 13:43:33 -05:00
Kit Langton 1ccaca826e feat(cli): update preflight UI while replacing the background service (#36448) 2026-07-11 14:43:16 -04:00
Aiden Cline 04f9b15178 fix(core): include child session id in background subagent start text (#36447) 2026-07-11 13:34:30 -05:00
Kit Langton 66b9cc7931 fix(client): accept larger SSE events (#36442) 2026-07-11 14:15:53 -04:00
Kit Langton e3f7637eb2 feat(tui): add semantic file path truncation (#36352) 2026-07-11 13:57:29 -04:00
Kit Langton 5945a8d429 fix(tui): restore queued compaction indicator (#36440) 2026-07-11 13:54:23 -04:00
Kit Langton c7ceccf869 fix(core): skip fff for aggregate locations (#36437) 2026-07-11 13:47:41 -04:00
Kit Langton 00ab94c44f fix(tui): stabilize compaction completion (#36435) 2026-07-11 13:06:14 -04:00
Aiden Cline fe09a2e9b7 feat(codemode): support Promise.any and new Promise construction (#36339) 2026-07-10 20:46:13 -05:00
Dax 59202356fe fix(tui): fork messages with agent attachments (#36338) 2026-07-10 21:23:46 -04:00
Dax 637282f3b9 fix(tui): make composer close action discoverable (#36337) 2026-07-10 21:19:57 -04:00
opencode-agent[bot] d1550cb599 feat(core): port GitHub Copilot OAuth (#36336)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-10 21:11:12 -04:00
Dax Raad ed6c117184 fix(tui): retain leader subagent shortcut 2026-07-10 21:01:55 -04:00
Dax Raad 6ce62bd84a feat(tui): open subagents with down 2026-07-10 21:01:04 -04:00
Dax Raad a4a948316b fix(core): skip unchanged plugin activation 2026-07-10 20:49:16 -04:00
Dax Raad aca3b7813d fix(core): publish skill source updates 2026-07-10 20:42:44 -04:00
Dax Raad 0eaa75ec0f fix(tui): total root session family cost 2026-07-10 19:51:10 -04:00
Kit Langton 6b3c4f5839 refactor(core): combine git discovery queries (#36321) 2026-07-10 19:41:48 -04:00
Aiden Cline 8e76adb08f feat(codemode): support promise chaining with .then/.catch/.finally (#36304) 2026-07-10 18:22:04 -05:00
Kit Langton 0bb24a46c1 fix(core): defer snapshot repository discovery (#36290) 2026-07-10 17:34:40 -04:00
James Long d3f4695af3 docs: add opencode-drive skill (#36319) 2026-07-10 17:30:41 -04:00
Dax Raad 5b2715d24f refactor(plugin): simplify promise adapter imports 2026-07-10 17:18:46 -04:00
Dax Raad 20e37e7122 fix(plugin): adapt promise host wire values 2026-07-10 17:18:03 -04:00
Dax Raad b87bb4486f fix(client): generate standalone promise DTOs 2026-07-10 17:06:45 -04:00
Dax Raad feaec7a6be fix overloaded models issue 2026-07-10 16:56:37 -04:00
Dax Raad 1c67004999 fix(cli): show background server startup status 2026-07-10 16:14:19 -04:00
opencode-agent[bot] 43ecf3ff1b chore: merge dev into v2 (#36312)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
2026-07-10 15:11:29 -05:00
Dax Raad 9028c2d8f8 docs: clarify beta binary and MCP route 2026-07-10 15:54:30 -04:00
James Long be18f22842 feat(simulation): record viewport resizes (#36306) 2026-07-10 15:08:43 -04:00
Dax Raad 6216dbae15 docs: simplify intro page 2026-07-10 13:39:46 -04:00
Kit Langton 96a9731947 feat(core): replace instruction checkpoints with value-delta sync (#36254) 2026-07-10 13:26:25 -04:00
Dax Raad 768a69bbbd sync 2026-07-10 12:30:49 -04:00
Kit Langton b7b941c354 fix(tui): unify pending tool styling (#36278) 2026-07-10 12:29:09 -04:00
Kit Langton cf5e0adf02 fix(core): simplify compaction semantics (#36267) 2026-07-10 11:36:29 -04:00
Kit Langton 41d0a9f010 fix(tui): hide editor context from transcript (#36264) 2026-07-10 11:33:04 -04:00
Dax Raad accaa792d8 docs: clarify opencode skill references 2026-07-10 11:24:03 -04:00
Dax Raad 76d7a11585 docs: expand opencode skill guidance 2026-07-10 11:23:20 -04:00
Dax Raad c867e66ab5 feat(plugin): add promise plugin cleanup 2026-07-10 11:23:20 -04:00
Kit Langton 278c510549 refactor(core): simplify session runner bookkeeping (#36200) 2026-07-10 09:46:34 -04:00
Dax Raad 0d6ccd2a50 docs: add build overview 2026-07-10 02:51:30 -04:00
Aiden Cline 39cceeb143 fix(codemode): return promises from combinators (#35782) 2026-07-10 00:33:21 -05:00
Aiden Cline a6449cb45c refactor(form): model links as fields (#36129) 2026-07-10 00:22:01 -05:00
Dax Raad 6a85f0d3db feat(client): add root promise entrypoint 2026-07-10 01:09:06 -04:00
Dax Raad fbb0fdf88a fix(plugin): select plugins by id 2026-07-10 00:44:43 -04:00
Dax Raad 2a4b298108 refactor(plugin): include name in promise tools 2026-07-10 00:22:45 -04:00
Dax Raad efeff63749 refactor(plugin): simplify promise tool declarations 2026-07-10 00:18:13 -04:00
Kit Langton d54038b9d2 docs(v2): consolidate specifications (#36186) 2026-07-09 22:47:37 -04:00
Dax Raad 8f04a09d1a refactor(plugin): remove protocol dependency 2026-07-09 22:25:38 -04:00
Dax Raad 761f37370f fix(plugin): restore package publishing 2026-07-09 22:18:58 -04:00
Kit Langton b452368b3b refactor(core): simplify tool admission flow (#36180) 2026-07-09 22:01:31 -04:00
Kit Langton 3785eddfa0 fix(core): preserve admitted tool generations (#36177) 2026-07-09 21:16:00 -04:00
Dax Raad 4a006b1210 refactor(core): derive config watches from entries 2026-07-09 21:12:15 -04:00
Aiden Cline 2db7ccb453 fix(core): restore resilient compaction (#36163) 2026-07-09 19:13:11 -05:00
Aiden Cline 629b304c48 fix(core): continue after configured permission denial (#36155) 2026-07-09 17:35:25 -05:00
Dax Raad 1004fbb2f5 docs: expand OpenCode 2 beta guides 2026-07-09 18:01:15 -04:00
opencode-agent[bot] a7746379d5 chore: merge dev into v2 (#36144)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-09 16:32:44 -05:00
Kit Langton a72992e00f feat(core): session.pending.list API with pending-only session_pending storage (#36126) 2026-07-09 17:25:40 -04:00
James Long 99156c10e6 feat(cli): export frontend logs over OTLP (#36152) 2026-07-09 17:19:22 -04:00
Dax Raad f6f0a5c10f docs: expand user guides 2026-07-09 16:57:13 -04:00
Dax Raad e2eed76101 refactor(tui): migrate v2 calls to new client 2026-07-09 16:57:13 -04:00
Kit Langton 9ff7ef3fb0 fix(tui): handle session search fetch failures without crashing (#36149) 2026-07-09 16:38:58 -04:00
Dax Raad 8fe78c8f8d fix(core): restore plugins after failed activation 2026-07-09 16:28:22 -04:00
Harsh Mathur bd0dffd781 fix(llm): preserve nested OpenAI stream errors (#36130)
Co-authored-by: Harsh Mathur <jeevesforharsh@gmail.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-09 14:40:28 -05:00
Dax Raad 0ac8f91ed6 fix(core): restore catalog status schema export 2026-07-09 14:20:36 -04:00
Dax Raad 4c80c23e11 fix(tui): reconcile refreshed messages 2026-07-09 14:12:02 -04:00
Dax Raad 33c0cc2bb6 fix: synchronize remote data refreshes 2026-07-09 13:56:28 -04:00
Dax Raad cc686ab8f6 fix(cli): unify server resolution 2026-07-09 13:28:01 -04:00
Kit Langton 6524dfc818 feat(core): resume suspended sessions after service restart (#36105) 2026-07-09 13:19:15 -04:00
Dax Raad c91604d519 chore(opencode): suppress dead v1 model types 2026-07-09 13:15:20 -04:00
Dax Raad a2718177be test(core): align models dev snapshot fixtures 2026-07-09 13:15:20 -04:00
Dax Raad 6fb526741b refactor(tui): rename link concept to pair 2026-07-09 13:15:20 -04:00
Dax Raad 85fdb3bde6 refactor(core): optimize location startup 2026-07-09 13:15:20 -04:00
James Long b642f9c7bc feat(tui): log event stream connections (#35973) 2026-07-09 13:13:34 -04:00
James Long 855a9569b5 feat(simulation): add literal screen text matching (#36085) 2026-07-09 10:50:35 -04:00
Kit Langton aa0e626b12 fix(core): defer permission rejection cleanup (#36067) 2026-07-09 09:49:14 -04:00
Aiden Cline 389c866cac docs(codemode): add interpreter doc (#36026) 2026-07-09 00:42:48 -05:00
Aiden Cline 7feefb697f refactor: remove todo tool (#35989) 2026-07-09 00:13:48 -05:00
Dax Raad d4155f2906 refactor(cli): simplify mini arguments 2026-07-08 23:28:40 -04:00
Dax Raad 67f2393f83 refactor(cli): parse variants from model refs 2026-07-08 23:25:37 -04:00
Dax Raad cccd013801 refactor(cli): centralize server resolution 2026-07-08 22:10:30 -04:00
Dax Raad be7a684791 refactor(cli): unify server endpoint handling 2026-07-08 22:10:30 -04:00
Kit Langton 984cab7938 feat(core): generalize session input inbox (#36005) 2026-07-08 22:07:45 -04:00
Aiden Cline 7eea97184a fix(tui): restore prompt after undo (#35987) 2026-07-08 19:54:29 -05:00
Kit Langton 0590819090 fix(server): absorb healthy event bursts 2026-07-08 20:33:28 -04:00
opencode-agent[bot] 687dbba6a3 chore: merge dev into v2 (#35962)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-08 17:58:55 -05:00
Dax Raad 2347f4d7b4 fix(tui): simplify server link title 2026-07-08 18:47:48 -04:00
Dax Raad 7698a5e6ac feat: add server link sharing 2026-07-08 18:42:54 -04:00
Aiden Cline ec8eea7cbe test(codemode): add Test262 array coverage (#35965) 2026-07-08 17:26:11 -05:00
Aiden Cline b44a981eef feat(core): global form support (#35959) 2026-07-08 17:25:34 -05:00
James Long 79415f625a feat(tui): revamp session export (#35971) 2026-07-08 18:14:43 -04:00
Simon Klee 19f42f7102 feat(v2/cli): add console login (#35969) 2026-07-09 00:00:09 +02:00
Aiden Cline 212c9f99ee test(codemode): add Test262 string coverage (#35940) 2026-07-08 15:51:54 -05:00
James Long 99e52303e6 feat(core): discover ecosystem skill directories (#35956) 2026-07-08 16:34:41 -04:00
Dax Raad 88925ec6e6 fix(tui): allow callers without log sink 2026-07-08 16:00:52 -04:00
Dax Raad cedf365674 feat(tui): add structured logging 2026-07-08 16:00:07 -04:00
Kit Langton 5b39972947 fix(core): await initial plugin readiness (#35755) 2026-07-08 15:12:45 -04:00
James Long 09553d46e0 refactor(simulation): remove filesystem layers (#35938) 2026-07-08 12:22:04 -04:00
James Long e1d72fa338 fix(simulation): stabilize screenshot artifacts (#35933) 2026-07-08 11:48:53 -04:00
Dax Raad b3cf749a7f fix(tui): show resolved skill name 2026-07-08 11:46:02 -04:00
Dax Raad dd842f9862 fix(cli): elect one service process 2026-07-08 11:46:02 -04:00
Kit Langton 3dd29094f4 feat(http-recorder): sync recorder v0.3 (#35619) 2026-07-08 11:41:59 -04:00
Aiden Cline 05e8d5be73 fix(core): preserve prompt attachment order (#35921) 2026-07-08 10:06:58 -05:00
Dax Raad 8b634e4a58 refactor(tui): simplify client data state 2026-07-08 10:37:48 -04:00
James Long c6156f171c feat(simulation): stream UI recordings (#35909) 2026-07-08 10:33:11 -04:00
Dax Raad 4d2b06f8cf docs: prohibit bypassing git hooks 2026-07-08 10:11:46 -04:00
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 bf15c97e4b Revert "fix(tui): paginate session history"
This reverts commit cc29f86cdc.
2026-07-08 13:45:20 +00:00
Shoubhit Dash 3cbcd8d727 feat(llm): complete provider package entrypoints (#35907) 2026-07-08 18:41:46 +05:30
Dax Raad cc29f86cdc fix(tui): paginate session history 2026-07-08 01:34:43 -04:00
Dax Raad 86e3828da6 fix(core): watch location only when vcs is present 2026-07-08 00:38:56 -04:00
James Long 8d00a51766 feat(simulation): support multiple tool calls (#35861) 2026-07-08 00:35:18 -04:00
opencode-agent[bot] ed4f833813 fix(core): preserve provider metadata namespaces (#35817)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-07 21:45:58 -05:00
opencode-agent[bot] e96c24ce2e test(core): fix mutable plugin fixture (#35837) 2026-07-07 22:22:12 -04:00
Kit Langton ed6ad272ec refactor(schema): apply session review decisions (#35793) 2026-07-07 22:10:11 -04:00
Kit Langton d27746e5b3 test(core): simplify runner test fixtures (#35832) 2026-07-07 21:58:52 -04:00
Kit Langton eb661e6e6d fix(core): settle unadvertised tool calls and repair session hook test contexts 2026-07-07 21:51:23 -04:00
Dax Raad 3379e44abb refactor(core): rename ApplyPatchTool to PatchTool 2026-07-07 21:16:00 -04:00
Dax e1abf59c59 fix(core): provide PluginHooks in plugin test layer (#35811) 2026-07-07 20:03:41 -04:00
Dax 4f976bcf1a feat(plugin): add session request hook (#35794) 2026-07-07 19:56:47 -04:00
Kit Langton e25a724bc2 test(tui): cover patch diff display aliases (#35810) 2026-07-07 19:44:54 -04:00
Kit Langton 521ea87192 fix(core): retain reasoning delta state (#35758) 2026-07-07 19:44:40 -04:00
Aiden Cline 3100701488 fix(tui): restore prompt context usage (#35795) 2026-07-07 17:49:33 -05:00
Kit Langton 9c54291ae8 fix(tui): restore forms on reconnect
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-07 22:47:13 +00:00
Dax Raad 52ad916ba4 fix(tui): restore permissions on reconnect
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-07 22:44:26 +00:00
Dax Raad 5db320b02d refactor: rename apply_patch tool 2026-07-07 16:27:25 -04:00
Aiden Cline 910e37f6d8 fix: update v2 session usage metrics (#35468) 2026-07-07 14:31:54 -05:00
Kit Langton 81f6e06681 feat: enable background subagents by default 2026-07-07 15:18:46 -04:00
James Long da68e2865e feat(tui): wire move session command (#35203) 2026-07-07 14:49:11 -04:00
Aiden Cline f86e1cc1ff fix(codemode): preserve collection value identity (#35776) 2026-07-07 13:40:55 -05:00
Aiden Cline 947bbf9490 feat(mcp): expose resource catalog API (#35773) 2026-07-07 13:34:29 -05:00
James Long 3e57b43a4a feat(session): support admit-only synthetic messages (#35774) 2026-07-07 14:17:20 -04:00
opencode-agent[bot] cb18a42a47 docs(codemode): clarify implicit return guidance (#35763)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-07 12:48:05 -05:00
Aiden Cline 1fb2837fd3 feat(codemode): expand numeric standard library (#35749) 2026-07-07 12:11:57 -05:00
Aiden Cline 66a8d830aa feat(core): support MCP resources (#35656) 2026-07-07 11:55:06 -05:00
opencode-agent[bot] d0733f83bf fix(codemode): return final top-level expression (#35759)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-07 11:44:50 -05:00
Kit Langton b2ce195bda fix(core): normalize Location.Ref keys in LocationServiceMap (#35757) 2026-07-07 12:25:29 -04:00
James Long 6c065ca6a5 feat(simulation): render driven UI screenshots and videos (#35739) 2026-07-07 12:15:01 -04:00
Dax Raad dd1c007877 docs 2026-07-07 12:08:45 -04:00
Kit Langton 8deb0e5780 fix(core): preserve SDK plugins across location eviction (#35725) 2026-07-07 11:43:40 -04:00
Aiden Cline f488089179 fix: preserve compaction files and classify Z.AI overflow (#35747)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
2026-07-07 09:54:31 -05:00
Kit Langton e3a39b214f fix(core): lower failed reasoning to text (#35735) 2026-07-07 09:49:17 -04:00
James Long b6a2912bb1 refactor(simulation): type RPC request payloads (#35734) 2026-07-07 09:21:19 -04:00
opencode-agent[bot] 93b7ca9b4d fix(tui): align switch reminders (#35637)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-06 23:02:34 -05:00
James Long fcb1d4b418 feat(simulation): add named drive instances (#35648) 2026-07-06 22:52:10 -04:00
Kit Langton 04b673432c feat(core): add durable compaction barrier (#35371) 2026-07-06 21:26:05 -04:00
Dax Raad bd947658bb docs: add v2 configuration reference 2026-07-06 20:09:35 -04:00
Kit Langton bb6ee0a5cc docs(codemode): document loop syntax (#35631) 2026-07-06 19:52:39 -04:00
Dax Raad fd4497e6b9 fix(tui): route shell termination by location 2026-07-06 19:39:22 -04:00
Dax Raad 59790380de fix(core): support unlimited shell timeouts 2026-07-06 19:32:22 -04:00
Dax Raad 7668eb728d fix(tui): normalize attachment labels 2026-07-06 19:22:25 -04:00
Dax Raad 79301fbed5 chore(cli): inline build sourcemaps 2026-07-06 19:22:12 -04:00
Dax Raad c0ea1a43f9 feat(tui): support session deletion 2026-07-06 19:19:22 -04:00
Dax Raad 96717c1a8c feat(tui): improve session fork handling 2026-07-06 18:59:01 -04:00
Dax Raad e312f0d775 fix(tui): provide location for home mentions 2026-07-06 18:50:24 -04:00
Aiden Cline bfdbf43ef8 fix(core): clarify MCP timeout budgets (#35626) 2026-07-06 17:47:20 -05:00
Aiden Cline 984430c97d chore: add typecheck profiling (#35622) 2026-07-06 17:17:30 -05:00
Kit Langton bf01264661 feat(core): finalize session event lifecycle (#35272) 2026-07-06 18:14:06 -04:00
Kit Langton b046bbe4e3 feat(codemode): support loop sequence syntax (#35618) 2026-07-06 17:59:13 -04:00
Aiden Cline 8dc9ac07a2 fix(tui): sync v2 terminal title (#35610) 2026-07-06 16:51:23 -05:00
Aiden Cline ba24037e64 feat(codemode): math parity (#35609) 2026-07-06 16:47:17 -05:00
Kit Langton df471872de fix(codemode): reject Promise.all immediately (#35604) 2026-07-06 17:37:50 -04:00
Dax Raad 8942f97998 sync 2026-07-06 17:36:28 -04:00
Kit Langton 754dd57177 fix(codemode): preserve async callback semantics (#35603) 2026-07-06 17:29:11 -04:00
Dax Raad f044def957 fix(tui): detect backgrounded subagents 2026-07-06 17:15:03 -04:00
Dax Raad 9c2842b9b0 fix(tui): track pending session inputs 2026-07-06 17:12:21 -04:00
Aiden Cline 9e0d3976e1 chore: merge dev into v2 (#35591)
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
2026-07-06 16:05:29 -05:00
Dax Raad f87998f37f fix(cli): align prompt attachment mentions 2026-07-06 16:28:37 -04:00
Shoubhit Dash e57d9ca390 feat(core): flatten provider config and load native packages (#35563)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-06 16:18:56 -04:00
Kit Langton d603eed5a1 feat(codemode): support destructuring assignment (#35600) 2026-07-06 15:56:52 -04:00
Kit Langton 0e1c0414c2 fix(codemode): align mutable JavaScript semantics (#35598) 2026-07-06 15:55:32 -04:00
Kit Langton dc99c600b6 feat(v2): expose canonical schema contracts (#35595) 2026-07-06 15:45:46 -04:00
James Long 6ffe61b9fe feat(simulation): isolate filesystem and disconnect llm (#35590) 2026-07-06 15:39:03 -04:00
Dax Raad 36d17c30a7 fix(sdk): expose prompt input contract 2026-07-06 15:05:43 -04:00
Dax Raad 4d100de194 feat(core): resolve prompt attachments 2026-07-06 15:04:23 -04:00
Dax Raad c13f06c30c feat(tui): use canonical prompt attachments 2026-07-06 14:35:31 -04:00
Kit Langton 91f1815732 refactor(core): rename system context to instructions (#35583) 2026-07-06 14:29:29 -04:00
Kit Langton 1a52e1118e refactor(core): remove unused session watermarks (#35579) 2026-07-06 13:55:47 -04:00
Kit Langton f2f62438b8 refactor(core): unify live event subscriptions (#35578) 2026-07-06 13:46:59 -04:00
Kit Langton 2830176972 fix(plugin): reload late SDK plugins (#35576) 2026-07-06 13:36:19 -04:00
Dax Raad 06dcf3f221 fix(tui): remove committed undo messages 2026-07-06 13:08:25 -04:00
Dax Raad f9442e7107 test(opencode): update active session mock 2026-07-06 13:01:36 -04:00
Dax Raad ff499c3603 refactor(api): remove watermark sync surface 2026-07-06 13:01:36 -04:00
Aiden Cline 4cee5bd824 refactor(codemode): split interpreter and stdlib (#35572) 2026-07-06 12:01:14 -05:00
Dax Raad f406fed924 fix(cli): ignore version mismatch on reconnect 2026-07-06 12:35:07 -04:00
Aiden Cline 5b2d90a441 feat(codemode): expand standard library support (#35566) 2026-07-06 10:36:02 -05:00
opencode-agent[bot] 04e8801f5b fix(tui): soften interrupted step styling (#35466)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-06 09:30:55 -05:00
Aiden Cline 7f008df79d fix(codemode): improve prompting (#35509) 2026-07-06 09:19:04 -05:00
Simon Klee 175d200e53 V2 opentui upgrade (#35544)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-06 15:34:57 +02:00
Simon Klee 41d11a8a89 cli: demote version mismatch to warning for now (#35548) 2026-07-06 14:39:58 +02:00
Simon Klee 0bba8c780a cli/mini: fix run failure handling (#35539) 2026-07-06 12:34:44 +02:00
Simon Klee 32cf36de9d mini migrate to v2 (#35526) 2026-07-06 11:03:30 +02:00
Simon Klee fb75ea2cf6 mini: fix default model (#35515) 2026-07-06 09:20:23 +02:00
Dax Raad 147da5d278 test(opencode): update project service stub 2026-07-06 01:32:58 -04:00
Dax Raad eed23d8ee9 feat(api): publish v2 reference 2026-07-06 01:29:08 -04:00
Dax Raad e6e951b252 docs: rebuild v2 documentation 2026-07-06 00:58:00 -04:00
Aiden Cline 4e019ba5d9 fix(core): expose MCP output schemas to code mode (#35502) 2026-07-05 23:12:45 -05:00
Aiden Cline 50a762e7b9 fix(mcp): preserve metadata across paginated tools (#35500) 2026-07-05 22:51:08 -05:00
Aiden Cline 780c99bc2e refactor(core): route questions through forms (#35422) 2026-07-05 22:28:05 -05:00
Aiden Cline 5bcf8d5a0b feat(tui): render session forms (#35421) 2026-07-05 19:35:30 -05:00
Dax Raad 652e8ff3fb fix(client): resolve codegen workspace version 2026-07-05 18:31:15 -04:00
Dax Raad 7b88de47c3 chore(client): publish client package 2026-07-05 18:21:23 -04:00
Dax Raad 33cb536879 feat(plugin): publish generation updates 2026-07-05 17:21:39 -04:00
Dax Raad 6ffecf9345 fix(core): reload local plugins on file changes 2026-07-05 16:54:17 -04:00
Dax Raad 08741f6b93 fix(core): scope observability to server 2026-07-05 16:22:04 -04:00
Dax Raad baacb2e776 fix(core): tolerate invalid OTLP configuration 2026-07-05 16:14:02 -04:00
Dax Raad a75978815a feat(server): add loaded locations debug endpoint 2026-07-05 15:59:44 -04:00
Dax Raad c3d26c4912 feat(core): improve runtime observability 2026-07-05 15:59:44 -04:00
Dax Raad a9b7bd9e2f feat(core): manage configurable plugin generations 2026-07-05 15:59:44 -04:00
Aiden Cline f9d1d3b259 refactor(codemode): namespace public types (#35435) 2026-07-05 11:51:50 -05:00
Aiden Cline f950497173 refactor(codemode): remove generic agent tool (#35420) 2026-07-05 10:41:21 -05:00
Aiden Cline 391aa38281 test(core): serve skill fixtures locally (#35429) 2026-07-05 10:40:55 -05:00
Aiden Cline 29e8502bb0 fix(tui): align integration empty state (#35414) 2026-07-05 09:08:09 -05:00
Kit Langton 44b182fe23 fix(core): validate scalar newtypes (#35381) 2026-07-04 21:43:08 -04:00
Kit Langton 905123b9c0 fix(protocol): keep internal events off SSE (#35378) 2026-07-04 21:12:45 -04:00
Kit Langton bb3b6a2f65 fix(protocol): expose MCP tool change events (#35373) 2026-07-04 20:47:33 -04:00
Aiden Cline b8efb33cde feat(codemode): add OpenAPI tool adapter (#35362) 2026-07-04 16:28:11 -05:00
Aiden Cline d65ecd4a90 feat(core): expose deferred tools through execute (#35361) 2026-07-04 16:20:56 -05:00
Simon Klee 57fb3e5cc5 fix(run): align mini with current session contracts (#35354) 2026-07-04 21:14:01 +02:00
Simon Klee ba07481b59 fix(run): restore subprocess output contracts 2026-07-04 21:06:32 +02:00
Aiden Cline b99759c7de fix(core): enforce mcp tool permissions (#35345) 2026-07-04 13:03:40 -05:00
Aiden Cline c590e27639 feat(core): add grouped and deferred tool registration (#35232) 2026-07-04 11:49:24 -05:00
Kit Langton 8f4b62eb49 fix(core): report missing search paths (#35337) 2026-07-04 12:12:56 -04:00
Kit Langton 945d1c8cb2 fix(core): bound recursive file watching (#35323) 2026-07-04 11:55:05 -04:00
Kit Langton 610e618bc5 fix(tui): clear completed background shell status (#35320) 2026-07-04 11:33:45 -04:00
Kit Langton 9daa4d85a4 fix(tui): reconcile session state after reconnect (#35262) 2026-07-04 11:05:41 -04:00
Kit Langton 62af66a74f fix(tui): distinguish variant switch notices (#35315) 2026-07-04 10:58:56 -04:00
Kit Langton 5b44e5bf41 test(core): release shell test locations (#35271) 2026-07-04 10:54:34 -04:00
Dax Raad a15afbe8f2 bye bye orchestrator 2026-07-04 02:04:58 -04:00
Dax Raad 8e0856c43b fix(core): disable external plugin hot reload 2026-07-04 01:58:17 -04:00
Dax Raad f66c829231 Revert "fix(core): bust external plugin import cache"
This reverts commit afe3ebbc35.
2026-07-04 01:54:30 -04:00
Aiden Cline 3baaabede8 fix(core): resolve mcp header env placeholders (#35236) 2026-07-03 23:00:10 -05:00
Dax Raad afe3ebbc35 fix(core): bust external plugin import cache 2026-07-03 23:12:09 -04:00
Kit Langton e2faeb84e5 fix(core): tolerate minimal FSWatcher typings (#35264) 2026-07-03 22:37:42 -04:00
Kit Langton 35ed09ff37 fix(tui): improve MCP error details (#35263) 2026-07-03 22:26:54 -04:00
Dax Raad 9751615651 fix(core): reload all config plugins 2026-07-03 20:41:33 -04:00
Dax Raad c9b24ef027 feat(core): reload config on filesystem changes 2026-07-03 20:35:04 -04:00
Aiden Cline b04d8d53e6 fix(tui): expand MCP server errors in dialog (#35243) 2026-07-03 18:19:16 -05:00
Dax Raad 7bd3f8ac83 chore(client): remove cyclic test dependencies 2026-07-03 18:12:22 -04:00
Dax Raad 6ae2fa5196 feat(plugin): align hooks with client APIs 2026-07-03 18:12:22 -04:00
Kit Langton 650d774372 refactor(schema): session shell payloads and event prefix restore (#35229) 2026-07-03 17:30:25 -04:00
Simon Klee 64e4f6f91b cli: route run commands through v2 APIs (#35234) 2026-07-03 22:47:04 +02:00
Aiden Cline d097cc8065 feat(tui): render execute child calls on v2 (#35231) 2026-07-03 15:10:25 -05:00
Aiden Cline 438654768c fix(codemode): require exact tool paths in guidance (#35224) 2026-07-03 14:55:03 -05:00
James Long 37b26e495b feat(simulation): share control protocol schemas (#35230) 2026-07-03 15:46:04 -04:00
Kit Langton 5d14c7a185 refactor(core): align runner naming with step vocabulary (#35227) 2026-07-03 15:41:50 -04:00
James Long e66cbf36e9 fix(core): constrain location services (#35228) 2026-07-03 15:34:30 -04:00
Dax Raad 50977dd4fe server: emit logs for every HTTP request to help debug API traffic 2026-07-03 15:13:37 -04:00
Dax Raad a644e0e7a0 sync 2026-07-03 14:58:23 -04:00
Kit Langton ca861fdf43 docs: consolidate session work-unit vocabulary (#35218) 2026-07-03 14:49:05 -04:00
Dax Raad 34a08cbdb8 fix(core): tolerate missing models.dev temperature 2026-07-03 14:46:54 -04:00
Aiden Cline bf3ae45439 fix(core): clean up mcp event surface (#35221) 2026-07-03 13:43:21 -05:00
Kit Langton 394e0b9045 refactor(schema): rename V2 session events and normalize payloads (#35217) 2026-07-03 14:25:59 -04:00
James Long 4790a2772c feat(simulation): add driver controlled backend LLM (#35186) 2026-07-03 14:19:25 -04:00
Dax Raad 2ff19171dc feat(tui): reload command restarts the service 2026-07-03 14:01:01 -04:00
Kit Langton 1401901529 chore: effect pattern lint infrastructure (#35210) 2026-07-03 13:58:26 -04:00
Kit Langton 1b88ff8d53 fix(core): clean up Effect audit patterns (#35174) 2026-07-03 13:54:13 -04:00
Dax Raad 08293e74a0 refactor(cli): single password config with legacy fallback 2026-07-03 13:41:15 -04:00
Dax Raad 24d26365e6 refactor(cli): manage environment variables with effect config 2026-07-03 13:37:12 -04:00
Dax Raad c22793d5f6 feat(cli): validate explicit server before starting the tui 2026-07-03 13:34:12 -04:00
Dax Raad c6a52a39b5 fix(cli): read OPENCODE_PASSWORD for explicit server auth 2026-07-03 13:34:12 -04:00
James Long aad8d90dd1 refactor(core): move path resolve into fs service (#35201) 2026-07-03 13:25:38 -04:00
Dax Raad 6f47459c68 chore: update lockfile 2026-07-03 12:42:09 -04:00
Dax Raad dd768e30e2 refactor(client): namespace service exports and share the registration contract 2026-07-03 12:36:56 -04:00
Kit Langton de476aa51b refactor(schema): declare event durability at definition level (#35172) 2026-07-03 12:35:35 -04:00
James Long bd8d858bf7 feat(core): implement V2 session.shell (#35183) 2026-07-03 12:19:09 -04:00
Aiden Cline 5fc4e18b82 test(core): cover when AND semantics and multiselect neq (#35182) 2026-07-03 11:10:49 -05:00
Aiden Cline 12d9f4b29b feat(codemode): add confined execution package (#35118)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-03 10:57:16 -05:00
James Long a2769b5ade fix(core): rewrite replacements while hoisting layers (#35176) 2026-07-03 11:26:58 -04:00
Simon Klee 1fa605ad5e fix(run): handle unattended form blockers (#35170) 2026-07-03 16:35:19 +02:00
Simon Klee 698ef25f33 fix(run): use parentID filter for subagent hydration (#35168) 2026-07-03 15:38:54 +02:00
Kit Langton 88dc960af8 refactor(core): migrate built-in tools to internal plugins (#34956) 2026-07-03 09:03:53 -04:00
Shoubhit Dash 5657cec4f2 feat(core): add mercurial adapter for vcs status and diff (#35141) 2026-07-03 17:42:25 +05:30
Shoubhit Dash 8d790a9ed5 fix(tui): keep session running until execution settles (#35136) 2026-07-03 17:23:30 +05:30
Simon Klee b62d24e40b fix(run/cli): wait for selected model catalog (#35130) 2026-07-03 13:21:06 +02:00
Shoubhit Dash a1b6c84135 feat(core): expose V2 VCS status and diff APIs (#35129) 2026-07-03 16:49:03 +05:30
Aiden Cline 85fde8c11d feat(core): rework form when conditions (#35115) 2026-07-03 04:23:20 -05:00
Aiden Cline c167bde6a0 feat(core): global forms support (#35106) 2026-07-03 03:55:15 -05:00
Aiden Cline d9bf30fc22 feat(server): add form routes (#35099)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-07-03 02:40:44 -05:00
Aiden Cline 3ce5e9800d feat(core): add form service (#35094) 2026-07-03 01:41:37 -05:00
Dax Raad af5eabcb26 refactor(client): split package into promise and effect entrypoints 2026-07-03 01:46:21 -04:00
Dax Raad 0087dd56d9 fix(schema): expose client-consumed events in the server manifest 2026-07-03 01:45:59 -04:00
Aiden Cline ef2140d121 fix(core): revert form service and mcp elicitation (#35080) 2026-07-03 00:32:10 -05:00
Dax Raad 1de3c6e4a6 feat(client): self-contained local service discovery and lifecycle 2026-07-03 01:19:43 -04:00
Dax Raad b6c3a8ea7b fix(ci): run typecheck on v2 2026-07-03 00:43:48 -04:00
Aiden Cline efcf2c3f5d feat(core): MCP elicitation support (#35064) 2026-07-02 23:25:54 -05:00
Kit Langton e65477ab1d refactor(core): polish runner drain and coordinator readability (#35051) 2026-07-02 22:01:53 -04:00
Kit Langton cd0b274856 refactor(core): simplify v2 prompt lifecycle and execution coordination (#35047) 2026-07-02 21:38:44 -04:00
Kit Langton 57e9e9771d feat(core): deterministic session log replay with synced watermark (#35040) 2026-07-02 21:38:20 -04:00
Aiden Cline 1aae92c42a fix(core): remove mcp tool call timeout (#35043) 2026-07-02 20:15:36 -05:00
opencode-agent[bot] 6555df912a feat(session): filter v2 session list by parent (#35037)
Co-authored-by: Dax <mail@thdxr.com>
2026-07-02 19:48:49 -04:00
Aiden Cline 7ebd344fa2 feat(core): add session form service (#34855) 2026-07-02 17:15:18 -05:00
Aiden Cline 460cdc5aec fix(llm): remove package retry policy (#35003) 2026-07-02 15:51:12 -05:00
James Long b0ca5520a1 feat(server): add lazy simulation layer replacements (#35024) 2026-07-02 16:45:09 -04:00
Kit Langton bc2e270f82 feat(server): durable log reads, changes feed, and watermarked snapshots (#34962) 2026-07-02 16:42:30 -04:00
Kit Langton 33705e632a fix(tui): use catalog display name in model switch notices (#34913) 2026-07-02 16:09:45 -04:00
Kit Langton cff2345c12 fix(core): record selected catalog model identity on assistant steps (#34911) 2026-07-02 16:09:33 -04:00
James Long 0405518180 feat: simulation control surface and architecture spec (#34801) 2026-07-02 15:19:19 -04:00
Aiden Cline c9604c86ec test(core): avoid shell PATH in command test (#34980) 2026-07-02 12:28:58 -05:00
Aiden Cline 967c44552e test(core): make command shell test portable (#34972) 2026-07-02 12:02:34 -05:00
Aiden Cline ec95694e2f docs(llm): track native provider parity (#34862) 2026-07-02 11:51:56 -05:00
Aiden Cline c176baee82 feat(core): port v2 command invocation (#34849) 2026-07-02 11:22:04 -05:00
Kit Langton 016d296f7c refactor(core): migrate glob tool to internal plugin (#34946) 2026-07-02 10:39:04 -04:00
Kit Langton dbaa53329c refactor(core): mechanism-neutral context entry rendering (#34945) 2026-07-02 10:16:24 -04:00
Kit Langton 7843f8fb38 feat(core): API-managed per-session context entries (#34941) 2026-07-02 10:05:47 -04:00
Kit Langton 4045041554 refactor(core): simplify system context into a belief model (#34917) 2026-07-02 09:32:35 -04:00
Shoubhit Dash 7ec7413fdb fix(tui): suppress transient reconnect overlay flashes (#34924) 2026-07-02 18:54:51 +05:30
Simon Klee f016392368 feat(mini): migrate mini to v2 (#34895)
feat(run): migrate non-interactive prompts to V2
feat(run): route mini prompts through V2
fix(run): use current session contracts
fix(run): fix V2 prompt turns
feat(cli): add mini subcommand
feat(run): use settled execution events
fix(run): handle remote prompt file attachments
feat(run): send prompt files as attachments
feat(run): use current APIs for run state
fix(run): adopt app-node runtime deps
feat(run): track subagent sessions
feat(run): move catalogs and default model onto current APIs
2026-07-02 12:06:49 +02:00
Aiden Cline 140224b0fc fix(core): derive models.dev reasoning variants (#34726) 2026-07-02 00:23:46 -05:00
opencode-agent[bot] cfd35c9354 fix(tui): include variant in model switch notice (#34856)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-02 00:17:18 -05:00
Kit Langton 674d08f9be fix(core): route ChatGPT OAuth to the codex backend (#34843) 2026-07-02 00:15:34 -04:00
opencode-agent[bot] 02f012f5d1 fix(tui): clear onboarding after provider connect (#34819)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-01 21:10:24 -05:00
Dax Raad bea6e1499d feat(core): discover instructions on directory reads 2026-07-01 21:11:22 -04:00
Dax Raad 50a1fe49bc fix(core): relativize instruction description to project root 2026-07-01 21:03:46 -04:00
Dax Raad 6380919fda fix(core): use relative paths in instruction load description 2026-07-01 21:02:47 -04:00
Dax Raad d972aa9d83 feat(core): tool execute hooks, session instructions, synthetic endpoint
- V2 tool execute hooks: add `ctx.tool.execute.before/after` plugin API,
  core ToolHooks service, and registry wiring so hosted/local tool calls
  run registered before/after hooks; provider-executed calls are excluded.
- Session synthetic endpoint: add `POST /api/session/:sessionID/synthetic`
  with text/description/metadata plus regenerated Promise/Effect/JS client
  surfaces.
- SessionInstructions service: read tool discovers nearby AGENTS.md walking
  up to the Location root (exclusive) and injects them as durable synthetic
  instructions, with lazy history dedup of prior claims.
- to-llm-message: stop forwarding synthetic message metadata to the model so
  bookkeeping annotations stay model-hidden.
- schema changelog entry for synthetic metadata and the metadata leak fix.
2026-07-01 20:50:10 -04:00
Dax Raad d544ab4d91 feat(tui): show background label on subagent line 2026-07-01 20:25:21 -04:00
Dax Raad ce228bfd7c feat(tui): maintain session family index in data context 2026-07-01 20:22:33 -04:00
Dax Raad eabf85aea2 fix(tui): wire subagent interrupt and drop granular background commands 2026-07-01 20:00:28 -04:00
Dax Raad 2f4a688790 chore: set up orchestrator plugin and mute prompt status labels 2026-07-01 18:51:18 -04:00
Dax Raad 4a90ffedfb fix(server): restore provider node deps after dev merge 2026-07-01 18:39:43 -04:00
Dax Raad 95cf5039be fix(tui): color prompt work counts 2026-07-01 18:23:07 -04:00
Dax Raad 041215996c chore: trigger deploy 2026-07-01 18:16:27 -04:00
Dax Raad d0b920e187 chore: trigger publish ci 2026-07-01 18:11:49 -04:00
James Long 8c94e9005f chore: merge dev into v2 (#34788)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com>
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Ben Guthrie <benjee.012@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
Co-authored-by: Max Anderson <max.a.anderson95@gmail.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
2026-07-01 17:12:00 -04:00
Shoubhit Dash 932a40cfd9 fix(tui): pin queued prompts below output (#34791) 2026-07-02 01:53:51 +05:30
Dax Raad 716f6658db feat(core): add skill autoinvoke metadata 2026-07-01 15:13:40 -04:00
Dax Raad 9f1a8f2149 docs: add sample skill 2026-07-01 14:40:56 -04:00
Dax Raad b3650e2316 fix(core): provide job service in session default layer 2026-07-01 14:38:35 -04:00
Dax Raad fb884bb91e feat: update session notices and skill reloads 2026-07-01 14:37:27 -04:00
Dax Raad 98c09884bf feat(protocol): publish package 2026-07-01 13:53:09 -04:00
Dax Raad 5e47501b8b feat(schema): publish package 2026-07-01 13:33:59 -04:00
Dax Raad d51ba6ed94 fix(cli): run update check in background 2026-07-01 13:18:00 -04:00
Dax Raad a6983b65fc fix: skip plugin publish 2026-07-01 12:57:48 -04:00
Dax Raad 1a06198954 fix(tui): indent synthetic session notices 2026-07-01 12:39:06 -04:00
Dax Raad 72ec09cf74 fix(tui): keep backgrounded subagents spinning 2026-07-01 12:33:01 -04:00
Dax Raad e2bca216a2 feat: background blocking tools 2026-07-01 12:18:04 -04:00
Dax Raad a10733dbf4 docs: add debug opencode skill 2026-07-01 12:18:04 -04:00
Shoubhit Dash b5823d1077 fix(tui): show queued prompt admissions (#34771) 2026-07-01 21:27:56 +05:30
Dax e1fafa2e54 fix(cli): start service in home directory 2026-07-01 11:36:57 -04:00
Dax Raad ae49b21376 fix(core): save external permissions at project root 2026-07-01 11:27:24 -04:00
Dax Raad 5710064ae1 feat(core): generate session titles from first prompt
Add SessionTitle service that generates a session title from the
session's sole user message via the customizable "title" agent, then
renames the session. Runs once per session, gated on durable history
having exactly one user message (no default-title string comparison).

Wire it into SessionRunner as a background fork after the first turn's
prompt promotion makes the user message visible; forking before
promotion caused the first-message lookup to see zero rows and silently
no-op.

Closes #34364
2026-07-01 11:07:44 -04:00
Shoubhit Dash 4617b03ca9 fix: surface shell stderr output (#34761) 2026-07-01 20:33:48 +05:30
Dax Raad 6a91a682e4 feat(core): add plugin list endpoint
Adds a plugin.list HTTP endpoint that reports currently loaded plugins, wires it through protocol, server, and generated clients, and merges it into sdk-next's plugin API. Also updates the sample plugin to register a primary agent instead of a subagent for local testing.
2026-07-01 00:53:53 -04:00
Aiden Cline f8626865b9 fix(core): expose models.dev modes as models (#34714) 2026-06-30 22:48:25 -05:00
opencode-agent[bot] 2324a63fc6 feat(cli): add session resume flags (#34713)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-06-30 23:37:25 -04:00
Kit Langton 1ce607c230 refactor(plugin): move tool implementation to plugin (#34665) 2026-06-30 23:30:56 -04:00
Dax Raad c26f6f95f7 fix(core): deny subagent tool for default subagents 2026-06-30 23:11:06 -04:00
Dax Raad d77b87ea65 feat(script): use GitHub run number for preview build identifier 2026-06-30 23:05:46 -04:00
Dax Raad 6ca6566bd3 fix(core): derive reasoning variants from models.dev 2026-06-30 22:50:20 -04:00
Dax Raad 8dd993d25a fix(tui): stabilize reload connection state 2026-06-30 22:31:51 -04:00
Dax Raad 24ab17e718 fix(tui): keep background shell spinner active 2026-06-30 21:56:45 -04:00
Aiden Cline 0a60662d71 feat(cli): add mcp list, add, auth, and logout commands (#34643) 2026-06-30 13:49:07 -05:00
Kit Langton 194b0615e0 feat(plugin): support plugin-provided tools (#34619) 2026-06-30 13:18:56 -04:00
opencode-agent[bot] a5c51e11d0 fix(core): drop legacy config filename (#34650)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 11:12:15 -05:00
opencode-agent[bot] 8f1db7d06d fix(tui): align cli model picker behavior (#34571)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 09:36:38 -05:00
Aiden Cline b1ca070b3b feat(core): add mcp support (#34513) 2026-06-30 08:37:44 -05:00
Aiden Cline 12887e572e fix(core): align agent tests with universal default permissions (#34561) 2026-06-30 08:24:54 -05:00
Aiden Cline 75715e2115 fix(core): parse models.dev reasoning options (#34618) 2026-06-30 07:54:18 -05:00
Dax Raad a1250cd690 test(sdk-next): update embedded client namespaces 2026-06-30 01:16:33 -04:00
Dax Raad 23adaaaeab feat(core): add native skill activation 2026-06-30 01:16:33 -04:00
opencode-agent[bot] 524ee8fc03 fix(core): gate v2 edit tools by model (#34558)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-29 23:53:41 -05:00
Dax Raad 0f9719a7b5 fix(cli): support service autostart setting 2026-06-30 00:28:11 -04:00
Dax Raad 1b83c08b8a Update service configuration CLI 2026-06-30 00:18:26 -04:00
Dax Raad 02cb350880 feat(tui): refresh agents after update events 2026-06-30 00:18:26 -04:00
opencode-agent[bot] 4ce830a919 fix(core): align v2 prompt tool names (#34557)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-29 22:53:56 -05:00
Kit Langton 461a1c3ab4 refactor(core): replace background job service (#34559) 2026-06-29 23:53:35 -04:00
Kit Langton 6846542115 fix(client): singularize generated api groups (#34534) 2026-06-29 21:07:01 -04:00
Aiden Cline f928b5be07 fix(core): sanitize registered tool names (#34512) 2026-06-29 18:30:40 -05:00
Kit Langton ecfa918760 feat(client): expose fs read in promise client (#34504) 2026-06-29 19:21:18 -04:00
Aiden Cline f80624cf17 fix(tui): surface provider error in assistant footer (#34511) 2026-06-29 16:46:50 -05:00
Dax Raad fe59174c23 fix(core): resume after synthetic session message 2026-06-29 17:32:54 -04:00
Dax Raad 2fe057324f fix(core): provide filesystem to shell tool 2026-06-29 17:27:24 -04:00
Dax Raad 19a5b5a05d feat(core): support background shell tool 2026-06-29 17:01:04 -04:00
Dax Raad ff4cab03c1 refactor(core): simplify session fork event 2026-06-29 16:16:13 -04:00
Dax Raad b2d46ecd7e feat(core): add durable session fork event 2026-06-29 16:13:51 -04:00
Dax Raad 360d85a521 fix(tui): indent subagent empty state 2026-06-29 15:46:51 -04:00
Dax Raad c65a7d50c1 feat(tui): integrate composer picker 2026-06-29 15:37:09 -04:00
Dax Raad fa73546a86 feat(tui): add composer tabs 2026-06-29 14:20:43 -04:00
Kit Langton 935ac2db91 feat(client): expose v2 project APIs (#34456) 2026-06-29 14:10:11 -04:00
Dax Raad 01edae4a7f fix(core): spawn shell non-interactively without sourcing rc files
ShellSelect.args() ran zsh/bash with -l and explicitly sourced .zshrc/.bashrc,
loading user functions and aliases that can shadow builtins and return non-zero
exit codes, breaking && chains. Match the old tool behavior: plain non-login
non-interactive shell -c command with cwd passed via spawn options.
2026-06-29 12:05:56 -04:00
Kit Langton 381d67572e refactor(tui): wire generated client reads (#34381) 2026-06-29 11:36:10 -04:00
Dax Raad e8ac44430b feat(cli): poll for updates every 10 minutes in serve 2026-06-29 00:59:01 -04:00
Dax Raad 5ae93092aa refactor(core): replace bash tool with shell tool 2026-06-29 00:44:17 -04:00
Kit Langton 595c6bd4a7 feat: add v2 generate text endpoint (#34371) 2026-06-28 22:43:58 -04:00
Kit Langton 7073e8797f fix(core): remove per-prompt system option (#34361) 2026-06-28 21:56:59 -04:00
Kit Langton f7034a35a8 feat(core): add v2 manual compaction (#34336) 2026-06-28 21:18:19 -04:00
Kit Langton 53b93b6991 feat(sdk-next): let embedders contribute plugins via opencode.plugin (#34356) 2026-06-28 20:30:21 -04:00
opencode-agent[bot] 6067019434 fix(core): restore v2 model prompts (#34275)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-28 14:47:20 -05:00
Dax 42a3cf9645 fix(core): commit staged revert before admitting new prompt (#34338) 2026-06-28 14:50:06 -04:00
Kit Langton 04c6bed240 feat(core): restore per-request system prompt (#34335) 2026-06-28 14:02:46 -04:00
Kit Langton 94e3a29d2f feat(core): wire v2 subagent tool (#34320) 2026-06-28 12:52:39 -04:00
Kit Langton 41283933ff chore: merge dev into v2 (#34317)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com>
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Ben Guthrie <benjee.012@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
2026-06-28 11:30:38 -04:00
Dax Raad 11bf8d8a42 refactor(cli): simplify updater, remove cache and confirmation
- Remove the 24h disk cache (State, readState/writeState, checkInterval)
  that served stale latest versions within the TTL window
- Remove file lock (Flock) and confirmation prompt (Prompt/Terminal)
- Every check now hits the npm registry directly
- Updates always auto-install on any version mismatch within the same
  major, including rollbacks where the registry version is lower
- Drop the interactive option from check() and the confirm/declined/
  dismissed action concepts
2026-06-27 20:52:03 -04:00
Dax Raad c22973ab9f fix(server): log undo revert operations 2026-06-27 20:32:58 -04:00
Dax Raad 612009e0f1 fix(tui): use span for inline styled hotkey in revert footer 2026-06-27 20:32:14 -04:00
Dax Raad a0a6c9545e fix(tui): style redo hotkey as normal text in revert footer 2026-06-27 20:30:52 -04:00
Dax Raad bceb6d0a9e feat(tui): add redo hotkey footer and pad file list in revert banner 2026-06-27 20:30:26 -04:00
Dax Raad 49e7dc191f fix(tui): make undo inclusive of last user message and simplify revert banner 2026-06-27 20:25:08 -04:00
Dax f4720be08e feat(tui): add session rename (#34264) 2026-06-27 20:07:23 -04:00
Dax d1d7ebc2c6 feat(tui): wire up undo/redo and revert for V2 sessions (#34263) 2026-06-28 00:04:53 +00:00
Dax Raad cd942d0669 fix(tui): normalize abbreviated home paths to forward slashes 2026-06-27 18:54:06 -04:00
Dax Raad 4741e3ee6b fix(tui): remove redundant top spacer in session history 2026-06-27 13:54:16 -04:00
Dax Raad 4673bfbaa0 feat(cli): log startup info
Emit a log entry on CLI startup with version, channel, and local flag
so every CLI process is visible in opencode.log from the start.
2026-06-27 13:45:42 -04:00
Dax Raad b458dd8c63 feat(cli): log updater check decisions
Add Effect.logInfo at every previously-silent path in the updater
check so each run is observable in opencode.log: skip reasons
(local install, disabled, policy), the resolved version with source
(cached vs registry), and the final action (up-to-date, dismissed,
declined).
2026-06-27 13:29:46 -04:00
Dax Raad 7edd6c3a1d feat(cli): use installation version and document API debugging
- Replace hardcoded "local" version string with InstallationVersion
- Document DESCRIPTION
  Make a request to the running server

USAGE
  opencode api [flags] <operation | method path...>

ARGUMENTS
  operation | method path... stringOpenAPI operation ID, or an HTTP method followed by a path

FLAGS
  --data, -d string      Request body
  --header, -H string    Request header in name:value form
  --param key=value      OpenAPI path or query parameter

GLOBAL FLAGS
  --help, -h                                                          Show help information
  --version, -v                                                       Show version information
  --completions <bash|zsh|fish|sh>                                    Print shell completion script (choices: bash, zsh, fish, sh)
  --log-level <all|trace|debug|info|warn|warning|error|fatal|none>    Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none) server/API debugging command in AGENTS.md
2026-06-27 13:18:22 -04:00
Dax Raad f43f066741 fix(tui): stop bash spinner when tool errors before command received 2026-06-27 13:17:21 -04:00
Dax Raad b6553d14e1 fix(core): fail interrupted session steps 2026-06-27 01:16:30 -04:00
Dax Raad ab0042a666 docs(opencode): mark package as v1 2026-06-27 01:16:30 -04:00
Dax Raad 5df049d081 feat(cli): add self-update service
Check the npm registry (at most once per 24h, file-locked) for newer
@opencode-ai/cli releases and act on a host-level autoupdate policy read
from config. Apply patch updates automatically, confirm minor updates
interactively, and never auto-update across majors. Skip local installs
and honor the disable flag. Wired into the default TUI command
(interactive) and serve (background).
2026-06-27 00:51:45 -04:00
Dax Raad 573ab9c24b fix(tui): interrupt v2 sessions with escape 2026-06-26 22:32:21 -04:00
Dax Raad 6a16c41e8f fix(core): continue after tool defects 2026-06-26 22:02:30 -04:00
Dax Raad a491cbee64 fix(tui): hide prompt for running shell commands 2026-06-26 21:35:20 -04:00
Dax Raad 658cbe9caf fix(core): load OpenCode provider config asynchronously 2026-06-26 21:16:15 -04:00
Dax Raad 62b2bc39df test(ci): skip v2 end-to-end checks 2026-06-26 21:04:10 -04:00
Dax Raad beb2c52c3f fix(cli): publish scoped package 2026-06-26 21:01:10 -04:00
Dax Raad 655adbf46e fix: stabilize v2 runtime behavior 2026-06-26 20:56:36 -04:00
Dax Raad ac2a78391f fix(ci): publish v2 as next 2026-06-26 15:55:04 -04:00
Dax Raad 9a9bdaba95 test(ci): drop legacy opencode tests 2026-06-26 14:58:40 -04:00
Dax Raad 8b682c42b6 refactor(tui): use v2 client transport 2026-06-26 14:57:01 -04:00
Dax Raad ad4f1c1018 refactor(ci): use branch npm tag 2026-06-26 14:54:35 -04:00
Dax Raad a0afb63ed0 fix(ci): use v2 npm tag 2026-06-26 14:53:35 -04:00
Dax Raad df9ecb8f6a fix(cli): publish v2 package 2026-06-26 14:52:30 -04:00
Dax Raad e6f660fecf feat(tui): add v2 terminal interface 2026-06-26 14:38:25 -04:00
1665 changed files with 320482 additions and 169737 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/client": patch
---
Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using.
+4
View File
@@ -1,4 +1,5 @@
adamdotdevin
arvsrn
Brendonovich
fwang
Hona
@@ -7,11 +8,14 @@ jayair
jlongster
kitlangton
kommander
ludvigrask
MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
thdxr
simonklee
Slickstef11
usrnk1
vimtor
starptech
+7 -2
View File
@@ -6,6 +6,7 @@ on:
branches:
- ci
- dev
- v2
- beta
- fix/npm-native-binary-install
- snapshot-*
@@ -31,6 +32,9 @@ permissions:
contents: write
packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
jobs:
version:
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -122,7 +126,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -221,7 +225,7 @@ jobs:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -447,6 +451,7 @@ jobs:
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
+2 -5
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- dev
- v2
pull_request:
workflow_dispatch:
@@ -74,13 +75,9 @@ jobs:
working-directory: packages/client
run: bun run check:generated
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
strategy:
fail-fast: false
matrix:
+2 -2
View File
@@ -2,9 +2,9 @@ name: typecheck
on:
push:
branches: [dev]
branches: [dev, v2]
pull_request:
branches: [dev]
branches: [dev, v2]
workflow_dispatch:
jobs:
+1
View File
@@ -13,6 +13,7 @@ tmp
dist
ts-dist
.turbo
.typecheck-profiles
**/.serena
.serena/
**/.omo
+254
View File
@@ -0,0 +1,254 @@
---
name: opencode-drive
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
---
# OpenCode Drive
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
on changes).
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
stops all processes, and cleans up all artifacts.
# Prepare The Environment
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
```bash
artifacts=$(opencode-drive init --name demo)
cp -R ./fixtures/home/. "$artifacts/"
cp -R ./fixtures/project/. "$artifacts/files/"
opencode-drive start --name demo --dev ~/projects/opencode
```
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
# Scripted usage
You can write scripts that walk through entire flows, and gives you full access to controlling
the backend too. See examples of the script API at the bottom of this file.
After creating or editing a script, always typecheck it before running. Never skip this step:
```bash
opencode-drive check ./reproduce-stale-exploring-empty.ts
```
Run it by passing `--script` to start:
```bash
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
```
It will output information about the run, including paths to log files which you can read
to inspect what happened. If you need to dig into failures that aren't clear, read those log
files. If the script is unsuccessful, automatically fix the script and run it again.
Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
```ts
import { defineScript } from "opencode-drive"
export default defineScript({
async setup({ fs, config }) {
config.autoupdate = false
await fs.writeFile("src/example.ts", "export const value = 1\n")
},
async run({ ui, llm }) {
await ui.submit("Open src/example.ts")
await llm.send(llm.text("The file exports `value`."))
await ui.waitFor("The file exports `value`.")
},
})
```
`setup` receives the current OpenCode config object, which starts from the
default drive config unless the prepared instance already has one. When a script
needs custom config, mutate this `config` parameter instead of generating and
writing a new config object from scratch, so the script keeps the default
provider/model settings unless it intentionally changes them.
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
Use `launch: "manual"` when the script needs to launch the server and every TUI
itself (this is extremely rare, do not use this unless explicitly asked). In this
mode `ui` is typed as `null`; call `server.launch()` exactly
once before launching clients. Each `clients.launch(name)` result provides the
same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
Use the exported `wait(milliseconds)` utility for an unconditional delay.
`await llm.send(...)` waits for the next request and resolves after OpenCode
acknowledges its complete response. `llm.queue(...)` declares responses in
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
`finish`, and `disconnect`. A normal response receives `finish("stop")`
automatically unless it yields or queues an explicit terminal event.
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
15-character target varied by plus or minus 5 per chunk.
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
delay between any two outputs.
Use `llm.serve` for an ongoing typed response generator:
```ts
llm.serve(async function* (request, index) {
yield llm.reasoning(`Handling request ${index + 1}`)
yield llm.text(`Received ${request.id}`)
yield llm.finish("stop")
})
```
The backend connection, response cleanup, cancellation, and recording
completion are automatic.
You can see some example scripts here:
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
## Prune
- `prune` removes artifact directories. These are always cleaned up after running a script
successfully, but leftover on failed runs. Always call this if a script fails.
```bash
opencode-drive prune --name demo
// --force cleans up all artifcat directories
opencode-dirve prune --force
```
# Live interaction usage
- Always give headless instances a unique `--name`. Visible instances may omit it.
- A normal headless `start` detaches automatically and returns after the instance is ready.
- Do not add `&`; the long-running owner already runs in the background.
- Configure simulated model responses after startup when needed.
- Send ordered UI commands with `send`.
- Always stop the instance when finished.
```bash
opencode-drive start --name demo
opencode-drive send --name demo \
--command.ui.type '{"text":"Explain this project"}' \
--command.ui.enter
opencode-drive stop --name demo
```
## Send UI Commands
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
- Combine typing and Enter in one command when submitting a prompt.
- JSON-valued commands require one JSON argument.
- Multiple command flags execute from left to right.
Commands:
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
- `--command.ui.enter` presses Enter. Arguments: none.
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
```bash
opencode-drive send --name demo \
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
--command.ui.enter
opencode-drive send --name demo \
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
opencode-drive send --name demo \
--command.ui.arrow '{"direction":"down"}'
opencode-drive send --name demo \
--command.ui.focus '{"target":12}'
opencode-drive send --name demo \
--command.ui.click '{"target":12,"x":4,"y":1}'
opencode-drive send --name demo \
--command.ui.matches '{"text":"OpenCode"}'
```
To read the UI state and see information about interactable elements, use the `ui.state` command:
```bash
opencode-drive send --name demo --command.ui.state
```
## Configure LLM Responses
- `responses` controls what the LLM responds with
- Only use this if you are wanting to reproduce an exact type of response
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
- `--tools` limits generated tool calls to names offered by OpenCode.
```bash
opencode-drive responses --name demo \
--types text,reasoning,diff,tool \
--tools write,apply_patch
opencode-drive responses --name demo \
--types tool \
--tools read,glob,grep
```
## Inspect The UI
- `ui.state` prints focus and interactive element metadata as JSON.
- `ui.matches` checks for literal, case-sensitive screen text.
- `screenshot` prints the generated image path.
```bash
opencode-drive screenshot --name demo
```
## Lifecycle
- `stop` waits for recording export and owner cleanup before returning.
```bash
opencode-drive stop --name demo
```
# Record The UI
- Start with `--record` to capture a headless instance from its first rendered frame.
- `stop` finishes the recording, exports an MP4, and prints its path.
```bash
opencode-drive start --name demo --record
opencode-drive send --name demo \
--command.ui.type '{"text":"Show me the current architecture"}' \
--command.ui.enter
opencode-drive stop --name demo
```
# Artifacts dir
- `dir` prints the artifact directory for the instance.
```bash
opencode-drive dir --name demo
```
+31
View File
@@ -0,0 +1,31 @@
---
name: sample-skill
description: Use when the user says sample skill, skill demo, or asks how an opencode SKILL.md should be structured; demonstrates a tiny project-local skill with practical assistant workflow guidance.
---
# Sample Skill
This is a minimal project-local opencode skill. It exists as a reference for how a skill is structured and as a tiny reusable workflow the assistant can load when the user asks for a skill example.
## When To Use
- Use when the user asks for a sample skill or skill template.
- Use when demonstrating the required `SKILL.md` frontmatter and body format.
- Do not use for unrelated coding tasks just because a skill exists.
## Workflow
- Confirm the specific outcome the user wants if the request is ambiguous.
- Inspect the relevant files before changing anything.
- Make the smallest correct change.
- Verify the result with a focused read, typecheck, test, or other lightweight check when available.
- Summarize the changed files and any required restart or reload step.
## Example Response Style
When this skill is relevant, keep responses direct and actionable:
```text
I created a project-local skill at .opencode/skills/sample-skill/SKILL.md.
Restart opencode for the new skill to be discovered by future sessions.
```
+13 -6
View File
@@ -1,6 +1,7 @@
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
@@ -18,12 +19,15 @@ Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes a
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or circumvent commit or push hooks. If a hook fails, fix the failure or stop and report it to the user.
## Style Guide
### General Principles
- Keep things in one function unless composable or reusable
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Use Bun APIs when possible, like `Bun.file()`
@@ -150,12 +154,15 @@ const table = sqliteTable("session", {
## V2 Session Core
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
+100 -80
View File
@@ -4,40 +4,50 @@ OpenCode sessions preserve durable conversational history while assembling the r
## Language
**System Context**:
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
_Avoid_: System prompt
**Model Context**:
The complete model-visible input assembled for one **Step**, including system instructions, **Session History**, tool definitions, and step-local additions. **Instructions** are one component of Model Context, not a synonym for it.
_Avoid_: System Context
**Instructions**:
The opaque algebra of independently refreshable typed instruction sources that render the durable instruction baseline and chronological updates shown to the model.
_Avoid_: Model Context, System Context
**Session History**:
The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs.
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
_Avoid_: Session Context
**Context Source**:
One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources.
**Instruction Source**:
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
_Avoid_: Prompt fragment
**System Context Registry**:
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
**InstructionEntry**:
One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/<key>` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `<context>` blocks: the model sees session context, not how it was attached.
**Mid-Conversation System Message**:
A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**.
_Avoid_: System update, system notification, raw text diff
**InstructionDiscovery**:
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
**Context Epoch**:
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
**Instruction State**:
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
**Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**.
**Instruction Update**:
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
_Avoid_: Correction, stored prose, raw text diff
**Initial Instructions**:
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
_Avoid_: Live system prompt
**Context Snapshot**:
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
**Instruction Epoch**:
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
**Unavailable Context**:
An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
**Instruction Values**:
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
**Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
**Unavailable Instruction Source**:
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
**Safe Step Boundary**:
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
@@ -45,11 +55,24 @@ A durable user input accepted into the Session inbox but not yet included in **S
**Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Provider Turn**:
One request to a model provider and the response projected from that request.
**Step**:
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
_Avoid_: provider turn, turn (unqualified)
**Physical Attempt**:
One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two.
**Assistant Turn**:
A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it.
**Settlement**:
The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed.
**Execution**:
One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity.
**Session Drain**:
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
@@ -87,52 +110,49 @@ _Avoid_: Response envelope
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
@@ -162,12 +182,12 @@ _Avoid_: Response envelope
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
@@ -175,27 +195,27 @@ _Avoid_: Response envelope
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- An **Instruction Update** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the aggregate discovered instruction set changes, its **Instruction Update** includes the complete current ordered set and supersedes the prior aggregate value; when no discovered instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
@@ -217,9 +237,9 @@ Before stabilizing the client API:
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
> **Dev:** "The date changed while the session was active. Should the **Instruction Update** say what the old date was?"
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current instructions."
## Flagged ambiguities
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics.
- Legacy `experimental.chat.system.transform` can mutate assembled system text arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, model dynamic uses as explicit **Instruction Sources**, or narrow its semantics.
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1305 -110
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -495,7 +495,6 @@ async function subscribeSessionEvents() {
console.log("Subscribing to session events...")
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
bash: ["Bash", "\x1b[31m\x1b[1m"],
edit: ["Edit", "\x1b[32m\x1b[1m"],
glob: ["Glob", "\x1b[34m\x1b[1m"],
+4
View File
@@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
configuration: {
enforceWorkgroupConfiguration: true,
publishCloudwatchMetricsEnabled: true,
// Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
// so a regression cannot silently burn money. Stats sync full passes scan
// ~250 GB as of 2026-07.
bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
resultConfiguration: {
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
},
+3 -1
View File
@@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "0.25 vCPU",
memory: "0.5 GB",
// 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
// stats queries (~$5/pass) every ~5 minutes instead of hourly.
memory: "2 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-KTyd2ISQ4n1E3tm1LMEHtz+rKRZga+SONn5J6H0pheQ=",
"aarch64-linux": "sha256-ZIeaaqRr4JorEmmwFYuitxfYEAtrP8C6IlD+pmFRko0=",
"aarch64-darwin": "sha256-Po8FISoBzUMwJSn6nw243/hLT/gAQCrm5HbwXe2uA0g=",
"x86_64-darwin": "sha256-5TZzrCnvg1z00YP6O9U0SXjL04r+VmF7LVrqQ9BbSn4="
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
}
}
+11 -3
View File
@@ -2,23 +2,29 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"typecheck": "bun turbo typecheck --concurrency=3",
"typecheck:profile": "bun script/profile-typecheck.ts",
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"translate:app": "bun run script/translate-app.ts",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
@@ -46,6 +52,7 @@
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@corvu/drawer": "0.2.4",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
"@types/semver": "7.7.1",
@@ -94,6 +101,7 @@
},
"devDependencies": {
"@actions/artifact": "5.0.1",
"@ast-grep/cli": "0.44.0",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
@@ -146,7 +154,7 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
@@ -20,7 +20,7 @@ const profiles = [
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
{
name: "multi patch",
tool: "apply_patch",
tool: "patch",
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
},
] as const
@@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
userMessage(),
assistantMessage(
[
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
textPart(followingID, "Following incremental patch"),
],
{ completed: false },
@@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated(
toolPart(
patchID,
"apply_patch",
"patch",
"running",
{ files: [first.filePath, second.filePath] },
{ metadata: { files: [first, second] } },
@@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated(
toolPart(
patchID,
"apply_patch",
"patch",
"completed",
{ files: [first.filePath, second.filePath, third.filePath] },
{ metadata: { files: [first, second, third] } },
@@ -33,11 +33,9 @@ test.describe("timeline tool state stability", () => {
}
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
const questionID = "prt_state_question"
const todoID = "prt_state_todo"
const initial = [
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
toolPart(questionID, "question", "pending", questionInput()),
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
textPart("prt_state_following", "Following lightweight tools"),
]
const childID = "ses_timeline_child"
@@ -49,7 +47,6 @@ test.describe("timeline tool state stability", () => {
await timeline.send(status("busy"), 120)
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
const regionIDs = [
"prt_state_webfetch",
@@ -105,7 +102,6 @@ test.describe("timeline tool state stability", () => {
]),
)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
await expect(
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
).toBeVisible()
@@ -295,7 +295,7 @@ function performanceTurn(index: number) {
messageID: assistantID,
type: "tool",
callID: `call_0000_${suffix}_patch`,
tool: "apply_patch",
tool: "patch",
state: {
status: "completed",
input: { patchText: realisticPatch(index) },
@@ -131,7 +131,7 @@ function toolPart(
): MessagePart {
const metadata =
metadataOverride ??
(tool === "apply_patch"
(tool === "patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
@@ -219,7 +219,7 @@ function turn(index: number): Message[] {
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
@@ -269,7 +269,6 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
@@ -90,7 +90,7 @@ async function mockServers(page: Page, requests: string[]) {
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const draftID = "draft_legacy_new_session"
const directory = "C:/OpenCode/LegacyNewSession"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("redirects a draft to the legacy new-session route", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_legacy_new_session",
worktree: directory,
vcs: "git",
name: "legacy-new-session",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
})
@@ -65,7 +65,7 @@ async function mockServers(page: Page) {
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
@@ -0,0 +1,155 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewOpenFile"
const projectID = "proj_review_open_file"
const sessionID = "ses_review_open_file"
const title = "Review open file"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "open-file-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [fileDiff("src/changed.ts")],
fileList: (path) => {
if (path) return []
return [
fileNode("README.md"),
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
]
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
findFiles: (input) => {
searches.push(input)
return input.query === "nested" ? ["src/nested.ts"] : []
},
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toBeVisible()
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await expect(sidebar).toBeVisible()
await filter.fill("nested")
const result = panel.getByRole("option", { name: /nested\.ts/ })
await expect(result).toBeVisible()
const resultID = await result.getAttribute("id")
expect(resultID).toBeTruthy()
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
})
function fileNode(path: string) {
return {
name: path,
path,
absolute: `${directory}/${path}`,
type: "file",
ignored: false,
}
}
function fileDiff(file: string) {
return {
file,
before: "before\n",
after: "after\n",
additions: 1,
deletions: 1,
status: "modified",
}
}
@@ -0,0 +1,152 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewStatePersistence"
const projectID = "proj_review_state_persistence"
const sessionA = "ses_review_state_a"
const sessionB = "ses_review_state_b"
const titleA = "Alpha review state"
const titleB = "Beta review state"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("restores review mode and selected file per session", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await selectMode(page, "Git changes", "Branch changes")
await selectFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
await switchSession(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await selectMode(page, "Branch changes", "Git changes")
await expectSelectedFile(page, "alpha.ts")
await selectMode(page, "Git changes", "Branch changes")
await expectSelectedFile(page, "beta.ts")
await page.reload()
await expectSessionTitle(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "gamma.ts")
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).click()
}
async function selectFile(page: Page, file: string) {
await page.getByRole("button", { name: file }).click()
await expectSelectedFile(page, file)
}
async function expectSelectedFile(page: Page, file: string) {
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
}
async function switchSession(page: Page, title: string) {
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
await expectSessionTitle(page, title)
}
async function setup(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "review-state-persistence",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }),
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
),
}),
)
await page.addInitScript(
({ directory, server, sessions }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function diff(file: string) {
return {
file,
additions: 1,
deletions: 1,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
}
}
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
@@ -9,12 +9,19 @@ const title = "Review terminal stacked"
const branchDiffs = [
fileDiff(".github/actions/setup-bun/action.yml", 7),
...Array.from({ length: 2_739 }, (_, index) =>
fileDiff(`src/branch/generated-${String(index).padStart(4, "0")}.ts`, 100),
fileDiff(
`src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
100,
false,
),
),
]
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
let detailVersion = 1
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
@@ -48,7 +55,10 @@ test("keeps the review tree and terminal sized when both panels are open", async
time: { created: 1700000000000, updated: 1700000000000 },
},
],
sessionStatus: { [sessionID]: { type: "idle" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
@@ -57,17 +67,25 @@ test("keeps the review tree and terminal sized when both panels are open", async
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
await page.route("**/vcs/diff**", (route) => {
const url = new URL(route.request().url())
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? branchDiffs
url.searchParams.get("mode") === "branch"
? detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
),
}),
)
})
})
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
@@ -96,7 +114,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
@@ -113,41 +131,65 @@ test("keeps the review tree and terminal sized when both panels are open", async
})
expect(bottomGap).toBeGreaterThanOrEqual(0)
expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
await lastFile.click()
await lazyDiff
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1")
detailVersion = 2
events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
events.push(statusEvent("idle"))
await refreshedDiff
await expect(preview).toContainText("after-2")
await selectMode(page, "Branch changes", "Git changes")
await expectTree(page, 8, "git-0.ts")
await page.getByRole("button", { name: "git-0.ts" }).click()
await selectMode(page, "Git changes", "Branch changes")
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts")
await filter.fill("")
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_745, "action.yml")
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
})
@@ -201,12 +243,21 @@ function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
function fileDiff(file: string, additions: number) {
function statusEvent(type: "busy" | "idle") {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
return {
file,
additions,
deletions: 0,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
patch: loaded
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
}
}
@@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => {
assistantMessage([
toolPart(
id,
"apply_patch",
"patch",
"completed",
{ files: ["src/a.ts"] },
{
@@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
assistantMessage([
toolPart(
patchID,
"apply_patch",
"patch",
"completed",
{ files: files.map((file) => file.filePath) },
{ metadata: { files } },
@@ -35,7 +35,6 @@ test.describe("session timeline projection", () => {
editPart("prt_edit"),
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
patchPart("prt_patch"),
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
toolPart(
"prt_question",
"question",
@@ -65,7 +64,6 @@ test.describe("session timeline projection", () => {
]) {
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
}
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
@@ -246,7 +244,7 @@ function editPart(id: string) {
function patchPart(id: string) {
return toolPart(
id,
"apply_patch",
"patch",
"completed",
{ files: ["src/a.ts", "src/b.ts"] },
{
@@ -0,0 +1,228 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import {
assistantMessage,
setupTimeline,
shell,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
for (const deviceScaleFactor of [1.25, 1.5]) {
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
const shellID = "prt_shell_outline"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
reducedMotion: true,
deviceScaleFactor,
})
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
const output = part.locator('[data-component="bash-output"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(output).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
if (!output) throw new Error("Shell output is unavailable")
const rowRect = element.getBoundingClientRect()
const outputRect = output.getBoundingClientRect()
// Match a rounded-down measurement at a fractional device-pixel phase.
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
element.style.transform = "translateY(0.25px)"
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
const style = getComputedStyle(output)
return {
outputWidth: outputRect.width,
outputHeight: outputRect.height,
borderColor: style.borderTopColor,
boxShadow: style.boxShadow,
clipMargin: getComputedStyle(element).overflowClipMargin,
}
})
await timeline.settle()
const clipped = await row.evaluate((element) => {
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
})
expect(clipped).toBeCloseTo(0.49, 1)
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
const edges = await captureCardEdges(page, output)
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
expect(geometry.boxShadow).toBe("none")
expect(geometry.clipMargin).toBe("0.5px")
expect(edges.magenta.top).toBeGreaterThan(0.75)
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
})
}
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
const patchID = "prt_patch_outline"
const file = {
filePath: "src/outline.ts",
relativePath: "src/outline.ts",
type: "update",
additions: 1,
deletions: 1,
before: "const outline = false\n",
after: "const outline = true\n",
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
]),
],
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
reducedMotion: true,
})
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(card).toBeVisible()
await timeline.settle()
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
if (!card) throw new Error("Patch card is unavailable")
const rowRect = element.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
const clipMargin = getComputedStyle(element).overflowClipMargin
const bottom = element.getBoundingClientRect().bottom
return {
overflow: card.getBoundingClientRect().bottom - bottom,
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
clipMargin,
cardWidth: cardRect.width,
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
expect(edges.luminance.top).toBeLessThan(245)
expect(edges.luminance.bottom).toBeLessThan(245)
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
expect(geometry.clipMargin).toBe("0.5px")
})
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
const secondUserID = "msg_outline_second_user"
await setupTimeline(page, {
messages: [
userMessage(undefined, {
summary: {
diffs: [
{
file: "src/summary.ts",
additions: 1,
deletions: 1,
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
},
],
},
}),
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
assistantMessage([], {
id: "msg_outline_second_assistant",
parentID: secondUserID,
created: 1700000011000,
}),
],
})
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
elements.map((element) => ({
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
clipMargin: getComputedStyle(element).overflowClipMargin,
})),
)
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
})
async function captureCardEdges(page: Page, card: Locator) {
const box = await card.boundingBox()
if (!box) throw new Error("Tool card bounds are unavailable")
const viewport = page.viewportSize()
if (!viewport) throw new Error("Viewport bounds are unavailable")
const screenshot = await page.screenshot()
return page.evaluate(
async ({ source, box, viewport }) => {
const image = new Image()
image.src = source
await image.decode()
const canvas = document.createElement("canvas")
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext("2d")
if (!context) throw new Error("2D canvas is unavailable")
context.drawImage(image, 0, 0)
const scale = {
x: image.naturalWidth / viewport.width,
y: image.naturalHeight / viewport.height,
}
const rows = (candidates: number[]) => {
const left = Math.floor((box.x + 8) * scale.x)
const width = Math.floor((box.width - 16) * scale.x)
return candidates.map((row) => {
const pixels = context.getImageData(left, row, width, 1).data
const indexes = Array.from({ length: width }, (_, index) => index * 4)
return {
luminance:
indexes
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
.reduce((sum, value) => sum + value, 0) / width,
magenta:
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
.length / width,
}
})
}
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
const columns = new Uint32Array(image.naturalWidth)
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
}
const top = box.y * scale.y
const bottom = (box.y + box.height) * scale.y
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
return {
box,
luminance: {
top: Math.min(...topRows.map((row) => row.luminance)),
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
},
magenta: {
top: Math.max(...topRows.map((row) => row.magenta)),
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
},
}
},
{
source: `data:image/png;base64,${screenshot.toString("base64")}`,
viewport,
box,
},
)
}
@@ -8,7 +8,7 @@ import {
} from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
)
@@ -17,13 +17,11 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
error: "The user dismissed this question",
}),
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
)
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
await expect(page.getByText(/dismissed/i)).toBeVisible()
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
for (let index = 0; index < ordinary.length; index++) {
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
}
@@ -90,7 +88,7 @@ function questionInput() {
function errorInput(tool: string) {
if (tool === "bash") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
if (tool === "apply_patch") return { files: ["src/error.ts"] }
if (tool === "patch") return { files: ["src/error.ts"] }
if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" }
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
@@ -1,186 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TodoDockNavigation"
const projectID = "proj_todo_dock_navigation"
const sourceID = "ses_todo_dock_source"
const otherID = "ses_todo_dock_other"
const sourceTitle = "Todo dock animation"
const otherTitle = "Separate session"
const activeTodos = [
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
]
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000)
const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "todo-dock-navigation",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"claude-opus-4-6": {
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
limit: { context: 200_000 },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
todos: (sessionID) => todos[sessionID] ?? [],
})
await configurePage(page)
await page.goto(sessionHref(sourceID))
await expectSessionTitle(page, sourceTitle)
const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toHaveCount(0)
events.push(statusEvent(sourceID, "busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await page.waitForTimeout(700)
const opening = sampleDock(page, 1_000)
todos[sourceID] = activeTodos
events.push(todoEvent(sourceID, activeTodos))
await expect(dock).toBeVisible()
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
await switchSession(page, otherID, otherTitle)
await expect(dock).toHaveCount(0)
const returningOpen = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
const openSamples = (await returningOpen).filter((sample) => sample.present)
expect(openSamples.length).toBeGreaterThan(0)
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
expect(openSamples[0]!.height).toBeGreaterThan(70)
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
const closing = sampleDock(page, 1_000)
todos[sourceID] = completedTodos
events.push(todoEvent(sourceID, completedTodos))
await expect(dock).toHaveCount(0)
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
todos[sourceID] = []
events.push(todoEvent(sourceID, []))
await switchSession(page, otherID, otherTitle)
const returningEmpty = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
await expect(dock).toHaveCount(0)
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
})
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
return {
directory,
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
}
}
async function configurePage(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, dirBase64, server, sessionIDs }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
)
}
function sessionHref(sessionID: string) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
return `/server/${base64Encode(server)}/session/${sessionID}`
}
async function switchSession(page: Page, sessionID: string, title: string) {
const href = sessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
function sampleDock(page: Page, duration: number) {
return page.evaluate(async (duration) => {
const samples: { present: boolean; height: number; opacity: number }[] = []
const start = performance.now()
while (performance.now() - start < duration) {
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
const clip = dock?.parentElement?.parentElement
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
samples.push({
present: !!dock,
height: clip?.getBoundingClientRect().height ?? 0,
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
})
await new Promise(requestAnimationFrame)
}
return samples
}, duration)
}
@@ -63,7 +63,7 @@ async function mockServer(page: Page) {
if (byId) return json(route, byId)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
@@ -120,7 +120,7 @@ function toolPart(
outputLength = 160,
): MessagePart {
const metadata =
tool === "apply_patch"
tool === "patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
@@ -199,7 +199,7 @@ function turn(index: number): Message[] {
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
@@ -229,7 +229,6 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
+11 -4
View File
@@ -17,11 +17,11 @@ export interface MockServerConfig {
onMessage?: (input: { sessionID: string; messageID: string }) => void
events?: () => unknown[]
eventRetry?: number
todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: unknown
}
@@ -55,7 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question")
@@ -66,6 +66,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
if (path === "/file/content" && config.fileContent)
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
if (path === "/find/file" && config.findFiles)
return json(
route,
await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("dirs") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
}),
)
if (path === "/api/reference")
return json(route, {
location: {
@@ -96,8 +105,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, message)
}
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.14",
"version": "1.17.18",
"description": "",
"type": "module",
"exports": {
@@ -47,6 +47,7 @@
"vite-plugin-solid": "catalog:"
},
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -88,6 +89,7 @@
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "catalog:"
}
}
+71 -17
View File
@@ -9,9 +9,10 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import { base64Encode } from "@opencode-ai/core/util/encode"
import {
type Component,
createEffect,
@@ -32,7 +33,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
@@ -52,9 +53,10 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -88,10 +90,14 @@ const SessionRoute = () => {
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return <SessionPage />
return (
<SessionRouteErrorBoundary sessionID={params.id}>
<SessionPage />
</SessionRouteErrorBoundary>
)
}
const TargetSessionRoute = () => {
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal()
const conn = createMemo(() => {
@@ -105,14 +111,47 @@ const TargetSessionRoute = () => {
// re-resolves reactively instead); both rely on this key for server changes.
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<TargetSessionRouteContent />
</ServerSyncProvider>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
const TargetSessionRoute = () => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)
function LegacyTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<TargetServerRoute>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<LegacyTargetSessionRedirect />
</SessionRouteErrorBoundary>
</TargetServerRoute>
)
}
function LegacyTargetSessionRedirect() {
const params = useParams<{ id: string }>()
const navigate = useNavigate()
const sync = useServerSync()
const current = createSessionLineage(
() => params.id,
() => sync().session.lineage,
)
createEffect(() => {
const directory = current()?.session.directory
if (!directory) return
navigate(legacySessionHref(directory, params.id), { replace: true })
})
return null
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
@@ -136,6 +175,7 @@ function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>)
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
@@ -144,7 +184,14 @@ function DraftRoute() {
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
{(draft) => (
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
>
<ResolvedDraftRoute draft={draft} />
</Show>
)}
</Show>
</Show>
)
@@ -186,7 +233,7 @@ declare global {
deepLinks?: string[]
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
}
}
@@ -320,8 +367,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<MetaProvider>
<Font />
<ThemeProvider
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })
onThemeApplied={(_, mode, scheme) => {
void window.api?.setTitlebar?.({ mode, scheme })
}}
>
<LanguageProvider locale={props.locale}>
@@ -542,7 +589,14 @@ function Routes(props: { serverScoped?: JSX.Element }) {
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
)}
>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Show when={!settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={LegacyHome} />
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
</>
}
</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
@@ -550,15 +604,15 @@ function Routes(props: { serverScoped?: JSX.Element }) {
</Route>
<Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} />
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</>
)
}
function LegacyTargetSessionRoute() {
function NewLayoutLegacySessionRedirect() {
const server = useServer()
const tabs = useTabs()
const params = useParams<{ id: string }>()
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,325 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js"
import { useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = {
id: string
type: "command" | "file" | "session"
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
const seen = new Set<string>()
return items.filter((item) => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
return {
id: "file:" + path,
type: "file",
title: path,
category,
path,
}
}
export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
const file = useFile()
const layout = useLayout()
const { tabs, view } = useSessionLayout()
return (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
onOpenFile?.(path)
tabs().setActive(value)
}
}
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()()
const serverSync = useServerSync()
const { params, tabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => {
if (filesOnly()) return []
return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandEntry(option, category))
})
const preferredCommandEntries = createMemo(() => {
const all = allowedCommands()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const recentFileEntries = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = language.t("palette.group.files")
return order
.map((item) => file.pathFromTab(item))
.filter((path): path is string => {
if (!path || seen.has(path)) return false
seen.add(path)
return true
})
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const rootFileEntries = createMemo(() => {
const category = language.t("palette.group.files")
return file.tree
.children("")
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return undefined
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const sessions = createSessionEntries({
workspaces,
label: (directory) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
openFile(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return {
language,
file,
commandEntries,
preferredCommandEntries,
recentFileEntries,
rootFileEntries,
sessions,
highlight,
select,
close: () => dialog.close(),
}
}
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return {
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
}
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
untitled: () => string
category: () => string
}) {
const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
return (text: string) => {
if (!text.trim()) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as CommandPaletteEntry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as CommandPaletteEntry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props
.load(directory)
.then((result) =>
(result.data ?? [])
.filter((session) => !!session?.id)
.map((session) => ({
id: session.id,
title: session.title ?? props.untitled(),
description,
directory,
archived: session.time?.archived,
updated: session.time?.updated,
})),
)
.catch(() => [] as SessionEntryInput[])
}),
)
.then((results) => {
if (state.token !== current) return [] as CommandPaletteEntry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, props.category()))
state.cached = next
return next
})
.catch(() => [] as CommandPaletteEntry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
}
type SessionEntryInput = {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
return {
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
}
}
@@ -0,0 +1,220 @@
.command-palette-v2 {
overflow: hidden;
}
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
top stays put while the content-driven height grows and shrinks. */
[data-component="dialog-v2"]:has(.command-palette-v2) {
align-items: flex-start;
}
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
width: min(calc(100vw - 24px), 640px);
height: auto;
min-height: 280px;
max-height: min(calc(100vh - 96px), 480px);
margin-top: max(48px, calc((100vh - 480px) / 2));
border-radius: 12px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-floating);
}
.command-palette-v2-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 0;
padding: 0;
}
.command-palette-v2-search {
flex-shrink: 0;
padding: 6px;
}
.command-palette-v2-search [data-component="text-input-v2"] {
width: 100%;
height: 36px;
border-radius: 6px;
outline: 0;
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
transition:
background-color 120ms ease-in-out,
box-shadow 120ms ease-in-out;
}
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
background: var(--v2-background-bg-layer-02);
box-shadow: none;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
border-radius: 6px;
background: transparent;
gap: 8px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
padding-left: 12px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
border-radius: 6px;
background: transparent;
}
.command-palette-v2-scroll {
min-height: 0;
flex: 1;
}
.command-palette-v2-results {
display: flex;
flex-direction: column;
gap: 16px;
padding: 6px 6px 8px;
}
.command-palette-v2-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.command-palette-v2-group-title {
margin: 6px 0;
padding: 0 12px;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
user-select: none;
}
.command-palette-v2-row {
display: flex;
width: 100%;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--v2-text-text-base);
text-align: left;
cursor: default;
scroll-margin: 6px 0;
}
.command-palette-v2-row[data-active] {
background: var(--v2-overlay-simple-overlay-hover);
}
.command-palette-v2-row:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
.command-palette-v2-row-main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 8px;
}
.command-palette-v2-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.command-palette-v2-row-text {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.command-palette-v2-title {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-description,
.command-palette-v2-meta {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-meta {
flex-shrink: 0;
}
.command-palette-v2-file-path {
display: flex;
min-width: 0;
align-items: baseline;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
}
.command-palette-v2-file-dir {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-weight: 440;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-file-name {
flex-shrink: 0;
color: var(--v2-text-text-base);
font-weight: 530;
white-space: nowrap;
}
.command-palette-v2-state {
display: grid;
min-height: 120px;
place-items: center;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
}
@media (max-width: 640px) {
.command-palette-v2-row-text {
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.command-palette-v2-description {
max-width: 100%;
}
}
@@ -0,0 +1,221 @@
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { formatKeybindParts } from "@/context/command"
import { useLanguage } from "@/context/language"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import "./dialog-command-palette-v2.css"
function groups(entries: CommandPaletteEntry[]) {
const map = new Map<string, CommandPaletteEntry[]>()
for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
}
function matchesEntry(entry: CommandPaletteEntry, query: string) {
const value = query.toLowerCase()
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
}
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => {
const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions.filter((entry) => matchesEntry(entry, q)),
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
}
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()])
createEffect(() => {
query()
visibleEntries()
setActive(0)
})
createEffect(() => {
palette.highlight(activeEntry())
})
let resultsRef: HTMLDivElement | undefined
const move = (delta: -1 | 1) => {
const count = visibleEntries().length
if (count === 0) return
setActive((index) => (index + delta + count) % count)
requestAnimationFrame(() => {
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
})
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault()
move(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
move(-1)
return
}
if (event.key === "Enter") {
event.preventDefault()
palette.select(activeEntry())
return
}
if (event.key === "Escape") {
event.preventDefault()
palette.close()
}
}
return (
<Dialog class="command-palette-v2" size="large">
<DialogBody class="command-palette-v2-body">
<div class="command-palette-v2-search">
<TextInputV2
value={query()}
autofocus
autocomplete="off"
spellcheck={false}
appearance="large"
placeholder={palette.language.t("palette.search.placeholder")}
leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
</div>
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-v2-results" role="listbox">
<Show
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-v2-state">
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
</div>
}
>
<For each={groupedEntries()}>
{(group) => (
<div class="command-palette-v2-group">
<Show when={group.category}>
<div class="command-palette-v2-group-title">{group.category}</div>
</Show>
<For each={group.entries}>
{(item) => (
<PaletteRow
item={item}
active={activeEntry()?.id === item.id}
language={palette.language}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => palette.select(item)}
/>
)}
</For>
</div>
)}
</For>
</Show>
</div>
</ScrollView>
</DialogBody>
</Dialog>
)
}
function PaletteRow(props: {
item: CommandPaletteEntry
active: boolean
language: ReturnType<typeof useLanguage>
onActive: () => void
onSelect: () => void
}) {
return (
<button
type="button"
class="command-palette-v2-row"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
onMouseMove={(event) => {
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
if (event.movementX === 0 && event.movementY === 0) return
props.onActive()
}}
onMouseDown={(event) => event.preventDefault()}
onClick={props.onSelect}
>
<Switch
fallback={
<div class="command-palette-v2-row-main">
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
<div class="command-palette-v2-file-path">
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
</div>
</div>
}
>
<Match when={props.item.type === "command"}>
<div class="command-palette-v2-row-main">
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title">{props.item.title}</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description">{props.item.description}</span>
</Show>
</div>
</div>
<Show when={props.item.keybind}>
<KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
</Show>
</Match>
<Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main">
<Icon name="status" class="command-palette-v2-row-icon" />
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title}
</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.description}
</span>
</Show>
</div>
</div>
<Show when={props.item.updated}>
<span class="command-palette-v2-meta">
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
</span>
</Show>
</Match>
</Switch>
</button>
)
}
@@ -177,7 +177,7 @@ export function CustomProviderForm() {
return (
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
</div>
@@ -1,329 +1,81 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteFileOpener,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type EntryType = "command" | "file" | "session"
type Entry = {
id: string
type: EntryType
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
type DialogSelectFileMode = "all" | "files"
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
const uniqueEntries = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
})
const createFileEntry = (path: string, category: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category,
path,
})
const createSessionEntry = (
input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
},
category: string,
): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
function createCommandEntries(props: {
filesOnly: () => boolean
command: ReturnType<typeof useCommand>
language: ReturnType<typeof useLanguage>
}) {
const allowed = createMemo(() => {
if (props.filesOnly()) return []
return props.command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const list = createMemo(() => {
const category = props.language.t("palette.group.commands")
return allowed().map((option) => createCommandEntry(option, category))
})
const picks = createMemo(() => {
const all = allowed()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = props.language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
return { allowed, list, picks }
}
function createFileEntries(props: {
file: ReturnType<typeof useFile>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage>
}) {
const tabState = createSessionTabs({
tabs: props.tabs,
pathFromTab: props.file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
})
const recent = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = props.language.t("palette.group.files")
const items: Entry[] = []
for (const item of order) {
const path = props.file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(createFileEntry(path, category))
}
return items.slice(0, ENTRY_LIMIT)
})
const root = createMemo(() => {
const category = props.language.t("palette.group.files")
const nodes = props.file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
})
return { recent, root }
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
serverSDK: ServerSDK
language: ReturnType<typeof useLanguage>
}) {
const state: {
token: number
inflight: Promise<Entry[]> | undefined
cached: Entry[] | undefined
} = {
token: 0,
inflight: undefined,
cached: undefined,
}
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as Entry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as Entry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.serverSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? props.language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(
() =>
[] as {
id: string
title: string
description: string
directory: string
archived?: number
updated?: number
}[],
)
}),
)
.then((results) => {
if (state.token !== current) return [] as Entry[]
const seen = new Set<string>()
const category = props.language.t("command.category.session")
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, category))
state.cached = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
return { sessions }
}
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files"
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const commandEntries = createCommandEntries({ filesOnly, command, language })
const fileEntries = createFileEntries({ file, tabs, language })
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
if (!filesOnly() && settings.general.newLayoutDesigns()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
}
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
}
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
}
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
const language = useLanguage()
const serverSDK = useServerSDK()
const { params } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
openFile(result)
}}
/>
)
}
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [grouped, setGrouped] = createSignal(false)
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
if (!query && filesOnly()) {
const loaded = file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : file.tree.list("")
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
if (!query && props.filesOnly()) {
const loaded = palette.file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
if (loaded || next.length > 0) {
void pending
@@ -331,79 +83,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
}
await pending
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
}
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
if (filesOnly()) {
const files = await file.searchFiles(query)
const category = language.t("palette.group.files")
return files.map((path) => createFileEntry(path, category))
if (props.filesOnly()) {
const files = await palette.file.searchFiles(query)
const category = palette.language.t("palette.group.files")
return files.map((path) => createCommandPaletteFileEntry(path, category))
}
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const category = language.t("palette.group.files")
const entries = files.map((path) => createFileEntry(path, category))
return [...commandEntries.list(), ...nextSessions, ...entries]
}
const handleMove = (item: Entry | undefined) => {
state.cleanup?.()
if (!item) return
if (item.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const open = (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
tabs().setActive(value)
}
const handleSelect = (item: Entry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
open(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
open(result)
}}
/>
)
const [files, nextSessions] = await Promise.all([
palette.file.searchFiles(query),
Promise.resolve(palette.sessions(query)),
])
const category = palette.language.t("palette.group.files")
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
return [...palette.commandEntries(), ...nextSessions, ...entries]
}
return (
@@ -411,21 +108,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
<List
class="px-3"
search={{
placeholder: filesOnly()
? language.t("session.header.searchFiles")
: language.t("palette.search.placeholder"),
placeholder: props.filesOnly()
? palette.language.t("session.header.searchFiles")
: palette.language.t("palette.search.placeholder"),
autofocus: true,
hideIcon: true,
}}
emptyMessage={language.t("palette.empty")}
loadingMessage={language.t("common.loading")}
emptyMessage={palette.language.t("palette.empty")}
loadingMessage={palette.language.t("common.loading")}
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove}
onSelect={handleSelect}
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
>
{(item) => (
<Switch
@@ -452,7 +149,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
</Show>
</div>
</Match>
@@ -479,7 +176,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</div>
<Show when={item.updated}>
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
</span>
</Show>
</div>
@@ -0,0 +1,173 @@
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
const local = useLocal()
const model = props.model ?? local.model
const dialog = useDialog()
const directory = () => decode64(local.slug())
const providers = useProviders(directory)
const language = useLanguage()
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
const currentKey = createMemo(() => {
const c = model.current()
return c ? `${c.provider.id}:${c.id}` : undefined
})
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController()
controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
})
}
const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
dialog.close()
}
// Focus starts on the dialog's close button, outside the list, so listen at the
// document level while the dialog is mounted instead of on the list container.
let listEl: HTMLDivElement | undefined
onMount(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
if (!listEl) return
const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
if (buttons.length === 0) return
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
const next =
index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
buttons[(next + buttons.length) % buttons.length]?.focus()
e.preventDefault()
}
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
})
return (
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
<DialogHeader closeLabel={language.t("common.close")}>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
</DialogHeader>
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
<DialogBody class="min-h-0 flex-1 gap-0">
<ScrollView class="min-h-0 flex-1 w-full">
<div ref={listEl} class="flex min-h-full flex-col">
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")}
</div>
</div>
<For each={model.list()}>
{(item) => (
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
>
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item)}>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
</Show>
<Show when={item.latest}>
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</TooltipV2>
)}
</For>
</div>
<div class="flex w-full flex-col p-2.5 pt-0">
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div>
</div>
<div class="flex w-full flex-col">
<For
each={[...providers.popular()].sort((a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
}
return a.name.localeCompare(b.name)
})}
>
{(provider) => (
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{provider.name}</span>
<Show when={provider.id === "opencode"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencode.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "opencode-go"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencodeGo.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "anthropic"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.anthropic.note")}
</span>
</Show>
</button>
)}
</For>
<button
type="button"
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders()}
>
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<Icon name="dot-grid" size="small" />
</span>
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.viewAll")}
</span>
</button>
</div>
</div>
</div>
</div>
</ScrollView>
</DialogBody>
</DialogV2>
)
}
@@ -24,6 +24,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
@@ -92,6 +93,7 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
@@ -446,26 +448,41 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.RadioGroup value={current()}>
<For each={group.items}>
{(item) => (
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate leading-5">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
</TooltipV2>
)}
</For>
</MenuV2.RadioGroup>
@@ -201,6 +201,7 @@ export default function FileTree(props: {
kinds?: ReadonlyMap<string, Kind>
draggable?: boolean
onFileClick?: (file: FileNode) => void
onFileDoubleClick?: (file: FileNode) => void
_filter?: Filter
_marks?: Set<string>
@@ -440,6 +441,7 @@ export default function FileTree(props: {
active={props.active}
draggable={props.draggable}
onFileClick={props.onFileClick}
onFileDoubleClick={props.onFileDoubleClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
@@ -462,6 +464,7 @@ export default function FileTree(props: {
as="button"
type="button"
onClick={() => props.onFileClick?.(node)}
onDblClick={() => props.onFileDoubleClick?.(node)}
>
<div class="w-4 shrink-0" />
<Switch>
+134 -44
View File
@@ -1,54 +1,144 @@
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import { Persist, persisted } from "@/utils/persist"
const helpIcon = (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
data-slot="icon-svg"
>
<path
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
fill="var(--v2-icon-icon-base)"
/>
</svg>
)
const triggerClass =
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
// TODO: wire to changelog / seen-state when available
const showPopover = () => true
export function HelpButton() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
const [shown, setShown] = createSignal(false)
const platform = usePlatform()
return (
<Show when={!state.dismissed}>
<div class="fixed bottom-4 right-4 z-50 hidden md:block">
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs="button"
triggerProps={{
type: "button",
"aria-label": "Help",
class:
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
}}
trigger={<span aria-hidden="true">?</span>}
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={8}
placement="top-end"
>
<Show when={shown()}>
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
<button
type="button"
aria-label="Close"
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
onClick={() => {
setShown(false)
setState("dismissed", true)
}}
>
<Icon name="xmark-small" />
</button>
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
<p class="text-12-regular text-text-weak">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
</p>
</div>
</Show>
</Popover>
</div>
</Show>
<a
href="https://opencode.ai"
aria-label="Open the OpenCode website"
data-component="icon-button-v2"
data-size="large"
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
onClick={(event) => {
event.preventDefault()
platform.openLink(event.currentTarget.href)
}}
>
{helpIcon}
</a>
)
}
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
// setState({ dismissed: false }) // for testing
const [drawerOpen, setDrawerOpen] = createSignal(false)
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={!state.dismissed}>
<div
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. A faster, more intuitive way to work."
>
<button
type="button"
aria-label="Dismiss Tabs information"
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={() => setState("dismissed", true)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
</svg>
</button>
<button
type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
onClick={() => {
setState("dismissed", true)
setDrawerOpen(true)
}}
>
<video
src={introducingTabsVideo}
class="absolute inset-0 h-full w-full object-cover"
loop
muted
autoplay
playsinline
aria-hidden="true"
onContextMenu={(event) => event.preventDefault()}
/>
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
Introducing Tabs
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
A faster, more intuitive way to work.
</p>
</div>
</button>
</div>
</Show>
<DrawerContent>
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
June 16
</p>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
/>
</div>
<div class="relative flex w-full flex-col items-start gap-6 p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs Navigation.
</p>
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
sessions.
</p>
</div>
</DrawerContent>
</Drawer>
)
}
+35 -2
View File
@@ -1,4 +1,4 @@
import { Show, type Component } from "solid-js"
import { Show, type Component, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
@@ -23,7 +23,18 @@ type ModelInfo = {
}
}
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => {
function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
return (
<div class="flex min-w-0 items-center gap-4">
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
</div>
)
}
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = (
props,
) => {
const language = useLanguage()
const sourceName = (model: ModelInfo) => {
const value = `${model.id} ${model.name}`.toLowerCase()
@@ -51,6 +62,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${sourceName(props.model)} ${props.model.name}${suffix}`
}
const name = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${props.model.name}${suffix}`
}
const inputs = () => {
if (props.model.capabilities) {
const input = props.model.capabilities.input
@@ -73,6 +91,21 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
: language.t("model.tooltip.reasoning.none")
}
const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
const contextLimit = () => props.model.limit.context.toLocaleString(language.intl())
if (props.v2) {
return (
<div class="flex w-[180px] flex-col gap-2">
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
<Show when={inputs()}>
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
</Show>
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
</div>
)
}
return (
<div class="flex flex-col gap-1 py-1">
@@ -1,8 +1,6 @@
// @ts-nocheck
import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2"
import { createPromptState } from "@/context/prompt"
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
import { createPromptInputHistory, PromptInput } from "./prompt-input"
function createPromptInputStoryRuntime() {
@@ -104,94 +102,6 @@ function PromptInputExample() {
)
}
const todos: Todo[] = [
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
]
function PromptInputWithOpenDock() {
const input = createPromptInputStoryRuntime()
const [controls, setControls] = createStore({
agent: "build",
activeTab: undefined as string | undefined,
todoCollapsed: false,
})
const inputControls = {
agents: {
available: [],
options: ["build"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
paid: true,
loading: false,
},
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => [],
open: () => {},
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: { opened: () => false, open: () => {} },
},
newLayoutDesigns: true,
}
const state = {
blocked: () => false,
questionRequest: () => undefined,
permissionRequest: () => undefined,
permissionResponding: () => false,
decide: () => {},
todos: () => todos,
dock: () => true,
closing: () => false,
opening: () => false,
}
return (
<SessionComposerRegion
controller={createSessionComposerRegionController({
state,
sessionKey: () => "story-session",
sessionID: () => "story-session",
prompt: input.state,
ready: () => true,
centered: () => false,
todo: {
collapsed: () => controls.todoCollapsed,
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
},
followup: () => undefined,
revert: () => undefined,
onResponseSubmit: () => {},
openParent: () => {},
setPromptRef: () => {},
setDockRef: () => {},
})}
promptInput={
<PromptInput
controls={inputControls}
{...input}
ref={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
/>
}
/>
)
}
export default {
title: "App/PromptInput",
id: "app-prompt-input",
@@ -206,12 +116,3 @@ export const Basic = {
</div>
),
}
export const DockAlreadyOpen = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
<PromptInputWithOpenDock />
</div>
),
}
+161 -52
View File
@@ -36,6 +36,8 @@ import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
@@ -43,6 +45,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
@@ -520,7 +524,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const setMode = (mode: "normal" | "shell") => {
setStore("mode", mode)
setStore("popover", null)
setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
requestAnimationFrame(() => editorRef?.focus())
}
@@ -554,7 +558,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
])
const closePopover = () => setStore("popover", null)
const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" })
const resetHistoryNavigation = (force = false) => {
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
@@ -800,17 +804,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return
const menu = store.slashMenu
closePopover()
const images = imageAttachments()
if (cmd.type === "custom") {
const text = `/${cmd.trigger} `
if (menu) {
editorRef.focus()
setCursorPosition(editorRef, 0)
addPart({ type: "text", content: text, start: 0, end: text.length })
focusEditorEnd()
return
}
setEditorText(text)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
focusEditorEnd()
return
}
if (menu) {
command.trigger(cmd.id, "slash")
return
}
clearEditor()
prompt.set([...DEFAULT_PROMPT, ...images], 0)
command.trigger(cmd.id, "slash")
@@ -1072,10 +1089,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (atMatch) {
atOnInput(atMatch[1])
setStore("popover", "at")
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
} else if (slashMatch) {
slashOnInput(slashMatch[1])
setStore("popover", "slash")
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
} else {
closePopover()
}
@@ -1171,6 +1188,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
const openCommands = () => {
const populated = prompt.dirty() || commentCount() > 0
requestAnimationFrame(() => {
if (!populated) {
if (!addPart({ type: "text", content: "/", start: 0, end: 0 })) return
slashOnInput("")
setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" })
return
}
slashOnInput("")
setStore({ popover: "slash", slashMenu: true, slashMenuQuery: "" })
})
}
const openContext = () => {
requestAnimationFrame(() => {
if (!addPart({ type: "text", content: "@", start: 0, end: 0 })) return
atOnInput("")
setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" })
})
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
}
@@ -1199,7 +1238,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
setStore("mode", "normal")
setStore("popover", null)
closePopover()
setStore("historyIndex", -1)
setStore("savedPrompt", null)
prompt.set(edit.prompt, promptLength(edit.prompt))
@@ -1286,13 +1325,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover),
setPopover: (popover) => {
if (!popover) return closePopover()
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
},
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1325,7 +1368,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const cursorPosition = getCursorPosition(editorRef)
if (cursorPosition === 0) {
setStore("mode", "shell")
setStore("popover", null)
closePopover()
event.preventDefault()
return
}
@@ -1460,6 +1503,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const handleSlashMenuKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closePopover()
requestAnimationFrame(() => editorRef.focus())
event.preventDefault()
return
}
if (event.key === "Tab") {
selectPopoverActive()
event.preventDefault()
return
}
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
if (!nav && !ctrlNav) return
slashOnKeyDown(event)
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView()
event.preventDefault()
}
const agentsLoading = () => props.controls.agents.loading
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
const providersLoading = () => props.controls.model.loading
@@ -1488,9 +1554,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
})
if (props.controls.newLayoutDesigns) {
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
return
}
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
},
}))
@@ -1527,6 +1595,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
slashActive={slashActive() ?? undefined}
setSlashActive={setSlashActive}
onSlashSelect={handleSlashSelect}
slashMenu={store.slashMenu}
slashMenuQuery={store.slashMenuQuery}
onSlashMenuInput={(value) => {
setStore("slashMenuQuery", value)
slashOnInput(value)
}}
onSlashMenuKeyDown={handleSlashMenuKeyDown}
commandKeybind={command.keybind}
commandKeybindParts={command.keybindParts}
newLayoutDesigns={props.controls.newLayoutDesigns}
@@ -1625,23 +1700,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
placement="top"
value={
<>
{language.t("prompt.action.attachFile")}
{language.t("prompt.menu.addImagesAndFiles")}
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
</>
}
>
<IconButton
data-action="prompt-attach"
type="button"
icon="plus"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
/>
<MenuV2 gutter={6} modal={false} placement="top-start">
<MenuV2.Trigger
as={IconButtonV2}
data-action="prompt-attach"
type="button"
icon={<IconV2 name="plus" />}
variant="ghost-muted"
size="large"
style={buttons()}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.menu.addImagesAndFiles")}
/>
<MenuV2.Portal>
<MenuV2.Content style={{ "min-width": "180px" }}>
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
{language.t("prompt.menu.imagesAndFiles")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={openCommands} shortcut="/">
{language.t("prompt.menu.commands")}
</MenuV2.Item>
<MenuV2.Item onSelect={openContext} shortcut="@">
{language.t("prompt.menu.context")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => setMode("shell")} shortcut="!">
{language.t("prompt.menu.shellCommand")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
<Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} />
@@ -1682,7 +1776,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="max-w-[160px] justify-start capitalize"
style={control()}
>
<span class="truncate">
<span class="truncate leading-5">
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
@@ -1969,11 +2063,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => (
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
))
})
dialog.show(() => (
<DialogSelectModelUnpaid model={props.controls.model.selection} />
))
}}
>
<Show when={props.controls.model.selection.current()?.provider?.id}>
@@ -2140,30 +2232,47 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
</>
}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style}
onClick={props.state.onUnpaidClick}
<Show
when={props.state.newLayoutDesigns}
fallback={
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class="-ml-1 shrink-0 flex size-fit">
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</Button>
}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class="-ml-1 shrink-0 flex size-fit">
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</Button>
<ButtonV2
data-action="prompt-model"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
<ModelControlContent state={props.state} v2 />
</ButtonV2>
</Show>
</TooltipV2>
}
>
@@ -41,6 +41,10 @@ type PromptPopoverProps = {
slashActive?: string
setSlashActive: (id: string) => void
onSlashSelect: (item: SlashCommand) => void
slashMenu: boolean
slashMenuQuery: string
onSlashMenuInput: (value: string) => void
onSlashMenuKeyDown: (event: KeyboardEvent) => void
commandKeybind: (id: string) => string | undefined
commandKeybindParts: (id: string) => string[]
newLayoutDesigns: boolean
@@ -254,6 +258,20 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
</Show>
</Match>
<Match when={props.popover === "slash"}>
<Show when={props.slashMenu}>
<div class="px-2 py-1">
<input
ref={(el) => requestAnimationFrame(() => el.focus())}
value={props.slashMenuQuery}
onInput={(event) => props.onSlashMenuInput(event.currentTarget.value)}
onKeyDown={props.onSlashMenuKeyDown}
onMouseDown={(event) => event.stopPropagation()}
aria-label={props.t("prompt.menu.commands")}
placeholder="/"
class="w-full bg-transparent outline-none text-[13px] leading-5 text-v2-text-text-base placeholder:text-v2-text-text-faint"
/>
</div>
</Show>
<Show
when={props.slashFlat.length > 0}
fallback={
@@ -1,5 +1,6 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
@@ -33,6 +34,10 @@ const prompt = {
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
model: {
current: () => undefined,
set: () => undefined,
},
reset: () => undefined,
set: () => undefined,
context: {
@@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => {
})
})
test("uses an injected model selection", async () => {
params = { id: "session-1" }
const model = {
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
variant: { current: () => "draft-variant" },
} as unknown as ModelSelection
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
model,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(optimistic[0]).toMatchObject({
message: {
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
},
})
})
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
@@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js"
import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk"
@@ -191,6 +191,7 @@ type PromptSubmitInput = {
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
model?: ModelSelection
}
export function createPromptSubmit(input: PromptSubmitInput) {
@@ -221,8 +222,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
serverSync().session.set("todo", sessionID, [])
input.onAbort?.()
const key = pendingKey(sessionID)
@@ -298,9 +297,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
const currentModel = local.model.current()
const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current()
const currentAgent = local.agent.current()
const variant = local.model.variant.current()
const variant = modelSelection.variant.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -374,13 +374,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (created) {
seed(sessionDirectory, created)
session = created
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
await startTransition(() => {
if (!session) return
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null,
})
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
})
}
}
if (!session) {
@@ -4,6 +4,8 @@ import type { PromptHistoryEntry } from "./history"
export type PromptInputTransientState = {
popover: "at" | "slash" | null
slashMenu: boolean
slashMenuQuery: string
historyIndex: number
savedPrompt: PromptHistoryEntry | null
placeholder: number
@@ -16,6 +18,8 @@ export type PromptInputTransientState = {
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
setStore({
popover: null,
slashMenu: false,
slashMenuQuery: "",
historyIndex: -1,
savedPrompt: null,
draggingType: null,
@@ -28,6 +32,8 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
const [store, setStore] = createStore<PromptInputTransientState>({
popover: null,
slashMenu: false,
slashMenuQuery: "",
historyIndex: -1,
savedPrompt: null,
placeholder,
@@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
@@ -11,9 +12,10 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
import { getSessionContext } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSettings } from "@/context/settings"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
@@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const layout = useLayout()
const language = useLanguage()
const sdk = useSDK()
const settings = useSettings()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const variant = createMemo(() => props.variant ?? "button")
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
@@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
})
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
@@ -69,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
)
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0)
})
@@ -127,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
<ContextTooltipRow
name={language.t("context.usage.tokens")}
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
value={context()?.total.toLocaleString(language.intl()) ?? "0"}
/>
</div>
)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
import { getSessionContext } from "./session-context-metrics"
const assistant = (
id: string,
@@ -38,10 +38,10 @@ const user = (id: string) => {
}
describe("getSessionContext", () => {
test("computes usage from latest assistant with tokens", () => {
test("computes token totals and usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
]
const providers = [
@@ -60,6 +60,8 @@ describe("getSessionContext", () => {
const ctx = getSessionContext(messages, providers)
expect(ctx?.message.id).toBe("a2")
expect(ctx?.total).toBe(500)
expect(ctx?.input).toBe(300)
expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.modelLabel).toBe("GPT-4.1")
@@ -94,15 +96,4 @@ describe("getSessionContext", () => {
expect(ctx).toBeUndefined()
})
test("computes stored session token totals", () => {
expect(
getSessionTokenTotal({
input: 10,
output: 20,
reasoning: 30,
cache: { read: 40, write: 50 },
}),
).toBe(150)
})
})
@@ -1,4 +1,4 @@
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
type Provider = {
id: string
@@ -21,6 +21,7 @@ type Context = {
modelLabel: string
limit: number | undefined
input: number
total: number
usage: number | null
}
@@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
}
}
@@ -61,8 +63,3 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
if (!tokens) return undefined
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
import { getSessionContext } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
@@ -135,7 +135,6 @@ export function SessionContextTab() {
)
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => {
@@ -204,14 +203,15 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
{
label: "context.stats.cacheTokens",
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
value: () =>
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -24,6 +24,7 @@ import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { fileManagerApp } from "@/utils/file-manager"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
@@ -175,11 +176,7 @@ export function SessionHeader() {
return LINUX_APPS
})
const fileManager = createMemo(() => {
if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
})
const fileManager = createMemo(() => fileManagerApp(os()))
createEffect(() => {
if (platform.platform !== "desktop") return
@@ -10,7 +10,7 @@ import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
return (
<div class="flex items-center gap-x-1.5 min-w-0">
<Show
@@ -22,12 +22,19 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
</span>
</Show>
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
<span class="text-14-medium truncate" classList={{ italic: props.temporary }}>
{getFilename(props.path)}
</span>
</div>
)
}
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
export function SortableTab(props: {
tab: string
temporary?: boolean
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
}): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
@@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} />
return <FileVisual path={value} temporary={props.temporary} />
})
return (
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
@@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
@@ -211,7 +211,17 @@ export function SortableTerminalTabV2(props: {
<MenuV2.Context.Trigger class="relative" as="div">
<Tabs.Trigger
value={props.terminal.id}
onClick={focus}
onMouseDown={(e) => {
// Switch on mousedown to shave the press-release delay off tab switches.
if (e.button !== 0) return
if (store.editing) return
focus()
}}
onClick={(e) => {
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (e.detail > 0) return
focus()
}}
closeButton={
<IconButton
icon="close-small"
@@ -4,7 +4,7 @@ import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
export function useSettingsDialog() {
export function useSettingsDialog(defaultValue?: string) {
const dialog = useDialog()
const params = useParams<{ id?: string }>()
let run = 0
@@ -19,7 +19,7 @@ export function useSettingsDialog() {
const sessionID = params.id
void import("@/components/settings-v2").then((module) => {
if (dead || run !== current) return
void dialog.show(() => <module.DialogSettings sessionID={sessionID} />)
void dialog.show(() => <module.DialogSettings sessionID={sessionID} defaultValue={defaultValue} />)
})
}
}
@@ -440,11 +440,6 @@ export const SettingsGeneral: Component = () => {
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -471,11 +466,6 @@ export const SettingsGeneral: Component = () => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -224,7 +224,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
>
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
@@ -422,11 +422,6 @@ export const SettingsGeneralV2: Component<{
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -454,11 +449,6 @@ export const SettingsGeneralV2: Component<{
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -221,7 +221,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
<div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead">
<ProviderIcon
id="synthetic"
id="session.synthetic"
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
@@ -20,6 +20,11 @@
height: 100%;
overflow-y: auto;
scrollbar-width: none;
user-select: none;
}
.settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.settings-v2-panel::-webkit-scrollbar {
@@ -181,6 +186,7 @@
flex-direction: column;
gap: 8px;
padding: 4px 0 4px 4px;
user-select: none;
}
.settings-v2-nav-footer > span {
+19 -2
View File
@@ -1,7 +1,8 @@
import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { HexColor } from "@opencode-ai/ui/theme/types"
import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve"
import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types"
import { showToast } from "@/utils/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
@@ -68,6 +69,19 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values)
}
const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => {
let current = tokens[key]
for (let i = 0; i < 8 && current; i++) {
const match = /^var\(--([^)]+)\)$/.exec(current.trim())
if (!match) {
const hex = current.trim()
if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7)
return hex
}
current = tokens[match[1]]
}
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
@@ -238,7 +252,10 @@ export const Terminal = (props: TerminalProps) => {
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
const background = settings.general.newLayoutDesigns()
? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ??
fallback.background)
: (resolved["background-stronger"] ?? fallback.background)
const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
const selectionBackground = withAlpha(base, alpha)
@@ -246,18 +246,23 @@ export function TabNavItem(props: {
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<Show when={props.session()}>
{(session) => (
<span data-slot="project-avatar-slot">
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
{(session) => (
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
</span>
)}
</Show>
)}
</Show>
</span>
<span
ref={(el) => {
titleEl = el
@@ -40,6 +40,7 @@ function SessionTabSlot(props: {
let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx()
@@ -71,8 +72,10 @@ function SessionTabSlot(props: {
createEffect(() => {
const value = session()
if (!value) return
tabs.rememberSessionInfo(props.tab, value)
const current = sdk()
if (!value || !current) return
if (!current) return
createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.directory),
id: value.id,
@@ -86,7 +89,7 @@ function SessionTabSlot(props: {
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() && !missingSession() }}
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
>
<TabNavItem
ref={(el) => {
@@ -95,7 +98,7 @@ function SessionTabSlot(props: {
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
+10 -2
View File
@@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server"
import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
@@ -324,13 +325,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const route = layout.route()
const activeSession = session()
if (route.type === "session" && activeSession) {
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
const sessionTab = {
type: "session" as const,
server: route.server ?? server.key,
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
return
}
const activeTab = currentTab()
if (activeTab?.type === "draft") {
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Taken from https://www.solid-ui.com/docs/components/drawer
* Only used in one place hence not a v2 component yet... can be promoted to ui/v2 later
*/
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import type { ContentProps, DescriptionProps, DynamicProps, LabelProps, OverlayProps } from "@corvu/drawer"
import DrawerPrimitive from "@corvu/drawer"
const Drawer = DrawerPrimitive
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
type DrawerOverlayProps<T extends ValidComponent = "div"> = OverlayProps<T> & { class?: string }
const DrawerOverlay = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerOverlayProps<T>>) => {
const [, rest] = splitProps(props as DrawerOverlayProps, ["class"])
const drawerContext = DrawerPrimitive.useContext()
const overlayStyle = () => {
const state = drawerContext.transitionState()
if (state === "opening" || state === "closing") return undefined
const open = drawerContext.openPercentage()
return {
opacity: open,
"backdrop-filter": `blur(${4 * open}px)`,
}
}
return (
<DrawerPrimitive.Overlay
class={props.class}
classList={{
"fixed inset-0 z-[100] bg-v2-overlay-simple-overlay-scrim opacity-0 backdrop-blur-none transition-[opacity,backdrop-filter] duration-300 data-[opening]:opacity-100 data-[opening]:backdrop-blur-[4px] data-[closing]:opacity-0 data-[closing]:backdrop-blur-none": true,
}}
style={overlayStyle()}
{...rest}
/>
)
}
type DrawerContentProps<T extends ValidComponent = "div"> = ContentProps<T> & {
class?: string
children?: JSX.Element
}
const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerContentProps<T>>) => {
const [, rest] = splitProps(props as DrawerContentProps, ["class", "children"])
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
class={props.class}
classList={{
"group/drawer-content fixed inset-y-[6px] right-[6px] left-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
}}
{...rest}
>
{props.children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
const DrawerHeader: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return <div class={props.class} classList={{ "grid gap-1.5 p-4 text-center sm:text-left": true }} {...rest} />
}
const DrawerFooter: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return <div class={props.class} classList={{ "mt-auto flex flex-col gap-2 p-4": true }} {...rest} />
}
type DrawerTitleProps<T extends ValidComponent = "div"> = LabelProps<T> & { class?: string }
const DrawerTitle = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerTitleProps<T>>) => {
const [, rest] = splitProps(props as DrawerTitleProps, ["class"])
return (
<DrawerPrimitive.Label
class={props.class}
classList={{ "text-base font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base": true }}
{...rest}
/>
)
}
type DrawerDescriptionProps<T extends ValidComponent = "div"> = DescriptionProps<T> & {
class?: string
}
const DrawerDescription = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerDescriptionProps<T>>) => {
const [, rest] = splitProps(props as DrawerDescriptionProps, ["class"])
return (
<DrawerPrimitive.Description
class={props.class}
classList={{
"text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-v2-text-text-muted": true,
}}
{...rest}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { upsertCommandRegistration } from "./command"
import { resolveKeybindOption, upsertCommandRegistration } from "./command"
describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => {
@@ -23,3 +23,19 @@ describe("upsertCommandRegistration", () => {
expect(next[1]?.options).toBe(one)
})
})
describe("resolveKeybindOption", () => {
test("prefers a matching contextual command over the global fallback", () => {
const fallback = { id: "tab.close", title: "Close tab" }
const contextual = { id: "terminal.close", title: "Close terminal", when: () => true }
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(contextual)
})
test("uses the global fallback outside the command context", () => {
const fallback = { id: "tab.close", title: "Close tab" }
const contextual = { id: "terminal.close", title: "Close terminal", when: () => false }
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback)
})
})
+16 -5
View File
@@ -82,10 +82,15 @@ export interface CommandOption {
suggested?: boolean
disabled?: boolean
hidden?: boolean
when?: (event: KeyboardEvent) => boolean
onSelect?: (source?: "palette" | "keybind" | "slash") => void
onHighlight?: () => (() => void) | void
}
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
}
type CommandSource = "palette" | "keybind" | "slash"
export type CommandCatalogItem = {
@@ -334,7 +339,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
})
const keymap = createMemo(() => {
const map = new Map<string, CommandOption>()
const map = new Map<string, CommandOption[]>()
for (const option of options()) {
if (option.id.startsWith(SUGGESTED_PREFIX)) continue
if (option.disabled) continue
@@ -344,8 +349,12 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
for (const kb of keybinds) {
if (!kb.key) continue
const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
if (map.has(sig)) continue
map.set(sig, option)
const existing = map.get(sig)
if (existing) {
existing.push(option)
continue
}
map.set(sig, [option])
}
}
return map
@@ -374,7 +383,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const sig = signatureFromEvent(event)
const isPalette = palette().has(sig)
const option = keymap().get(sig)
const option = resolveKeybindOption(keymap().get(sig), event)
const modified = event.ctrlKey || event.metaKey || event.altKey
const isTab = event.key === "Tab"
@@ -383,17 +392,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (isPalette) {
event.preventDefault()
event.stopPropagation()
showPalette()
return
}
if (!option) return
event.preventDefault()
event.stopPropagation()
option.onSelect?.("keybind")
}
onMount(() => {
makeEventListener(document, "keydown", handleKeyDown)
makeEventListener(document, "keydown", handleKeyDown, { capture: true })
})
function register(cb: () => CommandOption[]): void
@@ -11,7 +11,6 @@ const sessionFields = new Set([
"session_status",
"session_working",
"session_diff",
"todo",
"permission",
"question",
"message",
@@ -115,7 +114,6 @@ export const createDirSyncContext = (
index(sessionID)
},
diff: serverSync.session.diff,
todo: serverSync.session.todo,
history: serverSync.session.history,
evict(sessionID: string) {
serverSync.session.evict(sessionID)
+8 -4
View File
@@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise
}
const search = (query: string, dirs: "true" | "false") =>
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
sdk()
.client.find.files({ query, dirs })
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
.then(
(x) => (x.data ?? []).map(path.normalize),
() => [],
(error) => {
if (options?.signal?.aborted) throw error
return []
},
)
const stop = sdk().event.listen((e) => {
@@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setScrollLeft,
selectedLines,
setSelectedLines,
searchFiles: (query: string) => search(query, "false"),
searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) =>
search(query, "false", options),
searchFilesAndDirectories: (query: string) => search(query, "true"),
}
},
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/new.ts",
event: "add",
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/open.ts",
event: "change",
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src",
event: "change",
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/file.ts",
event: "change",
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: ".git/index.lock",
event: "change",
+1 -1
View File
@@ -16,7 +16,7 @@ type WatcherOps = {
}
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "file.watcher.updated") return
if (event.type !== "filesystem.changed") return
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
@@ -30,7 +30,6 @@ function directoryState() {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
@@ -223,7 +223,6 @@ export function createChildStoreManager(input: {
return (type ?? "idle") !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
get mcp_ready() {
@@ -72,7 +72,6 @@ const baseState = (input: Partial<State> = {}) =>
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
@@ -216,7 +215,6 @@ describe("applyDirectoryEvent", () => {
message: { ses_1: [message] },
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
session_diff: { ses_1: [] },
todo: { ses_1: [] },
permission: { ses_1: [] },
question: { ses_1: [] },
session_status: { ses_1: { type: "busy" } },
@@ -237,7 +235,6 @@ describe("applyDirectoryEvent", () => {
expect(store.message.ses_1).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
@@ -262,7 +259,6 @@ describe("applyDirectoryEvent", () => {
message: { [item.info.id]: [message] },
part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
session_diff: { [item.info.id]: [] },
todo: { [item.info.id]: [] },
permission: { [item.info.id]: [] },
question: { [item.info.id]: [] },
session_status: { [item.info.id]: { type: "busy" } },
@@ -283,7 +279,6 @@ describe("applyDirectoryEvent", () => {
expect(store.message[item.info.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[item.info.id]).toBeUndefined()
expect(store.todo[item.info.id]).toBeUndefined()
expect(store.permission[item.info.id]).toBeUndefined()
expect(store.question[item.info.id]).toBeUndefined()
expect(store.session_status[item.info.id]).toBeUndefined()
@@ -294,7 +289,6 @@ describe("applyDirectoryEvent", () => {
const dropped = rootSession({ id: "ses_b" })
const kept = rootSession({ id: "ses_a" })
const message = userMessage("msg_1", dropped.id)
const todos: string[] = []
const [store, setStore] = createStore(
baseState({
limit: 1,
@@ -302,7 +296,6 @@ describe("applyDirectoryEvent", () => {
message: { [dropped.id]: [message] },
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
session_diff: { [dropped.id]: [] },
todo: { [dropped.id]: [] },
permission: { [dropped.id]: [] },
question: { [dropped.id]: [] },
session_status: { [dropped.id]: { type: "busy" } },
@@ -316,21 +309,15 @@ describe("applyDirectoryEvent", () => {
push() {},
directory: "/tmp",
loadLsp() {},
setSessionTodo(sessionID, value) {
if (value !== undefined) return
todos.push(sessionID)
},
})
expect(store.session.map((x) => x.id)).toEqual([kept.id])
expect(store.message[dropped.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[dropped.id]).toBeUndefined()
expect(store.todo[dropped.id]).toBeUndefined()
expect(store.permission[dropped.id]).toBeUndefined()
expect(store.question[dropped.id]).toBeUndefined()
expect(store.session_status[dropped.id]).toBeUndefined()
expect(todos).toEqual([dropped.id])
})
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
@@ -8,8 +8,7 @@ import type {
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
@@ -19,7 +18,6 @@ import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([
"session.diff",
"todo.updated",
"session.status",
"message.updated",
"message.removed",
@@ -62,13 +60,8 @@ export function applyGlobalEvent(input: {
)
}
function cleanupSessionCaches(
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) {
if (!sessionID) return
setSessionTodo?.(sessionID, undefined)
setStore(
produce((draft) => {
dropSessionCaches(draft, [sessionID])
@@ -76,17 +69,11 @@ function cleanupSessionCaches(
)
}
export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
next: Session[],
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) {
const keep = new Set(next.map((item) => item.id))
const stale = [
...Object.keys(store.message),
...Object.keys(store.session_diff),
...Object.keys(store.todo),
...Object.keys(store.permission),
...Object.keys(store.question),
...Object.keys(store.session_status),
@@ -95,9 +82,6 @@ export function cleanupDroppedSessionCaches(
.filter((sessionID): sessionID is string => !!sessionID),
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
if (stale.length === 0) return
for (const sessionID of stale) {
setSessionTodo?.(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
@@ -114,7 +98,6 @@ export function applyDirectoryEvent(input: {
loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number
sessionContent?: boolean
permission?: State["permission"]
@@ -138,7 +121,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
break
}
@@ -155,7 +138,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
cleanupSessionCaches(input.setStore, info.id)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -168,7 +151,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
break
}
case "session.deleted": {
@@ -182,22 +165,16 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
cleanupSessionCaches(input.setStore, info.id)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
break
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
input.setStore("session_status", props.sessionID, reconcile(props.status))
@@ -5,8 +5,7 @@ import type {
PermissionRequest,
QuestionRequest,
SessionStatus,
SnapshotFileDiff,
Todo,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -33,8 +32,7 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -43,7 +41,6 @@ describe("app session cache", () => {
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] },
todo: { ses_1: [] as Todo[] },
message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
@@ -56,7 +53,6 @@ describe("app session cache", () => {
expect(store.message.ses_1).toBeUndefined()
expect(store.part.msg_1).toBeUndefined()
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
@@ -67,8 +63,7 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -77,7 +72,6 @@ describe("app session cache", () => {
} = {
session_status: {},
session_diff: {},
todo: {},
message: { ses_1: [m] },
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
@@ -4,16 +4,14 @@ import type {
PermissionRequest,
QuestionRequest,
SessionStatus,
SnapshotFileDiff,
Todo,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -36,7 +34,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
for (const sessionID of stale) {
delete store.message[sessionID]
delete store.todo[sessionID]
delete store.session_diff[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
@@ -13,8 +13,7 @@ import type {
ReferenceInfo,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
FileDiffInfo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
@@ -51,10 +50,7 @@ export type State = {
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: SnapshotFileDiff[]
}
todo: {
[sessionID: string]: Todo[]
[sessionID: string]: FileDiffInfo[]
}
permission: {
[sessionID: string]: PermissionRequest[]
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"
import {
SESSION_OPEN_FILE_TAB,
closeSessionTab,
openSessionTab,
previewSessionTab,
type SessionTabState,
} from "./layout-tabs"
const state = (all: string[], active?: string, preview?: string): SessionTabState => ({
tabs: { all, active },
preview,
})
describe("previewSessionTab", () => {
test("appends the Open File placeholder", () => {
expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual(
state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
)
})
test("replaces the current preview in place", () => {
expect(
previewSessionTab(
state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
"file://a.ts",
),
).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts"))
})
test("activates a durable tab without duplicating it", () => {
expect(
previewSessionTab(
state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
"file://b.ts",
),
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts"))
})
test("replaces a restored Open File placeholder", () => {
expect(
previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"),
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"))
})
})
describe("openSessionTab", () => {
test("pins the current preview", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual(
state(["file://a.ts"], "file://a.ts"),
)
})
test("replaces a preview with a directly opened file", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual(
state(["file://b.ts"], "file://b.ts"),
)
})
test("keeps the preview when switching to Review", () => {
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual(
state(["file://a.ts"], "review", "file://a.ts"),
)
})
test("replaces a restored Open File placeholder with a direct open", () => {
expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual(
state(["file://a.ts", "file://b.ts"], "file://b.ts"),
)
})
})
describe("closeSessionTab", () => {
test("clears preview metadata and selects the left neighbor", () => {
expect(
closeSessionTab(
state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"),
"file://b.ts",
),
).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts"))
})
})
+103
View File
@@ -0,0 +1,103 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export type SessionTabs = {
active?: string
all: string[]
}
export type SessionTabState = {
tabs: SessionTabs
preview?: string
}
const sessionTabPreview = (current: SessionTabState) =>
current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined)
export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState {
const preview = sessionTabPreview(current)
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
const existingIndex = current.tabs.all.indexOf(tab)
if (existingIndex !== -1) {
if (previewIndex === -1 || preview === tab) {
return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined }
}
return {
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
}
}
if (previewIndex === -1) {
return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab }
}
return {
tabs: {
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
active: tab,
},
preview: tab,
}
}
export function openSessionTab(current: SessionTabState, tab: string): SessionTabState {
const preview = sessionTabPreview(current)
if (tab === "review") {
return {
tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab },
preview,
}
}
if (tab === "context") {
return {
tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab },
preview,
}
}
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
const existingIndex = current.tabs.all.indexOf(tab)
if (existingIndex !== -1) {
if (previewIndex === -1 || preview === tab) {
return { tabs: { all: current.tabs.all, active: tab } }
}
return {
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
}
}
if (previewIndex === -1) {
return { tabs: { all: [...current.tabs.all, tab], active: tab } }
}
return {
tabs: {
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
active: tab,
},
}
}
export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState {
if (tab === "review") {
if (current.tabs.active !== tab) return current
return {
tabs: { all: current.tabs.all, active: current.tabs.all[0] },
preview: current.preview,
}
}
const all = current.tabs.all.filter((item) => item !== tab)
const preview = current.preview === tab ? undefined : current.preview
if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview }
const index = current.tabs.all.indexOf(tab)
return {
tabs: {
all,
active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0],
},
preview,
}
}
+82 -52
View File
@@ -19,6 +19,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
import { type DraftTab, useTabs } from "./tabs"
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -55,17 +56,13 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
return "gray"
}
type SessionTabs = {
active?: string
all: string[]
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
reviewMode?: ReviewChangeMode
reviewFile?: string
pendingMessage?: string
pendingMessageAt?: number
todoCollapsed?: boolean
}
type TabHandoff = {
@@ -79,6 +76,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute =
@@ -87,14 +85,6 @@ export type LayoutRoute =
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? []
if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
if (!all.includes(tab)) return { all: [...all, tab], active: tab }
return { all, active: tab }
}
const sessionPath = (key: string) => {
const dir = SessionStateKey.route(key).split("/")[0]
if (!dir) return
@@ -308,6 +298,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
sessionTabPreview: {} as Record<string, string | undefined>,
})
const MAX_SESSION_KEYS = 50
@@ -366,6 +357,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
scroll.drop(drop)
dropSessionState(drop)
setEphemeral(
"sessionTabPreview",
produce((draft) => {
for (const key of drop) delete draft[key]
}),
)
for (const key of drop) {
usage.used.delete(key)
@@ -791,6 +788,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
view(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
const mode = s().reviewMode
if (mode === "git" || mode === "branch" || mode === "turn") return mode
})
const reviewFile = createMemo(() => {
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
@@ -836,18 +841,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setScroll(tab: string, pos: SessionScroll) {
scroll.setScroll(key(), tab, pos)
},
todoCollapsed: {
get: () => s().todoCollapsed ?? false,
set(collapsed: boolean) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed })
} else {
setStore("sessionView", session, "todoCollapsed", collapsed)
}
},
},
terminal: {
opened: terminalOpened,
open() {
@@ -874,6 +867,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
review: {
mode: reviewMode,
setMode(mode: ReviewChangeMode) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewMode: mode })
prune(session)
return
}
if (current.reviewMode === mode) return
setStore("sessionView", session, "reviewMode", mode)
prune(session)
},
file: reviewFile,
setFile(file: string) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewFile: file })
prune(session)
return
}
if (current.reviewFile === file) return
setStore("sessionView", session, "reviewFile", file)
prune(session)
},
open: createMemo(() => s().reviewOpen ?? []),
setOpen(open: string[]) {
const session = key()
@@ -945,10 +964,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
const apply = (session: string, next: ReturnType<typeof openSessionTab>) => {
batch(() => {
setStore("sessionTabs", session, next.tabs)
setEphemeral("sessionTabPreview", session, next.preview)
})
}
return {
tabs,
active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
preview: createMemo(() => ephemeral.sessionTabPreview[key()]),
setActive(tab: string | undefined) {
const session = key()
const next = tab ? normalize(tab) : tab
@@ -961,40 +987,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setAll(all: string[]) {
const session = key()
const next = normalizeAll(all).filter((tab) => tab !== "review")
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
setStore("sessionTabs", session, "all", next)
}
batch(() => {
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
setStore("sessionTabs", session, "all", next)
}
const preview = ephemeral.sessionTabPreview[session]
if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined)
})
},
async open(tab: string) {
const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
setStore("sessionTabs", session, next)
apply(
session,
openSessionTab(
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
normalize(tab),
),
)
},
previewTab(tab: string) {
const session = key()
apply(
session,
previewSessionTab(
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
normalize(tab),
),
)
},
close(tab: string) {
const session = key()
const current = store.sessionTabs[session]
if (!current) return
if (tab === "review") {
if (current.active !== tab) return
setStore("sessionTabs", session, "active", current.all[0])
return
}
const all = current.all.filter((x) => x !== tab)
if (current.active !== tab) {
setStore("sessionTabs", session, "all", all)
return
}
const index = current.all.findIndex((f) => f === tab)
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
batch(() => {
setStore("sessionTabs", session, "all", all)
setStore("sessionTabs", session, "active", next)
})
apply(
session,
closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)),
)
},
move(tab: string, to: number) {
const session = key()
+6 -3
View File
@@ -67,7 +67,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved] = persisted(
const [saved, setSaved, , savedReady] = persisted(
{
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
migrate,
@@ -375,11 +375,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model,
agent,
session: {
ready: savedReady,
reset() {
setStore({ draft: undefined, promoting: undefined })
},
promote(dir: string, session: string) {
const next = clone(snapshot())
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
const key = handoffKey(serverSDK().scope, dir, session)
handoff.set(key, next)
@@ -409,3 +410,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return result
},
})
export type ModelSelection = ReturnType<typeof useLocal>["model"]
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
describe("prompt state initialization", () => {
test("initializes prompt text, cursor, and model together", () => {
createRoot((dispose) => {
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
const prompt = createPromptState({ prompt: "hello", model })
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
expect(prompt.cursor()).toBe(5)
expect(prompt.model.current()).toEqual(model)
expect(prompt.model.current()).not.toBe(model)
dispose()
})
})
test("uses the default prompt without initial values", () => {
createRoot((dispose) => {
const prompt = createPromptState()
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
expect(prompt.cursor()).toBeUndefined()
expect(prompt.model.current()).toBeUndefined()
dispose()
})
})
})
+265
View File
@@ -0,0 +1,265 @@
import { checksum } from "@opencode-ai/core/util/encode"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
import { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import type { ServerScope } from "@/utils/server-scope"
interface PartBase {
content: string
start: number
end: number
}
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type PromptModel = {
providerID: string
modelID: string
variant?: string | null
}
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export type PromptScope = { draftID: string } | { dir: string; id?: string }
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
type PromptStore = {
prompt: Prompt
cursor?: number
model?: PromptModel
context: {
items: (ContextItem & { key: string })[]
}
}
type InitialPrompt = {
prompt?: string
model?: PromptModel
}
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
function isPartEqual(partA: ContentPart, partB: ContentPart) {
switch (partA.type) {
case "text":
return partB.type === "text" && partA.content === partB.content
case "file":
return (
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":
return partB.type === "image" && partA.id === partB.id
}
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
if (!isPartEqual(promptA[i], promptB[i])) return false
}
return true
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
function promptStore(initial?: InitialPrompt): PromptStore {
const text = initial?.prompt
return {
prompt:
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
cursor: text === undefined ? undefined : text.length,
model: initial?.model ? { ...initial.model } : undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
const value = {
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
model: {
current: () => store.model,
set: (model: PromptModel | undefined) => setStore("model", model),
},
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
removeComment(path: string, commentID: string) {
setStore("context", "items", (items) =>
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
setStore("context", "items", (items) =>
items.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
const value = { ...item, ...next }
return { ...value, key: contextItemKey(value) }
}),
)
},
replaceComments(items: FileContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
])
},
},
set: actions.set,
reset: actions.reset,
capture: () => value,
}
return value
}
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
}
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
}
export type PromptSession = ReturnType<typeof createPromptSession>
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
export function createPromptState(initial?: InitialPrompt) {
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createPromptStateValue(store, setStore),
}
}
+49 -271
View File
@@ -1,186 +1,49 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { requireServerKey } from "@/utils/session-route"
import { ServerConnection } from "./server"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { useSettings } from "./settings"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import { ServerConnection } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
import {
createPromptReady,
createPromptSession,
type ContextItem,
type FileContextItem,
type Prompt,
type PromptModel,
type PromptScope,
type PromptSession,
} from "./prompt-state"
interface PartBase {
content: string
start: number
end: number
}
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type ContextItem = FileContextItem
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
function isPartEqual(partA: ContentPart, partB: ContentPart) {
switch (partA.type) {
case "text":
return partB.type === "text" && partA.content === partB.content
case "file":
return (
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":
return partB.type === "image" && partA.id === partB.id
}
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
if (!isPartEqual(promptA[i], promptB[i])) return false
}
return true
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) {
return `${key}:c=${item.commentID}`
}
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createPromptActions(
setStore: SetStoreFunction<{
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}>,
) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
export {
createPromptReady,
createPromptSession,
createPromptState,
DEFAULT_PROMPT,
isPromptEqual,
} from "./prompt-state"
export type {
AgentPart,
ContentPart,
ContextItem,
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
Prompt,
PromptModel,
PromptScope,
PromptSession,
TextPart,
} from "./prompt-state"
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type PromptStore = {
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}
type Scope = { draftID: string } | { dir: string; id?: string }
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) {
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
if (!scope.id) return
return (
@@ -189,7 +52,7 @@ export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnect
)
}
function scopeKey(scope: Scope) {
function scopeKey(scope: PromptScope) {
if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
}
@@ -199,91 +62,6 @@ type PromptCacheEntry = {
dispose: VoidFunction
}
function promptTarget(serverScope: ServerScope, scope: Scope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
promptTarget(serverScope, scope),
createStore<PromptStore>(promptStore()),
)
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
function promptStore(): PromptStore {
return {
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
const value = {
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
removeComment(path: string, commentID: string) {
setStore("context", "items", (items) =>
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
setStore("context", "items", (items) =>
items.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
const value = { ...item, ...next }
return { ...value, key: contextItemKey(value) }
}),
)
},
replaceComments(items: FileContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
])
},
},
set: actions.set,
reset: actions.reset,
capture: () => value,
}
return value
}
export function createPromptState() {
const [store, setStore] = createStore<PromptStore>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createPromptStateValue(store, setStore),
}
}
export const createTabPromptState = (
tabs: ReturnType<typeof useTabs>,
tab: Tab,
@@ -303,9 +81,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
for (const entry of cache.values()) {
entry.dispose()
}
for (const entry of cache.values()) entry.dispose()
cache.clear()
}
@@ -324,13 +100,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const owner = getOwner()
const serverKey = () =>
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const scope = () =>
const scope = (): PromptScope =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: Scope) => {
const load = (scope: PromptScope) => {
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
const key = scopeKey(scope)
const existing = cache.get(key)
@@ -354,15 +128,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const session = createMemo(() => load(scope()))
const pick = (scope?: Scope) => (scope ? load(scope) : session())
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)
return {
ready,
capture: (scope?: Scope) => pick(scope).capture(),
capture: (scope?: PromptScope) => pick(scope).capture(),
current: () => session().current(),
cursor: () => session().cursor(),
dirty: () => session().dirty(),
model: {
current: () => session().model.current(),
set: (model: PromptModel | undefined) => session().model.set(model),
},
context: {
items: () => session().context.items(),
add: (item: ContextItem) => session().context.add(item),
@@ -372,8 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
},
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: Scope) => pick(scope).reset(),
set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: PromptScope) => pick(scope).reset(),
}
},
})
+2 -1
View File
@@ -126,8 +126,9 @@ describe("enqueueServerEvent", () => {
enqueue(partUpdated("old"))
enqueue({
id: "event-delete",
type: "session.deleted",
properties: { sessionID: "session", info: { id: "session" } },
properties: { sessionID: "session" },
} as Event)
enqueue(partUpdated("new"))
@@ -151,7 +151,6 @@ function setup(sessions: Record<string, Session>) {
return response()
},
diff: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
},
} as unknown as OpencodeClient
return { get, messages, store: createServerSession(client) }
+4 -27
View File
@@ -8,8 +8,7 @@ import type {
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -139,8 +138,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, SnapshotFileDiff[]>,
todo: {} as Record<string, Todo[]>,
session_diff: {} as Record<string, FileDiffInfo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
message: {} as Record<string, Message[]>,
@@ -153,7 +151,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const requests = new Map<string, Promise<Session>>()
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const messageLoads = new Map<string, MessageLoadState>()
const pendingParts = new Map<string, Map<string, Set<string>>>()
@@ -199,7 +196,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...Object.entries(data.permission)
@@ -254,8 +250,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
!requests.has(sessionID) &&
!messageLoads.has(sessionID) &&
!inflight.has(sessionID) &&
!inflightDiff.has(sessionID) &&
!inflightTodo.has(sessionID)
!inflightDiff.has(sessionID)
)
generations.delete(sessionID)
}
@@ -418,7 +413,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
requests.delete(sessionID)
inflight.delete(sessionID)
inflightDiff.delete(sessionID)
inflightTodo.delete(sessionID)
messageLoads.delete(sessionID)
pendingParts.delete(sessionID)
orphanParts.delete(sessionID)
@@ -448,7 +442,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...Object.entries(data.permission)
@@ -769,15 +762,10 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
return
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
return
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
setData("session_status", props.sessionID, reconcile(props.status))
@@ -1140,17 +1128,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
})
})
},
todo(sessionID: string, options?: { force?: boolean }) {
touch(sessionID)
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID)
return retry(() => client.session.todo({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
})
})
},
history: {
more: (sessionID: string) =>
data.message[sessionID] !== undefined &&
+3
View File
@@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) {
}
return {
get<T>(key: string, name: string) {
return entries.get(key)?.get(name)?.value as T | undefined
},
ensure<T>(key: string, name: string, init: () => T) {
const state = entries.get(key) ?? new Map<string, Entry>()
if (!entries.has(key)) entries.set(key, state)
+2
View File
@@ -22,6 +22,8 @@ describe("tab memory", () => {
})
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
expect(memory.get<typeof first>("tab", "prompt")).toBe(first)
expect(memory.get("missing", "prompt")).toBeUndefined()
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
memory.remove("tab")

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