Compare commits

...

202 Commits

Author SHA1 Message Date
opencode-agent[bot] f6028df564 Apply PR #28937: fix(app): start MCP servers only for open directories
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
2026-05-28 17:41:44 +00:00
opencode-agent[bot] d2843392c8 Apply PR #28664: fix(tui): align wrapped inline tool rows 2026-05-28 17:40:33 +00:00
opencode-agent[bot] fe63467a4d Apply PR #28071: feat: add well-known auth service 2026-05-28 17:39:16 +00:00
opencode-agent[bot] 77cf46721f Apply PR #27805: Discover running serve instances from TUI 2026-05-28 17:33:25 +00:00
opencode-agent[bot] 3ab34035e2 Apply PR #26596: feat(tui): expand pasted summaries on click 2026-05-28 17:33:25 +00:00
opencode-agent[bot] 061ca12ec5 Apply PR #26387: tui: optimistically render submitted prompts 2026-05-28 17:33:24 +00:00
opencode-agent[bot] 9f719059ab Apply PR #23407: feat: desktop WSL onboarding + happy experience 2026-05-28 17:33:24 +00:00
opencode-agent[bot] 37561e4347 Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-05-28 17:29:58 +00:00
opencode-agent[bot] a59bd3e0bc Apply PR #11710: feat: Add the ability to include cleared prompts in the history, toggled by a KV-persisted command palette item (resolves #11489) 2026-05-28 17:26:21 +00:00
Luke Parker ec58afe5be Merge branch 'dev' into fix/mcp-async-status 2026-05-28 18:29:30 +10:00
LukeParkerDev 7bba9ed4d4 chore: remove websocket changes from mcp pr 2026-05-28 17:24:36 +10:00
LukeParkerDev b1a4f99181 Merge branch 'dev' into fix/mcp-async-status 2026-05-28 17:10:36 +10:00
LukeParkerDev b0f6dc506b fix(opencode): keep websocket idle timeouts refed 2026-05-28 16:35:45 +10:00
Luke Parker 146cce195a Merge branch 'dev' into fix/mcp-async-status 2026-05-28 15:16:07 +10:00
LukeParkerDev 5cfb70bff0 fix(app): enable MCP status query reactively 2026-05-28 09:36:26 +10:00
LukeParkerDev d73995a73a fix(app): normalize MCP directory consumers 2026-05-27 12:57:11 +10:00
LukeParkerDev 91ff955757 fix(app): disable MCP reads for closed directories 2026-05-27 12:27:30 +10:00
LukeParkerDev d9ee222c99 fix(app): start MCP servers only for open directories 2026-05-27 11:51:08 +10:00
Kit Langton 1462f9caf3 test: cover TUI optimistic prompt sync 2026-05-26 17:16:05 -04:00
Kit Langton ebaad55c8c tui: simplify optimistic prompt reconciliation 2026-05-26 17:14:34 -04:00
Kit Langton f5c770d65e tui: optimistically render submitted prompts 2026-05-26 17:13:38 -04:00
Kit Langton 27d6920d60 fix(tui): preserve spacing after tool blocks 2026-05-26 11:53:33 -04:00
LukeParkerDev 930526ddcd Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding
# Conflicts:
#	packages/app/src/app.tsx
#	packages/app/src/index.ts
#	packages/desktop/src/main/constants.ts
#	packages/desktop/src/main/index.ts
2026-05-23 10:59:21 +10:00
Kit Langton dd4aa2ab5a fix(tui): collapse inline tool errors 2026-05-21 15:56:53 -04:00
Kit Langton e03ffb9ed4 refactor(tui): use fixed inline tool icon cell 2026-05-21 15:53:49 -04:00
Kit Langton c9426d3a57 fix(tui): keep wrapped tool rows dense 2026-05-21 15:04:41 -04:00
Kit Langton eb5d30257a fix(tui): preserve inline tool left edge 2026-05-21 15:01:11 -04:00
Kit Langton 939426828d test(tui): mirror inline tool padding in snapshot 2026-05-21 15:01:11 -04:00
Kit Langton 1499daf15f test(tui): focus inline tool wrap snapshot 2026-05-21 15:01:11 -04:00
Kit Langton 540525e98e fix(tui): align wrapped inline tool rows 2026-05-21 15:01:10 -04:00
LukeParkerDev 5334fd6b81 fix(desktop): avoid autostarting WSL servers 2026-05-19 08:16:06 +10:00
LukeParkerDev 4cf3eb04cd Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding
# Conflicts:
#	packages/desktop/src/main/apps.ts
#	packages/desktop/src/main/index.ts
#	packages/desktop/src/main/ipc.ts
#	packages/desktop/src/main/server.ts
2026-05-19 08:00:22 +10:00
Ariane Emory 571ae48e14 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-05-18 06:15:05 -04:00
Dax Raad 9a0a1a801e test: avoid global fetch patching for well-known auth 2026-05-17 22:17:13 -04:00
Ariane Emory d34eca2f0d Merge branch 'dev' into feat/canceled-prompts-in-history 2026-05-17 19:41:19 -04:00
Dax Raad 5b1085d229 fix: store well-known logins in new auth service 2026-05-17 16:37:21 -04:00
Dax Raad 1f053c4942 test: share empty well-known auth layer 2026-05-17 16:32:20 -04:00
Dax Raad c25128a6f5 feat: add well-known auth service 2026-05-17 16:27:50 -04:00
Ariane Emory 24277b51e3 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-05-16 14:46:59 -04:00
Dax Raad 26f64f29fb refactor(cli): scope server discovery to CLI 2026-05-15 20:28:53 -04:00
Dax Raad 13822b9424 feat(cli): discover running serve instances 2026-05-15 20:25:59 -04:00
Ariane Emory 9332560ffd Merge branch 'dev' into feat/canceled-prompts-in-history 2026-05-11 15:49:47 -04:00
Ryan Vogel 5aad254f68 feat(tui): expand pasted summaries on click 2026-05-09 23:11:26 +00:00
LukeParkerDev 0ba2bea40c Merge remote-tracking branch 'upstream/dev' into pr-23407-merge
# Conflicts:
#	packages/desktop/src/index.tsx
#	packages/desktop/src/main/index.ts
#	packages/desktop/src/main/wsl-servers.ts
#	packages/desktop/src/main/wsl.ts
2026-05-06 10:21:50 +10:00
LukeParkerDev e16115f53f fix(desktop): improve WSL OpenCode updates 2026-05-05 15:27:10 +10:00
LukeParkerDev 26bf234228 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-05-05 15:15:18 +10:00
LukeParkerDev 148b3cf034 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-05-05 13:18:52 +10:00
LukeParkerDev de69c5af0a fix(app): stabilize server switching 2026-05-04 23:34:03 +10:00
LukeParkerDev e5a4f6f603 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-05-04 23:25:59 +10:00
Ariane Emory 4e8020b503 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-05-04 06:26:02 -04:00
LukeParkerDev 5778008596 deslop 2026-05-04 20:18:28 +10:00
LukeParkerDev b70fc22a4b fix(desktop): remove stale WSL toggle plumbing 2026-05-04 20:02:58 +10:00
LukeParkerDev affbea691b Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding
# Conflicts:
#	packages/opencode/src/cli/cmd/github.ts
#	packages/opencode/src/cli/cmd/providers.ts
2026-05-04 13:23:05 +10:00
LukeParkerDev cecaf82445 deslop 2026-05-04 13:20:21 +10:00
LukeParkerDev 777169ac17 deslop 2026-05-04 11:28:57 +10:00
LukeParkerDev 58f569fbf7 deslop 2026-05-04 11:26:25 +10:00
LukeParkerDev 7dd8b2b7f9 deslop 2026-05-04 11:24:14 +10:00
LukeParkerDev fcb4bc1b3a deslop 2026-05-04 11:20:57 +10:00
LukeParkerDev 6ada6e0db9 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding
# Conflicts:
#	packages/desktop-electron/src/renderer/webview-zoom.ts
2026-05-03 12:51:32 +10:00
LukeParkerDev cc1d7e5d5b Update dialog-wsl-server.tsx 2026-05-03 10:27:03 +10:00
LukeParkerDev 013bd4c918 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-05-03 10:16:35 +10:00
LukeParkerDev 4799efb9bf Delete todo.md 2026-04-29 08:38:08 +10:00
LukeParkerDev 2eb3072e3e review 2026-04-29 08:37:16 +10:00
LukeParkerDev 9cd611780f clean 2026-04-28 12:20:01 +10:00
LukeParkerDev 70443cd2de kill 2026-04-28 09:20:16 +10:00
LukeParkerDev ca8e6e4d67 clean 2026-04-28 09:15:25 +10:00
LukeParkerDev 7bcb519e0c wip 2026-04-28 09:03:48 +10:00
LukeParkerDev 4f6caacde4 . 2026-04-28 08:13:09 +10:00
LukeParkerDev 81e907febe . 2026-04-28 08:02:30 +10:00
LukeParkerDev ed4a41f1e0 . 2026-04-28 07:57:25 +10:00
LukeParkerDev cea9a9905d Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-04-27 16:15:37 +10:00
Ariane Emory 09e4e5a184 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-23 21:55:13 -04:00
LukeParkerDev 2e0b993d5d wip 2026-04-20 12:54:12 +10:00
LukeParkerDev babaf781da server key 2026-04-19 21:09:06 +10:00
LukeParkerDev a17ce350f1 tinkering 2026-04-19 20:59:38 +10:00
LukeParkerDev 0e04141849 tinkering 2026-04-19 20:35:43 +10:00
LukeParkerDev 29130af9ec Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-04-19 20:35:34 +10:00
LukeParkerDev 9235753aa0 Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding 2026-04-19 19:44:07 +10:00
LukeParkerDev 796991b3ea oops 2026-04-19 19:37:28 +10:00
LukeParkerDev 6ae05c541d fix(desktop): remove deprecated wsl platform properties from tauri 2026-04-19 13:23:24 +10:00
LukeParkerDev bc84698428 feedback n stuff? 2026-04-19 13:21:48 +10:00
LukeParkerDev e3d2a9ddbb clean? 2026-04-19 13:20:34 +10:00
LukeParkerDev 3d26bbed82 clean maybe 2026-04-19 13:20:34 +10:00
LukeParkerDev 3ad63536bf chore: remove solid-js owner/cleanup instrumentation
The instrumentation diagnosed the cleanNode reentry bug that's since been fixed (Terminal queueMicrotask, SortableWorkspace/LocalWorkspace undefined guards). Keeping it around imposes significant main-thread cost on busy subtrees: wraps every owner with accessor/Proxy traps, captures a fresh Error().stack on every owned mutation, and pushes to a ring buffer. On a ~40k-owner graph after a server switch this saturated the main thread and tripped Electron's unresponsive watchdog a few minutes in. Drop the file and its import.
2026-04-19 13:20:33 +10:00
LukeParkerDev f347d9a58a fix(desktop-wsl): drop DefaultUid=0 first-run gate, --user root bypasses OOBE
Verified with live probes: 'wsl -d Ubuntu-24.04 --user root -- echo OK' succeeds on a distro whose registry DefaultUid is still 0 (OOBE never ran). The prior hang symptom was from invoking wsl WITHOUT --user when DefaultUid=0, which triggers the interactive 'Create a default UNIX user account' prompt. With --user root explicit in wslArgs the OOBE hook is skipped and resolveWslOpencode finds /root/.opencode/bin/opencode etc. Remove the overly aggressive first-run gate from spawnWslSidecar; the registry probe helpers stay in wsl.ts for future diagnostic use. 20s timeout + failure logging remain as safety nets for true wsl.exe wedges.
2026-04-19 13:20:32 +10:00
LukeParkerDev 3822c0aaec fix(desktop-wsl): use reg.exe for LXSS registry probe instead of powershell
The PowerShell HKCU Registry-provider enumeration returned nothing when spawned by Electron's main process (both Ubuntu-24.04 and Debian were reported as 'not installed'), so the first-run gate blocked every distro unconditionally. reg.exe is a native Windows binary with a stable line-oriented output format that works regardless of PS host quirks; parse its 'HKEY_CURRENT_USER\\\\...\\\\Lxss\\\\{guid}' subkey blocks, pull DistributionName + DefaultUid + State + Version, and rebuild the list.
2026-04-19 13:20:09 +10:00
LukeParkerDev 16ada93dd4 fix(desktop-wsl): skip sidecar spawn when distro first-run is incomplete
Reading HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss tells us the DefaultUid for every registered distro without touching wsl.exe. On a freshly installed Ubuntu-24.04 the 'Create a default UNIX user account' prompt never ran and DefaultUid stays 0; every wsl.exe -d <distro> ... invocation in that state silently blocks on stdin forever, even with --user root. checkWslDistroFirstRun reads the registry via powershell, and spawnWslSidecar throws a human-readable error if the distro still needs setup, so the controller marks the server as failed with a clear message instead of hanging. Also emits 'wsl sidecar starting' to main.log for visibility.
2026-04-19 13:20:09 +10:00
LukeParkerDev 6fc5f342dd fix(desktop-wsl): time-bound every wsl.exe invocation to fail fast on wedge
Ubuntu-24.04 in the failed first-run state wedges wsl.exe silently - no stdout, no exit. Without a timeout resolveWslOpencode (and any other runWsl call) blocks the sidecar spawn flow forever, which hides the real failure from logs and from the controller's failed runtime state. Add a 20s default ceiling to runCommand; caller can override for long-running jobs (installs).
2026-04-19 13:19:41 +10:00
LukeParkerDev 0e7e791008 fix(layout): guard LocalWorkspace + projectId prop against undefined project
Previous commit only guarded SortableWorkspace. LocalWorkspace (rendered from layout.tsx:2243 with the same project={project()!} pattern) still accessed props.project.worktree directly inside four createMemo / useQuery bodies and threw the same 'worktree of undefined' during a server swap cascade. Route every worktree read through a worktree memo that returns the string or '', and guard the projectId prop on WorkspaceHeader.
2026-04-19 13:19:40 +10:00
LukeParkerDev 11528e43c0 chore(desktop-wsl): log sidecar start failures and flatten server-health logs
The WSL controller previously only stored a failed startup message in its renderer state, so Ubuntu-style silent failures left no trace in main.log. Inject the main-process logger into the controller and emit wsl sidecar ready / wsl sidecar failed to start entries. Also flatten the [server health] renderer logs into a single string argument because Electron's console-message bridge truncates extra args to [object Object].
2026-04-19 13:19:15 +10:00
LukeParkerDev 4560435dd9 fix(desktop-wsl): spawn WSL commands as root to bypass first-run setup
A freshly installed Ubuntu-24.04 distro prompts interactively for a new UNIX user on its first invocation; with piped stdio that prompt blocks forever and the sidecar never starts. Adding --user root to wslArgs sidesteps the whole first-run flow for every wsl.exe we spawn (sidecar, resolveWslOpencode, probes). opencode inside WSL only needs an HTTP listener so running as root is fine.
2026-04-19 13:19:15 +10:00
LukeParkerDev 3349fb95ca fix(layout): guard SortableWorkspace against undefined project during server swap
bootstrapDirectory's setStore fires while a server swap is cascading; currentProject() can already be undefined by the time these memos re-run, which threw 'Cannot read properties of undefined (reading worktree)' from local/workspaceValue/WorkspaceActions root. Optional-chain the props.project reads so the component survives the transient undefined before the enclosing Show unmounts it.
2026-04-19 13:19:14 +10:00
LukeParkerDev bff9e576b7 feat(desktop): show splash overlay during server switch
ServerKey's keyed <Show> remount is a multi-second synchronous cascade (dispose + rebuild of the whole app subtree) that used to leave the UI looking frozen. A tiny module-level serverSwitching signal now gates a fullscreen Splash rendered above the ServerKey boundary, and the status-popover click handler setTimeout-defers the batched navigate+setActive so the browser paints the splash before the freeze begins and dismisses it after the new subtree paints.
2026-04-19 13:19:14 +10:00
LukeParkerDev da2e640029 chore: improve desktop renderer diagnostics
Serialize non-Error promise rejections so unhandled rejections print type/ctor/keys/JSON instead of the unreadable '[object Object]'. Also emit [server health] logs when a health poll returns unhealthy and when polling switches servers, so a red dot in the status popover comes with a logged URL and auth presence. Minor cosmetic: restore session-header StatusPopover import position after the earlier titlebar experiment.
2026-04-19 13:19:13 +10:00
LukeParkerDev 8fd7bd19d6 fix: defer terminal cleanup state write to stop cleanNode reentry crash
Terminal onCleanup ran persistTerminal synchronously during a dispose cascade, which flowed through props.onCleanup -> ops.update -> update() in context/terminal.tsx and fired setStore on the terminal store. That store write reentered the reactive graph mid cleanNode iteration; solid then nulled an ancestors owned while an outer cleanNode recursion was still iterating it, crashing with Cannot read properties of null reading 1 at node.owned[i]. Wrapping finalize in queueMicrotask pushes the store write past the current synchronous cleanup cascade so the teardown cannot race with cleanNodes owned walk.
2026-04-19 13:19:13 +10:00
LukeParkerDev 902ac2dad9 chore: add solid owner/cleanup instrumentation for diagnosing cleanNode crashes
Hooks DEV.hooks.afterCreateOwner to wrap every owner's .owned and .cleanups with accessor traps that record every mutation to a ring buffer with tags, stacks, and cleanup-depth context. On any 'Cannot read properties of null' TypeError the buffer is dumped so the offending cleanup/origin that nulled an owner's owned mid-iteration is visible post-hoc. Also wraps owned arrays in a Proxy so cleanNode's index reads are logged and the suspect ownerTag at crash time can be identified. Debug only; zero cost until a crash fires.
2026-04-19 13:19:12 +10:00
LukeParkerDev 33f5b80235 fix: defer reactive root disposal in cache cleanups
Same nested-dispose-in-onCleanup bug as 7f36ac2481 but in three more
places: TerminalProvider.disposeAll, PromptProvider.disposeAll, and
scoped-cache.clear() (covers viewCache.clear and comments cache.clear).
All of them synchronously call createRoot dispose() on cached entries
inside onCleanup, which during a server switch nests into the outer
cleanNode cascade and throws TypeError at chunk-*.js:992.

Snapshot the pending disposers, clear the cache synchronously, and
fire the disposers on a microtask so the outer cleanup finishes first.
2026-04-19 13:19:12 +10:00
LukeParkerDev d04d13ea22 fix: defer child-store root disposal to avoid nested cleanNode
disposeDirectory called a createRoot dispose() synchronously. When
triggered by pinForOwner's onCleanup during a parent remount (e.g.
switching to a WSL server re-keys the ServerKey Show), the inner
dispose ran a nested cleanNode cascade on a sibling root while the
outer cascade was mid-traversal, corrupting solid-js's graph walk
state and surfacing as TypeError: Cannot read properties of null
(reading '1') at chunk-*.js:992 after ~155 recursive cleanNode frames.

Queue the dispose on a microtask so synchronous bookkeeping still
runs (map deletes, onDispose cache invalidation) but the reactive
cleanup happens after the outer traversal finishes.
2026-04-19 13:19:11 +10:00
LukeParkerDev cfcc6f1353 fix: batch server switch to avoid cleanNode crash
The status popover and select-server dialog used to call navigate('/') then
defer server.setActive to the next microtask. With multiple sidecars in v2,
that split triggered two separate disposal cascades - one for the route
change and a second for the ServerKey Show re-key - and the sidebar project
bucket also swaps (local -> wsl:Debian), tearing down every solid-dnd
sortable in the middle. Wrapping both calls in batch() lands them in a
single Solid update so disposal runs once.

Also raise Error.stackTraceLimit to 200 so future disposal crashes capture
the originating frame instead of truncating at the tenth cleanNode.
2026-04-19 13:19:11 +10:00
LukeParkerDev 3360480a2a fix: bind F12 and Ctrl+Shift+I to DevTools on all platforms 2026-04-19 13:19:10 +10:00
LukeParkerDev 3e7e709884 fix: preserve full error stacks in desktop renderer logs
Electron's console-message event only surfaces {level, message, line, sourceId}
without the stack, so uncaught errors showed up as 'line 1028 of chunk-*.js'
(SolidJS's rethrow site) with no way to find the real origin. Attach
window error and unhandledrejection listeners that log the full stack via
console.error, and reshape the main-process log line so newlines in the
stack survive instead of being JSON-escaped into one unreadable blob.
2026-04-19 13:19:09 +10:00
LukeParkerDev c510661ef3 fix: stop random hotkeys from snapping desktop zoom to 1
The main process was resetting webContents zoom to 1 on every
\zoom-changed\ event, which fires not just for native Chromium zoom
gestures but also for the renderer's own setZoomFactor IPC calls. Paired
with a keydown listener that re-sent the current zoom on every
ctrl-combination (ctrl+backspace, ctrl+z, ctrl+v, ...), this created a
self-triggered race that intermittently snapped the factor back to 1.

Make the renderer the single source of zoom truth: keyboard, wheel, and
menu all drive the same Solid signal, preventDefault blocks Chromium's
built-in accelerators before they race, and the main process disables
pinch zoom and no longer listens to zoom-changed.
2026-04-19 13:19:09 +10:00
LukeParkerDev 12fa782137 refactor: treat WSL servers as additive alongside windows local
Local Server is always Windows-native now; WSL lives as a separate list
of one-or-more distro-bound sidecars spawned alongside it. Manage Servers
shows an Add WSL button on Windows, each WSL server appears as its own
row with remove + retry, and the wizard runs scoped to a new distro.
2026-04-19 13:19:09 +10:00
LukeParkerDev 66dfdb933d fix: pin desktop renderer dev server to loopback 2026-04-19 13:19:08 +10:00
LukeParkerDev 3b1970a0f4 docs: reframe WSL work as additive servers beside windows local 2026-04-19 13:19:07 +10:00
LukeParkerDev d8b1d86092 Update dialog-local-server.tsx 2026-04-19 13:19:07 +10:00
LukeParkerDev d7111a7072 fix: prefer nerd fonts in desktop terminal 2026-04-19 13:19:06 +10:00
LukeParkerDev 17bd7ffbf1 refactor: confirm windows local server swap inline 2026-04-19 13:19:06 +10:00
LukeParkerDev 9fa3a99480 fix: make WSL opencode mismatch non-blocking 2026-04-19 13:19:06 +10:00
LukeParkerDev 4318b0902d fix: keep session header titlebar mounts in sync 2026-04-19 13:19:05 +10:00
LukeParkerDev 06b27db78e fix: keep sidebar projects stable across WSL refreshes 2026-04-19 13:18:36 +10:00
LukeParkerDev fcc9cab760 fix: separate sidebar drag ids by type 2026-04-19 13:18:36 +10:00
LukeParkerDev 0dae445f4f fix: detect opencode in existing WSL distros 2026-04-19 13:15:59 +10:00
LukeParkerDev 15092be204 fix: catch desktop local startup errors 2026-04-19 13:15:58 +10:00
LukeParkerDev 482ef946fe fix: remove route dependency from server dialog 2026-04-19 13:15:58 +10:00
LukeParkerDev 421f3bbd0b fix: surface desktop WSL startup failures 2026-04-19 13:15:57 +10:00
LukeParkerDev 76da54c596 fix: harden local server WSL onboarding checks 2026-04-19 13:15:26 +10:00
LukeParkerDev 1baa87bf0d fix: tighten local server wizard dialog flow 2026-04-19 13:15:26 +10:00
LukeParkerDev 8554345ba0 feat: turn local server setup into guided step flow 2026-04-19 13:15:25 +10:00
LukeParkerDev b321a2de2b fix: treat WSL sidecars as local 2026-04-19 13:15:25 +10:00
LukeParkerDev 2cd61113c1 feat: show other distro install options 2026-04-19 13:15:24 +10:00
LukeParkerDev c1dc769b5f fix: send plain local server config over ipc 2026-04-19 13:15:24 +10:00
LukeParkerDev 08d422dca1 fix: include local server stack traces in renderer logs 2026-04-19 13:15:24 +10:00
LukeParkerDev 6f2a6356ed fix: surface desktop renderer errors in logs 2026-04-19 13:15:23 +10:00
LukeParkerDev 58ab95e32c feat: add restart-to-apply local runtime changes 2026-04-19 13:15:23 +10:00
LukeParkerDev 5aa544179d feat: branch Electron local startup by runtime 2026-04-19 13:15:22 +10:00
LukeParkerDev 482dc3a15d refactor: carry local runtime metadata through startup 2026-04-19 13:14:44 +10:00
LukeParkerDev 710469cf09 feat: add local server opencode setup flow 2026-04-19 13:14:43 +10:00
LukeParkerDev e897883f91 feat: deep-link local server failures into management 2026-04-19 13:14:43 +10:00
LukeParkerDev 8d8e8fe8f4 feat: add local server management dialog shell 2026-04-19 13:14:42 +10:00
LukeParkerDev df635562e9 refactor: expose local server through app platform 2026-04-19 13:14:42 +10:00
LukeParkerDev c575415ec4 feat: add local server install actions 2026-04-19 13:14:41 +10:00
LukeParkerDev c0e449d3ac feat: add local server transcript and terminal action 2026-04-19 13:14:41 +10:00
LukeParkerDev ec1a5c261e feat: add Electron local server probe actions 2026-04-19 13:14:40 +10:00
LukeParkerDev 4212931808 feat: add Electron WSL command helpers 2026-04-19 13:14:40 +10:00
LukeParkerDev 1a215700c8 refactor: derive Electron WSL paths from local server config 2026-04-19 13:14:39 +10:00
LukeParkerDev 9d4737fdc9 refactor: track Electron local server startup state 2026-04-19 13:14:39 +10:00
LukeParkerDev d6f2b9f1b7 refactor: give desktop local server a windows key 2026-04-19 13:14:39 +10:00
LukeParkerDev 8af7b5cd65 refactor: scaffold Electron local server config 2026-04-19 13:14:38 +10:00
LukeParkerDev ba145fd61a docs: add WSL onboarding implementation backlog 2026-04-19 13:13:53 +10:00
LukeParkerDev 02a8711f48 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-19 13:10:30 +10:00
LukeParkerDev 41a6067a04 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-19 12:41:03 +10:00
LukeParkerDev 0f92cec469 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-19 12:10:22 +10:00
LukeParkerDev 5acfe3a20c Merge remote-tracking branch 'upstream/dev' into dev 2026-04-17 16:24:57 +10:00
Ariane Emory 731c1e58f2 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-16 20:22:02 -04:00
LukeParkerDev 4ef581c111 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-16 17:17:43 +10:00
LukeParkerDev a888bf2194 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-16 11:00:00 +10:00
LukeParkerDev f8a23b428d Merge remote-tracking branch 'upstream/dev' into dev 2026-04-15 17:15:00 +10:00
Brendan Allan c92aaf8b80 fix(ui): disable accordion items for binary files and improve disabled state styling (#22577) 2026-04-15 07:03:00 +00:00
LukeParkerDev 79552d31e6 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-15 14:48:37 +10:00
LukeParkerDev 885bb25f13 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-15 08:38:55 +10:00
LukeParkerDev 987afa3d71 Merge remote-tracking branch 'upstream/dev' into dev 2026-04-14 09:56:39 +10:00
LukeParkerDev a3f5c5d14d Merge remote-tracking branch 'upstream/dev' into dev 2026-04-13 16:24:08 +10:00
Ariane Emory c411d37484 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-12 04:22:06 -04:00
Luke Parker da2e91cf60 ci use node 24 in test workflow fixing random ECONNRESET (#21782) 2026-04-10 10:01:10 +10:00
Luke Parker f327584e0a fix windows e2e backend not stopping on sigterm waiting 10s for no reason (#21781) 2026-04-10 10:00:46 +10:00
Ariane Emory 97a94571a4 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-04-03 09:19:12 -04:00
Ariane Emory 6652585a7f Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-24 11:17:40 -04:00
Ariane Emory 532b64c0d5 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-24 07:43:03 -04:00
Ariane Emory eec4c775a7 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-23 21:10:21 -04:00
Ariane Emory 01e350449c Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-20 19:12:18 -04:00
Dax 5792a80a8c Merge branch 'dev' into feat/auto-accept-permissions 2026-03-20 10:46:31 -04:00
Dax Raad db039db7f5 regen js sdk 2026-03-20 10:21:10 -04:00
Dax Raad c1a3936b61 Merge remote-tracking branch 'origin/dev' into feat/auto-accept-permissions
# Conflicts:
#	packages/sdk/js/src/v2/gen/types.gen.ts
2026-03-20 10:20:26 -04:00
Ariane Emory a9d9e4d9c4 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-20 03:35:16 -04:00
Ariane Emory 2531b2d3a9 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-13 11:47:39 -04:00
Ariane Emory a718f86e0f Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-08 19:28:41 -04:00
Ariane Emory f3efdff861 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-08 08:36:02 -04:00
Ariane Emory 955d8591df Merge branch 'dev' into feat/canceled-prompts-in-history 2026-03-05 18:24:19 -05:00
Ariane Emory 33b3388bf4 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-26 17:50:11 -05:00
Ariane Emory 716f40b128 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-26 01:36:39 -05:00
Ariane Emory 0b06ff1407 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-20 21:24:12 -05:00
Ariane Emory 01ff5b5390 Merge branch 'dev' into feat/canceled-prompts-in-history
# Conflicts:
#	packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
2026-02-20 02:16:02 -05:00
Ariane Emory 3d1b121e70 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-19 19:18:48 -05:00
Ariane Emory b70629af27 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-18 19:10:26 -05:00
Ariane Emory b7b016fa28 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-17 00:09:51 -05:00
Ariane Emory 5ba2d7e5f0 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-15 12:27:51 -05:00
Ariane Emory 459b22b83d Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-14 19:21:47 -05:00
Ariane Emory 377812b98a Merge dev into feat/canceled-prompts-in-history 2026-02-14 06:28:48 -05:00
Ariane Emory 5cc0901e38 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-13 09:37:11 -05:00
Ariane Emory 7fb6b589d1 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-12 18:29:23 -05:00
Ariane Emory 3f37b43e7d Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-11 12:46:47 -05:00
Ariane Emory 8805dfc849 fix: deduplicate prompt history entries
Avoid adding duplicate entries to prompt history when the same input
is appended multiple times (e.g., clearing with ctrl+c then restoring
via history navigation and clearing again).
2026-02-10 22:21:39 -05:00
Ariane Emory ac5a5d8b16 Merge branch 'feat/canceled-prompts-in-history' of github.com:ariane-emory/opencode into feat/canceled-prompts-in-history 2026-02-10 16:37:55 -05:00
Ariane Emory eaf94ed047 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-10 16:29:05 -05:00
Ariane Emory b8031c5ae8 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-10 16:10:35 -05:00
Dax Raad a531f3f36d core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands 2026-02-07 20:00:09 -05:00
Dax Raad bb3382311d tui: standardize autoedit indicator text styling to match other status labels 2026-02-07 19:57:45 -05:00
Dax Raad ad545d0cc9 tui: allow auto-accepting only edit permissions instead of all permissions 2026-02-07 19:52:53 -05:00
Dax Raad ac244b1458 tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles 2026-02-07 17:03:34 -05:00
Dax Raad f202536b65 tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' 2026-02-07 16:57:48 -05:00
Dax Raad 405cc3f610 tui: streamline permission toggle command naming and add keyboard shortcut support
Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity
and move the command to the Agent category for better discoverability.
Add permission_auto_accept_toggle keybind to enable keyboard shortcut
toggling of auto-accept mode for permission requests.
2026-02-07 16:51:55 -05:00
Dax Raad 878c1b8c2d feat(tui): add auto-accept mode for permission requests
Add a toggleable auto-accept mode that automatically accepts all incoming
permission requests with a 'once' reply. This is useful for users who want
to streamline their workflow when they trust the agent's actions.

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
Ariane Emory d5dcadc000 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-07 13:34:42 -05:00
Ariane Emory 0c154e6a2f Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-06 15:59:50 -05:00
Ariane Emory 4f96975148 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-05 18:17:01 -05:00
Ariane Emory eaba99711b Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-04 19:33:59 -05:00
Ariane Emory f762125775 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-03 18:36:44 -05:00
Ariane Emory ded6bb6513 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-02 21:23:28 -05:00
Ariane Emory 39332f5be6 Merge branch 'dev' into feat/canceled-prompts-in-history 2026-02-01 22:33:29 -05:00
Ariane Emory 2c6ff35400 feat: add toggle to control whether cleared prompts are saved to history
Adds a toggle command in the System category that allows users to enable
or disable saving cleared prompts to history. The feature is disabled by
default to preserve existing behavior.

When enabled via the command palette ("Include cleared prompts in history"),
pressing Ctrl+C will save the current prompt to history before clearing it,
allowing users to navigate back with arrow keys.

The setting persists in kv.json.
2026-02-01 21:12:48 -05:00
Ariane Emory 738d6c8899 feat: save prompt to history when cleared with Ctrl+C
When users press Ctrl+C to clear the input field, the current prompt
is now saved to history before clearing. This allows users to navigate
back to cleared prompts using arrow keys, preventing loss of work.

Addresses #11489
2026-02-01 21:01:15 -05:00
59 changed files with 4101 additions and 670 deletions
+29 -24
View File
@@ -43,6 +43,7 @@ import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { WslServersProvider } from "@/context/wsl-servers"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
@@ -71,7 +72,7 @@ declare global {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
activeServer?: string
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
@@ -171,11 +172,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
}}
>
<QueryProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
<WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
</ErrorBoundary>
</UiI18nBridge>
@@ -298,11 +301,11 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
)
}
function ServerKey(props: ParentProps) {
function ServerKey(props: { children: (key: ServerConnection.Key) => JSX.Element }) {
const server = useServer()
return (
<Show when={server.key} keyed>
{props.children}
{(key) => props.children(key)}
</Show>
)
}
@@ -319,22 +322,24 @@ export function AppInterface(props: {
<ServersProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
{() => (
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
)}
</ServerKey>
</ConnectionGate>
</ServersProvider>
@@ -5,20 +5,26 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { batch, createEffect, createMemo, createResource, For, onCleanup, Show, untrack } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { DialogWslServer } from "@/components/dialog-wsl-server"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useWslServers } from "@/context/wsl-servers"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
const DEFAULT_USERNAME = "opencode"
interface DialogSelectServerProps {
onNavigateHome?: () => void
}
interface ServerFormProps {
value: string
name: string
@@ -27,7 +33,6 @@ interface ServerFormProps {
placeholder: string
busy: boolean
error: string
status: boolean | undefined
onChange: (value: string) => void
onNameChange: (value: string) => void
onUsernameChange: (value: string) => void
@@ -44,15 +49,17 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
})
}
function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Sidecar & { variant: "wsl" } {
return conn.type === "sidecar" && conn.variant === "wsl"
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
const [defaultKey, defaultActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
return (await platform.getDefaultServer?.()) ?? null
} catch (err) {
showRequestError(language, err)
return null
@@ -60,52 +67,18 @@ function useDefaultServer() {
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
defaultActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
return { previewStatus }
}
function ServerForm(props: ServerFormProps) {
const language = useLanguage()
const keyDown = (event: KeyboardEvent) => {
@@ -171,15 +144,18 @@ function ServerForm(props: ServerFormProps) {
)
}
export function DialogSelectServer() {
const navigate = useNavigate()
export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const wslServers = useWslServers()
const defaultServer = useDefaultServer()
const checkServerHealth = useCheckServerHealth()
let disposed = false
onCleanup(() => {
disposed = true
})
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
@@ -189,7 +165,9 @@ export function DialogSelectServer() {
password: "",
error: "",
showForm: false,
status: undefined as boolean | undefined,
},
addWsl: {
showWizard: false,
},
editServer: {
id: undefined as string | undefined,
@@ -198,7 +176,6 @@ export function DialogSelectServer() {
username: "",
password: "",
error: "",
status: undefined as boolean | undefined,
},
})
@@ -210,7 +187,6 @@ export function DialogSelectServer() {
password: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
@@ -221,7 +197,6 @@ export function DialogSelectServer() {
username: "",
password: "",
error: "",
status: undefined,
})
}
@@ -294,6 +269,32 @@ export function DialogSelectServer() {
},
}))
const removeWslMutation = useMutation(() => ({
mutationFn: async (key: ServerConnection.Key) => {
await platform.wslServers?.removeServer(key)
return key
},
onSuccess: async (key) => {
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
server.remove(key)
},
onError: (err) => showRequestError(language, err),
}))
const retryWslMutation = useMutation(() => ({
mutationFn: async (key: ServerConnection.Key) => {
await platform.wslServers?.startServer(key)
},
onError: (err) => showRequestError(language, err),
}))
const updateWslMutation = useMutation(() => ({
mutationFn: async (distro: string) => {
await platform.wslServers?.installOpencode(distro)
},
onError: (err) => showRequestError(language, err),
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const active = server.key
const newConn = server.add(next)
@@ -312,6 +313,32 @@ export function DialogSelectServer() {
})
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
const wslState = () => wslServers.data
const healthPollKey = createMemo(() =>
items()
.map((conn) =>
[ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"),
)
.join("\n\n"),
)
const health = (key: ServerConnection.Key) => store.status[key]
const wslRuntime = (conn: ServerConnection.Any) => {
if (!isWslSidecar(conn)) return
return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime
}
const nonReadyWslServers = createMemo(() =>
(wslState()?.servers ?? []).filter((item) => item.runtime.kind !== "ready"),
)
const canRetryWsl = (conn: ServerConnection.Any) => {
const runtime = wslRuntime(conn)
return runtime?.kind === "failed" || runtime?.kind === "stopped"
}
const canRetryWslRuntime = (kind: string) => kind === "failed" || kind === "stopped"
const wslRuntimeLabel = (kind: string) => {
if (kind === "starting") return "Starting"
if (kind === "failed") return "Failed"
return "Stopped"
}
const sortedItems = createMemo(() => {
const list = items()
@@ -326,7 +353,7 @@ export function DialogSelectServer() {
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
const diff = rank(health(ServerConnection.key(a))) - rank(health(ServerConnection.key(b)))
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
@@ -334,39 +361,60 @@ export function DialogSelectServer() {
async function refreshHealth() {
const results: Record<ServerConnection.Key, ServerHealth> = {}
const list = untrack(items)
await Promise.all(
items().map(async (conn) => {
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (disposed) return
setStore("status", reconcile(results))
}
createEffect(() => {
items()
healthPollKey()
void refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
const wslCheck = (conn: ServerConnection.Any) => {
if (!isWslSidecar(conn)) return null
return wslState()?.opencodeChecks[conn.distro] ?? null
}
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
if (!persist && health(ServerConnection.key(conn))?.healthy === false) return
const nextKey = ServerConnection.key(conn)
const changed = server.key !== nextKey
const navigateHome = () => props.onNavigateHome?.()
const apply = () => {
dialog.close()
if (persist && conn.type === "http") {
server.add(conn)
navigateHome()
return
}
batch(() => {
navigateHome()
server.setActive(nextKey)
})
}
if (!changed) {
await apply()
return
}
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
apply()
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddNameChange = (value: string) => {
@@ -377,25 +425,16 @@ export function DialogSelectServer() {
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
@@ -406,20 +445,15 @@ export function DialogSelectServer() {
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
const mode = createMemo<"list" | "add" | "edit">(() => {
const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => {
if (store.addWsl.showWizard) return "add-wsl"
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
@@ -433,9 +467,11 @@ export function DialogSelectServer() {
const resetForm = () => {
resetAdd()
resetEdit()
setStore("addWsl", "showWizard", false)
}
const startAdd = () => {
setStore("addWsl", "showWizard", false)
resetEdit()
setStore("addServer", {
showForm: true,
@@ -444,11 +480,11 @@ export function DialogSelectServer() {
username: DEFAULT_USERNAME,
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
setStore("addWsl", "showWizard", false)
resetAdd()
setStore("editServer", {
id: conn.http.url,
@@ -457,10 +493,22 @@ export function DialogSelectServer() {
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: store.status[ServerConnection.key(conn)]?.healthy,
})
}
const startAddWsl = () => {
resetAdd()
resetEdit()
setStore("addWsl", "showWizard", true)
}
const handleAddedWsl = async (distro: string) => {
const key = ServerConnection.Key.make(`wsl:${distro}`)
setStore("addWsl", "showWizard", false)
const conn = items().find((item) => ServerConnection.key(item) === key)
if (conn) await select(conn)
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
@@ -477,14 +525,22 @@ export function DialogSelectServer() {
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const isAddWslMode = createMemo(() => mode() === "add-wsl")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows")
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
<span>
{isAddWslMode()
? "Add WSL server"
: isAddMode()
? language.t("dialog.server.add.title")
: language.t("dialog.server.edit.title")}
</span>
</div>
)
})
@@ -495,37 +551,126 @@ export function DialogSelectServer() {
resetEdit()
})
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
void platform.setDefaultServer?.(null)
}
async function handleRemove(key: ServerConnection.Key) {
server.remove(key)
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
}
return (
<Dialog title={formTitle()}>
<div class="flex flex-1 min-h-0 flex-col gap-2">
<Dialog
title={formTitle()}
fit={isAddWslMode()}
class={isAddWslMode() ? "[&_[data-slot=dialog-body]]:flex-none [&_[data-slot=dialog-body]]:overflow-visible" : undefined}
>
<div class={isAddWslMode() ? "flex flex-col gap-2" : "flex flex-1 min-h-0 flex-col gap-2"}>
<Show
when={!isFormMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
<Show
when={isAddWslMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
>
<DialogWslServer onAdded={handleAddedWsl} />
</Show>
}
>
<Show when={nonReadyWslServers().length > 0}>
<div class="px-5">
<div class="bg-surface-base rounded-md overflow-hidden">
<For each={nonReadyWslServers()}>
{(item) => {
const key = ServerConnection.Key.make(item.config.id)
const retryable = () => canRetryWslRuntime(item.runtime.kind)
return (
<div class="min-h-14 p-3 flex items-center gap-3 border-b border-border-weak-base last:border-b-0">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-critical-base": item.runtime.kind === "failed",
"bg-border-weak-base": item.runtime.kind !== "failed",
}}
/>
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="text-14-medium text-text-base truncate">{item.config.distro}</span>
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
WSL
</span>
<Show when={defaultServer.defaultKey() === key}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs shrink-0">
{language.t("dialog.server.status.default")}
</span>
</Show>
<span class="text-12-regular text-text-weak truncate">
{wslRuntimeLabel(item.runtime.kind)}
</span>
</div>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<Show when={retryable()}>
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={defaultServer.canDefault() && defaultServer.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={defaultServer.canDefault() && defaultServer.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={retryable() || defaultServer.canDefault()}>
<DropdownMenu.Separator />
</Show>
<DropdownMenu.Item
onSelect={() => removeWslMutation.mutate(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)
}}
</For>
</div>
</div>
</Show>
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
@@ -534,7 +679,7 @@ export function DialogSelectServer() {
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x.http.url}
key={(x) => ServerConnection.key(x)}
onSelect={(x) => {
if (x) void select(x)
}}
@@ -543,18 +688,35 @@ export function DialogSelectServer() {
>
{(i) => {
const key = ServerConnection.key(i)
const wsl = isWslSidecar(i)
const wslDistro = wsl ? i.distro : undefined
const blocked = () => health(key)?.healthy === false
const canChangeDefault = () => defaultServer.canDefault() && (i.type === "http" || wsl)
const canRemove = () => i.type === "http" || wsl
const opencodeAction = () => {
const check = wslCheck(i)
if (!check) return null
if (!check.resolvedPath) return "Install OpenCode"
if (check.matchesDesktop === false) return "Update OpenCode"
return null
}
const updating = () => {
const job = wslState()?.job
return job?.kind === "install-opencode" && job.distro === wslDistro
}
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={store.status[key]} />
<ServerHealthIndicator health={health(key)} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
dimmed={blocked()}
status={health(key)}
version={wslCheck(i)?.version ?? undefined}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<Show when={defaultServer.defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -562,12 +724,32 @@ export function DialogSelectServer() {
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<div class="flex items-center justify-center gap-3 pl-4">
<Show when={wsl && opencodeAction()}>
{(label) => (
<Button
variant="secondary"
size="small"
disabled={!!wslState()?.job}
class="shrink-0"
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => {
e.stopPropagation()
if (wslDistro) updateWslMutation.mutate(wslDistro)
}}
>
<Show when={updating()}>
<Spinner class="size-3.5 shrink-0" />
</Show>
{label()}
</Button>
)}
</Show>
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<Show when={i.type === "http" || i.type === "sidecar"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
@@ -579,35 +761,54 @@ export function DialogSelectServer() {
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<Show when={i.type === "http"}>
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={wsl && canRetryWsl(i)}>
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canChangeDefault() && defaultServer.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<Show when={canChangeDefault() && defaultServer.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i))}>
<DropdownMenu.Separator />
</Show>
<Show when={canRemove()}>
<DropdownMenu.Item
onSelect={() => {
if (wsl) {
removeWslMutation.mutate(key)
return
}
void handleRemove(key)
}}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@@ -621,17 +822,32 @@ export function DialogSelectServer() {
<div class="shrink-0 px-5 pb-5">
<Show
when={isFormMode()}
when={!isAddWslMode() && isFormMode()}
fallback={
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
<Show when={!isAddWslMode()}>
<div class="flex items-center gap-2">
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
<Show when={canAddWsl()}>
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAddWsl}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
Add WSL
</Button>
</Show>
</div>
</Show>
}
>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
@@ -0,0 +1,582 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useWslServers } from "@/context/wsl-servers"
type WslServerStep = "wsl" | "distro" | "opencode"
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
function isHiddenDistro(name: string) {
return /^docker-desktop(?:-data)?$/i.test(name)
}
interface DialogWslServerProps {
onAdded?: (distro: string) => void | Promise<void>
}
export function DialogWslServer(props: DialogWslServerProps = {}) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const wslServers = useWslServers()
const api = platform.wslServers!
const [store, setStore] = createStore({
step: undefined as WslServerStep | undefined,
selectedDistro: null as string | null,
installTarget: undefined as string | undefined,
adding: false,
})
const current = () => wslServers.data
let disposed = false
onCleanup(() => {
disposed = true
})
const busy = createMemo(() => !!current()?.job || store.adding)
const selectedProbe = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return current()?.distroProbes[distro] ?? null
})
const selectedInstalled = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
})
const visibleInstalledDistros = createMemo(() =>
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
)
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
const opencodeCheck = createMemo(() => {
const distro = store.selectedDistro
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
if (distroReady()) return null
return probe
})
const distroUnavailableMessage = createMemo(() => {
const probe = distroWarningProbe()
const distro = store.selectedDistro
if (!probe || probe.canExecute || !distro) return null
if (!selectedInstalled()) return `${distro} is not installed yet.`
return `Open ${distro} once to finish setup.`
})
const distroMissingTools = createMemo(() => {
const probe = distroWarningProbe()
if (!probe?.canExecute) return null
if (probe.hasBash && probe.hasCurl) return null
return probe
})
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
const addableInstalledDistros = createMemo(() => {
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
})
const installableDistros = createMemo(() => {
const online = visibleOnlineDistros()
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
return online
.filter((item) => !installed.has(item.name))
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
})
const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null)
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
const installingOpencode = createMemo(() => {
const job = current()?.job
return job?.kind === "install-opencode" && job.distro === store.selectedDistro
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !store.selectedDistro) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
if (!job) return store.adding
return store.adding || job.kind !== "probe-opencode"
})
const recommendedStep = createMemo<WslServerStep>(() => {
if (!wslReady()) return "wsl"
if (!distroReady()) return "distro"
return "opencode"
})
// activeStep falls back to recommendedStep when the user hasn't picked one.
// Once the user clicks a step tab we respect their choice rather than snapping
// them back when a probe result updates recommendedStep.
const activeStep = createMemo(() => store.step ?? recommendedStep())
const autoProbe = createMemo(() => {
const state = current()
if (!state || busy()) return null
if (state.pendingRestart) return null
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
if (!wslReady()) return null
if (!state.installed.length && !state.online.length) {
return { key: "distros", run: () => api.refreshDistros() }
}
const distro = store.selectedDistro
if (distro && !state.distroProbes[distro]) {
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
}
if (!distro || !distroReady()) return null
if (!state.opencodeChecks[distro]) {
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
}
return null
})
let lastAutoProbe: string | null = null
createEffect(() => {
const probe = autoProbe()
if (!probe || probe.key === lastAutoProbe) return
const key = probe.key
lastAutoProbe = key
void (async () => {
try {
await probe.run()
} catch (err) {
if (disposed) return
// Allow the same probe to run again when reactive inputs next change
// (e.g. user reselects a distro). Without this the user would be stuck
// on a transient wsl.exe failure until they pick a different distro.
if (lastAutoProbe === key) lastAutoProbe = null
requestError(language, err)
}
})()
})
createEffect(() => {
const state = current()
const distro = defaultInstalledDistro()
if (!state || !distro || busy()) return
if (store.selectedDistro) return
if (existingServerDistros().has(distro.name)) return
setStore("selectedDistro", distro.name)
})
createEffect(() => {
const distros = installableDistros()
if (!distros.length) {
if (store.installTarget) setStore("installTarget", undefined)
return
}
if (store.installTarget && distros.some((item) => item.name === store.installTarget)) return
setStore("installTarget", distros[0]!.name)
})
const wslMessage = createMemo(() => {
const state = current()
if (!state || state.job?.kind === "runtime") return "Checking WSL..."
if (state.pendingRestart) return "Windows needs a restart to finish installing WSL."
if (state.runtime?.available) return state.runtime.version ?? "WSL is ready."
return state.runtime?.error ?? "WSL is required to continue."
})
const distroMessage = createMemo(() => {
const state = current()
if (!state) return "Checking distros..."
const distro = store.selectedDistro
if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...`
if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...`
if (state.job?.kind === "distros") return "Listing distros..."
if (distroUnavailableMessage()) return distroUnavailableMessage()!
if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.`
if (distro) return `Finishing setup for ${distro}.`
return "Pick a distro or install one below."
})
const opencodeMessage = createMemo(() => {
const state = current()
if (!state) return "Checking OpenCode..."
const distro = store.selectedDistro
if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") {
return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..."
}
if (opencodeCheck()?.error) return opencodeCheck()!.error
if (opencodeCheck()?.matchesDesktop === false) {
return distro ? `Update OpenCode in ${distro}.` : "Update OpenCode."
}
if (opencodeReady()) return distro ? `OpenCode is ready in ${distro}.` : "OpenCode is ready."
return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first."
})
const run = async (action: () => Promise<unknown>) => {
try {
await action()
} catch (err) {
requestError(language, err)
}
}
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
const distro = store.selectedDistro
if (!distro) return
void run(() => action(distro))
}
const selectDistro = (name: string) => {
setStore("selectedDistro", name)
setStore("step", undefined)
}
const finish = async () => {
const distro = store.selectedDistro
if (!distro) return
setStore("adding", true)
try {
await api.addServer(distro)
if (props.onAdded) {
await props.onAdded(distro)
} else {
dialog.close()
}
} catch (err) {
requestError(language, err)
} finally {
setStore("adding", false)
}
}
const steps = createMemo(() => {
const active = activeStep()
const activeIndex = STEPS.indexOf(active)
const recommendedIndex = STEPS.indexOf(recommendedStep())
return STEPS.map((step) => {
const index = STEPS.indexOf(step)
return {
step,
title: step === "wsl" ? "WSL" : step === "distro" ? "Choose distro" : "OpenCode",
state:
active === step
? "current"
: step === "wsl"
? wslReady()
? "done"
: "warning"
: step === "distro"
? distroReady()
? "done"
: index > activeIndex
? "locked"
: "warning"
: opencodeCheck()?.matchesDesktop === false
? "warning"
: opencodeReady()
? "done"
: index > activeIndex
? "locked"
: "warning",
locked: index > recommendedIndex,
}
})
})
const loadError = createMemo(() => {
const error = wslServers.error
if (!error) return "Failed to load WSL state."
return error instanceof Error ? error.message : String(error)
})
return (
<div class="px-5 pb-5 flex flex-col gap-4">
<Show when={!wslServers.isPending} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
<Show when={!wslServers.isError} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}>
<div class="flex gap-2 pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
classList={{
"border-border-strong-base bg-surface-base-hover": item.state === "current",
"border-icon-success-base/40 bg-surface-base": item.state === "done",
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
}}
disabled={item.locked}
onClick={() => setStore("step", item.step)}
>
<div class="text-13-medium text-text-strong">{item.title}</div>
</button>
)}
</For>
</div>
<Switch>
<Match when={activeStep() === "wsl"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">WSL</div>
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => api.installWsl())}
>
Install WSL
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
<Show when={current()?.pendingRestart}>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex items-center justify-between gap-3">
<div class="text-12-regular text-text-warning-base">Windows restart required.</div>
<Button variant="secondary" size="large" onClick={() => void platform.restart()}>
Relaunch OpenCode
</Button>
</div>
</Show>
<div class="flex items-center justify-end">
<Button variant="secondary" size="large" disabled={busy() || !wslReady()} onClick={() => setStore("step", "distro")}>
Next
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "distro"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">Choose a distro</div>
<Show when={store.selectedDistro}>
<Button
variant="ghost"
size="small"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
Refresh
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
<div class="flex flex-col gap-2">
<Show
when={addableInstalledDistros().length > 0}
fallback={
<div class="text-12-regular text-text-weak">
{visibleInstalledDistros().length
? "All installed distros are already added."
: current()?.runtime?.available
? "No distros detected yet."
: "Checking distros..."}
</div>
}
>
<For each={addableInstalledDistros()}>
{(item) => (
<button
type="button"
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
classList={{ "bg-surface-raised-base": store.selectedDistro === item.name }}
onClick={() => selectDistro(item.name)}
>
<div class="text-13-medium text-text-strong">{item.name}</div>
<Show when={item.isDefault}>
<div class="text-12-regular text-text-weak">Default</div>
</Show>
</button>
)}
</For>
</Show>
</div>
<Show when={installableDistros().length > 0}>
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
<div class="px-1 flex items-center justify-between gap-3">
<div class="text-12-medium text-text-weak">Install</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={installingDistro()}>
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
</Show>
<Button
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
>
{installingDistro() ? "Installing..." : "Install"}
</Button>
</div>
</div>
<div
role="radiogroup"
aria-label="Install distro"
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
>
<For each={installableDistros()}>
{(item) => {
const selected = () => store.installTarget === item.name
return (
<button
type="button"
role="radio"
aria-checked={selected()}
disabled={busy()}
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
classList={{
"bg-surface-raised-base": selected(),
"hover:bg-surface-base": !selected(),
}}
onClick={() => setStore("installTarget", item.name)}
>
<div
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
classList={{ "border-text-strong": selected() }}
>
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
</div>
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
</button>
)
}}
</For>
</div>
</div>
</Show>
<Show
when={
selectedInstalled()?.version === 1 ||
distroUnavailableMessage() ||
distroMissingTools()
}
>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<Show when={selectedInstalled()?.version === 1}>
<div class="text-12-regular text-text-warning-base">WSL 2 is required.</div>
</Show>
<Show when={distroUnavailableMessage()}>
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
</Show>
<Show when={distroMissingTools()}>
<div class="text-12-regular text-text-warning-base">This distro needs bash and curl.</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
>
Open terminal
</Button>
<Button
variant="ghost"
size="large"
disabled={busy() || !store.selectedDistro}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
Refresh
</Button>
</div>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !store.selectedDistro || !distroReady()}
onClick={() => setStore("step", "opencode")}
>
Next
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "opencode"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">OpenCode</div>
<div class="flex items-center gap-2">
<Show when={store.selectedDistro}>
<Button
variant="ghost"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
>
Refresh
</Button>
</Show>
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
>
<Show when={installingOpencode()}>
<Spinner class="size-4 shrink-0" />
</Show>
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
</Button>
</Show>
</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
{(check) => (
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<div class="text-12-regular text-text-weak">Path: {check().resolvedPath ?? "not found"}</div>
<div class="text-12-regular text-text-weak">
Version: {check().version ?? "unknown"}
<Show when={check().expectedVersion}>
{(expected) => <span>{` · desktop ${expected()}`}</span>}
</Show>
</div>
<div class="text-12-regular text-text-warning-base">
Installed version does not match the desktop app version.
</div>
</div>
)}
</Show>
</div>
</Match>
</Switch>
<Show when={activeStep() === "opencode" && allReady() && store.selectedDistro}>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
{store.adding ? "Adding..." : "Add WSL server"}
</Button>
</div>
</Show>
</Show>
</Show>
</div>
)
}
function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
@@ -17,6 +17,7 @@ import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
conn: ServerConnection.Any
status?: ServerHealth
version?: string
class?: string
nameClass?: string
versionClass?: string
@@ -31,6 +32,8 @@ export function ServerRow(props: ServerRowProps) {
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverName(props.conn))
const isWsl = createMemo(() => props.conn.type === "sidecar" && props.conn.variant === "wsl")
const version = createMemo(() => props.version ?? props.status?.version)
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -41,7 +44,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => {
name()
props.conn.http.url
props.status?.version
version()
queueMicrotask(check)
})
@@ -54,8 +57,11 @@ export function ServerRow(props: ServerRowProps) {
const tooltipValue = () => (
<span class="flex items-center gap-2">
<span>{serverName(props.conn, true)}</span>
<Show when={props.status?.version}>
<span class="text-text-invert-weak">v{props.status?.version}</span>
<Show when={isWsl()}>
<span class="text-text-invert-weak">WSL</span>
</Show>
<Show when={version()}>
<span class="text-text-invert-weak">v{version()}</span>
</Show>
</span>
)
@@ -76,15 +82,20 @@ export function ServerRow(props: ServerRowProps) {
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
{name()}
</span>
<Show when={isWsl()}>
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
WSL
</span>
</Show>
<Show
when={badge()}
fallback={
<Show when={props.status?.version}>
<Show when={version()}>
<span
ref={versionRef}
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
>
v{props.status?.version}
v{version()}
</span>
</Show>
}
@@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { useLocation, useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
@@ -286,7 +286,7 @@ function ServerStatusList(props: { state: ServerStatusState }) {
)
}
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: () => void }) {
const sync = useSync()
const servers = useServers()
const server = useServer()
@@ -294,6 +294,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const location = useLocation()
const fail = (err: unknown) => {
showToast({
@@ -374,8 +375,16 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
props.close?.()
navigate("/")
queueMicrotask(() => server.setActive(key))
const activate = () => {
if (location.pathname !== "/") {
setTimeout(activate, 16)
return
}
setTimeout(() => server.setActive(key), 0)
}
setTimeout(activate, 0)
}}
>
<ServerHealthIndicator health={servers.health[key]} />
@@ -67,7 +67,7 @@ export function StatusPopover() {
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
}
>
<Body shown={shown} />
<Body shown={shown} close={() => setShown(false)} />
</Suspense>
</Show>
</Popover>
+1 -1
View File
@@ -178,7 +178,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
type Child = ReturnType<(typeof serverSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => serverSync.child(directory))
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const target = (directory?: string) => {
if (!directory || directory === directory) return current()
return serverSync.child(directory)
@@ -10,6 +10,7 @@ const provider = { all: new Map(), connected: [], default: {} } satisfies Normal
describe("bootstrapDirectory", () => {
test("marks a loading directory partial during bootstrap and complete after success", async () => {
const mcpReads: string[] = []
const [store, setStore] = createStore<State>({
status: "loading",
agent: [],
@@ -44,6 +45,7 @@ describe("bootstrapDirectory", () => {
await bootstrapDirectory({
directory: "/project",
mcp: false,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
@@ -55,10 +57,20 @@ describe("bootstrapDirectory", () => {
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: { list: async () => ({ data: [] }) },
command: {
list: async () => {
mcpReads.push("command")
return { data: [] }
},
},
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
mcp: { status: async () => ({ data: {} }) },
mcp: {
status: async () => {
mcpReads.push("status")
return { data: {} }
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
store,
@@ -74,5 +86,6 @@ describe("bootstrapDirectory", () => {
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
expect(mcpReads).toEqual([])
})
})
@@ -198,6 +198,7 @@ export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
export async function bootstrapDirectory(input: {
directory: string
mcp: boolean
sdk: OpencodeClient
store: Store<State>
setStore: SetStoreFunction<State>
@@ -250,7 +251,7 @@ export async function bootstrapDirectory(input: {
if (next) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
@@ -304,7 +305,7 @@ export async function bootstrapDirectory(input: {
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
() =>
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory)
@@ -6,6 +6,7 @@ import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const queryGroups: Array<() => { queries: Array<{ enabled?: boolean }> }> = []
const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@@ -48,12 +49,15 @@ beforeAll(async () => {
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
}))
mock.module("@tanstack/solid-query", () => ({
useQueries: () => [
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
{ isLoading: false, data: {} },
{ isLoading: false, data: [] },
{ isLoading: false, data: provider },
],
useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => {
queryGroups.push(options)
return [
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
{ isLoading: false, data: {} },
{ isLoading: false, data: [] },
{ isLoading: false, data: provider },
]
},
}))
createChildStoreManager = (await import("./child-store")).createChildStoreManager
@@ -73,6 +77,7 @@ describe("createChildStoreManager", () => {
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
@@ -103,6 +108,7 @@ describe("createChildStoreManager", () => {
onBootstrap(directory) {
bootstraps.push(directory)
},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
@@ -121,4 +127,45 @@ describe("createChildStoreManager", () => {
dispose()
}
})
test("enables MCP only when requested for the directory", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = queryGroups.length
const mcpLoads: string[] = []
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp(directory) {
mcpLoads.push(directory)
},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [, setStore] = manager.child("/project", { bootstrap: false })
const queries = queryGroups[offset]
if (!queries) throw new Error("queries required")
expect(queries().queries[1]?.enabled).toBe(false)
setStore("status", "complete")
manager.child("/project", { bootstrap: false, mcp: true })
expect(queries().queries[1]?.enabled).toBe(true)
expect(mcpLoads).toEqual(["/project"])
manager.disableMcp("/project")
expect(queries().queries[1]?.enabled).toBe(false)
expect(manager.mcp("/project")).toBe(false)
} finally {
dispose()
}
})
})
@@ -1,4 +1,4 @@
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
@@ -24,6 +24,7 @@ export function createChildStoreManager(input: {
isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
onMcp: (directory: string, setStore: SetStoreFunction<State>) => void
onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string
queryOptions: QueryOptionsApi
@@ -39,6 +40,8 @@ export function createChildStoreManager(input: {
const pins = new Map<string, number>()
const ownerPins = new WeakMap<object, Set<string>>()
const disposers = new Map<string, () => void>()
const mcpDirectories = new Set<string>()
const mcpToggles = new Map<string, (enabled: boolean) => void>()
const markKey = (key: DirectoryKey) => {
if (!key) return
@@ -92,6 +95,24 @@ export function createChildStoreManager(input: {
})
}
function disposeChild(key: DirectoryKey) {
const dispose = disposers.get(key)
if (!key || !children[key]) return false
vcsCache.delete(key)
metaCache.delete(key)
iconCache.delete(key)
lifecycle.delete(key)
mcpDirectories.delete(key)
mcpToggles.delete(key)
disposers.delete(key)
delete children[key]
input.onDispose(key)
if (dispose) {
dispose()
}
return true
}
function disposeDirectory(directory: DirectoryKey) {
const key = directory
if (
@@ -105,19 +126,13 @@ export function createChildStoreManager(input: {
) {
return false
}
return disposeChild(key)
}
vcsCache.delete(key)
metaCache.delete(key)
iconCache.delete(key)
lifecycle.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
disposers.delete(key)
function disposeAll() {
for (const directory of Object.keys(children)) {
disposeChild(directoryKey(directory))
}
delete children[key]
input.onDispose(key)
return true
}
function runEviction(skip?: string) {
@@ -173,11 +188,12 @@ export function createChildStoreManager(input: {
createRoot((dispose) => {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
queries: [
input.queryOptions.path(key),
input.queryOptions.mcp(key),
{ ...input.queryOptions.mcp(key), enabled: mcpEnabled() },
input.queryOptions.lsp(key),
input.queryOptions.providers(key),
],
@@ -236,6 +252,7 @@ export function createChildStoreManager(input: {
})
children[key] = child
disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
@@ -274,6 +291,7 @@ export function createChildStoreManager(input: {
const key = directoryKey(directory)
const childStore = ensureChild(directory)
pinForOwner(key)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
@@ -284,6 +302,7 @@ export function createChildStoreManager(input: {
function peek(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory)
const childStore = ensureChild(directory)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
@@ -291,6 +310,19 @@ export function createChildStoreManager(input: {
return childStore
}
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
if (mcpDirectories.has(key)) return
mcpDirectories.add(key)
mcpToggles.get(key)?.(true)
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
}
function disableMcp(directory: string) {
const key = directoryKey(directory)
if (!mcpDirectories.delete(key)) return
mcpToggles.get(key)?.(false)
}
function projectMeta(directory: string, patch: ProjectMeta) {
const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory)
@@ -330,7 +362,10 @@ export function createChildStoreManager(input: {
pin,
unpin,
pinned,
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
disableMcp,
disposeDirectory,
disposeAll,
runEviction,
vcsCache,
metaCache,
@@ -98,6 +98,7 @@ export type IconCache = {
export type ChildOptions = {
bootstrap?: boolean
mcp?: boolean
}
export type DirState = {
+84 -5
View File
@@ -20,6 +20,88 @@ export type FatalRendererErrorLog = {
os?: DesktopOS
}
export type WslRuntimeCheck = {
available: boolean
version: string | null
error: string | null
}
export type WslInstalledDistro = {
name: string
version: number | null
isDefault: boolean
}
export type WslOnlineDistro = {
name: string
label: string
}
export type WslDistroProbe = {
name: string
canExecute: boolean
hasBash: boolean
hasCurl: boolean
error: string | null
}
export type WslOpencodeCheck = {
distro: string
resolvedPath: string | null
version: string | null
expectedVersion: string | null
matchesDesktop: boolean | null
error: string | null
}
export type WslServerConfig = {
id: string
distro: string
}
export type WslServerRuntime =
| { kind: "starting" }
| { kind: "ready"; url: string; username: string | null; password: string | null }
| { kind: "failed"; message: string }
| { kind: "stopped" }
export type WslServerItem = {
config: WslServerConfig
runtime: WslServerRuntime
}
export type WslJob =
| { kind: "runtime"; startedAt: number }
| { kind: "distros"; startedAt: number }
| { kind: "install-wsl"; startedAt: number }
| { kind: "install-distro"; distro: string; startedAt: number }
| { kind: "probe-distro"; distro: string; startedAt: number }
| { kind: "probe-opencode"; distro: string; startedAt: number }
| { kind: "install-opencode"; distro: string; startedAt: number }
export type WslServersState = {
runtime: WslRuntimeCheck | null
installed: WslInstalledDistro[]
online: WslOnlineDistro[]
distroProbes: Record<string, WslDistroProbe>
opencodeChecks: Record<string, WslOpencodeCheck>
pendingRestart: boolean
servers: WslServerItem[]
job: WslJob | null
}
export type WslServersEvent = { type: "state"; state: WslServersState }
export type WslServersPlatform = {
getState(): Promise<WslServersState>
subscribe(cb: (event: WslServersEvent) => void): () => void
probeRuntime(): Promise<void>
refreshDistros(): Promise<void>
installWsl(): Promise<void>
installDistro(name: string): Promise<void>
probeDistro(name: string): Promise<void>
probeOpencode(name: string): Promise<void>
installOpencode(name: string): Promise<void>
openTerminal(name: string): Promise<void>
addServer(distro: string): Promise<WslServerConfig>
removeServer(id: string): Promise<void>
startServer(id: string): Promise<void>
}
export type Platform = {
/** Platform discriminator */
platform: PlatformName
@@ -75,11 +157,8 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>
/** Set the configured WSL integration (desktop only) */
setWslEnabled?(config: boolean): Promise<void> | void
/** Manage WSL sidecar servers (Electron on Windows only) */
wslServers?: WslServersPlatform
/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
+18 -6
View File
@@ -31,6 +31,7 @@ import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { createRefCountMap } from "@/utils/refcount"
import { retry } from "@opencode-ai/core/util/retry"
type GlobalStore = {
ready: boolean
@@ -205,6 +206,15 @@ export function createServerSyncContext() {
onBootstrap: (directory) => {
void bootstrapInstance(directory)
},
onMcp: (directory, setStore) => {
void retry(() => sdkFor(directory).command.list().then((x) => setStore("command", x.data ?? []))).catch((err) => {
showToast({
variant: "error",
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
description: formatServerError(err, language.t),
})
})
},
onDispose: (directory) => {
const key = directoryKey(directory)
queue.clear(key)
@@ -311,6 +321,7 @@ export function createServerSyncContext() {
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
mcp: children.mcp(key),
global: {
config: globalStore.config,
path: globalStore.path,
@@ -382,11 +393,7 @@ export function createServerSyncContext() {
onCleanup(() => {
queue.dispose()
})
onCleanup(() => {
for (const directory of Object.keys(children.children)) {
children.disposeDirectory(directoryKey(directory))
}
})
onCleanup(children.disposeAll)
onMount(() => {
if (typeof requestAnimationFrame === "function") {
@@ -437,6 +444,7 @@ export function createServerSyncContext() {
},
child: children.child,
peek: children.peek,
disableMcp: children.disableMcp,
queryOptions: queryOptionsApi,
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
@@ -454,7 +462,11 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
return {
...sync,
createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)),
createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, sync),
(dir) => sync.disableMcp(dir),
directoryKey,
),
}
},
})
+8 -2
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js"
import { type Accessor, batch, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
@@ -151,6 +151,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (state.active !== input) setState("active", input)
}
createEffect(() => {
if (typeof window === "undefined") return
window.__OPENCODE__ ??= {}
window.__OPENCODE__.activeServer = state.active
})
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
@@ -187,7 +193,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
)
const isLocal = createMemo(() => {
const c = current()
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
})
return {
+35
View File
@@ -0,0 +1,35 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { queryOptions, skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, onCleanup } from "solid-js"
import type { WslServersPlatform, WslServersState } from "./platform"
import { usePlatform } from "./platform"
const wslServersQueryKey = ["platform", "wslServers"] as const
export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({
name: "WslServers",
init: () => {
const platform = usePlatform()
const queryClient = useQueryClient()
const query = useQuery(() => {
const api = platform.wslServers
return queryOptions<WslServersState>({
queryKey: wslServersQueryKey,
queryFn: api ? () => api.getState() : skipToken,
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
})
})
createEffect(() => {
const api = platform.wslServers
if (!api) return
const off = api.subscribe((event) => {
queryClient.setQueryData(wslServersQueryKey, event.state)
})
onCleanup(off)
})
return query
},
})
+19 -1
View File
@@ -2,6 +2,24 @@ export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
export { useWslServers } from "./context/wsl-servers"
export {
type DisplayBackend,
type FatalRendererErrorLog,
type Platform,
PlatformProvider,
type WslDistroProbe,
type WslInstalledDistro,
type WslJob,
type WslOnlineDistro,
type WslOpencodeCheck,
type WslRuntimeCheck,
type WslServerConfig,
type WslServerItem,
type WslServerRuntime,
type WslServersEvent,
type WslServersPlatform,
type WslServersState,
} from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"
+3 -2
View File
@@ -701,6 +701,7 @@ function LegacyHome() {
if (healthy === false) return "bg-icon-critical-base"
return "bg-border-weak-base"
})
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
function openProject(directory: string) {
layout.projects.open(directory)
@@ -719,7 +720,7 @@ function LegacyHome() {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
@@ -740,7 +741,7 @@ function LegacyHome() {
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer />)}
onClick={() => dialog.show(() => <DialogSelectServer onNavigateHome={() => navigate("/")} />)}
>
<div
classList={{
+3 -2
View File
@@ -151,6 +151,7 @@ export default function Layout(props: ParentProps) {
}
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => route().dir)
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
const [state, setState] = createStore({
autoselect: !initialDirectory && !newDesign(),
@@ -1221,7 +1222,7 @@ export default function Layout(props: ParentProps) {
const run = ++dialogRun
void import("@/components/dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />)
dialog.show(() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />)
})
}
@@ -1474,7 +1475,7 @@ export default function Layout(props: ParentProps) {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createRefCountMap } from "./refcount"
import { pathKey } from "./path-key"
describe("createRefCountMap", () => {
test("removes an item after its last owner is disposed", () => {
const removed: string[] = []
const map = createRefCountMap(
(key) => key,
(key) => removed.push(key),
)
const first = createRoot((dispose) => {
map("/project")
return dispose
})
const second = createRoot((dispose) => {
map("/project")
return dispose
})
first()
expect(removed).toEqual([])
second()
expect(removed).toEqual(["/project"])
})
test("keeps equivalent path consumers until the last owner is disposed", () => {
const removed: string[] = []
const map = createRefCountMap(
(key) => key,
(key) => removed.push(key),
pathKey,
)
const first = createRoot((dispose) => {
map("C:\\repo")
return dispose
})
const second = createRoot((dispose) => {
map("C:/repo/")
return dispose
})
first()
expect(removed).toEqual([])
second()
expect(removed).toEqual(["C:/repo"])
})
})
+15 -9
View File
@@ -1,26 +1,32 @@
import { onCleanup } from "solid-js"
export function createRefCountMap<T>(create: (key: string) => T) {
export function createRefCountMap<T>(
create: (key: string) => T,
remove?: (key: string) => void,
identity: (key: string) => string = (key) => key,
) {
const items = new Map<string, T>()
const refCounts = new Map<string, number>()
return (key: string) => {
const id = identity(key)
onCleanup(() => {
refCounts.set(key, (refCounts.get(key) ?? 0) - 1)
if (refCounts.get(key) === 0) {
items.delete(key)
refCounts.delete(key)
refCounts.set(id, (refCounts.get(id) ?? 0) - 1)
if (refCounts.get(id) === 0) {
remove?.(id)
items.delete(id)
refCounts.delete(id)
}
})
const cached = items.get(key)
const cached = items.get(id)
if (cached) {
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
refCounts.set(id, (refCounts.get(id) ?? 0) + 1)
return cached
}
const item = create(key)
items.set(key, item)
refCounts.set(key, 1)
items.set(id, item)
refCounts.set(id, 1)
return item
}
}
+10 -14
View File
@@ -1,6 +1,6 @@
import { useDragDropContext } from "@thisbeyond/solid-dnd"
import type { Transformer } from "@thisbeyond/solid-dnd"
import { createRoot, onCleanup, type JSXElement } from "solid-js"
import type { JSXElement } from "solid-js"
type DragEvent = { draggable?: { id?: unknown } }
@@ -27,20 +27,16 @@ const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSX
if (!context) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer = createTransformer(transformerId, axis)
const dispose = createRoot((dispose) => {
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return dispose
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
onCleanup(dispose)
return null
}
+242
View File
@@ -0,0 +1,242 @@
export * as AuthWellKnown from "./auth-well-known"
import path from "path"
import { Context, Effect, Layer, Option, Schema, SynchronizedRef } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { AppFileSystem } from "./filesystem"
import { Global } from "./global"
import { Substitution } from "./substitution"
export class Entry extends Schema.Class<Entry>("AuthWellKnown.Entry")({
key: Schema.String,
token: Schema.String,
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AuthWellKnown.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export class RemoteConfigError extends Schema.TaggedErrorClass<RemoteConfigError>()("AuthWellKnown.RemoteConfigError", {
url: Schema.String,
status: Schema.Number.pipe(Schema.optional),
cause: Schema.Defect.pipe(Schema.optional),
}) {}
export type Error = FileWriteError | RemoteConfigError
const RemoteConfig = Schema.Struct({
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
})
export class Metadata extends Schema.Class<Metadata>("AuthWellKnown.Metadata")({
auth: Schema.Struct({
command: Schema.Array(Schema.String),
env: Schema.String,
}).pipe(Schema.optional),
config: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
remote_config: RemoteConfig.pipe(Schema.optional),
}) {}
export type ConfigDocument = {
url: string
source: string
dir: string
content: unknown
}
export interface Interface {
readonly all: () => Effect.Effect<Record<string, Entry>, Error>
readonly get: (url: string) => Effect.Effect<Entry | undefined, Error>
readonly set: (url: string, entry: Entry) => Effect.Effect<void, Error>
readonly remove: (url: string) => Effect.Effect<void, Error>
readonly metadata: (url: string) => Effect.Effect<Metadata, Error>
readonly configs: () => Effect.Effect<ConfigDocument[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AuthWellKnown") {}
const decodeMetadata = Schema.decodeUnknownEffect(Metadata)
const decodeRemoteConfig = Schema.decodeUnknownEffect(RemoteConfig)
function loadLegacyAuth(input: {
fsys: AppFileSystem.Interface
dataDir: string
write: (data: Record<string, Entry>) => Effect.Effect<void, Error>
}) {
return Effect.gen(function* () {
const decodeLegacy = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
const decodeLegacyCredential = Schema.decodeUnknownOption(
Schema.Struct({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}),
)
const legacy = Object.fromEntries(
Object.entries(
Option.getOrElse(
decodeLegacy(
yield* input.fsys.readJson(path.join(input.dataDir, "auth.json")).pipe(Effect.orElseSucceed(() => null)),
),
() => ({}),
),
).flatMap(([url, value]) => {
const decoded = Option.getOrUndefined(decodeLegacyCredential(value))
return decoded ? [[url.replace(/\/+$/, ""), new Entry({ key: decoded.key, token: decoded.token })]] : []
}),
)
if (Object.keys(legacy).length > 0) yield* input.write(legacy).pipe(Effect.ignore)
return legacy
})
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const substitution = yield* Substitution.Service
const file = path.join(global.data, "well-known.json")
const decodeEntries = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry))
const normalizeUrl = (url: string) => url.replace(/\/+$/, "")
const write = (operation: "migrate" | "write", data: Record<string, Entry>) =>
fsys.writeJson(file, data, 0o600).pipe(Effect.mapError((cause) => new FileWriteError({ operation, cause })))
const load: () => Effect.Effect<Record<string, Entry>> = Effect.fnUntraced(function* () {
const current = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (current && typeof current === "object")
return Option.getOrElse(decodeEntries(current), () => ({}) as Record<string, Entry>)
return yield* loadLegacyAuth({ fsys, dataDir: global.data, write: (data) => write("migrate", data) })
})
const state = SynchronizedRef.makeUnsafe<Record<string, Entry>>(yield* load())
const metadata = Effect.fn("AuthWellKnown.metadata")(function* (url: string) {
const normalized = normalizeUrl(url)
const source = `${normalized}/.well-known/opencode`
const response = yield* HttpClientRequest.get(source).pipe(
HttpClientRequest.acceptJson,
http.execute,
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
)
if (response.status < 200 || response.status >= 300) {
return yield* new RemoteConfigError({ url: source, status: response.status })
}
const metadata = yield* response.json.pipe(
Effect.flatMap(decodeMetadata),
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
)
return { url: normalized, source, dir: path.dirname(source), metadata }
})
const remote = Effect.fn("AuthWellKnown.remote")(function* (input: { url: string; headers?: Record<string, string> }) {
const response = yield* HttpClientRequest.get(input.url).pipe(
HttpClientRequest.acceptJson,
input.headers ? HttpClientRequest.setHeaders(input.headers) : (request) => request,
http.execute,
Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })),
)
if (response.status < 200 || response.status >= 300) {
return yield* new RemoteConfigError({ url: input.url, status: response.status })
}
return yield* response.json.pipe(Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })))
})
return Service.of({
all: Effect.fn("AuthWellKnown.all")(function* () {
return yield* SynchronizedRef.get(state)
}),
get: Effect.fn("AuthWellKnown.get")(function* (url) {
return (yield* SynchronizedRef.get(state))[normalizeUrl(url)]
}),
set: Effect.fn("AuthWellKnown.set")(function* (url, entry) {
yield* SynchronizedRef.updateEffect(
state,
Effect.fnUntraced(function* (data) {
const next = { ...data, [normalizeUrl(url)]: entry }
yield* write("write", next)
return next
}),
)
}),
remove: Effect.fn("AuthWellKnown.remove")(function* (url) {
yield* SynchronizedRef.updateEffect(
state,
Effect.fnUntraced(function* (data) {
const next = { ...data }
delete next[url]
delete next[normalizeUrl(url)]
yield* write("write", next)
return next
}),
)
}),
metadata: Effect.fn("AuthWellKnown.metadata.public")(function* (url) {
return (yield* metadata(url)).metadata
}),
configs: Effect.fn("AuthWellKnown.configs")(function* () {
const documents = yield* Effect.all(
Object.entries(yield* SynchronizedRef.get(state)).map(([url, entry]) =>
Effect.gen(function* () {
const configs: ConfigDocument[] = []
const response = yield* metadata(url)
const env = { [entry.key]: entry.token }
if (response.metadata.config) {
configs.push({
url: response.url,
source: response.source,
dir: response.dir,
content: response.metadata.config,
})
}
if (response.metadata.remote_config) {
const remoteConfig = yield* substitution
.substitute({
text: JSON.stringify(response.metadata.remote_config),
type: "virtual",
dir: response.url,
source: response.source,
env,
})
.pipe(
Effect.flatMap((text) =>
Effect.try({
try: () => JSON.parse(text) as unknown,
catch: (cause) => new RemoteConfigError({ url: response.source, cause }),
}),
),
Effect.flatMap(decodeRemoteConfig),
Effect.mapError((cause) => new RemoteConfigError({ url: response.source, cause })),
)
configs.push({
url: remoteConfig.url,
source: remoteConfig.url,
dir: path.dirname(remoteConfig.url),
content: yield* remote({ url: remoteConfig.url, headers: remoteConfig.headers }),
})
}
return configs
}),
),
{ concurrency: "unbounded" },
)
return documents.flat()
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Substitution.defaultLayer),
)
+94
View File
@@ -0,0 +1,94 @@
export * as Substitution from "./substitution"
import os from "os"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { AppFileSystem } from "./filesystem"
type Source =
| {
type: "path"
path: string
}
| {
type: "virtual"
source: string
dir: string
}
export type Input = Source & {
text: string
missing?: "error" | "empty"
env?: Record<string, string | undefined>
}
export class FileReferenceError extends Schema.TaggedErrorClass<FileReferenceError>()("Substitution.FileReferenceError", {
source: Schema.String,
token: Schema.String,
resolved: Schema.String,
cause: Schema.Defect,
}) {}
export type Error = FileReferenceError
export interface Interface {
readonly substitute: (input: Input) => Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Substitution") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return Service.of({
substitute: Effect.fn("Substitution.substitute")(function* (input) {
const missing = input.missing ?? "error"
const text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return input.env?.[varName] ?? process.env[varName] ?? ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
const configSource = input.type === "path" ? input.path : input.source
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
const reference = token.replace(/^\{file:/, "").replace(/\}$/, "")
const filepath = reference.startsWith("~/") ? path.join(os.homedir(), reference.slice(2)) : reference
const resolved = path.isAbsolute(filepath) ? filepath : path.resolve(configDir, filepath)
const content = yield* fs.readFileString(resolved).pipe(
Effect.catch((cause) => {
if (missing === "empty") return Effect.succeed("")
return Effect.fail(new FileReferenceError({ source: configSource, token, resolved, cause }))
}),
)
out += JSON.stringify(content.trim()).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Substitution } from "@opencode-ai/core/substitution"
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
const unexpectedHttpClient = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.url}`))
const withAuthWellKnown = <A, E, R>(
dir: string,
effect: Effect.Effect<A, E, R | AuthWellKnown.Service>,
client = unexpectedHttpClient,
) =>
effect.pipe(
Effect.provide(AuthWellKnown.layer),
Effect.provide(AppFileSystem.defaultLayer),
Effect.provide(Global.layerWith({ data: dir })),
Effect.provide(Layer.succeed(HttpClient.HttpClient, client)),
Effect.provide(Substitution.defaultLayer),
)
const wellKnownConfigClient = HttpClient.make((request) => {
if (request.url === "https://example.com/.well-known/opencode") {
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
config: { instructions: ["local"] },
remote_config: {
url: "https://remote.example.com/config",
headers: {
authorization: "Bearer {env:TEST_TOKEN}",
},
},
}),
),
)
}
if (request.url === "https://remote.example.com/config") {
expect(request.headers.authorization).toBe("Bearer secret")
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ model: "remote/model" })))
}
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 })))
})
describe("AuthWellKnown", () => {
it.live("stores well-known credentials", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* withAuthWellKnown(
tmp.path,
Effect.gen(function* () {
const auth = yield* AuthWellKnown.Service
yield* auth.set("https://example.com/", new AuthWellKnown.Entry({ key: "TEST_TOKEN", token: "secret" }))
}),
)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({
"https://example.com": {
key: "TEST_TOKEN",
token: "secret",
},
})
}),
)
it.live("migrates legacy well-known auth records", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, "auth.json"),
JSON.stringify({
"https://example.com": {
type: "wellknown",
key: "TEST_TOKEN",
token: "secret",
},
}),
),
)
const entry = yield* withAuthWellKnown(
tmp.path,
Effect.gen(function* () {
const auth = yield* AuthWellKnown.Service
return yield* auth.get("https://example.com/")
}),
)
expect(entry).toEqual({
key: "TEST_TOKEN",
token: "secret",
})
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({
"https://example.com": {
key: "TEST_TOKEN",
token: "secret",
},
})
}),
)
it.live("loads config documents", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, "well-known.json"),
JSON.stringify({
"https://example.com": {
key: "TEST_TOKEN",
token: "secret",
},
}),
),
)
const result = yield* withAuthWellKnown(
tmp.path,
Effect.gen(function* () {
const auth = yield* AuthWellKnown.Service
return yield* auth.configs()
}),
wellKnownConfigClient,
)
expect(result).toEqual([
{
url: "https://example.com",
source: "https://example.com/.well-known/opencode",
dir: "https://example.com/.well-known",
content: { instructions: ["local"] },
},
{
url: "https://remote.example.com/config",
source: "https://remote.example.com/config",
dir: "https://remote.example.com",
content: { model: "remote/model" },
},
])
}),
)
})
+35 -10
View File
@@ -1,7 +1,8 @@
import { execFile, execFileSync } from "node:child_process"
import { execFile } from "node:child_process"
import { access, readFile, readdir } from "node:fs/promises"
import { dirname, extname, join } from "node:path"
import util from "node:util"
import { resolveWslHome, runWslInDistro } from "./wsl"
const execFilePromise = util.promisify(execFile)
@@ -21,20 +22,44 @@ export function resolveAppPath(appName: string) {
return resolveWindowsAppPath(appName)
}
export function wslPath(path: string, mode: "windows" | "linux" | null): string {
// Parses `\\wsl$\<distro>\...` and `\\wsl.localhost\<distro>\...` UNC paths that
// point *into* a WSL distro's rootfs. `wslpath -u` cannot handle these reliably:
// backslashes get shell-collapsed when passed through `wsl.exe`, turning
// `\\wsl.localhost\Debian\home\luke` into `/mnt/c/wsl.localhostDebianhomeluke`,
// which is a valid-looking path that wedges opencode on DrvFs stat calls.
function parseWslUncPath(value: string): { distro: string; subpath: string } | null {
// Normalise separators; both `\\` and `//` prefixes mean UNC.
const normalised = value.replace(/\\/g, "/").replace(/^\/+/, "//")
const match = /^\/\/(wsl\$|wsl\.localhost)\/([^/]+)(?:\/(.*))?$/i.exec(normalised)
if (!match) return null
const distro = match[2]
const subpath = match[3] ?? ""
return { distro, subpath }
}
export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise<string> {
if (process.platform !== "win32") return path
// `\\wsl$\<distro>\...` / `\\wsl.localhost\<distro>\...` -> `/<subpath>` in
// the target distro. Do the conversion in-process rather than shelling out
// to `wslpath -u`, which mangles backslashes via wsl.exe's command-line
// joiner. If the requested distro differs from the UNC distro, we still
// translate literally — callers are responsible for only picking paths
// inside the active distro.
if (mode === "linux") {
const unc = parseWslUncPath(path)
if (unc) return `/${unc.subpath}`
}
const flag = mode === "windows" ? "-w" : "-u"
try {
if (path.startsWith("~")) {
const suffix = path.slice(1)
const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"`
const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd])
return output.toString().trim()
const resolved = path.startsWith("~") ? `${await resolveWslHome(distro)}${path.slice(1)}` : path
const input = mode === "linux" ? resolved.replace(/\\/g, "/") : resolved
const output = await runWslInDistro(["wslpath", flag, input], distro)
if (output.code !== 0) {
throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`)
}
const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
return output.toString().trim()
return output.stdout.trim()
} catch (error) {
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
}
+1
View File
@@ -6,6 +6,7 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers"
export const WSL_ENABLED_KEY = "wslEnabled"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
+54 -14
View File
@@ -9,9 +9,10 @@ import { getCACertificates, setDefaultCACertificates } from "node:tls"
import type { Event } from "electron"
import { app, BrowserWindow } from "electron"
import { Deferred, Effect, Fiber } from "effect"
import contextMenu from "electron-context-menu"
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
@@ -20,13 +21,13 @@ import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import {
getDefaultServerUrl,
getWslConfig,
preferAppEnv,
setDefaultServerUrl,
setWslConfig,
spawnLocalServer,
spawnWslSidecar,
type SidecarListener,
} from "./server"
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
import {
createLoadingWindow,
createMainWindow,
@@ -35,9 +36,8 @@ import {
setBackgroundColor,
setDockIcon,
} from "./windows"
import { createWslServersController } from "./wsl-servers"
import { migrate } from "./migrate"
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
import { Deferred, Effect, Fiber } from "effect"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
@@ -145,6 +145,30 @@ const main = Effect.gen(function* () {
logger = initLogging()
initCrashReporter()
const wslServers = createWslServersController(
app.getVersion(),
async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
{
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
)
const stopSidecars = async () => {
await killSidecar()
wslServers.stopAll()
}
const relaunch = () => {
void stopSidecars().finally(() => {
app.relaunch()
app.exit(0)
})
}
try {
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
} catch (error) {
@@ -190,11 +214,11 @@ const main = Effect.gen(function* () {
})
app.on("before-quit", () => {
void killSidecar()
void stopSidecars()
})
app.on("will-quit", () => {
void killSidecar()
void stopSidecars()
})
app.on("child-process-gone", (_event, details) => {
@@ -214,7 +238,7 @@ const main = Effect.gen(function* () {
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
void killSidecar().finally(() => app.exit(0))
void stopSidecars().finally(() => app.exit(0))
})
}
@@ -223,6 +247,7 @@ const main = Effect.gen(function* () {
registerIpcHandlers({
killSidecar: () => killSidecar(),
relaunch,
awaitInitialization: Effect.fnUntraced(
function* (sendStep) {
sendStep(initStep)
@@ -239,22 +264,33 @@ const main = Effect.gen(function* () {
},
(e) => Effect.runPromise(e),
),
getWslServersState: () => wslServers.getState(),
onWslServersEvent: (listener) => wslServers.subscribe(listener),
wslServersProbeRuntime: () => wslServers.probeRuntime(),
wslServersRefreshDistros: () => wslServers.refreshDistros(),
wslServersInstallWsl: () => wslServers.installWsl(),
wslServersInstallDistro: (name) => wslServers.installDistro(name),
wslServersProbeDistro: (name) => wslServers.probeDistro(name),
wslServersProbeOpencode: (name) => wslServers.probeOpencode(name),
wslServersInstallOpencode: (name) => wslServers.installOpencode(name),
wslServersOpenTerminal: (name) => wslServers.openTerminal(name),
wslServersAddServer: (distro) => wslServers.addServer(distro),
wslServersRemoveServer: (id) => wslServers.removeServer(id),
wslServersStartServer: (id) => wslServers.startServer(id),
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
getWslConfig: () => Promise.resolve(getWslConfig()),
setWslConfig: (config: WslConfig) => setWslConfig(config),
getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown),
checkAppExists: (appName) => checkAppExists(appName),
wslPath: async (path, mode) => wslPath(path, mode),
wslPath: async (path, mode, distro) => wslPath(path, mode, distro),
resolveAppPath: async (appName) => resolveAppPath(appName),
loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void),
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar),
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, stopSidecars),
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(killSidecar),
installUpdate: async () => installUpdate(stopSidecars),
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
@@ -341,6 +377,10 @@ const main = Effect.gen(function* () {
password,
})
void wslServers
.initialize({ defaultServer: getDefaultServerUrl() })
.catch((error) => logger.error("wsl server initialization failed", error))
yield* Effect.promise(() => health.wait).pipe(
Effect.timeout("30 seconds"),
Effect.catch((e) =>
@@ -379,7 +419,7 @@ const main = Effect.gen(function* () {
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => {
void checkForUpdates(true, killSidecar)
void checkForUpdates(true, stopSidecars)
},
relaunch: () => {
void killSidecar().finally(() => {
+85 -10
View File
@@ -10,7 +10,9 @@ import type {
SqliteMigrationProgress,
TitlebarTheme,
WindowConfig,
WslConfig,
WslServerConfig,
WslServersEvent,
WslServersState,
} from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { getStore } from "./store"
@@ -23,18 +25,30 @@ const pickerFilters = (ext?: string[]) => {
type Deps = {
killSidecar: () => Promise<void> | void
relaunch: () => void
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getWslServersState: () => Promise<WslServersState> | WslServersState
onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void
wslServersProbeRuntime: () => Promise<void> | void
wslServersRefreshDistros: () => Promise<void> | void
wslServersInstallWsl: () => Promise<void> | void
wslServersInstallDistro: (name: string) => Promise<void> | void
wslServersProbeDistro: (name: string) => Promise<void> | void
wslServersProbeOpencode: (name: string) => Promise<void> | void
wslServersInstallOpencode: (name: string) => Promise<void> | void
wslServersOpenTerminal: (name: string) => Promise<void> | void
wslServersAddServer: (distro: string) => Promise<WslServerConfig> | WslServerConfig
wslServersRemoveServer: (id: string) => Promise<void> | void
wslServersStartServer: (id: string) => Promise<void> | void
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
getWslConfig: () => Promise<WslConfig>
setWslConfig: (config: WslConfig) => Promise<void> | void
getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | string
checkAppExists: (appName: string) => Promise<boolean> | boolean
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
resolveAppPath: (appName: string) => Promise<string | null>
loadingWindowComplete: () => void
runUpdater: (alertOnFail: boolean) => Promise<void> | void
@@ -46,27 +60,89 @@ type Deps = {
}
export function registerIpcHandlers(deps: Deps) {
const requireString = (name: string, value: unknown) => {
if (typeof value === "string" && value.length > 0) return value
throw new Error(`Invalid ${name}`)
}
const wslSubscriptions = new Map<number, () => void>()
const unsubscribeWsl = (id: number) => {
const off = wslSubscriptions.get(id)
if (!off) return
off()
wslSubscriptions.delete(id)
}
app.once("will-quit", () => {
for (const off of wslSubscriptions.values()) off()
wslSubscriptions.clear()
})
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)
})
ipcMain.handle("wsl-servers-subscribe", (event) => {
const id = event.sender.id
if (wslSubscriptions.has(id)) return
wslSubscriptions.set(
id,
deps.onWslServersEvent((payload) => {
if (event.sender.isDestroyed()) {
unsubscribeWsl(id)
return
}
event.sender.send("wsl-servers-event", payload)
}),
)
event.sender.once("destroyed", () => unsubscribeWsl(id))
})
ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribeWsl(event.sender.id))
ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState())
ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime())
ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros())
ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl())
ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersInstallDistro(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersProbeDistro(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersProbeOpencode(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersInstallOpencode(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
deps.wslServersOpenTerminal(requireString("distro", name)),
)
ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) =>
deps.wslServersAddServer(requireString("distro", distro)),
)
ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) =>
deps.wslServersRemoveServer(requireString("server id", id)),
)
ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) =>
deps.wslServersStartServer(requireString("server id", id)),
)
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
deps.setDefaultServerUrl(url),
)
ipcMain.handle("get-wsl-config", () => deps.getWslConfig())
ipcMain.handle("set-wsl-config", (_event: IpcMainInvokeEvent, config: WslConfig) => deps.setWslConfig(config))
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend),
)
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
ipcMain.handle("wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null) =>
deps.wslPath(path, mode),
ipcMain.handle(
"wsl-path",
(_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null, distro?: string | null) =>
deps.wslPath(path, mode, distro),
)
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
@@ -191,8 +267,7 @@ export function registerIpcHandlers(deps: Deps) {
})
ipcMain.on("relaunch", () => {
app.relaunch()
app.exit(0)
deps.relaunch()
})
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
+173 -13
View File
@@ -1,13 +1,15 @@
import { spawn } from "node:child_process"
import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { app, utilityProcess } from "electron"
import type { Details } from "electron"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import type { SqliteMigrationProgress } from "../preload/types"
import { DEFAULT_SERVER_URL_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import type { SqliteMigrationProgress } from "../preload/types"
export type WslConfig = { enabled: boolean }
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./wsl"
export type HealthCheck = { wait: Promise<void> }
@@ -46,15 +48,6 @@ export function setDefaultServerUrl(url: string | null) {
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export function getWslConfig(): WslConfig {
const value = getStore().get(WSL_ENABLED_KEY)
return { enabled: typeof value === "boolean" ? value : false }
}
export function setWslConfig(config: WslConfig) {
getStore().set(WSL_ENABLED_KEY, config.enabled)
}
export function preferAppEnv(userDataPath: string) {
const shell = process.platform === "win32" ? null : getUserShell()
Object.assign(process.env, {
@@ -201,6 +194,133 @@ export async function spawnLocalServer(
}
}
export type WslSidecar = {
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
url: string
username: string | null
password: string
}
export async function spawnWslSidecar(
distro: string,
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
): Promise<WslSidecar> {
// Do not pass --user here: the sidecar should inherit the distro's
// default user so config, auth, git, ssh, and file ownership match the
// user's normal WSL environment. If that default user is root, WSL will
// choose root itself.
const opencode = await resolveWslOpencode(distro)
if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
const port = await allocatePort()
const password = randomUUID()
const username = "opencode"
const logLevel = app.isPackaged ? "WARN" : "INFO"
const script = [
"set -euo pipefail",
// wsl.exe inherits the Windows-side cwd (e.g. C:\Users\Lukem) and maps it
// to the distro as /mnt/c/Users/Lukem — a DrvFs/9p path. opencode's
// instance middleware falls back to `process.cwd()` when a request
// arrives without a `directory=` query or `x-opencode-directory` header
// (see opencode server.ts InstanceMiddleware), and then calls
// `realpathSync(process.cwd())` synchronously on the main thread. A
// statx against a 9p path can wedge the whole event loop in kernel
// uninterruptible sleep, freezing the accept loop. Move cwd to the
// user's native Linux home so the fallback can't land on DrvFs.
'cd "$HOME" || cd /',
// wsl.exe by default splices the Windows %PATH% into the distro's $PATH
// via the interop layer (every `/mnt/c/Program Files/...` entry). Anything
// the sidecar spawns — PTY login shells, plugin helpers, etc. — then
// inherits it, which means `which pwsh.exe` resolves to the Windows
// PowerShell binary and bash-l profiles that end with
// eval "$(oh-my-posh init bash)" (or similar)
// silently run Windows pwsh for prompt rendering, whose banner
// ("Loading personal and system profiles took Xms.") then shows up in
// opencode's terminal pane. We want a clean, Linux-only environment in
// the sidecar, so filter every /mnt/* segment out of PATH and clear
// WSLENV so no further Windows vars leak in. Users who really need
// Windows binaries in the sidecar can invoke them by absolute path.
'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
"export PATH",
"export WSLENV=",
// WSL sidecars often target /mnt/* worktrees. Keep the desktop-only
// watcher/discovery features off there because DrvFs/9p stalls can wedge
// the server process after it starts listening.
"export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true",
"export OPENCODE_CLIENT=desktop",
`export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
'export XDG_STATE_HOME="$HOME/.local/state"',
`exec ${shellEscape(opencode)} --print-logs --log-level ${logLevel} serve --hostname 0.0.0.0 --port ${port}`,
].join("\n")
const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
})
child.stdin.end(script)
let settled = false
const recentOutput: string[] = []
const emit = (line: WslCommandLine) => {
if (settled || !line.text.trim()) return
recentOutput.push(`[${line.stream}] ${line.text}`)
if (recentOutput.length > 12) recentOutput.shift()
opts.onLine?.(line)
}
forwardLines(child.stdout, "stdout", emit)
forwardLines(child.stderr, "stderr", emit)
const exit = new Promise<never>((_, reject) => {
child.once("error", reject)
child.once("exit", (code, signal) => {
reject(new Error(startupFailure(code, signal, recentOutput)))
})
})
const url = `http://127.0.0.1:${port}`
const healthPromise = (async () => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (await checkHealth(url, password)) return
}
})()
const timeoutMs = opts.healthTimeoutMs ?? 30_000
const timeout = new Promise<never>((_, reject) => {
const id = setTimeout(
() => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)),
timeoutMs,
)
void healthPromise.finally(() => clearTimeout(id))
})
try {
await Promise.race([healthPromise, exit, timeout])
} catch (error) {
child.kill()
throw error
} finally {
settled = true
}
return {
listener: {
stop() {
child.kill()
},
onExit(cb) {
child.once("exit", cb)
},
},
url,
username,
password,
}
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
let healthUrl: URL
try {
@@ -227,6 +347,46 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
}
function allocatePort() {
return new Promise<number>((resolve, reject) => {
const server = createServer()
server.on("error", reject)
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (typeof address !== "object" || !address) {
server.close()
reject(new Error("Failed to get port"))
return
}
const port = address.port
server.close(() => resolve(port))
})
})
}
function forwardLines(
stream: NodeJS.ReadableStream,
source: WslCommandLine["stream"],
onLine: (line: WslCommandLine) => void,
) {
let pending = ""
stream.setEncoding("utf8")
stream.on("data", (chunk: string) => {
pending += chunk
const lines = pending.split(/\r?\n/g)
pending = lines.pop() ?? ""
for (const line of lines) onLine({ stream: source, text: line })
})
stream.on("end", () => {
if (pending) onLine({ stream: source, text: pending })
})
}
function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
}
function createSidecarEnv(): Record<string, string> {
const env = Object.fromEntries(
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
+447
View File
@@ -0,0 +1,447 @@
import type {
WslDistroProbe,
WslInstalledDistro,
WslJob,
WslOnlineDistro,
WslOpencodeCheck,
WslRuntimeCheck,
WslServerConfig,
WslServerItem,
WslServerRuntime,
WslServersEvent,
WslServersState,
} from "../preload/types"
import { WSL_SERVERS_KEY } from "./constants"
import { getStore } from "./store"
import {
installWslDistro,
installWslOpencode,
installWslRuntimeElevated,
listInstalledWslDistros,
listOnlineWslDistros,
openWslTerminal,
probeWslDistro,
probeWslRuntime,
readWslCommandVersion,
resolveWslOpencode,
summarize,
upgradeWslOpencode,
wslNeedsRestart,
} from "./wsl"
type RunningSidecar = {
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
url: string
username: string | null
password: string
}
type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
type ControllerLogger = {
log: (message: string, meta?: unknown) => void
error: (message: string, meta?: unknown) => void
}
export type WslServersController = ReturnType<typeof createWslServersController>
export function wslServerIdForDistro(distro: string) {
return `wsl:${distro}`
}
export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
let state: WslServersState = initialState()
const listeners = new Set<(event: WslServersEvent) => void>()
const sidecars = new Map<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
}
const setState = (next: Partial<WslServersState>) => {
state = { ...state, ...next }
emit()
}
const persistServers = (servers: WslServerConfig[]) => {
getStore().set(WSL_SERVERS_KEY, { servers })
}
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
setState({ servers: next })
}
const beginJob = (job: WslJob): AbortController => {
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
setState({ job })
return abort
}
const endJob = (abort: AbortController) => {
if (jobAbort !== abort) return
jobAbort = undefined
setState({ job: null })
}
const refreshFromStore = () => {
const persisted = readPersistedServers()
const items: WslServerItem[] = persisted.map((config) => {
const existing = state.servers.find((item) => item.config.id === config.id)
return {
config,
runtime: existing?.runtime ?? { kind: "stopped" },
}
})
setState({ servers: items })
}
const setRuntime = (id: string, runtime: WslServerRuntime) => {
updateServer(id, (item) => ({ ...item, runtime }))
}
const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
setState({
opencodeChecks: {
...state.opencodeChecks,
[distro]: check,
},
})
}
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
const resolved = await resolveWslOpencode(distro, opts)
const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
}
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
const [installed, online] = await Promise.all([
listInstalledWslDistros(opts),
listOnlineWslDistros(opts),
])
return { installed, online }
}
const nextStartAttempt = (id: string) => {
const next = (startAttempts.get(id) ?? 0) + 1
startAttempts.set(id, next)
return next
}
const invalidateStartAttempt = (id: string) => {
startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
}
const isCurrentStartAttempt = (id: string, attempt: number) => {
return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
}
const startServer = async (id: string) => {
const item = state.servers.find((x) => x.config.id === id)
if (!item) return
const attempt = nextStartAttempt(id)
await stopServerInternal(id)
if (!isCurrentStartAttempt(id, attempt)) return
setRuntime(id, { kind: "starting" })
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
try {
const sidecar = await spawnSidecar(item.config.distro)
if (!isCurrentStartAttempt(id, attempt)) {
try {
sidecar.listener.stop()
} catch {
// ignore stop errors for stale sidecars
}
return
}
sidecars.set(id, sidecar)
setRuntime(id, {
kind: "ready",
url: sidecar.url,
username: sidecar.username,
password: sidecar.password,
})
sidecar.listener.onExit((code, signal) => {
if (sidecars.get(id) !== sidecar) return
sidecars.delete(id)
const message = startupFailure(code, signal)
setRuntime(id, { kind: "failed", message })
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
})
void refreshOpencodeCheck(item.config.distro).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
})
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!isCurrentStartAttempt(id, attempt)) return
setRuntime(id, { kind: "failed", message })
// Without this, an Ubuntu-style silent failure leaves no trace in
// main.log — the controller captures the message in its state but
// nothing surfaces unless the user opens the WSL servers dialog.
logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
}
}
const stopServerInternal = async (id: string) => {
const existing = sidecars.get(id)
if (!existing) return
sidecars.delete(id)
try {
existing.listener.stop()
} catch {
// ignore stop errors
}
}
const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
const abort = beginJob(job)
try {
const value = await runner(abort)
endJob(abort)
return value
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
endJob(abort)
return undefined
}
const err = error instanceof Error ? error : new Error(String(error))
endJob(abort)
throw err
}
}
return {
getState() {
return state
},
subscribe(listener: (event: WslServersEvent) => void) {
listeners.add(listener)
return () => listeners.delete(listener)
},
async initialize(opts?: { defaultServer?: string | null }) {
refreshFromStore()
if (opts?.defaultServer?.startsWith("wsl:")) void startServer(opts.defaultServer)
},
async probeRuntime() {
await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
const runtime = await probeWslRuntime({ signal: abort.signal })
setState({
runtime,
pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
})
})
},
async refreshDistros() {
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
setState(await refreshDistroLists({ signal: abort.signal }))
})
},
async installWsl() {
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
const result = await installWslRuntimeElevated({ signal: abort.signal })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
throw new Error(message)
}
const pendingRestart = wslNeedsRestart(result)
setState({ pendingRestart })
if (!pendingRestart) {
const runtime = await probeWslRuntime({ signal: abort.signal })
setState({ runtime })
}
})
},
async installDistro(name: string) {
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
const result = await installWslDistro(name, { signal: abort.signal })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
throw new Error(message)
}
const distros = await refreshDistroLists({ signal: abort.signal })
const probe = await probeWslDistro(name, { signal: abort.signal })
setState({
...distros,
distroProbes: { ...state.distroProbes, [name]: probe },
})
})
},
async probeDistro(name: string) {
await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
const probe = await probeWslDistro(name, { signal: abort.signal })
setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
})
},
async probeOpencode(name: string) {
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
await refreshOpencodeCheck(name, { signal: abort.signal })
})
},
async installOpencode(name: string) {
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
const resolved = await resolveWslOpencode(name, { signal: abort.signal })
const existingVersion = resolved
? await readWslCommandVersion(resolved, name, { signal: abort.signal })
: null
const result =
resolved && existingVersion
? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal })
: await installWslOpencode(appVersion, name, { signal: abort.signal })
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
}
await refreshOpencodeCheck(name, { signal: abort.signal })
})
},
async openTerminal(name: string) {
await openWslTerminal(name)
},
async addServer(distro: string): Promise<WslServerConfig> {
const id = wslServerIdForDistro(distro)
if (state.servers.some((item) => item.config.id === id)) {
throw new Error(`${distro} is already added`)
}
const config: WslServerConfig = {
id,
distro,
}
persistServers([...readPersistedServers(), config])
setState({
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
})
void startServer(id)
return config
},
async removeServer(id: string) {
invalidateStartAttempt(id)
await stopServerInternal(id)
const remaining = readPersistedServers().filter((item) => item.id !== id)
persistServers(remaining)
setState({ servers: state.servers.filter((item) => item.config.id !== id) })
},
startServer,
stopAll() {
for (const item of state.servers) invalidateStartAttempt(item.config.id)
for (const existing of sidecars.values()) {
try {
existing.listener.stop()
} catch {
// ignore
}
}
sidecars.clear()
},
}
}
function initialState(): WslServersState {
return {
runtime: null,
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
servers: [],
job: null,
}
}
function readPersistedServers(): WslServerConfig[] {
const store = getStore()
const existing = store.get(WSL_SERVERS_KEY)
if (existing && typeof existing === "object") {
const record = existing as { servers?: unknown }
const list = Array.isArray(record.servers) ? record.servers : []
return list.flatMap(normalizePersistedServer)
}
return []
}
function normalizePersistedServer(value: unknown): WslServerConfig[] {
if (!value || typeof value !== "object") return []
const record = value as Record<string, unknown>
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
if (!distro) return []
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
return [
{
id,
distro,
},
]
}
function opencodeCheck(
distro: string,
resolvedPath: string | null,
version: string | null,
expectedVersion: string,
): WslOpencodeCheck {
if (!resolvedPath) {
return {
distro,
resolvedPath: null,
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is not installed in this distro",
}
}
if (!version) {
return {
distro,
resolvedPath,
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is installed but could not run",
}
}
return {
distro,
resolvedPath,
version,
expectedVersion,
matchesDesktop: version === expectedVersion,
error: null,
}
}
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
}
// Re-export types used by callers
export type {
WslInstalledDistro,
WslOnlineDistro,
WslRuntimeCheck,
WslDistroProbe,
WslOpencodeCheck,
WslServerConfig,
WslServerItem,
WslServerRuntime,
WslServersEvent,
WslServersState,
}
+422
View File
@@ -0,0 +1,422 @@
import { spawn } from "node:child_process"
import { existsSync } from "node:fs"
import { join } from "node:path"
/** @ts-expect-error */
import * as pty from "@lydell/node-pty"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types"
export type WslCommandLine = {
stream: "stdout" | "stderr"
text: string
}
export type WslCommandResult = {
code: number | null
signal: NodeJS.Signals | null
stdout: string
stderr: string
}
export type RunWslOptions = {
signal?: AbortSignal
/**
* Ceiling on how long we wait for the child process to exit. When the
* LXSS service or a specific distro wedges (e.g. Ubuntu-24.04 with a
* pending first-run prompt), `wsl.exe` never returns and any command
* that doesn't specify a timeout hangs the entire startup flow. Default
* is 20s — enough for slow cold-starts, short enough to fail fast on
* a wedge. Callers can override for longer-running jobs.
*/
timeoutMs?: number
}
const DEFAULT_WSL_TIMEOUT_MS = 20_000
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
}
export function runWsl(args: string[], opts: RunWslOptions = {}) {
return runCommand("wsl", args, opts)
}
function runPowerShell(command: string, opts: RunWslOptions = {}) {
return runCommand(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command],
opts,
)
}
function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
return new Promise<WslCommandResult>((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
signal: opts.signal,
})
// Guard every wsl.exe invocation with a timeout. When the distro or
// the LXSS service is wedged (Ubuntu first-run state, Windows update
// pending, etc.) wsl.exe produces no output and never exits; without
// this the whole sidecar spawn flow stalls the app forever.
const timeoutMs = opts.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS
const timeoutId = setTimeout(() => {
try {
child.kill()
} catch {
/* ignore */
}
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
}, timeoutMs)
let stdout = ""
let stderr = ""
const stdoutDecoder = createOutputDecoder()
const stderrDecoder = createOutputDecoder()
const append = (stream: WslCommandLine["stream"], chunk: string) => {
if (!chunk) return
if (stream === "stdout") {
stdout += chunk
return
}
stderr += chunk
}
child.stdout.on("data", (chunk: Buffer) => {
append("stdout", stdoutDecoder.decode(chunk))
})
child.stdout.on("end", () => {
append("stdout", stdoutDecoder.flush())
})
child.stderr.on("data", (chunk: Buffer) => {
append("stderr", stderrDecoder.decode(chunk))
})
child.stderr.on("end", () => {
append("stderr", stderrDecoder.flush())
})
child.once("error", (error) => {
clearTimeout(timeoutId)
reject(error)
})
child.once("close", (code, signal) => {
clearTimeout(timeoutId)
resolve({ code, signal, stdout, stderr })
})
})
}
function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
return new Promise<WslCommandResult>((resolve, reject) => {
const child = pty.spawn(command, args, {
name: "xterm-color",
cols: 80,
rows: 24,
cwd: process.cwd(),
env: process.env,
useConpty: true,
})
let settled = false
let stdout = ""
const cleanup = () => {
clearTimeout(timeoutId)
abortCleanup?.()
}
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs
const timeoutId = setTimeout(() => {
try {
child.kill()
} catch {
/* ignore */
}
if (settled) return
settled = true
cleanup()
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
}, timeoutMs)
const abortHandler = () => {
try {
child.kill()
} catch {
/* ignore */
}
if (settled) return
settled = true
cleanup()
reject(new DOMException("Aborted", "AbortError"))
}
const abortCleanup = opts.signal
? (() => {
opts.signal?.addEventListener("abort", abortHandler, { once: true })
return () => opts.signal?.removeEventListener("abort", abortHandler)
})()
: undefined
child.onData((data: string) => {
stdout += data
})
child.onExit((event: { exitCode: number }) => {
if (settled) return
settled = true
cleanup()
resolve({ code: event.exitCode, signal: null, stdout, stderr: "" })
})
})
}
function createOutputDecoder() {
let decoder: TextDecoder | undefined
return {
decode(chunk: Buffer) {
decoder ??= new TextDecoder(detectOutputEncoding(chunk))
return decoder.decode(chunk, { stream: true })
},
flush() {
return decoder?.decode() ?? ""
},
}
}
function detectOutputEncoding(chunk: Uint8Array) {
if (chunk[0] === 0xff && chunk[1] === 0xfe) return "utf-16le"
const pairs = Math.floor(chunk.length / 2)
if (pairs < 2) return "utf-8"
const oddZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2 + 1] === 0).length
const evenZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2] === 0).length
return oddZeroes >= Math.ceil(pairs / 3) && evenZeroes * 2 <= oddZeroes ? "utf-16le" : "utf-8"
}
export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) {
return runWsl(wslArgs(args, distro), opts)
}
export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) {
return runWslInDistro(["sh", "-lc", script], distro, opts)
}
export async function probeWslRuntime(opts?: RunWslOptions): Promise<WslRuntimeCheck> {
const version = await runWsl(["--version"], opts).catch((error) => ({
code: 1,
signal: null,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
}))
if (version.code !== 0) {
return {
available: false,
version: null,
error: summarize(version.stderr || version.stdout) || "WSL is unavailable",
}
}
return {
available: true,
version: firstLine(version.stdout),
error: null,
}
}
export async function listInstalledWslDistros(opts?: RunWslOptions) {
const result = await runWsl(["--list", "--verbose"], opts)
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros")
}
return parseInstalledDistros(result.stdout)
}
export async function listOnlineWslDistros(opts?: RunWslOptions) {
const result = await runWsl(["--list", "--online"], opts)
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros")
}
return parseOnlineDistros(result.stdout)
}
export async function installWslRuntimeElevated(opts?: RunWslOptions) {
const script = [
"$ErrorActionPreference = 'Stop'",
"$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
"if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
].join("; ")
return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
}
export async function installWslDistro(name: string, opts?: RunWslOptions) {
return runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
["--install", "-d", name, "--web-download", "--no-launch"],
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
}
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
return runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
wslArgs(["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`], distro),
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
}
export function wslNeedsRestart(result: WslCommandResult) {
return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`)
}
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
code: 1,
signal: null,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
}))
if (executable.code !== 0) {
return {
name,
canExecute: false,
hasBash: false,
hasCurl: false,
error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro",
}
}
const [bash, curl] = await Promise.all([
runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts),
runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts),
])
return {
name,
canExecute: true,
hasBash: bash.code === 0 && summarize(bash.stdout) === "yes",
hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes",
error: null,
}
}
export async function resolveWslHome(distro?: string | null, opts?: RunWslOptions) {
return firstLine((await runWslSh('printf "%s\\n" "$HOME"', distro, opts)).stdout) ?? "/"
}
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
const command = firstLine((await runWslSh("command -v opencode 2>/dev/null | grep -v '^/mnt/' | head -n 1 || true", distro, opts)).stdout)
if (command) return command
for (const candidate of [
'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi',
'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi',
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
'if [ -x "/usr/local/bin/opencode" ]; then printf "%s\\n" "/usr/local/bin/opencode"; fi',
]) {
const resolved = firstLine((await runWslSh(candidate, distro, opts)).stdout)
if (resolved) return resolved
}
return null
}
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
return firstLine(result.stdout)
}
export async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) {
return runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
wslArgs(["bash", "-lc", `${shellEscape(command)} upgrade ${shellEscape(target)}`], distro, "root"),
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
}
export function openWslTerminal(distro?: string | null) {
if (distro && !/^[a-zA-Z0-9_.-]+$/.test(distro)) {
return Promise.reject(new Error("Invalid distro name"))
}
return new Promise<void>((resolve, reject) => {
const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], {
detached: true,
stdio: "ignore",
windowsHide: true,
})
child.once("error", reject)
child.once("spawn", () => {
child.unref()
resolve()
})
})
}
function parseInstalledDistros(output: string) {
return output.split(/\r?\n/g).flatMap((line) => {
const trimmed = line.trim()
if (!trimmed) return []
const match = line.match(/^\s*(\*)?\s*(.*?)\s{2,}\S+\s+(\d+)\s*$/)
if (!match) return []
const [, marker, name, version] = match
if (!name || /^name$/i.test(name)) return []
return [
{
name: name.trim(),
version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10),
isDefault: marker === "*",
} satisfies WslInstalledDistro,
]
})
}
function parseOnlineDistros(output: string) {
return output.split(/\r?\n/g).flatMap((line) => {
const trimmed = line.trim()
if (!trimmed) return []
const match = trimmed.match(/^([A-Za-z0-9._-]+)\s{2,}(.+)$/)
if (!match) return []
const [, name, label] = match
if (/^name$/i.test(name)) return []
return [{ name, label: label.trim() } satisfies WslOnlineDistro]
})
}
function firstLine(value: string) {
return (
value
.split(/\r?\n/g)
.map((line) => line.trim())
.find(Boolean) ?? null
)
}
export function summarize(value: string) {
return value
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean)
.join("\n")
}
export function shellEscape(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}
function resolveSystem32Command(command: string) {
const root = process.env.SystemRoot ?? process.env.windir
if (!root) return command
const resolved = join(root, "System32", command)
return existsSync(resolved) ? resolved : command
}
function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions {
return {
...opts,
timeoutMs: opts?.timeoutMs ?? timeoutMs,
}
}
+25 -4
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types"
import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types"
const api: ElectronAPI = {
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
@@ -11,17 +11,38 @@ const api: ElectronAPI = {
ipcRenderer.removeListener("init-step", handler)
})
},
wslServers: {
getState: () => ipcRenderer.invoke("wsl-servers-get-state"),
subscribe: (cb) => {
const handler = (_: unknown, event: WslServersEvent) => cb(event)
ipcRenderer.on("wsl-servers-event", handler)
void ipcRenderer.invoke("wsl-servers-subscribe")
return () => {
ipcRenderer.removeListener("wsl-servers-event", handler)
void ipcRenderer.invoke("wsl-servers-unsubscribe")
}
},
probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"),
refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),
installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"),
installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name),
probeDistro: (name) => ipcRenderer.invoke("wsl-servers-probe-distro", name),
probeOpencode: (name) => ipcRenderer.invoke("wsl-servers-probe-opencode", name),
installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name),
openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name),
addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro),
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
},
getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
setWslConfig: (config) => ipcRenderer.invoke("set-wsl-config", config),
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
wslPath: (path, mode) => ipcRenderer.invoke("wsl-path", path, mode),
wslPath: (path, mode, distro) => ipcRenderer.invoke("wsl-path", path, mode, distro),
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),
+83 -4
View File
@@ -10,7 +10,87 @@ export type ServerReadyData = {
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type WslConfig = { enabled: boolean }
export type WslRuntimeCheck = {
available: boolean
version: string | null
error: string | null
}
export type WslInstalledDistro = {
name: string
version: number | null
isDefault: boolean
}
export type WslOnlineDistro = {
name: string
label: string
}
export type WslDistroProbe = {
name: string
canExecute: boolean
hasBash: boolean
hasCurl: boolean
error: string | null
}
export type WslOpencodeCheck = {
distro: string
resolvedPath: string | null
version: string | null
expectedVersion: string | null
matchesDesktop: boolean | null
error: string | null
}
export type WslServerConfig = {
id: string
distro: string
}
export type WslServerRuntime =
| { kind: "starting" }
| { kind: "ready"; url: string; username: string | null; password: string | null }
| { kind: "failed"; message: string }
| { kind: "stopped" }
export type WslServerItem = {
config: WslServerConfig
runtime: WslServerRuntime
}
export type WslJob =
| { kind: "runtime"; startedAt: number }
| { kind: "distros"; startedAt: number }
| { kind: "install-wsl"; startedAt: number }
| { kind: "install-distro"; distro: string; startedAt: number }
| { kind: "probe-distro"; distro: string; startedAt: number }
| { kind: "probe-opencode"; distro: string; startedAt: number }
| { kind: "install-opencode"; distro: string; startedAt: number }
export type WslServersState = {
runtime: WslRuntimeCheck | null
installed: WslInstalledDistro[]
online: WslOnlineDistro[]
distroProbes: Record<string, WslDistroProbe>
opencodeChecks: Record<string, WslOpencodeCheck>
pendingRestart: boolean
servers: WslServerItem[]
job: WslJob | null
}
export type WslServersEvent = { type: "state"; state: WslServersState }
export type WslServersAPI = {
getState: () => Promise<WslServersState>
subscribe: (cb: (event: WslServersEvent) => void) => () => void
probeRuntime: () => Promise<void>
refreshDistros: () => Promise<void>
installWsl: () => Promise<void>
installDistro: (name: string) => Promise<void>
probeDistro: (name: string) => Promise<void>
probeOpencode: (name: string) => Promise<void>
installOpencode: (name: string) => Promise<void>
openTerminal: (name: string) => Promise<void>
addServer: (distro: string) => Promise<WslServerConfig>
removeServer: (id: string) => Promise<void>
startServer: (id: string) => Promise<void>
}
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
@@ -32,17 +112,16 @@ export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
wslServers: WslServersAPI
getWindowConfig: () => Promise<WindowConfig>
consumeInitialDeepLinks: () => Promise<string[]>
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
getWslConfig: () => Promise<WslConfig>
setWslConfig: (config: WslConfig) => Promise<void>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
parseMarkdownCommand: (markdown: string) => Promise<string>
checkAppExists: (appName: string) => Promise<boolean>
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
resolveAppPath: (appName: string) => Promise<string | null>
storeGet: (name: string, key: string) => Promise<string | null>
storeSet: (name: string, key: string, value: string) => Promise<void>
+1
View File
@@ -5,6 +5,7 @@ declare global {
api: ElectronAPI
__OPENCODE__?: {
deepLinks?: string[]
activeServer?: string
}
}
}
+92 -71
View File
@@ -13,16 +13,18 @@ import {
PlatformProvider,
ServerConnection,
useCommand,
useWslServers,
} from "@opencode-ai/app"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createResource, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import "./styles.css"
import { Splash } from "@opencode-ai/ui/logo"
import { useTheme } from "@opencode-ai/ui/theme"
const root = document.getElementById("root")
@@ -79,25 +81,26 @@ const createPlatform = (): Platform => {
return undefined
})()
const isWslEnabled = async () => {
if (os !== "windows") return false
return window.api
.getWslConfig()
.then((config) => config.enabled)
.catch(() => false)
const activeWslDistro = () => {
const key = window.__OPENCODE__?.activeServer
if (!key || !key.startsWith("wsl:")) return undefined
return key.slice("wsl:".length)
}
const wslHome = async () => {
if (!(await isWslEnabled())) return undefined
return window.api.wslPath("~", "windows").catch(() => undefined)
const distro = activeWslDistro()
if (!distro) return undefined
return window.api.wslPath("~", "windows", distro)
}
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
if (!result || !(await isWslEnabled())) return result
const handleWslPicker = async <T extends string | string[] | null>(result: T): Promise<T> => {
const distro = activeWslDistro()
if (!result || !distro) return result
const convert = (path: string) => window.api.wslPath(path, "linux", distro)
if (Array.isArray(result)) {
return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any
return (await Promise.all(result.map(convert))) as T
}
return window.api.wslPath(result, "linux").catch(() => result) as any
return (await convert(result)) as T
}
const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
@@ -143,6 +146,8 @@ const createPlatform = (): Platform => {
}
})()
const wslServersApi = os === "windows" ? window.api.wslServers : undefined
return {
platform: "desktop",
os,
@@ -183,10 +188,8 @@ const createPlatform = (): Platform => {
if (os === "windows") {
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
const resolvedPath = await (async () => {
if (await isWslEnabled()) {
const converted = await window.api.wslPath(path, "windows").catch(() => null)
if (converted) return converted
}
const distro = activeWslDistro()
if (distro) return window.api.wslPath(path, "windows", distro)
return path
})()
return window.api.openPath(resolvedPath, resolvedApp ?? undefined)
@@ -241,16 +244,7 @@ const createPlatform = (): Platform => {
}
},
fetch: (input, init) => {
if (input instanceof Request) return fetch(input)
return fetch(input, init)
},
getWslEnabled: () => isWslEnabled(),
setWslEnabled: async (enabled) => {
await window.api.setWslConfig({ enabled })
},
fetch,
getDefaultServer: async () => {
const url = await window.api.getDefaultServerUrl().catch(() => null)
@@ -262,6 +256,8 @@ const createPlatform = (): Platform => {
await window.api.setDefaultServerUrl(url)
},
wslServers: wslServersApi,
getDisplayBackend: async () => {
return window.api.getDisplayBackend().catch(() => null)
},
@@ -303,7 +299,6 @@ listenForDeepLinks()
render(() => {
const platform = createPlatform()
const [windowConfig] = createResource(() => window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })))
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
@@ -318,32 +313,11 @@ render(() => {
const [windowCount] = createResource(() => window.api.getWindowCount())
// Fetch sidecar credentials (available immediately, before health check)
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
const [defaultServer] = createResource(() =>
platform.getDefaultServer?.().then((url) => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(loadLocale)
const servers = () => {
const data = sidecar()
if (!data) return []
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
},
}
return [server] as ServerConnection.Any[]
}
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
@@ -370,6 +344,73 @@ render(() => {
return null
}
function App() {
const wslServers = useWslServers()
const splash = (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
)
const ready = createMemo(
() =>
!defaultServer.loading &&
!sidecar.loading &&
!windowCount.loading &&
!locale.loading,
)
const servers = createMemo(() => {
const data = sidecar()
const list: ServerConnection.Any[] = []
if (data) {
list.push({
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
},
})
}
for (const item of wslServers.data?.servers ?? []) {
const runtime = item.runtime
if (runtime.kind !== "ready") continue
list.push({
displayName: item.config.distro,
type: "sidecar",
variant: "wsl",
distro: item.config.distro,
http: {
url: runtime.url,
username: runtime.username ?? undefined,
password: runtime.password ?? undefined,
},
})
}
return list
})
const effectiveDefaultServer = createMemo(() => {
const key = defaultServer.latest ?? ServerConnection.Key.make("sidecar")
if (!key.startsWith("wsl:")) return key
const item = wslServers.data?.servers.find((item) => item.config.id === key)
if (item?.runtime.kind === "ready") return key
return ServerConnection.Key.make("sidecar")
})
if (!ready()) return splash
return (
<Show when={effectiveDefaultServer()} keyed>
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={MemoryRouter}>
<Inner />
</AppInterface>
)}
</Show>
)
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
@@ -380,27 +421,7 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale={locale.latest}>
<Show
when={
!defaultServer.loading &&
!sidecar.loading &&
!windowConfig.loading &&
!windowCount.loading &&
!locale.loading
}
>
{(_) => {
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
>
<Inner />
</AppInterface>
)
}}
</Show>
<App />
</AppBaseProviders>
</PlatformProvider>
)
+34 -14
View File
@@ -1,4 +1,5 @@
import { Auth } from "../../auth"
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
import { cmd } from "./cmd"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
@@ -252,6 +253,7 @@ export const ProvidersListCommand = effectCmd({
instance: false,
handler: Effect.fn("Cli.providers.list")(function* (_args) {
const authSvc = yield* Auth.Service
const authWellKnown = yield* AuthWellKnown.Service
const modelsDev = yield* ModelsDev.Service
UI.empty()
@@ -259,7 +261,8 @@ export const ProvidersListCommand = effectCmd({
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
const results = Object.entries(yield* Effect.orDie(authSvc.all())).filter(([, result]) => result.type !== "wellknown")
const wellKnownResults = Object.entries(yield* Effect.orDie(authWellKnown.all()))
const database = yield* modelsDev.get()
for (const [providerID, result] of results) {
@@ -267,7 +270,11 @@ export const ProvidersListCommand = effectCmd({
yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
yield* Prompt.outro(`${results.length} credentials`)
for (const [url] of wellKnownResults) {
yield* Prompt.log.info(`${url} ${UI.Style.TEXT_DIM}wellknown`)
}
yield* Prompt.outro(`${results.length + wellKnownResults.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
@@ -316,19 +323,19 @@ export const ProvidersLoginCommand = effectCmd({
}),
handler: Effect.fn("Cli.providers.login")(function* (args) {
const authSvc = yield* Auth.Service
const authWellKnown = yield* AuthWellKnown.Service
UI.empty()
yield* Prompt.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
)) as {
auth: { command: string[]; env: string }
}
const wellknown = yield* authWellKnown.metadata(url).pipe(
Effect.mapError((error) => new CliError({ message: `Failed to load auth provider metadata from ${url}: ${errorMessage(error)}` })),
)
if (!wellknown.auth) return yield* fail(`Auth provider metadata from ${url} is missing auth configuration`)
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const abort = new AbortController()
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
const proc = Process.spawn([...wellknown.auth.command], { stdout: "pipe", stderr: "inherit", abort: abort.signal })
if (!proc.stdout) {
yield* Prompt.log.error("Failed")
yield* Prompt.outro("Done")
@@ -342,7 +349,7 @@ export const ProvidersLoginCommand = effectCmd({
yield* Prompt.outro("Done")
return
}
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
yield* Effect.orDie(authWellKnown.set(url, new AuthWellKnown.Entry({ key: wellknown.auth.env, token: token.trim() })))
yield* Prompt.log.success("Logged into " + url)
yield* Prompt.outro("Done")
return
@@ -492,10 +499,20 @@ export const ProvidersLogoutCommand = effectCmd({
instance: false,
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
const authSvc = yield* Auth.Service
const authWellKnown = yield* AuthWellKnown.Service
const modelsDev = yield* ModelsDev.Service
UI.empty()
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
const credentials = [
...Object.entries(yield* Effect.orDie(authSvc.all()))
.filter(([, value]) => value.type !== "wellknown")
.map(([key, value]) => ({ key, type: value.type, auth: "provider" as const })),
...Object.keys(yield* Effect.orDie(authWellKnown.all())).map((key) => ({
key,
type: "wellknown" as const,
auth: "wellknown" as const,
})),
]
yield* Prompt.intro("Remove credential")
if (credentials.length === 0) {
yield* Prompt.log.error("No credentials found")
@@ -504,12 +521,15 @@ export const ProvidersLogoutCommand = effectCmd({
const database = yield* modelsDev.get()
const selected = yield* Prompt.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
options: credentials.map((item, index) => ({
label: (database[item.key]?.name || item.key) + UI.Style.TEXT_DIM + " (" + item.type + ")",
value: index,
})),
})
yield* Effect.orDie(authSvc.remove(yield* promptValue(selected)))
const credential = credentials[yield* promptValue(selected)]
if (!credential) return
if (credential.auth === "wellknown") yield* Effect.orDie(authWellKnown.remove(credential.key))
else yield* Effect.orDie(authSvc.remove(credential.key))
yield* Prompt.outro("Logout successful")
}),
})
+28 -10
View File
@@ -1,24 +1,42 @@
import { Effect } from "effect"
import { Server } from "../../server/server"
import { ServerDiscovery } from "@/cli/server-discovery"
import { effectCmd } from "../effect-cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "@opencode-ai/core/flag/flag"
export const ServeCommand = effectCmd({
command: "serve",
builder: (yargs) => withNetworkOptions(yargs),
builder: (yargs) =>
withNetworkOptions(yargs).option("discoverable", {
type: "boolean",
describe: "write this server to the local discovery file for default TUI startup",
default: false,
}),
describe: "starts a headless opencode server",
// Server loads instances per-request via x-opencode-directory header — no
// need for an ambient project InstanceContext at startup.
instance: false,
handler: Effect.fn("Cli.serve")(function* (args) {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
handler: (args) =>
Effect.gen(function* () {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
const discovery = args.discoverable ? yield* ServerDiscovery.Service : undefined
if (discovery) {
yield* discovery.write(server.url)
process.on("exit", ServerDiscovery.removeSync)
}
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
yield* Effect.never
}),
yield* Effect.never.pipe(
Effect.ensuring(
discovery
? discovery.remove().pipe(Effect.ensuring(Effect.sync(() => process.off("exit", ServerDiscovery.removeSync))))
: Effect.void,
),
)
}).pipe(Effect.provide(ServerDiscovery.defaultLayer)),
})
+11
View File
@@ -929,6 +929,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
name: "app.toggle.clear_prompt_history",
title: kv.get("clear_prompt_save_history", false)
? "Don't include cleared prompts in history"
: "Include cleared prompts in history",
category: "System",
run: () => {
kv.set("clear_prompt_save_history", !kv.get("clear_prompt_save_history", false))
dialog.clear()
},
},
].map((command) => ({
namespace: "palette",
...command,
@@ -87,6 +87,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
return store.history.at(store.index)
},
append(item: PromptInfo) {
if (store.history.at(-1)?.input === item.input) return
const entry = structuredClone(unwrap(item))
if (isDuplicateEntry(store.history.at(-1), entry)) {
setStore("index", 0)
@@ -157,6 +157,7 @@ export function Prompt(props: PromptProps) {
const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
const kv = useKV()
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
@@ -404,6 +405,15 @@ export function Prompt(props: PromptProps) {
const promptCommands = createMemo(() =>
[
{
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
name: "permission.auto_accept.toggle",
category: "Agent",
run: () => {
setAutoaccept((current) => (current === "none" ? "edit" : "none"))
dialog.clear()
},
},
{
title: "Clear prompt",
name: "prompt.clear",
@@ -799,6 +809,67 @@ export function Prompt(props: PromptProps) {
)
}
function expandPasteExtmark(extmark: { id: number; start: number; end: number }) {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
if (part?.type !== "text" || !part.source?.text) return false
const nextInput = store.prompt.input.slice(0, extmark.start) + part.text + store.prompt.input.slice(extmark.end)
const delta = part.text.length - (extmark.end - extmark.start)
const nextParts = store.prompt.parts
.flatMap((item, index) => {
if (index === partIndex) return []
const next = structuredClone(unwrap(item))
if (next.type === "agent" && next.source && next.source.start >= extmark.end) {
next.source.start += delta
next.source.end += delta
}
if (next.type === "file" && next.source?.text && next.source.text.start >= extmark.end) {
next.source.text.start += delta
next.source.text.end += delta
}
if (next.type === "text" && next.source?.text && next.source.text.start >= extmark.end) {
next.source.text.start += delta
next.source.text.end += delta
}
return [next]
})
.filter((item): item is PromptInfo["parts"][number] => item !== undefined)
input.setText(nextInput)
setStore("prompt", {
input: nextInput,
parts: nextParts,
})
restoreExtmarksFromParts(nextParts)
input.cursorOffset = extmark.start + part.text.length
return true
}
function expandPasteBlockAtMouse(event: MouseEvent) {
if (event.button !== 0) return false
const localX = event.x - input.x
const localY = event.y - input.y
if (localX < 0 || localY < 0 || localX >= input.width || localY >= input.height) return false
const previousOffset = input.cursorOffset
input.editorView.setLocalSelection(localX, localY, localX, localY, undefined, undefined, true, false)
input.editorView.resetLocalSelection()
const offset = input.cursorOffset
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((item) => {
const partIndex = store.extmarkToPartIndex.get(item.id)
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
if (part?.type !== "text") return false
return (offset >= item.start && offset <= item.end) || (offset + 1 >= item.start && offset + 1 <= item.end)
})
if (!extmark) {
input.cursorOffset = previousOffset
return false
}
return expandPasteExtmark(extmark)
}
const stashCommands = createMemo(() =>
[
{
@@ -1181,25 +1252,27 @@ export function Prompt(props: PromptProps) {
})),
})
} else {
const parts = [
...editorParts,
{
id: PartID.ascending(),
type: "text" as const,
text: inputText,
},
...nonTextParts.map(assign),
]
const request = {
sessionID,
messageID,
agent: agent.name,
model: selectedModel,
variant,
parts,
}
sync.session.addOptimisticPrompt(request)
sdk.client.session
.prompt({
sessionID,
...selectedModel,
messageID,
agent: agent.name,
model: selectedModel,
variant,
parts: [
...editorParts,
{
id: PartID.ascending(),
type: "text",
text: inputText,
},
...nonTextParts.map(assign),
],
})
.catch(() => {})
.prompt(request)
.catch(() => sync.session.removeOptimisticPrompt(request.sessionID, request.messageID))
if (editorParts.length > 0) editor.markSelectionSent()
}
history.append({
@@ -1372,7 +1445,8 @@ export function Prompt(props: PromptProps) {
}
function clearPrompt() {
if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) {
const shouldSave = store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0
if (shouldSave || kv.get("clear_prompt_save_history", false)) {
history.append({
...store.prompt,
mode: store.mode,
@@ -1559,6 +1633,11 @@ export function Prompt(props: PromptProps) {
}, 0)
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
onMouseUp={(event: MouseEvent) => {
if (!expandPasteBlockAtMouse(event)) return
event.preventDefault()
event.stopPropagation()
}}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
syntaxStyle={syntax()}
@@ -1595,11 +1674,14 @@ export function Prompt(props: PromptProps) {
)}
</Show>
</box>
<Show when={hasRightContent()}>
<box flexDirection="row" gap={1} alignItems="center">
{props.right}
</box>
</Show>
<box flexDirection="row" gap={1} alignItems="center">
<Show when={autoaccept() === "edit"}>
<text>
<span style={{ fg: theme.warning }}>autoedit</span>
</text>
</Show>
<Show when={hasRightContent()}>{props.right}</Show>
</box>
</box>
</box>
</box>
@@ -12,6 +12,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Substitution } from "@opencode-ai/core/substitution"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { TuiKeybind } from "./keybind"
@@ -19,7 +20,6 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm"
import type { DeepMutable } from "@opencode-ai/core/schema"
import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
@@ -98,6 +98,7 @@ function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: str
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
const afs = yield* AppFileSystem.Service
const substitution = yield* Substitution.Service
let appliedOrder = 0
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
@@ -112,9 +113,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
const expanded = yield* Effect.promise(() =>
ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }),
)
const expanded = yield* substitution.substitute({ text, type: "path", path: configFilepath, missing: "empty" }).pipe(Effect.orDie)
const data = ConfigParse.jsonc(expanded, configFilepath)
if (!isRecord(data)) return {} as Info
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
@@ -295,7 +294,11 @@ export const layer = Layer.effect(
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Npm.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Substitution.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -0,0 +1,50 @@
import type { AgentPartInput, FilePartInput, Message, Part, SubtaskPartInput, TextPartInput } from "@opencode-ai/sdk/v2"
import { Binary } from "@opencode-ai/core/util/binary"
export type OptimisticPromptPart = (TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput) & { id: string }
export function optimisticParts(input: { sessionID: string; messageID: string; parts: OptimisticPromptPart[] }) {
return input.parts.map((part): Part => {
const withIDs = {
...part,
sessionID: input.sessionID,
messageID: input.messageID,
}
if (withIDs.type === "file") return { ...withIDs, url: "" }
return withIDs
})
}
export function mergeFetchedMessages(input: {
currentMessages: Message[]
currentParts: Record<string, Part[] | undefined>
fetched: { info: Message; parts: Part[] }[]
optimisticMessages: ReadonlySet<string>
}) {
const fetchedIDs = new Set(input.fetched.map((message) => message.info.id))
const messages = input.fetched.map((message) => message.info)
const parts = new Map<string, Part[]>()
const resolved = new Set<string>()
for (const message of input.currentMessages) {
if (input.optimisticMessages.has(message.id) && !fetchedIDs.has(message.id)) {
Binary.insert(messages, message, (item) => item.id)
}
}
for (const message of input.fetched) {
if (message.parts.length > 0) {
resolved.add(message.info.id)
parts.set(message.info.id, message.parts)
continue
}
if (input.optimisticMessages.has(message.info.id)) {
const current = input.currentParts[message.info.id]
if (current) parts.set(message.info.id, current)
continue
}
parts.set(message.info.id, message.parts)
}
return { messages, parts, resolved }
}
@@ -33,6 +33,7 @@ import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
import path from "path"
import { useKV } from "./kv"
import { aggregateFailures } from "./aggregate-failures"
import { mergeFetchedMessages, optimisticParts, type OptimisticPromptPart } from "./sync-optimistic"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -111,8 +112,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const project = useProject()
const sdk = useSDK()
const kv = useKV()
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
const fullSyncedSessions = new Set<string>()
const optimisticMessages = new Set<string>()
function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
@@ -152,6 +155,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "permission.asked": {
const request = event.properties
if (autoaccept() === "edit" && request.permission === "edit") {
void sdk.client.permission.reply({
reply: "once",
requestID: request.id,
})
break
}
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
@@ -219,6 +229,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
case "session.deleted": {
for (const message of store.message[event.properties.info.id] ?? []) optimisticMessages.delete(message.id)
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore(
@@ -290,6 +301,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "message.removed": {
optimisticMessages.delete(event.properties.messageID)
const messages = store.message[event.properties.sessionID]
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
if (result.found) {
@@ -304,6 +316,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "message.part.updated": {
optimisticMessages.delete(event.properties.part.messageID)
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
@@ -518,6 +531,66 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (last.role === "user") return "working"
return last.time.completed ? "idle" : "working"
},
addOptimisticPrompt(input: {
sessionID: string
messageID: string
agent: string
model: { providerID: string; modelID: string }
variant?: string
parts: OptimisticPromptPart[]
}) {
optimisticMessages.add(input.messageID)
const messages = store.message[input.sessionID]
const match = messages ? Binary.search(messages, input.messageID, (m) => m.id) : undefined
const info: Message = {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: {
providerID: input.model.providerID,
modelID: input.model.modelID,
...(input.variant ? { variant: input.variant } : {}),
},
}
batch(() => {
if (!messages) {
setStore("message", input.sessionID, [info])
} else if (!match?.found) {
setStore(
"message",
input.sessionID,
produce((draft) => {
Binary.insert(draft, info, (message) => message.id)
}),
)
}
setStore("part", input.messageID, reconcile(optimisticParts(input)))
})
},
removeOptimisticPrompt(sessionID: string, messageID: string) {
if (!optimisticMessages.delete(messageID)) return
const messages = store.message[sessionID]
const match = messages ? Binary.search(messages, messageID, (m) => m.id) : undefined
batch(() => {
if (match?.found) {
setStore(
"message",
sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
}
setStore(
"part",
produce((draft) => {
delete draft[messageID]
}),
)
})
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
const [session, messages, todo, diff] = await Promise.all([
@@ -529,15 +602,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
const merged = mergeFetchedMessages({
currentMessages: draft.message[sessionID] ?? [],
currentParts: draft.part,
fetched: messages.data ?? [],
optimisticMessages,
})
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
const infos: (typeof draft.message)[string] = []
for (const message of messages.data ?? []) {
infos.push(message.info)
draft.part[message.info.id] = message.parts
draft.message[sessionID] = merged.messages
for (const messageID of merged.resolved) {
optimisticMessages.delete(messageID)
}
for (const [messageID, parts] of merged.parts) {
draft.part[messageID] = parts
}
draft.message[sessionID] = infos
draft.session_diff[sessionID] = diff.data ?? []
}),
)
@@ -1524,6 +1524,8 @@ const PART_MAPPING = {
reasoning: ReasoningPart,
}
const INLINE_TOOL_ICON_WIDTH = 2
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
const { theme } = useTheme()
const ctx = use()
@@ -1795,6 +1797,7 @@ function InlineTool(props: {
const sync = useSync()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
const [errorExpanded, setErrorExpanded] = createSignal(false)
const permission = createMemo(() => {
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
@@ -1802,14 +1805,6 @@ function InlineTool(props: {
return callID === props.part.callID
})
const fg = createMemo(() => {
if (props.color) return props.color
if (permission()) return theme.warning
if (hover() && props.onClick) return theme.text
if (props.complete) return theme.textMuted
return theme.text
})
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
const denied = createMemo(
@@ -1820,14 +1815,29 @@ function InlineTool(props: {
error()?.includes("user dismissed"),
)
const failed = createMemo(() => Boolean(error() && !denied()))
const clickable = createMemo(() => Boolean(props.onClick || failed()))
const fg = createMemo(() => {
if (permission()) return theme.warning
if (failed()) return theme.error
if (props.color) return props.color
if (hover() && props.onClick) return theme.text
if (props.complete) return theme.textMuted
return theme.text
})
return (
<box
marginTop={margin()}
paddingLeft={3}
onMouseOver={() => props.onClick && setHover(true)}
onMouseOver={() => clickable() && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
if (failed()) {
setErrorExpanded((value) => !value)
return
}
props.onClick?.()
}}
renderBefore={function () {
@@ -1836,21 +1846,10 @@ function InlineTool(props: {
if (!parent) {
return
}
if (el.height > 1) {
setMargin(1)
return
}
const children = parent.getChildren()
const index = children.indexOf(el)
const previous = children[index - 1]
if (!previous) {
setMargin(0)
return
}
if (previous.height > 1 || previous.id.startsWith("text-")) {
setMargin(1)
return
}
setMargin(previous?.id.startsWith("text-") || previous?.id.startsWith("tool-block-") ? 1 : 0)
}}
>
<Switch>
@@ -1858,15 +1857,37 @@ function InlineTool(props: {
<Spinner color={fg()} children={props.children} />
</Match>
<Match when={true}>
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
</Show>
</text>
<Show
fallback={
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
~ {props.pending}
</text>
}
when={props.complete}
>
<box flexDirection="row">
<text
width={INLINE_TOOL_ICON_WIDTH}
fg={failed() ? theme.error : (props.iconColor ?? fg())}
attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.icon}
</text>
<text
flexGrow={1}
fg={failed() ? theme.error : fg()}
attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.children}
</text>
</box>
</Show>
</Match>
</Switch>
<Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text>
<Show when={failed() && errorExpanded()}>
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={theme.error}>{error()}</text>
</box>
</Show>
</box>
)
@@ -1885,6 +1906,7 @@ function BlockTool(props: {
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
return (
<box
id={props.part ? "tool-block-" + props.part.id : undefined}
border={["left"]}
paddingTop={1}
paddingBottom={1}
@@ -9,6 +9,8 @@ import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import { ServerAuth } from "@/server/auth"
import { ServerDiscovery } from "@/cli/server-discovery"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
@@ -197,16 +199,26 @@ export const TuiThreadCommand = cmd({
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const discovered = external ? undefined : await ServerDiscovery.find()
const transport = external
? {
url: (await client.call("server", network)).url,
fetch: undefined,
headers: ServerAuth.headers(),
events: undefined,
}
: discovered
? {
url: discovered,
fetch: undefined,
headers: ServerAuth.headers(),
events: undefined,
}
: {
url: "http://opencode.internal",
fetch: createWorkerFetch(client),
headers: undefined,
events: createEventSource(client),
}
@@ -216,6 +228,7 @@ export const TuiThreadCommand = cmd({
sessionID: args.session,
directory: cwd,
fetch: transport.fetch,
headers: transport.headers,
})
} catch (error) {
UI.error(errorMessage(error))
@@ -241,6 +254,7 @@ export const TuiThreadCommand = cmd({
config,
directory: cwd,
fetch: transport.fetch,
headers: transport.headers,
events: transport.events,
args: {
continue: args.continue,
@@ -52,6 +52,7 @@ export interface DialogSelectOption<T = any> {
value: T
description?: string
details?: string[]
search?: string
footer?: JSX.Element | string
category?: string
categoryView?: JSX.Element
@@ -0,0 +1,112 @@
export * as ServerDiscovery from "./server-discovery"
import { makeRuntime } from "@/effect/run-service"
import { ServerAuth } from "@/server/auth"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { readFileSync, unlinkSync } from "fs"
import path from "path"
export const file = path.join(Global.Path.state, "server.json")
const Entry = Schema.Struct({
url: Schema.String,
pid: Schema.Number,
})
type Entry = typeof Entry.Type
const decodeEntry = Schema.decodeUnknownOption(Entry)
export interface Interface {
readonly write: (url: URL) => Effect.Effect<void>
readonly remove: () => Effect.Effect<void>
readonly find: () => Effect.Effect<string | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/CliServerDiscovery") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const read = Effect.fn("CliServerDiscovery.read")(function* () {
const entry = yield* fs.readJson(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
return Option.getOrUndefined(decodeEntry(entry))
})
const remove = Effect.fn("CliServerDiscovery.remove")(function* () {
const entry = yield* read()
if (entry?.pid !== process.pid) return
yield* fs.remove(file).pipe(Effect.ignore)
})
const removeStale = Effect.fn("CliServerDiscovery.removeStale")(function* (entry: Entry) {
const current = yield* read()
if (current?.pid !== entry.pid || current.url !== entry.url) return
yield* fs.remove(file).pipe(Effect.ignore)
})
return Service.of({
write: Effect.fn("CliServerDiscovery.write")(function* (url) {
yield* fs.writeJson(file, { url: localURL(url).toString(), pid: process.pid }, 0o600).pipe(Effect.orDie)
}),
remove,
find: Effect.fn("CliServerDiscovery.find")(function* () {
const entry = yield* read()
if (!entry) return undefined
const url = yield* healthy(entry.url)
if (url) return url
yield* removeStale(entry)
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export const find = () => runPromise((discovery) => discovery.find())
export function removeSync() {
const entry = readSync()
if (entry?.pid !== process.pid) return
try {
unlinkSync(file)
} catch {}
}
function readSync() {
try {
return Option.getOrUndefined(decodeEntry(JSON.parse(readFileSync(file, "utf8"))))
} catch {
return undefined
}
}
function healthy(input: string) {
return Effect.tryPromise({
try: async () => {
const url = new URL(input)
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
const response = await fetch(new URL("/global/health", url), {
headers: ServerAuth.headers(),
signal: AbortSignal.timeout(1000),
})
if (!response.ok) return undefined
const body = (await response.json()) as unknown
if (typeof body === "object" && body !== null && "healthy" in body && body.healthy === true) {
return url.toString()
}
},
catch: () => undefined,
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
}
function localURL(url: URL) {
const result = new URL(url)
if (result.hostname === "0.0.0.0") result.hostname = "127.0.0.1"
if (result.hostname === "::") result.hostname = "::1"
return result
}
+26 -128
View File
@@ -8,7 +8,8 @@ import { Global } from "@opencode-ai/core/global"
import fsNode from "fs/promises"
import { NamedError } from "@opencode-ai/core/util/error"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Auth } from "../auth"
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
import { Substitution } from "@opencode-ai/core/substitution"
import { Env } from "../env"
import { applyEdits, modify } from "jsonc-parser"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -19,7 +20,6 @@ import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { containsPath, type InstanceContext } from "../project/instance-context"
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
@@ -40,9 +40,7 @@ import { ConfigProvider } from "./provider"
import { ConfigReference } from "./reference"
import { ConfigServer } from "./server"
import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
import { Npm } from "@opencode-ai/core/npm"
import { withTransientReadRetry } from "@/util/effect-http-client"
const log = Log.create({ service: "config" })
@@ -72,48 +70,6 @@ function normalizeLoadedConfig(data: unknown, source: string) {
return copy
}
async function substituteWellKnownRemoteConfig(input: {
value: unknown
dir: string
source: string
env: Record<string, string>
}) {
if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined
const url = await ConfigVariable.substitute({
text: input.value.url,
type: "virtual",
dir: input.dir,
source: input.source,
env: input.env,
})
const headers = isRecord(input.value.headers)
? Object.fromEntries(
await Promise.all(
Object.entries(input.value.headers)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(async ([key, value]) => [
key,
await ConfigVariable.substitute({
text: value,
type: "virtual",
dir: input.dir,
source: input.source,
env: input.env,
}),
]),
),
)
: undefined
return { url, headers }
}
const WellKnownConfig = Schema.Struct({
config: Schema.optional(Schema.Json),
remote_config: Schema.optional(Schema.Json),
})
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
@@ -382,44 +338,22 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const authSvc = yield* Auth.Service
const authWellKnown = yield* AuthWellKnown.Service
const substitution = yield* Substitution.Service
const accountSvc = yield* Account.Service
const env = yield* Env.Service
const npmSvc = yield* Npm.Service
const http = yield* HttpClient.HttpClient
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
const fetchRemoteJson = Effect.fnUntraced(function* <S extends Schema.Top>(
url: string,
headers: Record<string, string> | undefined,
schema: S,
) {
const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http))
.execute(
HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})),
)
.pipe(
Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))),
)
return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(
Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))),
)
})
const loadConfig = Effect.fnUntraced(function* (
text: string,
options: { path: string } | { dir: string; source: string },
env?: Record<string, string>,
) {
const source = "path" in options ? options.path : options.source
const expanded = yield* Effect.promise(() =>
ConfigVariable.substitute(
"path" in options
? { text, type: "path", path: options.path, env }
: { text, type: "virtual", ...options, env },
),
)
const expanded = yield* substitution.substitute(
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
).pipe(Effect.orDie)
const parsed = ConfigParse.jsonc(expanded, source)
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
if (!("path" in options)) return data
@@ -433,14 +367,14 @@ export const layer = Layer.effect(
return data
})
const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) {
const loadFile = Effect.fnUntraced(function* (filepath: string) {
log.info("loading", { path: filepath })
const text = yield* readConfigFile(filepath)
if (!text) return {} as Info
return yield* loadConfig(text, { path: filepath }, env)
return yield* loadConfig(text, { path: filepath })
})
const loadGlobal = Effect.fnUntraced(function* (env?: Record<string, string>) {
const loadGlobal = Effect.fnUntraced(function* () {
let result: Info = {}
// Seed the default global config with the schema for editor completion, but avoid writing when the user
// explicitly routes config through env-provided paths or content.
@@ -452,9 +386,9 @@ export const layer = Layer.effect(
.pipe(Effect.catch(() => Effect.void))
}
}
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
@@ -509,10 +443,7 @@ export const layer = Layer.effect(
const loadInstanceState = Effect.fn("Config.loadInstanceState")(
function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {}
const authEnv: Record<string, string> = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
@@ -549,59 +480,26 @@ export const layer = Layer.effect(
return mergePluginOrigins(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
authEnv[value.key] = value.token
const wellknownURL = `${url}/.well-known/opencode`
log.debug("fetching remote config", { url: wellknownURL })
const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig)
const remote = yield* Effect.promise(() =>
substituteWellKnownRemoteConfig({
value: wellknown.remote_config,
dir: url,
source: wellknownURL,
env: authEnv,
}),
)
const fetchedConfig = remote
? yield* Effect.gen(function* () {
log.debug("fetching remote config", { url: remote.url })
const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json)
if (isRecord(data) && isRecord(data.config)) return data.config
if (isRecord(data)) return data
return yield* Effect.die(
new Error(`failed to decode remote config from ${remote.url}: expected object`),
)
})
: {}
const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig)
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = wellknownURL
const next = yield* loadConfig(
JSON.stringify(remoteConfig),
{
dir: path.dirname(source),
source,
},
authEnv,
)
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
for (const item of yield* authWellKnown.configs().pipe(Effect.orDie)) {
yield* merge(
item.source,
yield* loadConfig(JSON.stringify(item.content), { dir: item.dir, source: item.source }),
"global",
)
log.debug("loaded well-known config", { url: item.url })
}
const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal()
const global = yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
yield* merge(file, yield* loadFile(file, authEnv), "local")
yield* merge(file, yield* loadFile(file), "local")
}
}
@@ -622,7 +520,7 @@ export const layer = Layer.effect(
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source, authEnv))
yield* merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
@@ -875,10 +773,10 @@ export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(AuthWellKnown.defaultLayer),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Account.defaultLayer),
Layer.provide(Npm.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
export * as Config from "./config"
-91
View File
@@ -1,91 +0,0 @@
export * as ConfigVariable from "./variable"
import path from "path"
import os from "os"
import { Filesystem } from "@/util/filesystem"
import { InvalidError } from "./error"
type ParseSource =
| {
type: "path"
path: string
}
| {
type: "virtual"
source: string
dir: string
}
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
env?: Record<string, string>
}
function source(input: ParseSource) {
return input.type === "path" ? input.path : input.source
}
function dir(input: ParseSource) {
return input.type === "path" ? path.dirname(input.path) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export async function substitute(input: SubstituteInput) {
const missing = input.missing ?? "error"
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return (input.env?.[varName] ?? process.env[varName]) || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
@@ -3,6 +3,7 @@ import { attach } from "./run-service"
import * as Observability from "@opencode-ai/core/effect/observability"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
import { Bus } from "@/bus"
import { Auth } from "@/auth"
import { Account } from "@/account/account"
@@ -62,6 +63,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
AppFileSystem.defaultLayer,
AuthWellKnown.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
@@ -1,5 +1,6 @@
import { expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Substitution } from "@opencode-ai/core/substitution"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import path from "path"
@@ -12,6 +13,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { AuthWellKnownTest } from "../fake/auth-well-known"
import { NpmTest } from "../fake/npm"
import { ProviderTest } from "../fake/provider"
import { SkillTest } from "../fake/skill"
@@ -26,6 +28,8 @@ const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "age
const provider = ProviderTest.fake()
const configLayer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(AuthWellKnownTest.empty),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthTest.empty),
Layer.provide(AccountTest.empty),
@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import { mergeFetchedMessages, optimisticParts } from "@/cli/cmd/tui/context/sync-optimistic"
function user(id: string): Message {
return {
id,
sessionID: "ses_test",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "test", modelID: "model" },
}
}
function text(messageID: string, text: string): Part {
return {
id: `part_${messageID}`,
sessionID: "ses_test",
messageID,
type: "text",
text,
}
}
describe("TUI optimistic prompt sync", () => {
test("keeps an optimistic message while session sync has not fetched it yet", () => {
const merged = mergeFetchedMessages({
currentMessages: [user("msg_2")],
currentParts: { msg_2: [text("msg_2", "optimistic")] },
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
optimisticMessages: new Set(["msg_2"]),
})
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1", "msg_2"])
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
expect(merged.resolved.has("msg_2")).toBe(false)
})
test("preserves optimistic parts when sync fetches the message before its parts", () => {
const merged = mergeFetchedMessages({
currentMessages: [user("msg_1")],
currentParts: { msg_1: [text("msg_1", "optimistic")] },
fetched: [{ info: user("msg_1"), parts: [] }],
optimisticMessages: new Set(["msg_1"]),
})
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1"])
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["optimistic"])
expect(merged.resolved.has("msg_1")).toBe(false)
})
test("replaces optimistic parts once real fetched parts arrive", () => {
const merged = mergeFetchedMessages({
currentMessages: [user("msg_1")],
currentParts: { msg_1: [text("msg_1", "optimistic")] },
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
optimisticMessages: new Set(["msg_1"]),
})
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
expect(merged.resolved.has("msg_1")).toBe(true)
})
test("strips file URLs from optimistic render parts", () => {
const parts = optimisticParts({
sessionID: "ses_test",
messageID: "msg_1",
parts: [
{
id: "part_file",
type: "file",
mime: "image/png",
filename: "image.png",
url: "data:image/png;base64,large",
},
],
})
expect(parts).toEqual([
{
id: "part_file",
sessionID: "ses_test",
messageID: "msg_1",
type: "file",
mime: "image/png",
filename: "image.png",
url: "",
},
])
})
})
@@ -0,0 +1,41 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = `
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = `
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
No LSP server available for this file type.
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = `
"
# List files
$ ls
file.ts
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createSignal, For } from "solid-js"
import { testRender } from "@opentui/solid"
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
afterEach(() => {
testSetup?.renderer.destroy()
testSetup = undefined
})
type ToolFixture = { icon: string; label: string; error?: string }
const INLINE_TOOL_ICON_WIDTH = 2
const tools: readonly ToolFixture[] = [
{
icon: "✱",
label:
'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)',
},
{
icon: "✱",
label: 'Glob "**/*db*" in packages/opencode (6 matches)',
},
{
icon: "→",
label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]",
},
{
icon: "→",
label: "Read packages/opencode/src/index.ts [offset=1, limit=100]",
error: "No LSP server available for this file type.",
},
{
icon: "✱",
label:
'Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)',
},
] as const
function InlineToolRow(props: { item: ToolFixture; errorExpanded?: boolean }) {
const [margin, setMargin] = createSignal(0)
return (
<box
marginTop={margin()}
paddingLeft={3}
renderBefore={function () {
const parent = this.parent
if (!parent) return
const previous = parent.getChildren()[parent.getChildren().indexOf(this) - 1]
setMargin(previous?.id.startsWith("text-") || previous?.id.startsWith("tool-block-") ? 1 : 0)
}}
>
<box flexDirection="row">
<text width={INLINE_TOOL_ICON_WIDTH}>{props.item.icon}</text>
<text flexGrow={1}>{props.item.label}</text>
</box>
{props.item.error && props.errorExpanded && (
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text>{props.item.error}</text>
</box>
)}
</box>
)
}
function ShellOutput() {
return (
<box id="tool-block-shell" marginTop={1} paddingTop={1} paddingBottom={1} paddingLeft={2} gap={1}>
<text paddingLeft={3}># List files</text>
<box gap={1}>
<text>$ ls</text>
<text>file.ts</text>
</box>
</box>
)
}
function Fixture(props: { errorExpanded?: boolean; shellOutput?: boolean }) {
return (
<box flexDirection="column" width={72}>
<box flexDirection="column">
{props.shellOutput && <ShellOutput />}
<For each={tools}>{(item) => <InlineToolRow item={item} errorExpanded={props.errorExpanded} />}</For>
</box>
</box>
)
}
describe("TUI inline tool wrapping", () => {
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
testSetup = await testRender(() => <Fixture />, { width: 72, height: 12 })
await testSetup.renderOnce()
await testSetup.renderOnce()
expect(
testSetup
.captureCharFrame()
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trimEnd(),
).toMatchSnapshot()
})
test("snapshots expanded tool errors under the tool text", async () => {
testSetup = await testRender(() => <Fixture errorExpanded />, { width: 72, height: 12 })
await testSetup.renderOnce()
await testSetup.renderOnce()
expect(
testSetup
.captureCharFrame()
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trimEnd(),
).toMatchSnapshot()
})
test("keeps separation after a shell output block", async () => {
testSetup = await testRender(() => <Fixture shellOutput />, { width: 72, height: 16 })
await testSetup.renderOnce()
await testSetup.renderOnce()
expect(
testSetup
.captureCharFrame()
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trimEnd(),
).toMatchSnapshot()
})
})
@@ -6,6 +6,7 @@ import { Config } from "@/config/config"
import { ConfigManaged } from "@/config/managed"
import { ConfigParse } from "../../src/config/parse"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Substitution } from "@opencode-ai/core/substitution"
import { InstanceRef } from "../../src/effect/instance-ref"
import type { InstanceContext } from "../../src/project/instance-context"
@@ -36,6 +37,7 @@ import { Filesystem } from "@/util/filesystem"
import { ConfigPlugin } from "@/config/plugin"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { AuthWellKnownTest } from "../fake/auth-well-known"
import { NpmTest } from "../fake/npm"
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
@@ -94,7 +96,9 @@ const configLayer = (
) =>
Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthWellKnownTest.empty),
Layer.provide(options.auth ?? AuthTest.empty),
Layer.provide(options.account ?? AccountTest.empty),
Layer.provideMerge(infra),
@@ -1503,7 +1507,9 @@ test("remote well-known config can use FetchHttpClient layer", async () => {
Config.layer.pipe(
Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthWellKnownTest.empty),
Layer.provide(wellKnownAuth(server.url.origin)),
Layer.provide(AccountTest.empty),
Layer.provideMerge(infra),
@@ -0,0 +1,8 @@
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
import { Effect, Layer } from "effect"
export const AuthWellKnownTest = {
empty: Layer.mock(AuthWellKnown.Service, {
configs: () => Effect.succeed([]),
}),
}
@@ -4,6 +4,7 @@ import { FetchHttpClient } from "effect/unstable/http"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Substitution } from "@opencode-ai/core/substitution"
import path from "path"
import { pathToFileURL } from "url"
import { Bus } from "../../src/bus"
@@ -17,10 +18,13 @@ import { testEffect } from "../lib/effect"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { AuthWellKnownTest } from "../fake/auth-well-known"
const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(AuthWellKnownTest.empty),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthTest.empty),
Layer.provide(AccountTest.empty),
@@ -4,6 +4,7 @@ import { FetchHttpClient } from "effect/unstable/http"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Substitution } from "@opencode-ai/core/substitution"
import path from "path"
import { pathToFileURL } from "url"
import { Auth } from "../../src/auth"
@@ -26,10 +27,13 @@ import { testEffect } from "../lib/effect"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { AuthWellKnownTest } from "../fake/auth-well-known"
const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(AuthWellKnownTest.empty),
Layer.provide(Substitution.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(AuthTest.empty),
Layer.provide(AccountTest.empty),