Compare commits

..

126 Commits

Author SHA1 Message Date
opencode-agent[bot]
25710c6709 Apply PR #16918: opencode 2-0 2026-03-11 00:45:43 +00:00
opencode-agent[bot]
1ef740aa10 Apply PR #16889: wip(app): colors 2026-03-11 00:45:17 +00:00
opencode-agent[bot]
b0ee463a88 Apply PR #16069: feat(windows): add first-class pwsh/powershell support 2026-03-11 00:44:53 +00:00
opencode-agent[bot]
7d0953aeb5 Apply PR #15697: tweak(ui): make questions popup collapsible 2026-03-11 00:44:17 +00:00
opencode-agent[bot]
0516947603 Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-03-11 00:44:16 +00:00
opencode-agent[bot]
a711494468 Apply PR #14307: fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering 2026-03-11 00:44:16 +00:00
opencode-agent[bot]
cd0ff68eb2 Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-03-11 00:44:16 +00:00
opencode-agent[bot]
5cdf53b240 Apply PR #12022: feat: update tui model dialog to utilize model family to reduce noise in list 2026-03-11 00:44:15 +00:00
Kit Langton
a1b06d63c9 fix(account): resilient orgs fetch (#16944) 2026-03-11 00:39:07 +00:00
Dax Raad
1b6820bab5 sync 2026-03-10 20:13:56 -04:00
Adam
89bf199c07 chore(app): fix tests 2026-03-10 19:03:44 -05:00
Aiden Cline
5acfdd1c5d chore: kill old copilot 403 message that was used for old plugin migration (#16904) 2026-03-10 16:20:41 -05:00
Dax
6ad171dba9 Merge branch 'dev' into opencode-2-0 2026-03-10 17:20:19 -04:00
Dax Raad
556703f8ab ci: cancel duplicate workflow runs and add read permissions
- Add concurrency settings to cancel outdated runs when new commits are pushed
- Add contents: read permission for security hardening
- Remove redundant required job that checked test results
2026-03-10 17:17:11 -04:00
Dax Raad
cb5674edc7 sync 2026-03-10 17:00:15 -04:00
Dax Raad
b99de4118e refactor(npm): inline pkgPath and lockPath variables 2026-03-10 16:59:01 -04:00
Dax Raad
040700dbc4 unbreak 2026-03-10 16:07:25 -04:00
Dax Raad
4d5da9697e sync 2026-03-10 16:02:40 -04:00
Dax Raad
a28648f530 core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-10 16:02:40 -04:00
Dax Raad
4d81e2d4d9 sync 2026-03-10 16:02:40 -04:00
Dax Raad
21e72cbf42 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 16:02:40 -04:00
Dax Raad
5f277d1e62 core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 16:02:40 -04:00
Dax Raad
d67e877e28 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 16:02:40 -04:00
Dax Raad
d4e51e04b3 sync 2026-03-10 16:02:40 -04:00
Dax Raad
070c1679e4 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 16:02:40 -04:00
Dax Raad
406d216cd2 refactor(server): replace Bun serve with Hono node adapters 2026-03-10 16:02:40 -04:00
Dax Raad
5dc8b4ef29 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 16:02:39 -04:00
Luke Parker
2f41d89163 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 16:02:39 -04:00
Dax Raad
b2eae867a1 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 16:02:39 -04:00
Dax Raad
3c2fda4d91 core: fix custom tool loading to properly resolve module paths 2026-03-10 16:02:39 -04:00
Dax Raad
2678ceb45e sync 2026-03-10 16:02:39 -04:00
Dax Raad
58a4cd00b6 sync 2026-03-10 16:02:39 -04:00
Dax Raad
0faa191b6d sync 2026-03-10 16:02:39 -04:00
Dax Raad
58cf092105 core: log npm install errors to console for debugging dependency failures 2026-03-10 16:02:39 -04:00
Dax Raad
0ff8bfe1d9 sync 2026-03-10 16:02:39 -04:00
Dax Raad
ceb79c786a core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 16:02:39 -04:00
Dax Raad
b1a15d559b sync 2026-03-10 16:02:39 -04:00
Dax Raad
124a8abf9b tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-10 16:02:39 -04:00
Dax Raad
85c2bb342b core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
4c57e39466 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
0cdd4e4e16 core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-10 16:02:39 -04:00
Dax Raad
a9b01be0c2 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-10 16:02:39 -04:00
Dax Raad
528daf5490 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-10 16:02:39 -04:00
Dax Raad
0e176d3ac3 sync 2026-03-10 16:02:39 -04:00
Dax
27f359852e Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax
173128d431 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax Raad
e8ee1e239f sync 2026-03-10 16:02:39 -04:00
Dax Raad
656fa191c1 refactor: lsp server and core improvements 2026-03-10 16:02:39 -04:00
Frank
6b9f8fb9b3 zen: raise limit 2026-03-10 15:22:02 -04:00
David Hill
f77e5cf8fb feat(ui): restyle Card and improve tool error cards (#16888)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-03-10 13:52:32 -05:00
Frank
e6cdc21f2d zen: raise limit 2026-03-10 14:40:18 -04:00
Dax Raad
1fe8d4d7ad ci: exclude draft PRs from beta labeling process to prevent unfinished work from being included in releases 2026-03-10 14:08:55 -04:00
Dax Raad
e44320980d ci: install setuptools to prevent Python distutils errors during dependency installation 2026-03-10 14:08:38 -04:00
Adam
f5d7fe3072 chore: cleanup 2026-03-10 13:00:14 -05:00
Adam
835a27cf51 fix(app): terminal jank 2026-03-10 13:00:14 -05:00
Adam
85afaaa13d fix(app): terminal focus issues and jank 2026-03-10 13:00:14 -05:00
opencode-agent[bot]
490615169e chore: update nix node_modules hashes 2026-03-10 17:14:55 +00:00
Dax
bb232247d0 Fix ESM imports for @opencode-ai/plugin (#16916) 2026-03-10 13:11:36 -04:00
opencode-agent[bot]
94c128f73b chore: generate 2026-03-10 16:56:30 +00:00
Dax
613562f504 core: make account login upgrades safe while adding multi-account workspace auth (#15487)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:53:47 -04:00
Adam
9c4325bcf8 fix(core): don't permit access to system directories (#16891) 2026-03-10 11:32:05 -05:00
Adam
781104c1ec wip(app): colors 2026-03-10 11:27:32 -05:00
Aiden Cline
ad08fd57df chore: rekram1-node is no longer on vacation (#16905) 2026-03-10 10:27:04 -05:00
opencode-agent[bot]
54ba59d3e1 chore: generate 2026-03-10 15:14:46 +00:00
James Long
a4330a225d feat(core): allow passing workspaceID into session create endpoint (#16798) 2026-03-10 11:12:51 -04:00
James Long
69ddc91c35 fix(core): a chunk timeout when processing llm stream (#16366) 2026-03-10 11:12:14 -04:00
James Long
4c4aed5a87 fix(core): make worktrees read the project id from local workspace (#16795) 2026-03-10 11:11:28 -04:00
opencode-agent[bot]
5a40158abf chore: generate 2026-03-10 15:07:35 +00:00
Jérôme Benoit
4dce485854 fix(opencode): add thinking variants support for SAP AI provider (#14958)
Co-authored-by: Test <test@test.com>
Co-authored-by: Stephen Collings <stevoland@gmail.com>
2026-03-10 10:05:45 -05:00
Adam
c24f440e94 wip(app): colors 2026-03-10 08:36:11 -05:00
Adam
5ec5d1dace chore(app): debug window 2026-03-10 07:05:54 -05:00
opencode-agent[bot]
d2c765e2b3 chore: generate 2026-03-10 11:01:22 +00:00
bhaktatejas922
d036c57d59 docs: update opencode-morph-plugin in all language ecosystem pages (#16869) 2026-03-10 06:00:13 -05:00
opencode-agent[bot]
e7493e2204 chore: update nix node_modules hashes 2026-03-10 10:16:15 +00:00
Sebastian
3500bf64b8 upgrade opentui to v0.1.87 (#16772) 2026-03-10 11:03:05 +01:00
opencode-agent[bot]
4f982ddb94 chore: generate 2026-03-10 02:02:18 +00:00
adam jones
ff3bb7424d fix(mcp): fix OAuth auto-connect failing on first connection (#15547)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-09 21:01:19 -05:00
LukeParkerDev
fb7dd0661a fix(e2e): wait for shell output before switching terminal tabs 2026-03-08 08:14:19 +10:00
Luke Parker
3e19a8dcba Merge branch 'dev' into experimental/windows-shells 2026-03-08 07:22:41 +10:00
LukeParkerDev
a61f9b01e0 Merge remote-tracking branch 'upstream/dev' into experimental/windows-shells
# Conflicts:
#	packages/opencode/src/util/filesystem.ts
2026-03-07 16:58:47 +10:00
LukeParkerDev
c0eb929465 fix(ci): pass full env to opencode test task in turbo
Turbo's strict env mode was stripping PATH and other system variables from nested shell processes, causing pwsh-spawned commands to produce empty output on Windows CI.
2026-03-06 17:31:23 +10:00
LukeParkerDev
f8810780cc Merge remote-tracking branch 'upstream/dev' into experimental/windows-shells 2026-03-06 17:04:20 +10:00
LukeParkerDev
68c435db79 fix(windows): widen PowerShell path permission detection
Handle wildcard path arguments, missing env expansion, and paths that appear after non-path PowerShell switches so external directory prompts stay accurate on Windows. Add focused regression coverage for those shell cases.
2026-03-06 15:45:37 +10:00
LukeParkerDev
4bcfb37567 fix(windows): tighten PowerShell shell handling
Centralize shell normalization and move bash permission inference onto native path handling so Windows shells behave consistently without Git Bash internals. Add focused shell, PTY, and bash tool coverage to keep Git Bash, pwsh, powershell, and cmd working on Windows.
2026-03-06 15:02:17 +10:00
LukeParkerDev
a6265531d6 Merge remote-tracking branch 'upstream/dev' into experimental/windows-shells 2026-03-06 14:11:01 +10:00
LukeParkerDev
2499be3622 Revert "refactor(shell): rename bash tool and migrate legacy config"
This reverts commit f6d989f836.
2026-03-06 14:06:03 +10:00
LukeParkerDev
f6d989f836 refactor(shell): rename bash tool and migrate legacy config
Rename the terminal tool around shell semantics instead of Bash while preserving backward compatibility for existing messages, permissions, and prompts. Automatically migrate file-based bash config to shell so Windows beta testing works without manual config edits.
2026-03-06 13:59:06 +10:00
Luke Parker
fd365f9d1f Merge branch 'dev' into experimental/windows-shells 2026-03-06 12:48:25 +10:00
LukeParkerDev
2241568a72 test(windows): stop bash permission checks before shell spawn 2026-03-06 11:05:55 +10:00
LukeParkerDev
2eb9f52fd1 test(windows): normalize temp dir permission glob 2026-03-06 10:17:25 +10:00
LukeParkerDev
eefe8a6d6d fix(windows): detect external paths for PowerShell cmdlets 2026-03-06 10:07:47 +10:00
LukeParkerDev
bd5f54887c fix(windows): normalize shell permission paths
Use the PowerShell parser for pwsh and collapse equivalent Windows path forms into canonical permission keys.
2026-03-06 09:34:40 +10:00
LukeParkerDev
6b00f4cb58 fix(windows): use typed powershell spawn options 2026-03-05 12:56:11 +10:00
LukeParkerDev
3392f89559 fix(windows): stabilize bash tool powershell execution 2026-03-05 12:54:06 +10:00
LukeParkerDev
81037a4e30 test(windows): cover bash tool shells 2026-03-05 11:45:29 +10:00
LukeParkerDev
807b8784e1 test(windows): use absolute fixture path in bash tests 2026-03-05 11:20:45 +10:00
LukeParkerDev
50188cdce3 test(windows): use absolute bun path in bash fixtures 2026-03-05 11:08:23 +10:00
LukeParkerDev
2a098f68ed test(windows): stabilize bash truncation fixtures 2026-03-05 11:00:47 +10:00
LukeParkerDev
be7e4f5813 fix(windows): resolve shell paths and adjust bash hints 2026-03-05 10:51:05 +10:00
LukeParkerDev
515687074e test(windows): avoid POSIX-only bash fixtures 2026-03-05 10:38:03 +10:00
LukeParkerDev
45adf54904 refactor(windows): inline shell blacklist check
Remove the one-off helper used for shell name normalization and keep the Windows blacklist check inline. This keeps the shell selection logic simpler without changing behavior.
2026-03-05 10:10:23 +10:00
LukeParkerDev
b384dac4b7 fix(windows): prefer PowerShell defaults for shell tools
Use pwsh and powershell before Git Bash when SHELL is unset on Windows so the default shell matches native expectations more closely. Surface the active OS and shell in the bash tool definition so agents can reason about the runtime they are executing in.
2026-03-05 10:04:09 +10:00
MakonnenMak
e993acec31 Merge remote-tracking branch 'upstream/dev' into fix/clock-skew-prompt-loop-exit
# Conflicts:
#	packages/ui/src/components/session-turn.tsx
2026-03-02 13:30:01 -05:00
David Hill
611e616010 tui: add right margin to question progress indicator so it doesn't touch the container edge 2026-03-02 14:27:43 +00:00
David Hill
b286c0ae3f tweak(ui): restore questions progress indicator 2026-03-02 12:08:37 +00:00
David Hill
81a61f8dbd tweak(ui): improve collapse area 2026-03-02 11:14:01 +00:00
David Hill
752e449e38 tweak(ui): improve collpase area 2026-03-02 11:11:52 +00:00
David Hill
5d419a0211 tweak(ui): expand question dock toggle area 2026-02-27 21:30:49 +00:00
David Hill
8b168981aa tweak(ui): active state on type your own answer 2026-02-27 18:50:50 +00:00
David Hill
724dd665ec tweak(ui): collapse questions 2026-02-27 18:47:53 +00:00
MakonnenMak
fc258ea74f fix: remove as any type cast in processor exit logic 2026-02-20 13:20:10 -05:00
Makonnen
abd9e195ac fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering
When the client clock is ahead of the server, user message IDs (generated
client-side) sort after assistant message IDs (generated server-side).
This broke the prompt loop exit check and the UI message pairing logic.

- Extract shouldExitLoop() into a pure function that uses parentID matching
  instead of relying on ID ordering
- Extract findAssistantMessages() with forward+backward scan to handle
  messages sorted out of expected order due to clock skew
- Remove debug console.log statements added during investigation
- Add tests for both extracted functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:20:10 -05:00
Adam
9d78b69cd3 wip(app): beta badge 2026-02-20 10:59:59 -06:00
Dax
e31f00ad22 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-16 21:50:34 -05:00
LukeParkerDev
a90e8de050 add missing return 2026-02-11 13:24:17 +10:00
Aiden Cline
eabf770053 Merge branch 'dev' into utilize-family-in-dialog 2026-02-10 14:43:15 -06:00
Dax
86d7bdc542 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:55:01 -05:00
Dax
d3ab78bba0 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:04:40 -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
Aiden Cline
bb4d978684 feat: update tui model dialog to utilize model family to reduce noise in list 2026-02-03 15:48:40 -06:00
218 changed files with 9218 additions and 5823 deletions

View File

@@ -31,6 +31,10 @@ runs:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
shell: bash
- name: Install dependencies
run: bun install
shell: bash

View File

@@ -6,6 +6,14 @@ on:
- dev
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
unit:
name: unit (${{ matrix.settings.name }})
@@ -86,18 +94,3 @@ jobs:
path: |
packages/app/e2e/test-results
packages/app/e2e/playwright-report
required:
name: test (linux)
runs-on: blacksmith-4vcpu-ubuntu-2404
needs:
- unit
- e2e
if: always()
steps:
- name: Verify upstream test jobs passed
run: |
echo "unit=${{ needs.unit.result }}"
echo "e2e=${{ needs.e2e.result }}"
test "${{ needs.unit.result }}" = "success"
test "${{ needs.e2e.result }}" = "success"

View File

@@ -1,3 +1,6 @@
plans/
bun.lock
node_modules
plans
package.json
bun.lock
.gitignore
package-lock.json

View File

@@ -1,912 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { extend, useKeyboard, useTerminalDimensions, type RenderableConstructor } from "@opentui/solid"
import { RGBA, VignetteEffect, type OptimizedBuffer, type RenderContext } from "@opentui/core"
import { ThreeRenderable, THREE } from "@opentui/core/3d"
import type { TuiApi, TuiKeybindSet, TuiPluginInit, TuiPluginInput } from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
modal: "ctrl+shift+m",
screen: "ctrl+shift+o",
home: "escape,ctrl+h",
left: "left,h",
right: "right,l",
up: "up,k",
down: "down,j",
alert: "a",
confirm: "c",
prompt: "p",
select: "s",
modal_accept: "enter,return",
modal_close: "escape",
dialog_close: "escape",
local: "x",
local_push: "enter,return",
local_close: "q,backspace",
host: "z",
}
const pick = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback
if (!value.trim()) return fallback
return value
}
const num = (value: unknown, fallback: number) => {
if (typeof value !== "number") return fallback
return value
}
const rec = (value: unknown) => {
if (!value || typeof value !== "object") return
return value as Record<string, unknown>
}
type Cfg = {
label: string
route: string
vignette: number
keybinds: Record<string, unknown> | undefined
}
type Route = {
modal: string
screen: string
}
type State = {
tab: number
count: number
source: string
note: string
selected: string
local: number
}
const cfg = (options: Record<string, unknown> | undefined) => {
return {
label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: rec(options?.keybinds),
}
}
const names = (input: Cfg) => {
return {
modal: `${input.route}.modal`,
screen: `${input.route}.screen`,
}
}
type Keys = TuiKeybindSet
const ui = {
panel: "#1d1d1d",
border: "#4a4a4a",
text: "#f0f0f0",
muted: "#a5a5a5",
accent: "#5f87ff",
}
type Color = RGBA | string
const tone = (api: TuiApi) => {
const map = api.theme.current as Record<string, unknown>
const get = (name: string, fallback: string): Color => {
const value = map[name]
if (typeof value === "string") return value
if (value && typeof value === "object") return value as RGBA
return fallback
}
return {
panel: get("backgroundPanel", ui.panel),
border: get("border", ui.border),
text: get("text", ui.text),
muted: get("textMuted", ui.muted),
accent: get("primary", ui.accent),
selected: get("selectedListItemText", ui.text),
}
}
type Skin = {
panel: Color
border: Color
text: Color
muted: Color
accent: Color
selected: Color
}
type CubeOpts = ConstructorParameters<typeof ThreeRenderable>[1] & {
tint?: Color
spec?: Color
ambient?: Color
key_light?: Color
fill_light?: Color
}
const rgb = (value: unknown, fallback: string) => {
if (typeof value === "string") return new THREE.Color(value)
if (value && typeof value === "object") {
const item = value as { r?: unknown; g?: unknown; b?: unknown }
if (typeof item.r === "number" && typeof item.g === "number" && typeof item.b === "number") {
return new THREE.Color(item.r, item.g, item.b)
}
}
return new THREE.Color(fallback)
}
class Cube extends ThreeRenderable {
private cube: THREE.Mesh
private mat: THREE.MeshPhongMaterial
private amb: THREE.AmbientLight
private key: THREE.DirectionalLight
private fill: THREE.DirectionalLight
constructor(ctx: RenderContext, opts: CubeOpts) {
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100)
camera.position.set(0, 0, 2.55)
const amb = new THREE.AmbientLight(rgb(opts.ambient, "#666666"), 1.0)
scene.add(amb)
const key = new THREE.DirectionalLight(rgb(opts.key_light, "#fff2e6"), 1.2)
key.position.set(2.5, 2.0, 3.0)
scene.add(key)
const fill = new THREE.DirectionalLight(rgb(opts.fill_light, "#80b3ff"), 0.6)
fill.position.set(-2.0, -1.5, 2.5)
scene.add(fill)
const geo = new THREE.BoxGeometry(1.0, 1.0, 1.0)
const mat = new THREE.MeshPhongMaterial({
color: rgb(opts.tint, "#40ccff"),
shininess: 80,
specular: rgb(opts.spec, "#e6e6ff"),
})
const cube = new THREE.Mesh(geo, mat)
cube.scale.setScalar(1.12)
scene.add(cube)
super(ctx, {
...opts,
scene,
camera,
renderer: {
focalLength: 8,
alpha: true,
backgroundColor: RGBA.fromValues(0, 0, 0, 0),
},
})
this.cube = cube
this.mat = mat
this.amb = amb
this.key = key
this.fill = fill
}
set tint(value: Color | undefined) {
this.mat.color.copy(rgb(value, "#40ccff"))
}
set spec(value: Color | undefined) {
this.mat.specular.copy(rgb(value, "#e6e6ff"))
}
set ambient(value: Color | undefined) {
this.amb.color.copy(rgb(value, "#666666"))
}
set key_light(value: Color | undefined) {
this.key.color.copy(rgb(value, "#fff2e6"))
}
set fill_light(value: Color | undefined) {
this.fill.color.copy(rgb(value, "#80b3ff"))
}
protected override renderSelf(buf: OptimizedBuffer, dt: number): void {
const delta = dt / 1000
this.cube.rotation.x += delta * 0.6
this.cube.rotation.y += delta * 0.4
this.cube.rotation.z += delta * 0.2
super.renderSelf(buf, dt)
}
}
declare module "@opentui/solid" {
interface OpenTUIComponents {
smoke_cube: RenderableConstructor
}
}
extend({ smoke_cube: Cube as unknown as RenderableConstructor })
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
return (
<box
onMouseUp={() => {
props.run()
}}
backgroundColor={props.on ? props.skin.accent : props.skin.border}
paddingLeft={1}
paddingRight={1}
>
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
</box>
)
}
const parse = (params: Record<string, unknown> | undefined) => {
const tab = typeof params?.tab === "number" ? params.tab : 0
const count = typeof params?.count === "number" ? params.count : 0
const source = typeof params?.source === "string" ? params.source : "unknown"
const note = typeof params?.note === "string" ? params.note : ""
const selected = typeof params?.selected === "string" ? params.selected : ""
const local = typeof params?.local === "number" ? params.local : 0
return {
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
count,
source,
note,
selected,
local: Math.max(0, local),
}
}
const current = (api: TuiApi, route: Route) => {
const value = api.route.current
const ok = Object.values(route).includes(value.name)
if (!ok) return parse(undefined)
if (!("params" in value)) return parse(undefined)
return parse(value.params)
}
const opts = [
{
title: "Overview",
value: 0,
description: "Switch to overview tab",
},
{
title: "Counter",
value: 1,
description: "Switch to counter tab",
},
{
title: "Help",
value: 2,
description: "Switch to help tab",
},
]
const host = (api: TuiApi, input: Cfg, skin: Skin) => {
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{input.label} host overlay</b>
</text>
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
<box flexDirection="row" gap={1}>
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
</box>
</box>
))
}
const warn = (api: TuiApi, route: Route, value: State) => {
const DialogAlert = api.ui.DialogAlert
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogAlert
title="Smoke alert"
message="Testing built-in alert dialog"
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
/>
))
}
const check = (api: TuiApi, route: Route, value: State) => {
const DialogConfirm = api.ui.DialogConfirm
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogConfirm
title="Smoke confirm"
message="Apply +1 to counter?"
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
/>
))
}
const entry = (api: TuiApi, route: Route, value: State) => {
const DialogPrompt = api.ui.DialogPrompt
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogPrompt
title="Smoke prompt"
value={value.note}
onConfirm={(note) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
}}
onCancel={() => {
api.ui.dialog.clear()
api.route.navigate(route.screen, value)
}}
/>
))
}
const picker = (api: TuiApi, route: Route, value: State) => {
const DialogSelect = api.ui.DialogSelect
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogSelect
title="Smoke select"
options={opts}
current={value.tab}
onSelect={(item) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, {
...value,
tab: typeof item.value === "number" ? item.value : value.tab,
selected: item.title,
source: "select",
})
}}
/>
))
}
const Screen = (props: {
api: TuiApi
input: Cfg
route: Route
keys: Keys
meta: TuiPluginInit
params?: Record<string, unknown>
}) => {
const dim = useTerminalDimensions()
const value = parse(props.params)
const skin = tone(props.api)
const set = (local: number, base?: State) => {
const next = base ?? current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
}
const push = (base?: State) => {
const next = base ?? current(props.api, props.route)
set(next.local + 1, next)
}
const open = () => {
const next = current(props.api, props.route)
if (next.local > 0) return
set(1, next)
}
const pop = (base?: State) => {
const next = base ?? current(props.api, props.route)
const local = Math.max(0, next.local - 1)
set(local, next)
}
const show = () => {
setTimeout(() => {
open()
}, 0)
}
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (props.api.ui.dialog.open) {
if (props.keys.match("dialog_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.ui.dialog.clear()
return
}
return
}
if (next.local > 0) {
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
pop(next)
return
}
if (props.keys.match("local_push", evt)) {
evt.preventDefault()
evt.stopPropagation()
push(next)
return
}
return
}
if (props.keys.match("home", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return
}
if (props.keys.match("left", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
return
}
if (props.keys.match("right", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
return
}
if (props.keys.match("up", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (props.keys.match("down", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (props.keys.match("modal", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (props.keys.match("local", evt)) {
evt.preventDefault()
evt.stopPropagation()
open()
return
}
if (props.keys.match("host", evt)) {
evt.preventDefault()
evt.stopPropagation()
host(props.api, props.input, skin)
return
}
if (props.keys.match("alert", evt)) {
evt.preventDefault()
evt.stopPropagation()
warn(props.api, props.route, next)
return
}
if (props.keys.match("confirm", evt)) {
evt.preventDefault()
evt.stopPropagation()
check(props.api, props.route, next)
return
}
if (props.keys.match("prompt", evt)) {
evt.preventDefault()
evt.stopPropagation()
entry(props.api, props.route, next)
return
}
if (props.keys.match("select", evt)) {
evt.preventDefault()
evt.stopPropagation()
picker(props.api, props.route, next)
}
})
return (
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
<box
flexDirection="column"
width="100%"
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
>
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
<text fg={skin.text}>
<b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span>
</text>
<text fg={skin.muted}>{props.keys.print("home")} home</text>
</box>
<box flexDirection="row" gap={1} paddingBottom={1}>
{tabs.map((item, i) => {
const on = value.tab === i
return (
<Btn
txt={item}
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
skin={skin}
on={on}
/>
)
})}
</box>
<box
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexGrow={1}
>
{value.tab === 0 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Route: {props.route.screen}</text>
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
<text fg={skin.muted}>
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.entry.load_count}
</text>
<text fg={skin.muted}>plugin source: {props.meta.entry.source}</text>
<text fg={skin.muted}>source: {value.source}</text>
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
<text fg={skin.muted}>local stack depth: {value.local}</text>
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
</box>
) : null}
{value.tab === 1 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}>
{props.keys.print("up")} / {props.keys.print("down")} change value
</text>
</box>
) : null}
{value.tab === 2 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.muted}>
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
</text>
<text fg={skin.muted}>
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
</text>
<text fg={skin.muted}>
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
close
</text>
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
</box>
) : null}
</box>
<box flexDirection="row" gap={1} paddingTop={1}>
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
<Btn txt="local overlay" run={show} skin={skin} />
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
</box>
</box>
<box
visible={value.local > 0}
width={dim().width}
height={dim().height}
alignItems="center"
position="absolute"
zIndex={3000}
paddingTop={dim().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
onMouseUp={() => {
pop()
}}
>
<box
onMouseUp={(evt) => {
evt.stopPropagation()
}}
width={60}
maxWidth={dim().width - 2}
backgroundColor={skin.panel}
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
flexDirection="column"
>
<text fg={skin.text}>
<b>{props.input.label} local overlay</b>
</text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<text fg={skin.muted}>
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
</text>
<box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on />
<Btn txt="pop" run={pop} skin={skin} />
</box>
</box>
</box>
</box>
)
}
const Modal = (props: { api: TuiApi; input: Cfg; route: Route; keys: Keys; params?: Record<string, unknown> }) => {
const Dialog = props.api.ui.Dialog
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.modal) return
if (props.keys.match("modal_accept", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
return
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
}
})
return (
<box width="100%" height="100%" backgroundColor={skin.panel}>
<Dialog onClose={() => props.api.route.navigate("home")}>
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{props.input.label} modal</b>
</text>
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
<text fg={skin.muted}>
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
</text>
<box flexDirection="row" gap={1}>
<Btn
txt="open screen"
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
skin={skin}
on
/>
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
</box>
</box>
</Dialog>
</box>
)
}
const slot = (input: Cfg) => ({
id: "workspace-smoke",
slots: {
home_logo(ctx) {
const map = ctx.theme.current as Record<string, unknown>
const get = (name: string, fallback: string) => {
const value = map[name]
if (typeof value === "string") return value
if (value && typeof value === "object") return value as RGBA
return fallback
}
const art = [
" $$\\",
" $$ |",
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
]
const ink = [
get("primary", ui.accent),
get("textMuted", ui.muted),
get("info", ui.accent),
get("text", ui.text),
get("success", ui.accent),
get("warning", ui.accent),
get("secondary", ui.accent),
get("error", ui.accent),
]
return (
<box flexDirection="column">
{art.map((line, i) => (
<text fg={ink[i]}>{line}</text>
))}
</box>
)
},
sidebar_top(ctx, value) {
const map = ctx.theme.current as Record<string, unknown>
const get = (name: string, fallback: string) => {
const item = map[name]
if (typeof item === "string") return item
if (item && typeof item === "object") return item as RGBA
return fallback
}
return (
<smoke_cube
id={`smoke-cube-${value.session_id.slice(0, 8)}`}
width="100%"
height={16}
tint={get("primary", ui.accent)}
spec={get("text", ui.text)}
ambient={get("textMuted", ui.muted)}
key_light={get("success", ui.accent)}
fill_light={get("info", ui.accent)}
/>
)
},
},
})
const reg = (api: TuiApi, input: Cfg, keys: Keys) => {
const route = names(input)
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: keys.get("modal"),
category: "Plugin",
slash: {
name: "smoke",
},
onSelect: () => {
api.route.navigate(route.modal, { source: "command" })
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: keys.get("screen"),
category: "Plugin",
slash: {
name: "smoke-screen",
},
onSelect: () => {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
},
},
{
title: `${input.label} alert dialog`,
value: "plugin.smoke.alert",
category: "Plugin",
slash: {
name: "smoke-alert",
},
onSelect: () => {
warn(api, route, current(api, route))
},
},
{
title: `${input.label} confirm dialog`,
value: "plugin.smoke.confirm",
category: "Plugin",
slash: {
name: "smoke-confirm",
},
onSelect: () => {
check(api, route, current(api, route))
},
},
{
title: `${input.label} prompt dialog`,
value: "plugin.smoke.prompt",
category: "Plugin",
slash: {
name: "smoke-prompt",
},
onSelect: () => {
entry(api, route, current(api, route))
},
},
{
title: `${input.label} select dialog`,
value: "plugin.smoke.select",
category: "Plugin",
slash: {
name: "smoke-select",
},
onSelect: () => {
picker(api, route, current(api, route))
},
},
{
title: `${input.label} host overlay`,
value: "plugin.smoke.host",
keybind: keys.get("host"),
category: "Plugin",
slash: {
name: "smoke-host",
},
onSelect: () => {
host(api, input, tone(api))
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: api.route.current.name !== "home",
onSelect: () => {
api.route.navigate("home")
},
},
{
title: `${input.label} toast`,
value: "plugin.smoke.toast",
category: "Plugin",
onSelect: () => {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
])
}
const tui = async (input: TuiPluginInput, options: Record<string, unknown> | null, meta: TuiPluginInit) => {
if (options?.enabled === false) return
await input.api.theme.install("./smoke-theme.json")
input.api.theme.set("smoke-theme")
const value = cfg(options ?? undefined)
const route = names(value)
const keys = input.api.keybind.create(bind, value.keybinds)
const fx = new VignetteEffect(value.vignette)
input.renderer.addPostProcessFn(fx.apply.bind(fx))
input.api.route.register([
{
name: route.screen,
render: ({ params }) => (
<Screen api={input.api} input={value} route={route} keys={keys} meta={meta} params={params} />
),
},
{
name: route.modal,
render: ({ params }) => <Modal api={input.api} input={value} route={route} keys={keys} params={params} />,
},
])
reg(input.api, value, keys)
input.slots.register(slot(value))
}
export default {
tui,
}

View File

@@ -1 +0,0 @@
smoke-theme.json

View File

@@ -48,11 +48,11 @@
"light": "nord10"
},
"text": {
"dark": "nord6",
"dark": "nord4",
"light": "nord0"
},
"textMuted": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord1"
},
"background": {
@@ -64,7 +64,7 @@
"light": "nord5"
},
"backgroundElement": {
"dark": "nord2",
"dark": "nord1",
"light": "nord4"
},
"border": {
@@ -88,11 +88,11 @@
"light": "nord11"
},
"diffContext": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord3"
},
"diffHunkHeader": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord3"
},
"diffHighlightAdded": {
@@ -104,12 +104,12 @@
"light": "nord11"
},
"diffAddedBg": {
"dark": "#36413C",
"light": "#E6EBE7"
"dark": "#3B4252",
"light": "#E5E9F0"
},
"diffRemovedBg": {
"dark": "#43393D",
"light": "#ECE6E8"
"dark": "#3B4252",
"light": "#E5E9F0"
},
"diffContextBg": {
"dark": "nord1",
@@ -120,12 +120,12 @@
"light": "nord4"
},
"diffAddedLineNumberBg": {
"dark": "#303A35",
"light": "#DDE4DF"
"dark": "#3B4252",
"light": "#E5E9F0"
},
"diffRemovedLineNumberBg": {
"dark": "#3C3336",
"light": "#E4DDE0"
"dark": "#3B4252",
"light": "#E5E9F0"
},
"markdownText": {
"dark": "nord4",
@@ -148,7 +148,7 @@
"light": "nord14"
},
"markdownBlockQuote": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord3"
},
"markdownEmph": {
@@ -160,7 +160,7 @@
"light": "nord13"
},
"markdownHorizontalRule": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord3"
},
"markdownListItem": {
@@ -184,7 +184,7 @@
"light": "nord0"
},
"syntaxComment": {
"dark": "#8B95A7",
"dark": "nord3",
"light": "nord3"
},
"syntaxKeyword": {

View File

@@ -1,6 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
@@ -24,7 +23,16 @@ interface PR {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),

View File

@@ -1,10 +0,0 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.

View File

@@ -1,20 +1,11 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: [
"thdxr",
"kommander",
// "rekram1-node" (on vacation)
],
core: [
"thdxr",
// "rekram1-node", (on vacation)
"jlongster",
],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
@@ -48,9 +39,17 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: {
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
@@ -73,8 +72,7 @@ export default tool({
results.push("Dropped label: nix (issue does not mention nix)")
}
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = web ? pick(TEAM.desktop) : args.assignee
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")

View File

@@ -1,8 +0,0 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
(Note: rekram1-node is on vacation, do not assign issues to him.)

View File

@@ -1,19 +0,0 @@
{
"$schema": "https://opencode.ai/tui.json",
"theme": "smoke-theme",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": true,
"label": "workspace",
"keybinds": {
"modal": "ctrl+alt+m",
"screen": "ctrl+alt+o",
"home": "escape,ctrl+shift+h",
"dialog_close": "escape,q"
}
}
]
]
}

View File

@@ -122,3 +122,7 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.

View File

@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
#### How is this different from Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences:
It's very similar to Claude Code in terms of capability. Here are the key differences::
- 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.

431
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-+SMpaj0jeIHjlddAu6QIwojmWFVIiA8/G32hiQMjcOk=",
"aarch64-linux": "sha256-uo63IF6OCMab+O3ngn1sVxqIGJMm04HXuDgIRmXNTNk=",
"aarch64-darwin": "sha256-yB2tWm6AsX6UifnDqe7VldhN5zTQkDoqZ87AGQYjxT4=",
"x86_64-darwin": "sha256-nNhtqMSG4/y+uxjj14Jc5QQ7X6hQli9ni4v56XAvaAU="
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
}
}

View File

@@ -41,8 +41,9 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.29",
"drizzle-kit": "1.0.0-beta.16-c2458b2",
"drizzle-orm": "1.0.0-beta.16-c2458b2",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -100,6 +101,7 @@
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],

View File

@@ -9,14 +9,12 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await expect(terminal).not.toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await prompt.fill("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await prompt.fill("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible()

View File

@@ -1,5 +1,6 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'

View File

@@ -27,6 +27,8 @@ async function run(page: Page, cmd: string) {
await terminal.click()
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
// powershell + windows just isnt that fast... we need to wait
await page.waitForTimeout(3_000)
}
async function store(page: Page, key: string) {

View File

@@ -49,14 +49,19 @@ const bad = (n: number | undefined, limit: number, low = false) => {
const session = (path: string) => path.includes("/session")
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string }) {
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
return (
<Tooltip value={props.tip} placement="left">
<div class="flex w-full flex-col items-center px-0.5 py-1 text-center">
<div class="text-[7px] font-black uppercase tracking-[0.04em] opacity-70 leading-none">{props.label}</div>
<Tooltip value={props.tip} placement="top">
<div
classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
<div
classList={{
"text-[9px] font-semibold leading-none tabular-nums": true,
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
@@ -355,10 +360,13 @@ export function DebugBar() {
return (
<aside
aria-label="Development performance diagnostics"
class="pointer-events-auto h-full min-h-0 w-[36px] shrink-0 overflow-y-auto text-text-on-interactive-base no-scrollbar sm:w-[38px]"
style={{ "background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)" }}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
style={{
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
"border-color": "color-mix(in srgb, white 14%, transparent)",
}}
>
<div class="flex min-h-full flex-col gap-0.5 py-2 font-mono">
<div class="grid grid-cols-5 gap-px font-mono">
<Cell
label="NAV"
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
@@ -425,6 +433,7 @@ export function DebugBar() {
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
wide
/>
</div>
</aside>

View File

@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
function user(id: string): Message {
return {
id,
role: "user",
sessionID: "session-1",
time: { created: 1 },
} as unknown as Message
}
function assistant(id: string, parentID: string): Message {
return {
id,
role: "assistant",
sessionID: "session-1",
parentID,
time: { created: 1 },
} as unknown as Message
}
describe("findAssistantMessages", () => {
test("normal ordering: assistant after user in array → found via forward scan", () => {
const messages = [user("u1"), assistant("a1", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("clock skew: assistant before user in array → found via backward scan", () => {
// When client clock is ahead, user ID sorts after assistant ID,
// so assistant appears earlier in the ID-sorted message array
const messages = [assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 1, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("no assistant messages → returns empty array", () => {
const messages = [user("u1"), user("u2")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(0)
})
test("multiple assistant messages with matching parentID → all found", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("a1")
expect(result[1].id).toBe("a2")
})
test("does not return assistant messages with different parentID", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops forward scan at next user message", () => {
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops backward scan at previous user message", () => {
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 3, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("invalid index returns empty array", () => {
const messages = [user("u1")]
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
})
})

View File

@@ -21,6 +21,8 @@ import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover"
@@ -229,6 +231,7 @@ export function SessionHeader() {
const sync = useSync()
const platform = usePlatform()
const language = useLanguage()
const terminal = useTerminal()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
@@ -296,6 +299,16 @@ export function SessionHeader() {
] as const
})
const toggleTerminal = () => {
const next = !view().terminal.opened()
view().terminal.toggle()
if (!next) return
const id = terminal.active()
if (!id) return
focusTerminalById(id)
}
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
@@ -617,39 +630,39 @@ export function SessionHeader() {
</div>
</Show>
<div class="flex items-center gap-1">
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().terminal.toggle()}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}

View File

@@ -185,9 +185,7 @@ export function StatusPopover() {
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() =>
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
)
const plugins = createMemo(() => sync.data.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
const overallHealthy = createMemo(() => {

View File

@@ -17,6 +17,7 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
autoFocus?: boolean
onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onConnect?: () => void
@@ -157,7 +158,7 @@ export const Terminal = (props: TerminalProps) => {
const language = useLanguage()
const server = useServer()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
@@ -386,7 +387,7 @@ export const Terminal = (props: TerminalProps) => {
handleLinkClick,
})
focusTerminal()
if (local.autoFocus !== false) focusTerminal()
if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(scheduleFit)

View File

@@ -266,6 +266,9 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">

View File

@@ -32,8 +32,9 @@ import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
import { createOpenReviewFile, createSizing, focusTerminalById } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
@@ -267,6 +268,7 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const comments = useComments()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
createEffect(() => {
@@ -759,8 +761,11 @@ export default function Page() {
return
}
// Don't autofocus chat if desktop terminal panel is open
if (isDesktop() && view().terminal.opened()) return
// Prefer the open terminal over the composer when it can take focus
if (view().terminal.opened()) {
const id = terminal.active()
if (id && focusTerminalById(id)) return
}
// Only treat explicit scroll keys as potential "user scroll" gestures.
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {

View File

@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
@@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
sending: false,
collapsed: false,
})
let root: HTMLDivElement | undefined
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const last = createMemo(() => store.tab >= total() - 1)
const fold = () => setStore("collapsed", (value) => !value)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
const next = value.trim()
@@ -257,9 +262,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
kind="question"
ref={(el) => (root = el)}
header={
<>
<div
data-action="session-question-toggle"
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
role="button"
tabIndex={0}
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
onClick={fold}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress">
<div data-slot="question-progress" class="ml-auto mr-1">
<For each={questions()}>
{(_, i) => (
<button
@@ -271,13 +288,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={store.sending}
onClick={() => jump(i())}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
jump(i())
}}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
</div>
</>
<div>
<IconButton
data-action="session-question-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
fold()
}}
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
/>
</div>
</div>
}
footer={
<>
@@ -297,56 +339,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
<div
data-slot="question-text"
class="cursor-default"
classList={{
"mb-6": store.collapsed && picked() === 0,
}}
role={store.collapsed ? "button" : undefined}
tabIndex={store.collapsed ? 0 : undefined}
onClick={fold}
onKeyDown={(event) => {
if (!store.collapsed) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
{question()?.question}
</div>
<Show when={store.collapsed && picked() > 0}>
<div data-slot="question-hint" class="cursor-default mb-6">
{picked()} answer{picked() === 1 ? "" : "s"} selected
</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
data-slot="question-option"
data-picked={picked()}
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
aria-checked={on()}
disabled={store.sending}
onClick={() => selectOption(i())}
onClick={customOpen}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending}
onClick={customOpen}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
@@ -365,80 +472,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
</div>
</div>
</DockPrompt>
)

View File

@@ -138,7 +138,6 @@ export function SessionTodoDock(props: {
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
}}
>
<AnimatedNumber value={done()} />
@@ -196,7 +195,6 @@ export function SessionTodoDock(props: {
style={{
visibility: off() ? "hidden" : "visible",
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
}}
>
<TodoList todos={props.todos} open={!store.collapsed} />

View File

@@ -1,6 +1,5 @@
import { For, Show, createEffect, createMemo, on } from "solid-js"
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
@@ -17,7 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers"
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
export function TerminalPanel() {
@@ -27,13 +26,10 @@ export function TerminalPanel() {
const language = useLanguage()
const command = useCommand()
const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const opened = createMemo(() => view().terminal.opened())
const open = createMemo(() => isDesktop() && opened())
const panel = createPresence(open)
const size = createSizing()
const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close()
@@ -42,6 +38,25 @@ export function TerminalPanel() {
const [store, setStore] = createStore({
autoCreated: false,
activeDraggable: undefined as string | undefined,
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
})
const max = () => store.view * 0.6
const pane = () => Math.min(height(), max())
createEffect(() => {
if (typeof window === "undefined") return
const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
const port = window.visualViewport
sync()
window.addEventListener("resize", sync)
port?.addEventListener("resize", sync)
onCleanup(() => {
window.removeEventListener("resize", sync)
port?.removeEventListener("resize", sync)
})
})
createEffect(() => {
@@ -66,21 +81,42 @@ export function TerminalPanel() {
),
)
const focus = (id: string) => {
focusTerminalById(id)
const frame = requestAnimationFrame(() => {
if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
})
const timers = [120, 240].map((ms) =>
window.setTimeout(() => {
if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
}, ms),
)
return () => {
cancelAnimationFrame(frame)
for (const timer of timers) clearTimeout(timer)
}
}
createEffect(
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !panel.open()) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
setTimeout(() => focusTerminalById(activeId), 0)
() => [opened(), terminal.active()] as const,
([next, id]) => {
if (!next || !id) return
const stop = focus(id)
onCleanup(stop)
},
),
)
createEffect(() => {
if (panel.open()) return
if (opened()) return
const active = document.activeElement
if (!(active instanceof HTMLElement)) return
if (!root?.contains(active)) return
@@ -138,150 +174,156 @@ export function TerminalPanel() {
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
requestAnimationFrame(() => {
if (terminal.active() !== activeId) return
focusTerminalById(activeId)
}, 0)
})
}
return (
<Show when={panel.show()}>
<div
ref={root}
id="terminal-panel"
role="region"
aria-label={language.t("terminal.title")}
aria-hidden={!opened()}
inert={!opened()}
class="relative w-full shrink-0 overflow-hidden bg-background-stronger"
classList={{
"border-t border-border-weak-base": opened(),
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
!size.active(),
}}
style={{ height: opened() ? `${pane()}px` : "0px" }}
>
<div
ref={root}
id="terminal-panel"
role="region"
aria-label={language.t("terminal.title")}
aria-hidden={!panel.open()}
inert={!panel.open()}
class="relative w-full shrink-0 overflow-hidden"
class="absolute inset-x-0 top-0 flex flex-col"
classList={{
"opacity-100": panel.open(),
"opacity-0 pointer-events-none": !panel.open(),
"transition-[height,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
"translate-y-0": opened(),
"translate-y-full pointer-events-none": !opened(),
"transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none":
!size.active(),
}}
style={{ height: panel.open() ? `${height()}px` : "0px" }}
style={{ height: `${pane()}px` }}
>
<div class="size-full flex flex-col border-t border-border-weak-base">
<div onPointerDown={() => size.start()}>
<ResizeHandle
direction="vertical"
size={height()}
min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
}}
onCollapse={close}
/>
</div>
<Show
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
</div>
)}
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">
{language.t("terminal.loading")}
</div>
</div>
}
>
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="flex flex-col h-full">
<Tabs
variant="alt"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10 border-b border-border-weaker-base">
<SortableProvider ids={ids()}>
<For each={ids()}>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
</Show>
)}
</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
title={language.t("command.terminal.new")}
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={terminal.new}
aria-label={language.t("command.terminal.new")}
/>
</TooltipKeybind>
<div class="hidden md:block" onPointerDown={() => size.start()}>
<ResizeHandle
direction="vertical"
size={pane()}
min={100}
max={max()}
collapseThreshold={50}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
}}
onCollapse={close}
/>
</div>
<Show
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
</div>
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
onConnect={() => terminal.trim(id)}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(id)}
/>
</div>
)}
</Show>
)}
</Show>
)}
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
</div>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
</div>
}
>
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="flex flex-col h-full">
<Tabs
variant="alt"
value={terminal.active()}
onChange={(id) => terminal.open(id)}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10 border-b border-border-weaker-base">
<SortableProvider ids={ids()}>
<For each={ids()}>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
</Show>
)}
</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
title={language.t("command.terminal.new")}
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={terminal.new}
aria-label={language.t("command.terminal.new")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
autoFocus={opened()}
onConnect={() => terminal.trim(id)}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(id)}
/>
</div>
)}
</Show>
)}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
</div>
</div>
</div>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div>
)}
</Show>
)}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
</div>
</Show>
</div>
)
}

View File

@@ -99,7 +99,13 @@ export async function handler(
const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
const trialProvider = await trialLimiter?.check()
const rateLimiter = createRateLimiter(modelInfo.allowAnonymous, ip, input.request)
const rateLimiter = createRateLimiter(
modelInfo.id,
modelInfo.allowAnonymous,
modelInfo.rateLimit,
ip,
input.request,
)
await rateLimiter?.check()
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
const stickyProvider = await stickyTracker?.get()

View File

@@ -6,39 +6,63 @@ import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
export function createRateLimiter(allowAnonymous: boolean | undefined, rawIp: string, request: Request) {
export function createRateLimiter(
modelId: string,
allowAnonymous: boolean | undefined,
rateLimit: number | undefined,
rawIp: string,
request: Request,
) {
if (!allowAnonymous) return
const dict = i18n(localeFromRequest(request))
const limits = Subscription.getFreeLimits()
const limitValue =
limits.checkHeader && !request.headers.get(limits.checkHeader) ? limits.fallbackValue : limits.dailyRequests
const headerExists = request.headers.has(limits.checkHeader)
const dailyLimit = !headerExists ? limits.fallbackValue : (rateLimit ?? limits.dailyRequests)
const isDefaultModel = headerExists && !rateLimit
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()
const interval = buildYYYYMMDD(now)
const lifetimeInterval = ""
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
let _isNew: boolean
return {
track: async () => {
await Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values({ ip, interval, count: 1 })
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
)
},
check: async () => {
const rows = await Database.use((tx) =>
tx
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, [interval]))),
.where(
and(
eq(IpRateLimitTable.ip, ip),
isDefaultModel
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
: inArray(IpRateLimitTable.interval, [dailyInterval]),
),
),
)
const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`)
if (total >= limitValue)
const lifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
const dailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
_isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
if ((_isNew && dailyCount >= dailyLimit * 2) || (!_isNew && dailyCount >= dailyLimit))
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], getRetryAfterDay(now))
},
track: async () => {
await Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values([
{ ip, interval: dailyInterval, count: 1 },
...(_isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
])
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
)
},
}
}

View File

@@ -28,6 +28,7 @@ export namespace ZenData {
stickyProvider: z.enum(["strict", "prefer"]).optional(),
trialProvider: z.string().optional(),
fallbackProvider: z.string().optional(),
rateLimit: z.number().optional(),
providers: z.array(
z.object({
id: z.string(),

View File

@@ -0,0 +1,17 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY,
`email` text NOT NULL,
`url` text NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`token_expiry` integer,
`selected_org_id` text,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `account_state` (
`id` integer PRIMARY KEY NOT NULL,
`active_account_id` text,
FOREIGN KEY (`active_account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set null
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
ALTER TABLE `account_state` ADD `active_org_id` text;--> statement-breakpoint
UPDATE `account_state` SET `active_org_id` = (SELECT `selected_org_id` FROM `account` WHERE `account`.`id` = `account_state`.`active_account_id`);--> statement-breakpoint
ALTER TABLE `account` DROP COLUMN `selected_org_id`;

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"test": "bun test --timeout 30000 registry",
"build": "bun run script/build.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
@@ -25,6 +25,13 @@
"exports": {
"./*": "./src/*.ts"
},
"imports": {
"#db": {
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
}
},
"devDependencies": {
"@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
@@ -41,13 +48,14 @@
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "catalog:",
"drizzle-kit": "catalog:",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -80,9 +88,12 @@
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -91,8 +102,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.0.0-20260307-536c401b",
"@opentui/solid": "0.0.0-20260307-536c401b",
"@opentui/core": "0.1.86",
"@opentui/solid": "0.1.86",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -107,7 +118,7 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "catalog:",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
@@ -126,6 +137,7 @@
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist",
format: "esm",
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")

View File

@@ -4,7 +4,7 @@ import { $ } from "bun"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import solidPlugin from "@opentui/solid/bun-plugin"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -59,7 +59,6 @@ console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const plugin = createSolidTransformPlugin({ mode: "build" })
const allTargets: {
os: string
@@ -174,7 +173,7 @@ for (const item of targets) {
await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
plugins: [solidPlugin],
sourcemap: "external",
compile: {
autoloadBunfig: false,

View File

@@ -0,0 +1,35 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../storage/schema.sql"
export const AccountTable = sqliteTable("account", {
id: text().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
...Timestamps,
})
export const AccountStateTable = sqliteTable("account_state", {
id: integer().primaryKey(),
active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text(),
})
// LEGACY
export const ControlAccountTable = sqliteTable(
"control_account",
{
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
},
(table) => [primaryKey({ columns: [table.email, table.url] })],
)

View File

@@ -0,0 +1,43 @@
import { Effect, Option, ServiceMap } from "effect"
import {
Account as AccountSchema,
type AccountError,
type AccessToken,
AccountID,
AccountService,
OrgID,
} from "./service"
export { AccessToken, AccountID, OrgID } from "./service"
import { runtime } from "@/effect/runtime"
type AccountServiceShape = ServiceMap.Service.Shape<typeof AccountService>
function runSync<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountService.use(f))
}
function runPromise<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountService.use(f))
}
export namespace Account {
export const Account = AccountSchema
export type Account = AccountSchema
export function active(): Account | undefined {
return Option.getOrUndefined(runSync((service) => service.active()))
}
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
const config = await runPromise((service) => service.config(accountID, orgID))
return Option.getOrUndefined(config)
}
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const token = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(token)
}
}

View File

@@ -0,0 +1,146 @@
import { eq } from "drizzle-orm"
import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql"
import { Account, AccountID, AccountRepoError, OrgID } from "./schema"
export type AccountRow = (typeof AccountTable)["$inferSelect"]
const decodeAccount = Schema.decodeUnknownSync(Account)
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
const ACCOUNT_STATE_ID = 1
const db = <A>(run: (db: DbClient) => A) =>
Effect.try({
try: () => Database.use(run),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
if (!state?.active_account_id) return
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
if (!account) return
return { ...account, active_org_id: state.active_org_id ?? null }
}
const setState = (db: DbClient, accountID: AccountID, orgID: string | null) =>
db
.insert(AccountStateTable)
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: orgID })
.onConflictDoUpdate({
target: AccountStateTable.id,
set: { active_account_id: accountID, active_org_id: orgID },
})
.run()
export class AccountRepo extends ServiceMap.Service<
AccountRepo,
{
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
readonly list: () => Effect.Effect<Account[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
readonly persistToken: (input: {
accountID: AccountID
accessToken: string
refreshToken: string
expiry: Option.Option<number>
}) => Effect.Effect<void, AccountRepoError>
readonly persistAccount: (input: {
id: AccountID
email: string
url: string
accessToken: string
refreshToken: string
expiry: number
orgID: Option.Option<OrgID>
}) => Effect.Effect<void, AccountRepoError>
}
>()("@opencode/AccountRepo") {
static readonly layer: Layer.Layer<AccountRepo> = Layer.succeed(
AccountRepo,
AccountRepo.of({
active: Effect.fn("AccountRepo.active")(() =>
db((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decodeAccount(row)) : Option.none()))),
),
list: Effect.fn("AccountRepo.list")(() =>
db((db) =>
db
.select()
.from(AccountTable)
.all()
.map((row) => decodeAccount({ ...row, active_org_id: null })),
),
),
remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
db((db) =>
Database.transaction((tx) => {
tx.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}),
).pipe(Effect.asVoid),
),
use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid),
),
getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
Effect.map(Option.fromNullishOr),
),
),
persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
db((db) =>
db
.update(AccountTable)
.set({
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: Option.getOrNull(input.expiry),
})
.where(eq(AccountTable.id, input.accountID))
.run(),
).pipe(Effect.asVoid),
),
persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => {
const orgID = Option.getOrNull(input.orgID)
return db((db) =>
Database.transaction((tx) => {
tx.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url: input.url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
setState(tx, input.id, orgID)
}),
).pipe(Effect.asVoid)
}),
}),
)
}

View File

@@ -0,0 +1,73 @@
import { Schema } from "effect"
import { withStatics } from "@/util/schema"
export const AccountID = Schema.String.pipe(
Schema.brand("AccountId"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type AccountID = Schema.Schema.Type<typeof AccountID>
export const OrgID = Schema.String.pipe(
Schema.brand("OrgId"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type OrgID = Schema.Schema.Type<typeof OrgID>
export const AccessToken = Schema.String.pipe(
Schema.brand("AccessToken"),
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
)
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
export class Account extends Schema.Class<Account>("Account")({
id: AccountID,
email: Schema.String,
url: Schema.String,
active_org_id: Schema.NullOr(OrgID),
}) {}
export class Org extends Schema.Class<Org>("Org")({
id: OrgID,
name: Schema.String,
}) {}
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export type AccountError = AccountRepoError | AccountServiceError
export class Login extends Schema.Class<Login>("Login")({
code: Schema.String,
user: Schema.String,
url: Schema.String,
server: Schema.String,
expiry: Schema.Number,
interval: Schema.Number,
}) {}
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
email: Schema.String,
}) {}
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
cause: Schema.Defect,
}) {}
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
export type PollResult = Schema.Schema.Type<typeof PollResult>

View File

@@ -0,0 +1,392 @@
import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
Account,
AccountID,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
} from "./schema"
export * from "./schema"
export type AccountOrgs = {
account: Account
orgs: Org[]
}
const RemoteOrg = Schema.Struct({
id: Schema.optional(OrgID),
name: Schema.optional(Schema.String),
})
const RemoteOrgs = Schema.Array(RemoteOrg)
const RemoteConfig = Schema.Struct({
config: Schema.Record(Schema.String, Schema.Json),
})
const TokenRefresh = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
const DeviceCode = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
verification_uri_complete: Schema.String,
expires_in: Schema.Number,
interval: Schema.Number,
})
const DeviceToken = Schema.Struct({
access_token: Schema.optional(Schema.String),
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
error: Schema.optional(Schema.String),
error_description: Schema.optional(Schema.String),
})
const User = Schema.Struct({
id: Schema.optional(AccountID),
email: Schema.optional(Schema.String),
})
const ClientId = Schema.Struct({ client_id: Schema.String })
const DeviceTokenRequest = Schema.Struct({
grant_type: Schema.String,
device_code: Schema.String,
client_id: Schema.String,
})
const clientId = "opencode-cli"
const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
const mapAccountServiceError =
(operation: string, message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((error) =>
error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error),
),
)
export class AccountService extends ServiceMap.Service<
AccountService,
{
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly orgsByAccount: () => Effect.Effect<AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
>()("@opencode/Account") {
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
AccountService,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
const executeEffect = <E>(operation: string, request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError(operation, "HTTP request failed"),
)
const okOrNone = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
HttpClientResponse.filterStatusOk(response).pipe(
Effect.map(Option.some),
Effect.catch((error) =>
HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError"
? Effect.succeed(Option.none<HttpClientResponse.HttpClientResponse>())
: Effect.fail(error),
),
mapAccountServiceError(operation),
)
const tokenForRow = Effect.fn("AccountService.tokenForRow")(function* (found: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (found.token_expiry && found.token_expiry > now) return Option.some(AccessToken.make(found.access_token))
const response = yield* execute(
"token.refresh",
HttpClientRequest.post(`${found.url}/oauth/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bodyUrlParams({
grant_type: "refresh_token",
refresh_token: found.refresh_token,
}),
),
)
const ok = yield* okOrNone("token.refresh", response)
if (Option.isNone(ok)) return Option.none()
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(ok.value).pipe(
mapAccountServiceError("token.refresh", "Failed to decode response"),
)
const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
yield* repo.persistToken({
accountID: AccountID.make(found.id),
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token ?? found.refresh_token,
expiry,
})
return Option.some(AccessToken.make(parsed.access_token))
})
const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
const account = maybeAccount.value
const accessToken = yield* tokenForRow(account)
if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
return Option.some({ account, accessToken: accessToken.value })
})
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
const response = yield* executeRead(
"orgs",
HttpClientRequest.get(`${account.url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
const ok = yield* okOrNone("orgs", response)
if (Option.isNone(ok)) return []
const orgs = yield* HttpClientResponse.schemaBodyJson(RemoteOrgs)(ok.value).pipe(
mapAccountServiceError("orgs", "Failed to decode response"),
)
return orgs
.filter((org) => org.id !== undefined && org.name !== undefined)
.map((org) => new Org({ id: org.id!, name: org.name! }))
})
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
"config",
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
const ok = yield* okOrNone("config", response)
if (Option.isNone(ok)) return Option.none()
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe(
mapAccountServiceError("config", "Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("AccountService.login")(function* (server: string) {
const response = yield* executeEffect(
"login",
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
),
)
const ok = yield* okOrNone("login", response)
if (Option.isNone(ok)) {
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
return yield* toAccountServiceError(`Failed to initiate device flow: ${body || response.status}`)
}
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceCode)(ok.value).pipe(
mapAccountServiceError("login", "Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
const response = yield* executeEffect(
"poll",
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("poll", "Failed to decode response"),
)
if (!parsed.access_token) {
if (parsed.error === "authorization_pending") return new PollPending()
if (parsed.error === "slow_down") return new PollSlow()
if (parsed.error === "expired_token") return new PollExpired()
if (parsed.error === "access_denied") return new PollDenied()
return new PollError({ cause: parsed.error })
}
const access = parsed.access_token
const fetchUser = executeRead(
"poll.user",
HttpClientRequest.get(`${input.server}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(access),
),
).pipe(
Effect.flatMap((r) =>
HttpClientResponse.schemaBodyJson(User)(r).pipe(
mapAccountServiceError("poll.user", "Failed to decode response"),
),
),
)
const fetchOrgs = executeRead(
"poll.orgs",
HttpClientRequest.get(`${input.server}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(access),
),
).pipe(
Effect.flatMap((r) =>
HttpClientResponse.schemaBodyJson(RemoteOrgs)(r).pipe(
mapAccountServiceError("poll.orgs", "Failed to decode response"),
),
),
)
const [user, remoteOrgs] = yield* Effect.all([fetchUser, fetchOrgs], { concurrency: 2 })
const userId = user.id
const userEmail = user.email
if (!userId || !userEmail) {
return new PollError({ cause: "No id or email in response" })
}
const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none()
const now = yield* Clock.currentTimeMillis
const expiry = now + (parsed.expires_in ?? 0) * 1000
const refresh = parsed.refresh_token ?? ""
if (!refresh) {
yield* Effect.logWarning(
"Server did not return a refresh token — session may expire without ability to refresh",
)
}
yield* repo.persistAccount({
id: userId,
email: userEmail,
url: input.server,
accessToken: access,
refreshToken: refresh,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: userEmail })
})
return AccountService.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
static readonly defaultLayer = AccountService.layer.pipe(
Layer.provide(AccountRepo.layer),
Layer.provide(FetchHttpClient.layer),
)
}

View File

@@ -63,6 +63,7 @@ export namespace Agent {
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
edit: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",

View File

@@ -1,13 +1,5 @@
import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { text } from "node:stream/consumers"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
import { Process } from "../util/process"
export namespace BunProc {
@@ -45,88 +37,4 @@ export namespace BunProc {
export function which() {
return process.execPath
}
export const InstallFailedError = NamedError.create(
"BunInstallFailedError",
z.object({
pkg: z.string(),
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest") {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
const result = { dependencies: {} as Record<string, string> }
await Filesystem.writeJson(pkgjsonPath, result)
return result
})
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
const dependencies = parsed.dependencies
const modExists = await Filesystem.exists(mod)
const cachedVersion = dependencies[pkg]
if (!modExists || !cachedVersion) {
// continue to install
} else if (version === "latest") {
if (!PackageRegistry.online()) return mod
const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!stale) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
} else if (cachedVersion === version) {
return mod
}
// Build command arguments
const args = [
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
]
// Let Bun handle registry resolution:
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
log.info("installing package using Bun's default registry resolution", {
pkg,
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
// Resolve actual version from installed package when using "latest"
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
() => null,
)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Filesystem.writeJson(pkgjsonPath, parsed)
return mod
}
}

View File

@@ -1,4 +1,3 @@
import semver from "semver"
import { text } from "node:stream/consumers"
import { Log } from "../util/log"
import { Process } from "../util/process"
@@ -10,24 +9,11 @@ export namespace PackageRegistry {
return process.execPath
}
export function online() {
const nav = globalThis.navigator
if (!nav || typeof nav.onLine !== "boolean") return true
return nav.onLine
}
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
if (!online()) {
log.debug("offline, skipping bun info", { pkg, field })
return null
}
const result = Process.spawn([which(), "info", pkg, field], {
cwd,
stdout: "pipe",
stderr: "pipe",
abort: AbortSignal.timeout(4_000),
timeout: 750,
env: {
...process.env,
BUN_BE_BUN: "1",
@@ -47,17 +33,4 @@ export namespace PackageRegistry {
if (!value) return null
return value
}
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
}

View File

@@ -0,0 +1,194 @@
import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { runtime } from "@/effect/runtime"
import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService
yield* Prompt.intro("Log in")
const login = yield* service.login(url)
yield* Prompt.log.info("Go to: " + login.url)
yield* Prompt.log.info("Enter code: " + login.user)
yield* openBrowser(login.url)
const s = Prompt.spinner()
yield* s.start("Waiting for authorization...")
const poll = (wait: number): Effect.Effect<PollResult, AccountError> =>
Effect.gen(function* () {
yield* Effect.sleep(wait)
const result = yield* service.poll(login)
if (result._tag === "PollPending") return yield* poll(wait)
if (result._tag === "PollSlow") return yield* poll(wait + 5000)
return result
})
const result = yield* poll(login.interval * 1000).pipe(
Effect.timeout(Duration.seconds(login.expiry)),
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
)
yield* Match.valueTags(result, {
PollSuccess: (r) =>
Effect.gen(function* () {
yield* s.stop("Logged in as " + r.email)
yield* Prompt.outro("Done")
}),
PollExpired: () => s.stop("Device code expired", 1),
PollDenied: () => s.stop("Authorization denied", 1),
PollError: (r) => s.stop("Error: " + String(r.cause), 1),
PollPending: () => s.stop("Unexpected state", 1),
PollSlow: () => s.stop("Unexpected state", 1),
})
})
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountService
const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in")
if (email) {
const match = accounts.find((a) => a.email === email)
if (!match) return yield* println("Account not found: " + email)
yield* service.remove(match.id)
yield* Prompt.outro("Logged out from " + email)
return
}
const active = yield* service.active()
const activeID = Option.map(active, (a) => a.id)
yield* Prompt.intro("Log out")
const opts = accounts.map((a) => {
const isActive = Option.isSome(activeID) && activeID.value === a.id
const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
return {
value: a,
label: isActive ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)" : `${a.email} ${server}`,
}
})
const selected = yield* Prompt.select({ message: "Select account to log out", options: opts })
if (Option.isNone(selected)) return
yield* service.remove(selected.value.id)
yield* Prompt.outro("Logged out from " + selected.value.email)
})
interface OrgChoice {
orgID: OrgID
accountID: AccountID
label: string
}
const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
const opts = groups.flatMap((group) =>
group.orgs.map((org) => {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive
? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
: `${org.name} (${group.account.email})`,
}
}),
)
if (opts.length === 0) return yield* println("No orgs found")
yield* Prompt.intro("Switch org")
const selected = yield* Prompt.select<OrgChoice>({ message: "Select org", options: opts })
if (Option.isNone(selected)) return
const choice = selected.value
yield* service.use(choice.accountID, Option.some(choice.orgID))
yield* Prompt.outro("Switched to " + choice.label)
})
const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found")
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
for (const group of groups) {
for (const org of group.orgs) {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
yield* println(` ${dot} ${name} ${email} ${id}`)
}
}
})
export const LoginCommand = cmd({
command: "login <url>",
describe: false,
builder: (yargs) =>
yargs.positional("url", {
describe: "server URL",
type: "string",
demandOption: true,
}),
async handler(args) {
UI.empty()
await runtime.runPromise(loginEffect(args.url))
},
})
export const LogoutCommand = cmd({
command: "logout [email]",
describe: false,
builder: (yargs) =>
yargs.positional("email", {
describe: "account email to log out from",
type: "string",
}),
async handler(args) {
UI.empty()
await runtime.runPromise(logoutEffect(args.email))
},
})
export const SwitchCommand = cmd({
command: "switch",
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(switchEffect())
},
})
export const OrgsCommand = cmd({
command: "orgs",
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(orgsEffect())
},
})

View File

@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
process.env.OPENCODE_CLIENT = "acp"
await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,

View File

@@ -10,7 +10,7 @@ import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData =
| { type: "session"; data: SDKSession }
| { type: "message"; data: Message }
@@ -24,6 +24,14 @@ export function parseShareUrl(url: string): string | null {
return match ? match[1] : null
}
export function shouldAttachShareAuthHeaders(shareUrl: string, accountBaseUrl: string): boolean {
try {
return new URL(shareUrl).origin === new URL(accountBaseUrl).origin
} catch {
return false
}
}
/**
* Transform ShareNext API response (flat array) into the nested structure for local file storage.
*
@@ -97,8 +105,21 @@ export const ImportCommand = cmd({
return
}
const baseUrl = await ShareNext.url()
const response = await fetch(`${baseUrl}/api/share/${slug}/data`)
const parsed = new URL(args.file)
const baseUrl = parsed.origin
const req = await ShareNext.request()
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
const dataPath = req.api.data(slug)
let response = await fetch(`${baseUrl}${dataPath}`, {
headers,
})
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
headers,
})
}
if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)

View File

@@ -13,14 +13,9 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]>
/**
* Handle plugin-based authentication flow.
* Returns true if auth was handled, false if it should fall through to default handling.
*/
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
let index = 0
if (methodName) {
@@ -33,7 +28,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
index = match
} else if (plugin.auth.methods.length > 1) {
const selected = await prompts.select({
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
@@ -42,13 +37,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})),
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
index = parseInt(selected)
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await sleep(10)
await new Promise((r) => setTimeout(r, 10))
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
@@ -171,11 +165,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
return false
}
/**
* Build a deduplicated list of plugin-registered auth providers that are not
* already present in models.dev, respecting enabled/disabled provider lists.
* Pure function with no side effects; safe to test without mocking.
*/
export function resolvePluginProviders(input: {
hooks: Hooks[]
existingProviders: Record<string, unknown>
@@ -203,19 +192,20 @@ export function resolvePluginProviders(input: {
return result
}
export const AuthCommand = cmd({
command: "auth",
describe: "manage credentials",
export const ProvidersCommand = cmd({
command: "providers",
aliases: ["auth"],
describe: "manage AI providers and credentials",
builder: (yargs) =>
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
async handler() {},
})
export const AuthListCommand = cmd({
export const ProvidersListCommand = cmd({
command: "list",
aliases: ["ls"],
describe: "list providers",
async handler() {
describe: "list providers and credentials",
async handler(_args) {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
@@ -231,7 +221,6 @@ export const AuthListCommand = cmd({
prompts.outro(`${results.length} credentials`)
// Environment variables section
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
@@ -258,7 +247,7 @@ export const AuthListCommand = cmd({
},
})
export const AuthLoginCommand = cmd({
export const ProvidersLoginCommand = cmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
@@ -356,7 +345,7 @@ export const AuthLoginCommand = cmd({
value: x.id,
hint: {
opencode: "recommended",
anthropic: "Claude Max or API key",
anthropic: "API key",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
@@ -409,7 +398,6 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
// Check if a plugin provides auth for this custom provider
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
@@ -461,10 +449,10 @@ export const AuthLoginCommand = cmd({
},
})
export const AuthLogoutCommand = cmd({
export const ProvidersLogoutCommand = cmd({
command: "logout",
describe: "log out from a configured provider",
async handler() {
async handler(_args) {
UI.empty()
const credentials = await Auth.all().then((x) => Object.entries(x))
prompts.intro("Remove credential")

View File

@@ -370,6 +370,11 @@ export const RunCommand = cmd({
action: "deny",
pattern: "*",
},
{
permission: "edit",
action: "allow",
pattern: "*",
},
]
function title() {

View File

@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})

View File

@@ -1,25 +1,13 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { createCliRenderer, MouseButton, TextAttributes, type CliRendererConfig, type ParsedKey } from "@opentui/core"
import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
Switch,
Match,
createEffect,
createMemo,
untrack,
ErrorBoundary,
createSignal,
onMount,
batch,
Show,
on,
} from "solid-js"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Installation } from "@/installation"
import { Flag } from "@/flag/flag"
import { Dialog as DialogUI, DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { SyncProvider, useSync } from "@tui/context/sync"
@@ -33,7 +21,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
@@ -41,9 +29,6 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { DialogPrompt } from "./ui/dialog-prompt"
import { DialogSelect } from "./ui/dialog-select"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
@@ -56,8 +41,6 @@ import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import type { TuiApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import { TuiPlugin } from "./plugin"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -121,25 +104,6 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
import type { EventSource } from "./context/sdk"
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
return {
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
Clipboard.copy(text).catch((error) => {
console.error(`Failed to copy console selection to clipboard: ${error}`)
})
},
},
}
}
export function tui(input: {
url: string
args: Args
@@ -165,57 +129,73 @@ export function tui(input: {
resolve()
}
const renderer = await createCliRenderer(rendererConfig(input.config))
await render(() => {
return (
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
>
<ArgsProvider {...input.args}>
<ExitProvider onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</ErrorBoundary>
)
}, renderer)
render(
() => {
return (
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
>
<ArgsProvider {...input.args}>
<ExitProvider onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</ErrorBoundary>
)
},
{
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
Clipboard.copy(text).catch((error) => {
console.error(`Failed to copy console selection to clipboard: ${error}`)
})
},
},
},
)
})
}
@@ -228,226 +208,12 @@ function App() {
const local = useLocal()
const kv = useKV()
const command = useCommandDialog()
const keybind = useKeybind()
const sdk = useSDK()
const toast = useToast()
const themeState = useTheme()
const { theme, mode, setMode } = themeState
const { theme, mode, setMode } = useTheme()
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
const routes = new Map<string, { key: symbol; render: TuiRouteDefinition["render"] }[]>()
const [routeRev, setRouteRev] = createSignal(0)
const routeView = (name: string) => {
routeRev()
return routes.get(name)?.at(-1)?.render
}
const api: TuiApi<JSX.Element> = {
command: {
register(cb) {
command.register(() => cb())
},
trigger(value) {
command.trigger(value)
},
},
route: {
register(input) {
const key = Symbol()
for (const item of input) {
const list = routes.get(item.name) ?? []
list.push({ key, render: item.render })
routes.set(item.name, list)
}
setRouteRev((x) => x + 1)
return () => {
for (const item of input) {
const list = routes.get(item.name)
if (!list) continue
routes.set(
item.name,
list.filter((x) => x.key !== key),
)
if (!routes.get(item.name)?.length) routes.delete(item.name)
}
setRouteRev((x) => x + 1)
}
},
navigate(name, params) {
if (name === "home") {
route.navigate({ type: "home" })
return
}
if (name === "session") {
const sessionID = params?.sessionID
if (typeof sessionID !== "string") return
route.navigate({ type: "session", sessionID })
return
}
route.navigate({ type: "plugin", id: name, data: params })
},
get current() {
if (route.data.type === "home") return { name: "home" }
if (route.data.type === "session") {
return {
name: "session",
params: {
sessionID: route.data.sessionID,
initialPrompt: route.data.initialPrompt,
},
}
}
return {
name: route.data.id,
params: route.data.data,
}
},
},
ui: {
Dialog(props) {
return (
<DialogUI size={props.size} onClose={props.onClose}>
{props.children as JSX.Element}
</DialogUI>
)
},
DialogAlert(props) {
return <DialogAlert {...props} />
},
DialogConfirm(props) {
return <DialogConfirm {...props} />
},
DialogPrompt(props) {
return <DialogPrompt {...props} description={props.description as (() => JSX.Element) | undefined} />
},
DialogSelect(props) {
const list = props.options.map((item) => ({
...item,
footer: item.footer as JSX.Element | string | undefined,
onSelect: () => item.onSelect?.(),
}))
return (
<DialogSelect
title={props.title}
placeholder={props.placeholder}
options={list}
flat={props.flat}
onMove={
props.onMove
? (item) =>
props.onMove?.({
title: item.title,
value: item.value,
description: item.description,
footer: item.footer,
category: item.category,
disabled: item.disabled,
})
: undefined
}
onFilter={props.onFilter}
onSelect={
props.onSelect
? (item) =>
props.onSelect?.({
title: item.title,
value: item.value,
description: item.description,
footer: item.footer,
category: item.category,
disabled: item.disabled,
})
: undefined
}
skipFilter={props.skipFilter}
current={props.current}
/>
)
},
toast(input) {
toast.show({
title: input.title,
message: input.message,
variant: input.variant ?? "info",
duration: input.duration,
})
},
dialog: {
replace(render, onClose) {
dialog.replace(render, onClose)
},
clear() {
dialog.clear()
},
setSize(size) {
dialog.setSize(size)
},
get size() {
return dialog.size
},
get depth() {
return dialog.stack.length
},
get open() {
return dialog.stack.length > 0
},
},
},
keybind: {
parse(evt: ParsedKey) {
return keybind.parse(evt)
},
match(key, evt: ParsedKey) {
return keybind.match(key, evt)
},
print(key) {
return keybind.print(key)
},
create(defaults, overrides) {
return keybind.create(defaults, overrides)
},
},
theme: {
get current() {
return theme
},
get selected() {
return themeState.selected
},
has(name) {
return themeState.has(name)
},
set(name) {
return themeState.set(name)
},
async install(_jsonPath) {
throw new Error("theme.install is only available in plugin context")
},
mode() {
return themeState.mode()
},
get ready() {
return themeState.ready
},
},
}
const [ready, setReady] = createSignal(false)
TuiPlugin.init({
client: sdk.client,
event: sdk.event,
renderer,
api,
})
.catch((error) => {
console.error("Failed to load TUI plugins", error)
})
.finally(() => {
setReady(true)
})
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
@@ -490,6 +256,10 @@ function App() {
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
createEffect(() => {
console.log(JSON.stringify(route.data))
})
// Update terminal window title based on current route and session
createEffect(() => {
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
@@ -506,13 +276,9 @@ function App() {
return
}
// Truncate title to 40 chars max
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`OC | ${title}`)
return
}
if (route.data.type === "plugin") {
renderer.setTerminalTitle(`OC | ${route.data.id}`)
}
})
@@ -636,9 +402,12 @@ function App() {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
const workspaceID =
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
route.navigate({
type: "home",
initialPrompt: currentPrompt,
workspaceID,
})
dialog.clear()
},
@@ -711,6 +480,7 @@ function App() {
{
title: "Toggle MCPs",
value: "mcp.list",
search: "toggle mcps",
category: "Agent",
slash: {
name: "mcps",
@@ -786,8 +556,9 @@ function App() {
category: "System",
},
{
title: "Toggle appearance",
title: mode() === "dark" ? "Light mode" : "Dark mode",
value: "theme.switch_mode",
search: "toggle appearance",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -826,6 +597,7 @@ function App() {
},
{
title: "Toggle debug panel",
search: "toggle debug",
category: "System",
value: "app.debug",
onSelect: (dialog) => {
@@ -835,6 +607,7 @@ function App() {
},
{
title: "Toggle console",
search: "toggle console",
category: "System",
value: "app.console",
onSelect: (dialog) => {
@@ -875,6 +648,7 @@ function App() {
{
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle",
search: "toggle terminal title",
keybind: "terminal_title_toggle",
category: "System",
onSelect: (dialog) => {
@@ -890,6 +664,7 @@ function App() {
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
search: "toggle animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
@@ -899,6 +674,7 @@ function App() {
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
search: "toggle diff wrapping",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")
@@ -983,58 +759,29 @@ function App() {
})
})
const plugin = createMemo(() => {
if (route.data.type !== "plugin") return
const render = routeView(route.data.id)
if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
return render({ params: route.data.data })
})
return (
<Show when={ready()}>
<box
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
<box
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={
Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)
}
>
<Show when={Flag.OPENCODE_SHOW_TTFD}>
<TimeToFirstDraw />
</Show>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
{plugin()}
<TuiPlugin.Slot name="app" />
</box>
</Show>
)
}
function PluginRouteMissing(props: { id: string; onHome: () => void }) {
const { theme } = useTheme()
return (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={theme.text}>go home</text>
</box>
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
</box>
)
}

View File

@@ -10,7 +10,7 @@ import {
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
import { type KeybindKey, useKeybind } from "@tui/context/keybind"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
@@ -21,7 +21,7 @@ export type Slash = {
}
export type CommandOption = DialogSelectOption<string> & {
keybind?: string
keybind?: KeybindKey
suggested?: boolean
slash?: Slash
hidden?: boolean

View File

@@ -7,6 +7,27 @@ import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import type { Provider } from "@opencode-ai/sdk/v2"
function pickLatest(models: [string, Provider["models"][string]][]) {
const picks: Record<string, [string, Provider["models"][string]]> = {}
for (const item of models) {
const model = item[0]
const info = item[1]
const key = info.family ?? model
const prev = picks[key]
if (!prev) {
picks[key] = item
continue
}
if (info.release_date !== prev[1].release_date) {
if (info.release_date > prev[1].release_date) picks[key] = item
continue
}
if (model > prev[0]) picks[key] = item
}
return Object.values(picks)
}
export function useConnected() {
const sync = useSync()
@@ -21,6 +42,7 @@ export function DialogModel(props: { providerID?: string }) {
const dialog = useDialog()
const keybind = useKeybind()
const [query, setQuery] = createSignal("")
const [all, setAll] = createSignal(false)
const connected = useConnected()
const providers = createDialogProviderOptions()
@@ -72,8 +94,8 @@ export function DialogModel(props: { providerID?: string }) {
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
flatMap((provider) => {
const items = pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
@@ -104,8 +126,9 @@ export function DialogModel(props: { providerID?: string }) {
(x) => x.footer !== "Free",
(x) => x.title,
),
),
),
)
return items
}),
)
const popularProviders = !connected()
@@ -154,6 +177,13 @@ export function DialogModel(props: { providerID?: string }) {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
{
keybind: keybind.all.model_show_all_toggle?.[0],
title: all() ? "Show latest only" : "Show all models",
onTrigger: () => {
setAll((value) => !value)
},
},
]}
onFilter={setQuery}
flat={true}

View File

@@ -36,7 +36,7 @@ export function createDialogProviderOptions() {
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],

View File

@@ -16,8 +16,7 @@ export function DialogStatus() {
const plugins = createMemo(() => {
const list = sync.data.config.plugin ?? []
const result = list.map((item) => {
const value = typeof item === "string" ? item : item[0]
const result = list.map((value) => {
if (value.startsWith("file://")) {
const path = fileURLToPath(value)
const parts = path.split("/")

View File

@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { setTimeout as sleep } from "node:timers/promises"
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
@@ -47,7 +48,7 @@ async function openWorkspace(input: {
}
let created: Session | undefined
while (!created) {
const result = await client.session.create({}).catch(() => undefined)
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to open workspace",
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await Bun.sleep(1000)
await sleep(1000)
continue
}
if (!result.data) {

View File

@@ -37,6 +37,7 @@ import { DialogSkill } from "../dialog-skill"
export type PromptProps = {
sessionID?: string
workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
@@ -77,6 +78,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
function promptModelWarning() {
toast.show({
@@ -170,6 +172,17 @@ export function Prompt(props: PromptProps) {
command.register(() => {
return [
{
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
value: "permission.auto_accept.toggle",
search: "toggle permissions",
keybind: "permission_auto_accept_toggle",
category: "Agent",
onSelect: (dialog) => {
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
dialog.clear()
},
},
{
title: "Clear prompt",
value: "prompt.clear",
@@ -542,7 +555,9 @@ export function Prompt(props: PromptProps) {
let sessionID = props.sessionID
if (sessionID == null) {
const res = await sdk.client.session.create({})
const res = await sdk.client.session.create({
workspaceID: props.workspaceID,
})
if (res.error) {
console.log("Creating a session failed:", res.error)
@@ -1009,23 +1024,30 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
</Show>
</box>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box>
</Show>
</box>
<Show when={autoaccept() === "edit"}>
<text>
<span style={{ fg: theme.warning }}>autoedit</span>
</text>
</Show>
</box>
</box>

View File

@@ -1,44 +0,0 @@
import type { ParsedKey } from "@opentui/core"
export type PluginKeybindMap = Record<string, string>
type Base<Info> = {
parse: (evt: ParsedKey) => Info
match: (key: string, evt: ParsedKey) => boolean
print: (key: string) => string
}
export type PluginKeybind<Info> = {
readonly all: PluginKeybindMap
get: (name: string) => string
parse: (evt: ParsedKey) => Info
match: (name: string, evt: ParsedKey) => boolean
print: (name: string) => string
}
const txt = (value: unknown) => {
if (typeof value !== "string") return
if (!value.trim()) return
return value
}
export function createPluginKeybind<Info>(
base: Base<Info>,
defaults: PluginKeybindMap,
overrides?: Record<string, unknown>,
): PluginKeybind<Info> {
const all = Object.freeze(
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
) as PluginKeybindMap
const get = (name: string) => all[name] ?? name
return {
get all() {
return all
},
get,
parse: (evt) => base.parse(evt),
match: (name, evt) => base.match(get(name), evt),
print: (name) => base.print(get(name)),
}
}

View File

@@ -6,7 +6,6 @@ import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { createPluginKeybind, type PluginKeybindMap } from "./keybind-plugin"
import { useTuiConfig } from "./tui-config"
export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
@@ -81,27 +80,21 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
}
return Keybind.fromParsedKey(evt, store.leader)
},
match(key: string, evt: ParsedKey) {
const list = keybinds()[key] ?? Keybind.parse(key)
if (!list.length) return false
match(key: KeybindKey, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
const parsed: Keybind.Info = result.parse(evt)
for (const item of list) {
if (Keybind.match(item, parsed)) {
for (const key of keybind) {
if (Keybind.match(key, parsed)) {
return true
}
}
return false
},
print(key: string) {
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
print(key: KeybindKey) {
const first = keybinds()[key]?.at(0)
if (!first) return ""
const text = Keybind.toString(first)
const lead = keybinds().leader?.[0]
if (!lead) return text
return text.replace("<leader>", Keybind.toString(lead))
},
create(defaults: PluginKeybindMap, overrides?: Record<string, unknown>) {
return createPluginKeybind(result, defaults, overrides)
const result = Keybind.toString(first)
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
},
}
return result

View File

@@ -5,6 +5,7 @@ import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = {
type: "home"
initialPrompt?: PromptInfo
workspaceID?: string
}
export type SessionRoute = {
@@ -13,13 +14,7 @@ export type SessionRoute = {
initialPrompt?: PromptInfo
}
export type PluginRoute = {
type: "plugin"
id: string
data?: Record<string, unknown>
}
export type Route = HomeRoute | SessionRoute | PluginRoute
export type Route = HomeRoute | SessionRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
@@ -37,6 +32,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store
},
navigate(route: Route) {
console.log("navigate", route)
setStore(route)
},
}

View File

@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { useKV } from "./kv"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
@@ -106,6 +107,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const sdk = useSDK()
const kv = useKV()
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
async function syncWorkspaces() {
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
@@ -136,6 +139,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "permission.asked": {
const request = event.properties
if (autoaccept() === "edit" && request.permission === "edit") {
sdk.client.permission.reply({
reply: "once",
requestID: request.id,
})
break
}
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
@@ -451,6 +461,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() {
return store.status !== "loading"
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)

View File

@@ -42,7 +42,6 @@ import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { useTuiConfig } from "./tui-config"
import { isRecord } from "@/util/record"
type ThemeColors = {
primary: RGBA
@@ -175,56 +174,6 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
carbonfox,
}
type State = {
themes: Record<string, ThemeJson>
mode: "dark" | "light"
active: string
ready: boolean
}
const [store, setStore] = createStore<State>({
themes: DEFAULT_THEMES,
mode: "dark",
active: "opencode",
ready: false,
})
function mergeThemes(themes: Record<string, ThemeJson>) {
setStore(
produce((draft) => {
for (const [name, theme] of Object.entries(themes)) {
if (draft.themes[name]) continue
draft.themes[name] = theme
}
}),
)
}
export function allThemes() {
return store.themes
}
function isTheme(theme: unknown): theme is ThemeJson {
if (!isRecord(theme)) return false
if (!isRecord(theme.theme)) return false
return true
}
export function hasTheme(name: string) {
if (!name) return false
return allThemes()[name] !== undefined
}
export function addTheme(name: string, theme: unknown) {
if (!name) return false
if (!isTheme(theme)) return false
if (hasTheme(name)) return false
mergeThemes({
[name]: theme,
})
return true
}
function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA {
@@ -333,14 +282,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
init: (props: { mode: "dark" | "light" }) => {
const config = useTuiConfig()
const kv = useKV()
setStore(
produce((draft) => {
draft.mode = kv.get("theme_mode", props.mode)
draft.active = (config.theme ?? kv.get("theme", "opencode")) as string
draft.ready = false
}),
)
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode),
active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
createEffect(() => {
const theme = config.theme
@@ -348,49 +295,55 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
function init() {
Promise.allSettled([
resolveSystemTheme(),
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
Object.assign(draft.themes, custom)
}),
)
})
.catch(() => {
setStore("active", "opencode")
}),
]).finally(() => {
setStore("ready", true)
})
resolveSystemTheme()
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
Object.assign(draft.themes, custom)
}),
)
})
.catch(() => {
setStore("active", "opencode")
})
.finally(() => {
if (store.active !== "system") {
setStore("ready", true)
}
})
}
onMount(init)
function resolveSystemTheme() {
return renderer
console.log("resolveSystemTheme")
renderer
.getPalette({
size: 16,
})
.then((colors) => {
console.log(colors.palette)
if (!colors.palette[0]) {
if (store.active === "system") {
setStore("active", "opencode")
setStore(
produce((draft) => {
draft.active = "opencode"
draft.ready = true
}),
)
}
return
}
setStore(
produce((draft) => {
draft.themes.system = generateSystem(colors, store.mode)
if (store.active === "system") {
draft.ready = true
}
}),
)
})
.catch(() => {
if (store.active === "system") {
setStore("active", "opencode")
}
})
}
const renderer = useRenderer()
@@ -417,10 +370,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return store.active
},
all() {
return allThemes()
},
has(name: string) {
return hasTheme(name)
return store.themes
},
syntax,
subtleSyntax,
@@ -432,10 +382,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
kv.set("theme_mode", mode)
},
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
kv.set("theme", theme)
return true
},
get ready() {
return store.ready

View File

@@ -1,323 +0,0 @@
import {
type TuiPlugin as TuiPluginFn,
type TuiPluginInit,
type TuiPluginInput,
type TuiTheme,
type TuiSlotContext,
type TuiSlotMap,
type TuiSlots,
type SlotMode,
} from "@opencode-ai/plugin/tui"
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
import type { CliRenderer } from "@opentui/core"
import "@opentui/solid/preload"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "@/config/config"
import { TuiConfig } from "@/config/tui"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import { resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared"
import { PluginMeta } from "@/plugin/meta"
import { addTheme, hasTheme } from "./context/theme"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
type SlotProps<K extends keyof TuiSlotMap> = {
name: K
mode?: SlotMode
children?: JSX.Element
} & TuiSlotMap[K]
type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
type InitInput = Omit<TuiPluginInput<CliRenderer>, "slots">
function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
return null
}
function isTuiSlotPlugin(value: unknown): value is SolidPlugin<TuiSlotMap, TuiSlotContext> {
if (!isRecord(value)) return false
if (typeof value.id !== "string") return false
if (!isRecord(value.slots)) return false
return true
}
function getTuiSlotPlugin(value: unknown) {
if (isTuiSlotPlugin(value)) return value
if (!isRecord(value)) return
if (!isTuiSlotPlugin(value.slots)) return
return value.slots
}
function isTuiPlugin(value: unknown): value is TuiPluginFn<CliRenderer> {
return typeof value === "function"
}
function getTuiPlugin(value: unknown) {
if (!isRecord(value) || !("tui" in value)) return
if (!isTuiPlugin(value.tui)) return
return value.tui
}
function isTheme(value: unknown) {
if (!isRecord(value)) return false
if (!isRecord(value.theme)) return false
return true
}
function localDir(file: string) {
const dir = path.dirname(file)
if (path.basename(dir) === ".opencode") return path.join(dir, "themes")
return path.join(dir, ".opencode", "themes")
}
function scopeDir(pluginMeta: TuiConfig.PluginMeta) {
if (pluginMeta.scope === "local") return localDir(pluginMeta.source)
return path.join(Global.Path.config, "themes")
}
function pluginRoot(spec: string, target: string) {
if (spec.startsWith("file://")) return path.dirname(fileURLToPath(spec))
if (target.startsWith("file://")) return path.dirname(fileURLToPath(target))
return target
}
function resolveThemePath(root: string, file: string) {
if (file.startsWith("file://")) return fileURLToPath(file)
if (path.isAbsolute(file)) return file
return path.resolve(root, file)
}
function themeName(file: string) {
return path.basename(file, path.extname(file))
}
function getPluginMeta(config: TuiConfig.Info, item: Config.PluginSpec) {
const key = Config.getPluginName(item)
const value = config.plugin_meta?.[key]
if (!value) {
throw new Error(`missing plugin metadata for ${key}`)
}
return value
}
function makeInstallFn(meta: TuiConfig.PluginMeta, root: string): TuiTheme["install"] {
return async (file) => {
const src = resolveThemePath(root, file)
const theme = themeName(src)
if (hasTheme(theme)) return
const text = await Bun.file(src)
.text()
.catch((error) => {
throw new Error(`failed to read theme at ${src}: ${error}`)
})
const data = JSON.parse(text)
if (!isTheme(data)) {
throw new Error(`invalid theme at ${src}`)
}
const dest = path.join(scopeDir(meta), `${theme}.json`)
if (!(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text)
}
addTheme(theme, data)
}
}
export namespace TuiPlugin {
const log = Log.create({ service: "tui.plugin" })
let loaded: Promise<void> | undefined
let view: Slot = empty
export const Slot: Slot = (props) => view(props)
function setupSlots(input: InitInput): TuiSlots {
const reg = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(
input.renderer,
{
theme: input.api.theme,
},
{
onPluginError(event) {
console.error("[tui.slot] plugin error", {
plugin: event.pluginId,
slot: event.slot,
phase: event.phase,
source: event.source,
message: event.error.message,
})
},
},
)
const slot = createSlot<TuiSlotMap, TuiSlotContext>(reg)
view = (props) => slot(props)
return {
register(pluginSlot) {
if (!isTuiSlotPlugin(pluginSlot)) return () => {}
return reg.register(pluginSlot)
},
}
}
export async function init(input: InitInput) {
if (loaded) return loaded
loaded = load({
...input,
slots: setupSlots(input),
})
return loaded
}
async function load(input: TuiPluginInput<CliRenderer>) {
const dir = process.cwd()
await Instance.provide({
directory: dir,
fn: async () => {
const config = await TuiConfig.get()
const plugins = config.plugin ?? []
let deps: Promise<void> | undefined
const wait = async () => {
if (deps) {
await deps
return
}
deps = TuiConfig.waitForDependencies().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error })
})
await deps
}
const prep = async (item: (typeof plugins)[number], retry = false) => {
const spec = Config.pluginSpecifier(item)
log.info("loading tui plugin", { path: spec, retry })
const target = await resolvePluginTarget(spec).catch((error) => {
log.error("failed to resolve tui plugin", { path: spec, retry, error })
return
})
if (!target) return
const meta = await PluginMeta.touch(spec, target).catch((error) => {
log.warn("failed to track tui plugin", { path: spec, retry, error })
})
if (meta && meta.state !== "same") {
log.info("tui plugin metadata updated", {
path: spec,
retry,
state: meta.state,
source: meta.entry.source,
version: meta.entry.version,
modified: meta.entry.modified,
})
}
const now = Date.now()
const init: TuiPluginInit = meta
? {
state: meta.state,
entry: meta.entry,
}
: {
state: "first",
entry: {
name: spec,
source: spec.startsWith("file://") ? "file" : "npm",
spec,
target,
first_time: now,
last_time: now,
time_changed: now,
load_count: 1,
fingerprint: target,
},
}
const root = pluginRoot(spec, target)
const install = makeInstallFn(getPluginMeta(config, item), root)
const mod = await import(target).catch((error) => {
log.error("failed to load tui plugin", { path: spec, retry, error })
return
})
if (!mod) return
return {
item,
spec,
mod,
install,
init,
}
}
try {
const loaded = await Promise.all(plugins.map((item) => prep(item)))
for (let i = 0; i < plugins.length; i++) {
let load = loaded[i]
if (!load) {
const item = plugins[i]
if (!item) continue
const spec = Config.pluginSpecifier(item)
if (!spec.startsWith("file://")) continue
await wait()
load = await prep(item, true)
}
if (!load) continue
// Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering.
for (const [name, value] of uniqueModuleEntries(load.mod)) {
if (!value || typeof value !== "object") {
log.warn("ignoring non-object tui plugin export", {
path: load.spec,
name,
type: value === null ? "null" : typeof value,
})
continue
}
const slotPlugin = getTuiSlotPlugin(value)
if (slotPlugin) input.slots.register(slotPlugin)
const tuiPlugin = getTuiPlugin(value)
if (!tuiPlugin) continue
await tuiPlugin(
{
...input,
api: {
command: input.api.command,
route: input.api.route,
ui: input.api.ui,
keybind: input.api.keybind,
theme: Object.create(input.api.theme, {
install: {
value: load.install,
configurable: true,
enumerable: true,
},
}),
},
},
Config.pluginOptions(load.item) ?? null,
load.init,
)
}
}
} finally {
await PluginMeta.persist().catch((error) => {
log.warn("failed to persist tui plugin metadata", { error })
})
}
},
}).catch((error) => {
log.error("failed to load tui plugins", { directory: dir, error })
})
}
}

View File

@@ -15,7 +15,6 @@ import { Installation } from "@/installation"
import { useKV } from "../context/kv"
import { useCommandDialog } from "../component/dialog-command"
import { useLocal } from "../context/local"
import { TuiPlugin } from "../plugin"
// TODO: what is the best way to do this?
let once = false
@@ -48,6 +47,7 @@ export function Home() {
{
title: tipsHidden() ? "Show tips" : "Hide tips",
value: "tips.toggle",
search: "toggle tips",
keybind: "tips_toggle",
category: "System",
onSelect: (dialog) => {
@@ -58,8 +58,8 @@ export function Home() {
])
const Hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<Show when={connectedMcpCount() > 0}>
<Show when={connectedMcpCount() > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
@@ -72,8 +72,8 @@ export function Home() {
</Match>
</Switch>
</text>
</Show>
</box>
</box>
</Show>
)
let prompt: PromptRef
@@ -112,9 +112,7 @@ export function Home() {
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<TuiPlugin.Slot name="home_logo" mode="replace">
<Logo />
</TuiPlugin.Slot>
<Logo />
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
@@ -124,6 +122,7 @@ export function Home() {
promptRef.set(r)
}}
hint={Hint}
workspaceID={route.workspaceID}
/>
</box>
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>

View File

@@ -70,6 +70,7 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
@@ -382,7 +383,12 @@ export function Session() {
sessionID: route.sessionID,
})
.then((res) => copy(res.data!.share!.url))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
.catch((error) => {
toast.show({
message: error instanceof Error ? error.message : "Failed to share session",
variant: "error",
})
})
dialog.clear()
},
},
@@ -485,7 +491,12 @@ export function Session() {
sessionID: route.sessionID,
})
.then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
.catch(() => toast.show({ message: "Failed to unshare session", variant: "error" }))
.catch((error) => {
toast.show({
message: error instanceof Error ? error.message : "Failed to unshare session",
variant: "error",
})
})
dialog.clear()
},
},
@@ -557,6 +568,7 @@ export function Session() {
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle",
search: "toggle sidebar",
keybind: "sidebar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -571,6 +583,7 @@ export function Session() {
{
title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal",
search: "toggle code concealment",
keybind: "messages_toggle_conceal" as any,
category: "Session",
onSelect: (dialog) => {
@@ -581,6 +594,7 @@ export function Session() {
{
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
value: "session.toggle.timestamps",
search: "toggle timestamps",
category: "Session",
slash: {
name: "timestamps",
@@ -594,6 +608,7 @@ export function Session() {
{
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
search: "toggle thinking",
keybind: "display_thinking",
category: "Session",
slash: {
@@ -608,6 +623,7 @@ export function Session() {
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
search: "toggle tool details",
keybind: "tool_details",
category: "Session",
onSelect: (dialog) => {
@@ -616,8 +632,9 @@ export function Session() {
},
},
{
title: "Toggle session scrollbar",
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
value: "session.toggle.scrollbar",
search: "toggle session scrollbar",
keybind: "scrollbar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -896,12 +913,12 @@ export function Session() {
const filename = options.filename.trim()
const filepath = path.join(exportDir, filename)
await Bun.write(filepath, transcript)
await Filesystem.write(filepath, transcript)
// Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) {
await Bun.write(filepath, result)
await Filesystem.write(filepath, result)
}
toast.show({ message: `Session exported to ${filename}`, variant: "success" })

View File

@@ -11,7 +11,6 @@ import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"
import { TuiPlugin } from "../../plugin"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
@@ -91,7 +90,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
}}
>
<box flexShrink={0} gap={1} paddingRight={1}>
<TuiPlugin.Slot name="sidebar_top" session_id={props.sessionID} />
<box paddingRight={1}>
<text fg={theme.text}>
<b>{session().title}</b>

View File

@@ -34,6 +34,7 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
search?: string
footer?: JSX.Element | string
category?: string
disabled?: boolean
@@ -85,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
// users typically search by the item name, and not its category.
const result = fuzzysort
.go(needle, options, {
keys: ["title", "category"],
scoreFn: (r) => r[0].score * 2 + r[1].score,
keys: ["title", "category", "search"],
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
})
.map((x) => x.obj)

View File

@@ -35,7 +35,6 @@ export function Dialog(
height={dimensions().height}
alignItems="center"
position="absolute"
zIndex={3000}
paddingTop={dimensions().height / 4}
left={0}
top={0}
@@ -71,10 +70,8 @@ function init() {
useKeyboard((evt) => {
if (store.stack.length === 0) return
if (evt.defaultPrevented) return
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
@@ -154,7 +151,6 @@ export function DialogProvider(props: ParentProps) {
{props.children}
<box
position="absolute"
zIndex={3000}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return

View File

@@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
@@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Bun.Server<BunWebSocketData> | undefined
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStream = {
abort: undefined as AbortController | undefined,
@@ -120,7 +119,7 @@ export const rpc = {
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true)
server = Server.listen(input)
server = await Server.listen(input)
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
@@ -143,7 +142,7 @@ export const rpc = {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
await Instance.disposeAll()
if (server) server.stop(true)
if (server) await server.stop(true)
},
}

View File

@@ -37,7 +37,7 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
UI.empty()
UI.println(UI.logo(" "))
UI.empty()

View File

@@ -0,0 +1,25 @@
import * as prompts from "@clack/prompts"
import { Effect, Option } from "effect"
export const intro = (msg: string) => Effect.sync(() => prompts.intro(msg))
export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
export const log = {
info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
}
export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
Effect.tryPromise(() => prompts.select(opts)).pipe(
Effect.map((result) => {
if (prompts.isCancel(result)) return Option.none<Value>()
return Option.some(result)
}),
)
export const spinner = () => {
const s = prompts.spinner()
return {
start: (msg: string) => Effect.sync(() => s.start(msg)),
stop: (msg: string, code?: number) => Effect.sync(() => s.stop(msg, code)),
}
}

View File

@@ -12,6 +12,7 @@ import { lazy } from "../util/lazy"
import { NamedError } from "@opencode-ai/util/error"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { Env } from "../env"
import {
type ParseError as JsoncParseError,
applyEdits,
@@ -21,7 +22,6 @@ import {
} from "jsonc-parser"
import { Instance } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
@@ -29,21 +29,14 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { isRecord } from "@/util/record"
import { Control } from "@/control"
import { Account } from "@/account"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import { Npm } from "@/npm"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
const PluginOptions = z.record(z.string(), z.unknown())
export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
export type PluginOptions = z.infer<typeof PluginOptions>
export type PluginSpec = z.infer<typeof PluginSpec>
const log = Log.create({ service: "config" })
@@ -114,10 +107,6 @@ export namespace Config {
}
}
const token = await Control.token()
if (token) {
}
// Global user config overrides remote config.
result = mergeConfigConcatArrays(result, await global())
@@ -161,8 +150,7 @@ export namespace Config {
deps.push(
iife(async () => {
const shouldInstall = await needsInstall(dir)
if (shouldInstall) await installDependencies(dir)
await installDependencies(dir)
}),
)
@@ -184,6 +172,32 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const active = Account.active()
if (active?.active_org_id) {
try {
const [config, token] = await Promise.all([
Account.config(active.id, active.active_org_id),
Account.token(active.id),
])
if (token) {
process.env["OPENCODE_CONSOLE_TOKEN"] = token
Env.set("OPENCODE_CONSOLE_TOKEN", token)
}
if (config) {
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(config), {
dir: path.dirname(`${active.url}/api/config`),
source: `${active.url}/api/config`,
}),
)
}
} catch (err: any) {
log.debug("failed to fetch remote account config", { error: err?.message ?? err })
}
}
// Load managed config files last (highest priority) - enterprise admin-controlled
// Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions
@@ -252,6 +266,10 @@ export namespace Config {
}
export async function installDependencies(dir: string) {
if (!(await isWritable(dir))) {
log.info("config dir is not writable, skipping dependency install", { dir })
return
}
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
@@ -265,22 +283,15 @@ export namespace Config {
await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore")
const hasGitIgnore = await Filesystem.exists(gitignore)
if (!hasGitIgnore)
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
if (!(await Filesystem.exists(gitignore)))
await Filesystem.write(
gitignore,
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
)
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch((err) => {
log.warn("failed to install dependencies", { dir, error: err })
})
await Npm.install(dir)
}
async function isWritable(dir: string) {
@@ -292,42 +303,6 @@ export namespace Config {
}
}
export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
if (!writable) {
log.debug("config dir is not writable, skipping dependency install", { dir })
return false
}
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgExists = await Filesystem.exists(pkg)
if (!pkgExists) return true
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
if (!PackageRegistry.online()) return false
const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!stale) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
})
return true
}
if (depVersion === targetVersion) return false
return true
}
function rel(item: string, patterns: string[]) {
const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) {
@@ -456,7 +431,7 @@ export namespace Config {
}
async function loadPlugin(dir: string) {
const plugins: PluginSpec[] = []
const plugins: string[] = []
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
@@ -469,32 +444,6 @@ export namespace Config {
return plugins
}
export function pluginSpecifier(plugin: PluginSpec): string {
return Array.isArray(plugin) ? plugin[0] : plugin
}
export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
return Array.isArray(plugin) ? plugin[1] : undefined
}
export function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): PluginSpec {
const spec = pluginSpecifier(plugin)
try {
const resolved = import.meta.resolve!(spec, configFilepath)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
} catch {
try {
const require = createRequire(configFilepath)
const resolved = pathToFileURL(require.resolve(spec)).href
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
} catch {
return plugin
}
}
}
/**
* Extracts a canonical plugin name from a plugin specifier.
* - For file:// URLs: extracts filename without extension
@@ -505,16 +454,15 @@ export namespace Config {
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
*/
export function getPluginName(plugin: PluginSpec): string {
const spec = pluginSpecifier(plugin)
if (spec.startsWith("file://")) {
return path.parse(new URL(spec).pathname).name
export function getPluginName(plugin: string): string {
if (plugin.startsWith("file://")) {
return path.parse(new URL(plugin).pathname).name
}
const lastAt = spec.lastIndexOf("@")
const lastAt = plugin.lastIndexOf("@")
if (lastAt > 0) {
return spec.substring(0, lastAt)
return plugin.substring(0, lastAt)
}
return spec
return plugin
}
/**
@@ -528,14 +476,14 @@ export namespace Config {
* Since plugins are added in low-to-high priority order,
* we reverse, deduplicate (keeping first occurrence), then restore order.
*/
export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
export function deduplicatePlugins(plugins: string[]): string[] {
// seenNames: canonical plugin names for duplicate detection
// e.g., "oh-my-opencode", "@scope/pkg"
const seenNames = new Set<string>()
// uniqueSpecifiers: full plugin specifiers to return
// e.g., "oh-my-opencode@2.4.3", ["file:///path/to/plugin.js", { ... }]
const uniqueSpecifiers: PluginSpec[] = []
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
const uniqueSpecifiers: string[] = []
for (const specifier of plugins.toReversed()) {
const name = getPluginName(specifier)
@@ -806,6 +754,7 @@ export namespace Config {
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
@@ -846,7 +795,12 @@ export namespace Config {
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
permission_auto_accept_toggle: z
.string()
.optional()
.default("shift+tab")
.describe("Toggle auto-accept mode for permissions"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
@@ -1006,6 +960,14 @@ export namespace Config {
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
chunkTimeout: z
.number()
.int()
.positive()
.optional()
.describe(
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
),
})
.catchall(z.any())
.optional(),
@@ -1031,7 +993,7 @@ export namespace Config {
ignore: z.array(z.string()).optional(),
})
.optional(),
plugin: PluginSpec.array().optional(),
plugin: z.string().array().optional(),
snapshot: z.boolean().optional(),
share: z
.enum(["manual", "auto", "disabled"])
@@ -1279,7 +1241,19 @@ export namespace Config {
const data = parsed.data
if (data.plugin && isFile) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = resolvePluginSpec(data.plugin[i], options.path)
const plugin = data.plugin[i]
try {
data.plugin[i] = import.meta.resolve!(plugin, options.path)
} catch (e) {
try {
// import.meta.resolve sometimes fails with newly created node_modules
const require = createRequire(options.path)
const resolvedPath = require.resolve(plugin)
data.plugin[i] = pathToFileURL(resolvedPath).href
} catch {
// Ignore, plugin might be a generic string identifier like "mcp-server"
}
}
}
}
return data
@@ -1326,6 +1300,10 @@ export namespace Config {
return candidates[0]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
if (!isRecord(patch)) {
const edits = modify(input, path, patch, {
@@ -1419,3 +1397,5 @@ export namespace Config {
return state().then((x) => x.directories)
}
}
Filesystem.write
Filesystem.write

View File

@@ -29,7 +29,6 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
plugin: Config.PluginSpec.array().optional(),
})
.extend(TuiOptions.shape)
.strict()

View File

@@ -8,7 +8,6 @@ import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
export namespace TuiConfig {
@@ -16,43 +15,10 @@ export namespace TuiConfig {
export const Info = TuiInfo
export type PluginMeta = {
scope: "global" | "local"
source: string
}
type PluginEntry = {
item: Config.PluginSpec
meta: PluginMeta
}
export type Info = z.output<typeof Info> & {
plugin_meta?: Record<string, PluginMeta>
}
function pluginScope(file: string): PluginMeta["scope"] {
if (Instance.containsPath(file)) return "local"
return "global"
}
function dedupePlugins(list: PluginEntry[]) {
const seen = new Set<string>()
const result: PluginEntry[] = []
for (const item of list.toReversed()) {
const name = Config.getPluginName(item.item)
if (seen.has(name)) continue
seen.add(name)
result.push(item)
}
return result.toReversed()
}
export type Info = z.output<typeof Info>
function mergeInfo(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = [...target.plugin, ...source.plugin]
}
return merged
return mergeDeep(target, source)
}
function customPath() {
@@ -73,74 +39,37 @@ export namespace TuiConfig {
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
let result: Info = {}
const pluginEntries: PluginEntry[] = []
const mergeFile = async (file: string) => {
const data = await loadFile(file)
result = mergeInfo(result, data)
if (!data.plugin?.length) return
const sourceScope = pluginScope(file)
for (const item of data.plugin) {
pluginEntries.push({
item,
meta: {
scope: sourceScope,
source: file,
},
})
}
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(file)
result = mergeInfo(result, await loadFile(file))
}
if (custom) {
await mergeFile(custom)
result = mergeInfo(result, await loadFile(custom))
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
await mergeFile(file)
result = mergeInfo(result, await loadFile(file))
}
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(file)
result = mergeInfo(result, await loadFile(file))
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
await mergeFile(file)
result = mergeInfo(result, await loadFile(file))
}
}
const merged = dedupePlugins(pluginEntries)
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
result.plugin = merged.map((item) => item.item)
result.plugin_meta = merged.length
? Object.fromEntries(merged.map((item) => [Config.getPluginName(item.item), item.meta]))
: undefined
const deps: Promise<void>[] = []
if (result.plugin?.length) {
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
deps.push(
(async () => {
const shouldInstall = await Config.needsInstall(dir)
if (!shouldInstall) return
await Config.installDependencies(dir)
})(),
)
}
}
return {
config: result,
deps,
}
})
@@ -148,11 +77,6 @@ export namespace TuiConfig {
return state().then((x) => x.config)
}
export async function waitForDependencies() {
const deps = await state().then((x) => x.deps)
await Promise.all(deps)
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
@@ -163,19 +87,19 @@ export namespace TuiConfig {
}
async function load(text: string, configFilepath: string): Promise<Info> {
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!isRecord(raw)) return {}
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = (() => {
const copy = { ...raw }
const copy = { ...(data as Record<string, unknown>) }
if (!("tui" in copy)) return copy
if (!isRecord(copy.tui)) {
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
delete copy.tui
return copy
}
const tui = copy.tui
const tui = copy.tui as Record<string, unknown>
delete copy.tui
return {
...tui,
@@ -189,13 +113,6 @@ export namespace TuiConfig {
return {}
}
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = Config.resolvePluginSpec(data.plugin[i], configFilepath)
}
}
return data
return parsed.data
}
}

View File

@@ -1,3 +1,4 @@
import { createAdaptorServer } from "@hono/node-server"
import { Hono } from "hono"
import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap"
@@ -55,10 +56,24 @@ export namespace WorkspaceServer {
}
export function Listen(opts: { hostname: string; port: number }) {
return Bun.serve({
hostname: opts.hostname,
port: opts.port,
const server = createAdaptorServer({
fetch: App().fetch,
})
server.listen(opts.port, opts.hostname)
return {
hostname: opts.hostname,
port: opts.port,
stop() {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
},
}
}
}

View File

@@ -1,5 +1,5 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "@/project/project.sql"
import { ProjectTable } from "../project/project.sql"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().primaryKey(),

View File

@@ -1,4 +1,5 @@
import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { Identifier } from "@/id/id"
import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db"
@@ -116,7 +117,7 @@ export namespace Workspace {
const adaptor = await getAdaptor(space.type)
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
if (!res || !res.ok || !res.body) {
await Bun.sleep(1000)
await sleep(1000)
continue
}
await parseSSE(res.body, stop, (event) => {
@@ -126,7 +127,7 @@ export namespace Workspace {
})
})
// Wait 250ms and retry if SSE connection fails
await Bun.sleep(250)
await sleep(250)
}
}

View File

@@ -1,22 +0,0 @@
import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
import { eq } from "drizzle-orm"
import { Timestamps } from "@/storage/schema.sql"
export const ControlAccountTable = sqliteTable(
"control_account",
{
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
},
(table) => [
primaryKey({ columns: [table.email, table.url] }),
// uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)),
],
)

View File

@@ -1,67 +0,0 @@
import { eq, and } from "drizzle-orm"
import { Database } from "@/storage/db"
import { ControlAccountTable } from "./control.sql"
import z from "zod"
export * from "./control.sql"
export namespace Control {
export const Account = z.object({
email: z.string(),
url: z.string(),
})
export type Account = z.infer<typeof Account>
function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account {
return {
email: row.email,
url: row.url,
}
}
export function account(): Account | undefined {
const row = Database.use((db) =>
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
)
return row ? fromRow(row) : undefined
}
export async function token(): Promise<string | undefined> {
const row = Database.use((db) =>
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
)
if (!row) return undefined
if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
const res = await fetch(`${row.url}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
}).toString(),
})
if (!res.ok) return
const json = (await res.json()) as {
access_token: string
refresh_token?: string
expires_in?: number
}
Database.use((db) =>
db
.update(ControlAccountTable)
.set({
access_token: json.access_token,
refresh_token: json.refresh_token ?? row.refresh_token,
token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
})
.where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url)))
.run(),
)
return json.access_token
}
}

View File

@@ -0,0 +1,4 @@
import { ManagedRuntime } from "effect"
import { AccountService } from "@/account/service"
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)

View File

@@ -11,6 +11,7 @@ import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { git } from "@/util/git"
import { Protected } from "./protected"
export namespace File {
const log = Log.create({ service: "file" })
@@ -345,10 +346,7 @@ export namespace File {
if (isGlobalHome) {
const dirs = new Set<string>()
const ignore = new Set<string>()
if (process.platform === "darwin") ignore.add("Library")
if (process.platform === "win32") ignore.add("AppData")
const ignore = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name)

View File

@@ -0,0 +1,59 @@
import path from "path"
import os from "os"
const home = os.homedir()
// macOS directories that trigger TCC (Transparency, Consent, and Control)
// permission prompts when accessed by a non-sandboxed process.
const DARWIN_HOME = [
// Media
"Music",
"Pictures",
"Movies",
// User-managed folders synced via iCloud / subject to TCC
"Downloads",
"Desktop",
"Documents",
// Other system-managed
"Public",
"Applications",
"Library",
]
const DARWIN_LIBRARY = [
"Application Support/AddressBook",
"Calendars",
"Mail",
"Messages",
"Safari",
"Cookies",
"Application Support/com.apple.TCC",
"PersonalizationPortrait",
"Metadata/CoreSpotlight",
"Suggestions",
]
const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes", "/.fseventsd"]
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
export namespace Protected {
/** Directory basenames to skip when scanning the home directory. */
export function names(): ReadonlySet<string> {
if (process.platform === "darwin") return new Set(DARWIN_HOME)
if (process.platform === "win32") return new Set(WIN32_HOME)
return new Set()
}
/** Absolute paths that should never be watched, stated, or scanned. */
export function paths(): string[] {
if (process.platform === "darwin")
return [
...DARWIN_HOME.map((n) => path.join(home, n)),
...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)),
...DARWIN_ROOT,
]
if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
return []
}
}

View File

@@ -14,6 +14,7 @@ import type ParcelWatcher from "@parcel/watcher"
import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises"
import { git } from "@/util/git"
import { Protected } from "./protected"
const SUBSCRIBE_TIMEOUT_MS = 10_000
@@ -76,7 +77,7 @@ export namespace FileWatcher {
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
const pending = w.subscribe(Instance.directory, subscribe, {
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()],
backend,
})
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {

View File

@@ -14,12 +14,10 @@ export namespace Flag {
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export declare const OPENCODE_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: string | undefined
export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD")
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
@@ -107,17 +105,6 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
configurable: false,
})
// Dynamic getter for OPENCODE_PLUGIN_META_FILE
// This must be evaluated at access time, not module load time,
// because tests and external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", {
get() {
return process.env["OPENCODE_PLUGIN_META_FILE"]
},
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_CLIENT
// This must be evaluated at access time, not module load time,
// because some commands override the client at runtime

View File

@@ -1,40 +1,40 @@
import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
import { Npm } from "@/npm"
export interface Info {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
enabled(): Promise<string[] | false>
}
export const gofmt: Info = {
name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
return which("gofmt") !== null
const p = which("gofmt")
if (p === null) return false
return [p, "-w", "$FILE"]
},
}
export const mix: Info = {
name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
return which("mix") !== null
const p = which("mix")
if (p === null) return false
return [p, "format", "$FILE"]
},
}
export const prettier: Info = {
name: "prettier",
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -73,8 +73,9 @@ export const prettier: Info = {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.prettier) return true
if (json.devDependencies?.prettier) return true
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
return [await Npm.which("prettier"), "--write", "$FILE"]
}
}
return false
},
@@ -82,7 +83,6 @@ export const prettier: Info = {
export const oxfmt: Info = {
name: "oxfmt",
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -95,8 +95,9 @@ export const oxfmt: Info = {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.oxfmt) return true
if (json.devDependencies?.oxfmt) return true
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
return [await Npm.which("oxfmt"), "$FILE"]
}
}
return false
},
@@ -104,7 +105,6 @@ export const oxfmt: Info = {
export const biome: Info = {
name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -141,7 +141,7 @@ export const biome: Info = {
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
return true
return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"]
}
}
return false
@@ -150,47 +150,49 @@ export const biome: Info = {
export const zig: Info = {
name: "zig",
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
return which("zig") !== null
const p = which("zig")
if (p === null) return false
return [p, "fmt", "$FILE"]
},
}
export const clang: Info = {
name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() {
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
return items.length > 0
if (items.length === 0) return false
return ["clang-format", "-i", "$FILE"]
},
}
export const ktlint: Info = {
name: "ktlint",
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
return which("ktlint") !== null
const p = which("ktlint")
if (p === null) return false
return [p, "-F", "$FILE"]
},
}
export const ruff: Info = {
name: "ruff",
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (!which("ruff")) return false
const p = which("ruff")
if (p === null) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0])
if (content.includes("[tool.ruff]")) return true
if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
} else {
return true
return [p, "format", "$FILE"]
}
}
}
@@ -199,7 +201,7 @@ export const ruff: Info = {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
if (found.length > 0) {
const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return true
if (content.includes("ruff")) return [p, "format", "$FILE"]
}
}
return false
@@ -208,14 +210,13 @@ export const ruff: Info = {
export const rlang: Info = {
name: "air",
command: ["air", "format", "$FILE"],
extensions: [".R"],
async enabled() {
const airPath = which("air")
if (airPath == null) return false
try {
const proc = Process.spawn(["air", "--help"], {
const proc = Process.spawn([airPath, "--help"], {
stdout: "pipe",
stderr: "pipe",
})
@@ -227,7 +228,10 @@ export const rlang: Info = {
const firstLine = output.split("\n")[0]
const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter")
return hasR && hasFormatter
if (hasR && hasFormatter) {
return [airPath, "format", "$FILE"]
}
return false
} catch (error) {
return false
}
@@ -236,14 +240,14 @@ export const rlang: Info = {
export const uvformat: Info = {
name: "uv",
command: ["uv", "format", "--", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
if (which("uv") !== null) {
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const uvPath = which("uv")
if (uvPath !== null) {
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
return code === 0
if (code === 0) return [uvPath, "format", "--", "$FILE"]
}
return false
},
@@ -251,108 +255,118 @@ export const uvformat: Info = {
export const rubocop: Info = {
name: "rubocop",
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return which("rubocop") !== null
const path = which("rubocop")
if (path === null) return false
return [path, "--autocorrect", "$FILE"]
},
}
export const standardrb: Info = {
name: "standardrb",
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return which("standardrb") !== null
const path = which("standardrb")
if (path === null) return false
return [path, "--fix", "$FILE"]
},
}
export const htmlbeautifier: Info = {
name: "htmlbeautifier",
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
return which("htmlbeautifier") !== null
const path = which("htmlbeautifier")
if (path === null) return false
return [path, "$FILE"]
},
}
export const dart: Info = {
name: "dart",
command: ["dart", "format", "$FILE"],
extensions: [".dart"],
async enabled() {
return which("dart") !== null
const path = which("dart")
if (path === null) return false
return [path, "format", "$FILE"]
},
}
export const ocamlformat: Info = {
name: "ocamlformat",
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"],
async enabled() {
if (!which("ocamlformat")) return false
const path = which("ocamlformat")
if (!path) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
return items.length > 0
if (items.length === 0) return false
return [path, "-i", "$FILE"]
},
}
export const terraform: Info = {
name: "terraform",
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"],
async enabled() {
return which("terraform") !== null
const path = which("terraform")
if (path === null) return false
return [path, "fmt", "$FILE"]
},
}
export const latexindent: Info = {
name: "latexindent",
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"],
async enabled() {
return which("latexindent") !== null
const path = which("latexindent")
if (path === null) return false
return [path, "-w", "-s", "$FILE"]
},
}
export const gleam: Info = {
name: "gleam",
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"],
async enabled() {
return which("gleam") !== null
const path = which("gleam")
if (path === null) return false
return [path, "format", "$FILE"]
},
}
export const shfmt: Info = {
name: "shfmt",
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"],
async enabled() {
return which("shfmt") !== null
const path = which("shfmt")
if (path === null) return false
return [path, "-w", "$FILE"]
},
}
export const nixfmt: Info = {
name: "nixfmt",
command: ["nixfmt", "$FILE"],
extensions: [".nix"],
async enabled() {
return which("nixfmt") !== null
const path = which("nixfmt")
if (path === null) return false
return [path, "$FILE"]
},
}
export const rustfmt: Info = {
name: "rustfmt",
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
return which("rustfmt") !== null
const path = which("rustfmt")
if (path === null) return false
return [path, "$FILE"]
},
}
export const pint: Info = {
name: "pint",
command: ["./vendor/bin/pint", "$FILE"],
extensions: [".php"],
async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
@@ -361,8 +375,9 @@ export const pint: Info = {
require?: Record<string, string>
"require-dev"?: Record<string, string>
}>(item)
if (json.require?.["laravel/pint"]) return true
if (json["require-dev"]?.["laravel/pint"]) return true
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
return ["./vendor/bin/pint", "$FILE"]
}
}
return false
},
@@ -370,27 +385,30 @@ export const pint: Info = {
export const ormolu: Info = {
name: "ormolu",
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"],
async enabled() {
return which("ormolu") !== null
const path = which("ormolu")
if (path === null) return false
return [path, "-i", "$FILE"]
},
}
export const cljfmt: Info = {
name: "cljfmt",
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() {
return which("cljfmt") !== null
const path = which("cljfmt")
if (path === null) return false
return [path, "fix", "--quiet", "$FILE"]
},
}
export const dfmt: Info = {
name: "dfmt",
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"],
async enabled() {
return which("dfmt") !== null
const path = which("dfmt")
if (path === null) return false
return [path, "-i", "$FILE"]
},
}

View File

@@ -25,14 +25,14 @@ export namespace Format {
export type Status = z.infer<typeof Status>
const state = Instance.state(async () => {
const enabled: Record<string, boolean> = {}
const cache: Record<string, string[] | false> = {}
const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) {
log.info("all formatters are disabled")
return {
enabled,
cache,
formatters,
}
}
@@ -46,43 +46,41 @@ export namespace Format {
continue
}
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (result.command.length === 0) continue
result.enabled = async () => true
result.enabled = async () => item.command ?? false
result.name = name
formatters[name] = result
}
return {
enabled,
cache,
formatters,
}
})
async function isEnabled(item: Formatter.Info) {
async function resolveCommand(item: Formatter.Info) {
const s = await state()
let status = s.enabled[item.name]
if (status === undefined) {
status = await item.enabled()
s.enabled[item.name] = status
let command = s.cache[item.name]
if (command === undefined) {
log.info("resolving command", { name: item.name })
command = await item.enabled()
s.cache[item.name] = command
}
return status
return command
}
async function getFormatter(ext: string) {
const formatters = await state().then((x) => x.formatters)
const result = []
const result: { info: Formatter.Info; command: string[] }[] = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
const command = await resolveCommand(item)
if (!command) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
result.push({ info: item, command })
}
return result
}
@@ -91,11 +89,11 @@ export namespace Format {
const s = await state()
const result: Status[] = []
for (const formatter of Object.values(s.formatters)) {
const enabled = await isEnabled(formatter)
const command = await resolveCommand(formatter)
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled,
enabled: !!command,
})
}
return result
@@ -108,29 +106,27 @@ export namespace Format {
log.info("formatting", { file })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
for (const { info, command } of await getFormatter(ext)) {
const replaced = command.map((x) => x.replace("$FILE", file))
log.info("running", { replaced })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const proc = Process.spawn(replaced, {
cwd: Instance.directory,
env: { ...process.env, ...info.environment },
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command: item.command,
...item.environment,
command,
...info.environment,
})
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
command,
...info.environment,
file,
})
}

View File

@@ -18,7 +18,7 @@ export namespace Global {
return process.env.OPENCODE_TEST_HOME || os.homedir()
},
data,
bin: path.join(data, "bin"),
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
cache,
config,

View File

@@ -3,7 +3,8 @@ import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import { Log } from "./util/log"
import { AuthCommand } from "./cli/cmd/auth"
import { LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account"
import { ProvidersCommand } from "./cli/cmd/providers"
import { AgentCommand } from "./cli/cmd/agent"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { UninstallCommand } from "./cli/cmd/uninstall"
@@ -134,7 +135,11 @@ let cli = yargs(hideBin(process.argv))
.command(RunCommand)
.command(GenerateCommand)
.command(DebugCommand)
.command(AuthCommand)
.command(LoginCommand)
.command(LogoutCommand)
.command(SwitchCommand)
.command(OrgsCommand)
.command(ProvidersCommand)
.command(AgentCommand)
.command(UpgradeCommand)
.command(UninstallCommand)

View File

@@ -3,7 +3,6 @@ import path from "path"
import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -13,6 +12,7 @@ import { Archive } from "../util/archive"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module"
import { Npm } from "@/npm"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -102,7 +102,7 @@ export namespace LSPServer {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver })
if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
const proc = spawn(await Npm.which("typescript-language-server"), ["--stdio"], {
cwd: root,
env: {
...process.env,
@@ -128,29 +128,8 @@ export namespace LSPServer {
let binary = which("vue-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"@vue",
"language-server",
"bin",
"vue-language-server.js",
)
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@vue/language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -213,7 +192,7 @@ export namespace LSPServer {
log.info("installed VS Code ESLint server", { serverPath })
}
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
const proc = spawn(await Npm.which("tsx"), [serverPath, "--stdio"], {
cwd: root,
env: {
...process.env,
@@ -344,8 +323,8 @@ export namespace LSPServer {
if (!bin) {
const resolved = Module.resolve("biome", root)
if (!resolved) return
bin = BunProc.which()
args = ["x", "biome", "lsp-proxy", "--stdio"]
bin = await Npm.which("biome")
args = ["lsp-proxy", "--stdio"]
}
const proc = spawn(bin, args, {
@@ -371,9 +350,7 @@ export namespace LSPServer {
},
extensions: [".go"],
async spawn(root) {
let bin = which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("gopls")
if (!bin) {
if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -408,9 +385,7 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("rubocop")
if (!bin) {
const ruby = which("ruby")
const gem = which("gem")
@@ -515,19 +490,8 @@ export namespace LSPServer {
let binary = which("pyright-langserver")
const args = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
}).exited
}
binary = BunProc.which()
args.push(...["run", js])
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("pyright")
}
args.push("--stdio")
@@ -629,9 +593,7 @@ export namespace LSPServer {
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
let bin = which("zls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("zls")
if (!bin) {
const zig = which("zig")
@@ -741,9 +703,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = which("csharp-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("csharp-ls")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls")
@@ -780,9 +740,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = which("fsautocomplete", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("fsautocomplete")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
@@ -1048,22 +1006,8 @@ export namespace LSPServer {
let binary = which("svelteserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("svelte-language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1095,22 +1039,8 @@ export namespace LSPServer {
let binary = which("astro-ls")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@astrojs/language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1359,31 +1289,8 @@ export namespace LSPServer {
let binary = which("yaml-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"yaml-language-server",
"out",
"server",
"src",
"server.js",
)
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("yaml-language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1412,9 +1319,7 @@ export namespace LSPServer {
]),
extensions: [".lua"],
async spawn(root) {
let bin = which("lua-language-server", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("lua-language-server")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1550,22 +1455,8 @@ export namespace LSPServer {
let binary = which("intelephense")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("intelephense")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1647,22 +1538,8 @@ export namespace LSPServer {
let binary = which("bash-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("bash-language-server")
}
args.push("start")
const proc = spawn(binary, args, {
@@ -1683,9 +1560,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
let bin = which("terraform-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("terraform-ls")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1766,9 +1641,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
let bin = which("texlab", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("texlab")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1859,22 +1732,8 @@ export namespace LSPServer {
let binary = which("docker-langserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("dockerfile-language-server-nodejs")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1965,9 +1824,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
let bin = which("tinymist", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("tinymist")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return

View File

@@ -11,6 +11,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
@@ -166,14 +167,10 @@ export namespace MCP {
const queue = [pid]
while (queue.length > 0) {
const current = queue.shift()!
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
() => [-1, ""] as const,
)
if (code !== 0) continue
for (const tok of out.trim().split(/\s+/)) {
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
for (const tok of lines) {
const cpid = parseInt(tok, 10)
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
if (!isNaN(cpid) && !pids.includes(cpid)) {
pids.push(cpid)
queue.push(cpid)
}
@@ -396,8 +393,14 @@ export namespace MCP {
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
// Handle OAuth-specific errors
if (error instanceof UnauthorizedError) {
// Handle OAuth-specific errors.
// The SDK throws UnauthorizedError when auth() returns 'REDIRECT',
// but may also throw plain Errors when auth() fails internally
// (e.g. during discovery, registration, or state generation).
// When an authProvider is attached, treat both cases as auth-related.
const isAuthError =
error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth"))
if (isAuthError) {
log.info("mcp server requires authentication", { key, transport: name })
// Check if this is a "needs registration" error

View File

@@ -1,4 +1,5 @@
import { createConnection } from "net"
import { createServer } from "http"
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -52,11 +53,74 @@ interface PendingAuth {
}
export namespace McpOAuthCallback {
let server: ReturnType<typeof Bun.serve> | undefined
let server: ReturnType<typeof createServer> | undefined
const pendingAuths = new Map<string, PendingAuth>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404)
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
export async function ensureRunning(): Promise<void> {
if (server) return
@@ -66,75 +130,14 @@ export namespace McpOAuthCallback {
return
}
server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
return new Response("Not found", { status: 404 })
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
return new Response(HTML_ERROR("No authorization code provided"), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
},
server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
server!.listen(OAUTH_CALLBACK_PORT, () => {
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
resolve()
})
server!.on("error", reject)
})
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
}
export function waitForCallback(oauthState: string): Promise<string> {
@@ -174,7 +177,7 @@ export namespace McpOAuthCallback {
export async function stop(): Promise<void> {
if (server) {
server.stop()
await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined
log.info("oauth callback server stopped")
}

View File

@@ -144,10 +144,19 @@ export class McpOAuthProvider implements OAuthClientProvider {
async state(): Promise<string> {
const entry = await McpAuth.get(this.mcpName)
if (!entry?.oauthState) {
throw new Error(`No OAuth state saved for MCP server: ${this.mcpName}`)
if (entry?.oauthState) {
return entry.oauthState
}
return entry.oauthState
// Generate a new state if none exists — the SDK calls state() as a
// generator, not just a reader, so we need to produce a value even when
// startAuth() hasn't pre-saved one (e.g. during automatic auth on first
// connect).
const newState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
await McpAuth.updateOAuthState(this.mcpName, newState)
return newState
}
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {

View File

@@ -0,0 +1,8 @@
import { Server } from "./server/server"
const result = await Server.listen({
port: 1338,
hostname: "0.0.0.0",
})
console.log(result)

View File

@@ -0,0 +1,158 @@
// Workaround: Bun on Windows does not support the UV_FS_O_FILEMAP flag that
// the `tar` package uses for files < 512KB (fs.open returns EINVAL).
// tar silently swallows the error and skips writing files, leaving only empty
// directories. Setting __FAKE_PLATFORM__ makes tar fall back to the plain 'w'
// flag. See tar's get-write-flag.js.
// Must be set before @npmcli/arborist is imported since tar caches the flag
// at module evaluation time — so we use a dynamic import() below.
if (process.platform === "win32") {
process.env.__FAKE_PLATFORM__ = "linux"
}
import semver from "semver"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global"
import { Lock } from "../util/lock"
import { Log } from "../util/log"
import path from "path"
import { readdir } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
const { Arborist } = await import("@npmcli/arborist")
export namespace Npm {
const log = Log.create({ service: "npm" })
export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
z.object({
pkg: z.string(),
}),
)
function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", pkg)
}
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
export async function add(pkg: string) {
using _ = await Lock.write("npm-install")
log.info("installing package", {
pkg,
})
const hash = pkg
const dir = directory(hash)
const arborist = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
const first = tree.edgesOut.values().next().value?.to
if (first) {
log.info("package already installed", { pkg })
return first.path
}
}
const result = await arborist
.reify({
add: [pkg],
save: true,
saveType: "prod",
})
.catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
)
})
const first = result.edgesOut.values().next().value?.to
if (!first) throw new InstallFailedError({ pkg })
return first.path
}
export async function install(dir: string) {
log.info("checking dependencies", { dir })
const reify = async () => {
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
await arb.reify().catch(() => {})
}
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
log.info("node_modules missing, reifying")
await reify()
return
}
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
log.info("dependency not in lock file, reifying", { name })
await reify()
return
}
}
log.info("dependencies in sync")
}
export async function which(pkg: string) {
const dir = path.join(directory(pkg), "node_modules", ".bin")
const files = await readdir(dir).catch(() => [])
if (!files.length) {
await add(pkg)
return which(pkg)
}
return path.join(dir, files[0])
}
}

View File

@@ -5,6 +5,7 @@ import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { ProviderTransform } from "@/provider/transform"
import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
const log = Log.create({ service: "plugin.codex" })
@@ -240,7 +241,7 @@ interface PendingOAuth {
reject: (error: Error) => void
}
let oauthServer: ReturnType<typeof Bun.serve> | undefined
let oauthServer: ReturnType<typeof createServer> | undefined
let pendingOAuth: PendingOAuth | undefined
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
@@ -248,77 +249,83 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
oauthServer = Bun.serve({
port: OAUTH_PORT,
fetch(req) {
const url = new URL(req.url)
oauthServer = createServer((req, res) => {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const current = pendingOAuth
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response("Login cancelled", { status: 200 })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
return new Response("Not found", { status: 404 })
},
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const current = pendingOAuth
pendingOAuth = undefined
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
return
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
pendingOAuth = undefined
res.writeHead(200)
res.end("Login cancelled")
return
}
res.writeHead(404)
res.end("Not found")
})
await new Promise<void>((resolve, reject) => {
oauthServer!.listen(OAUTH_PORT, () => {
log.info("codex oauth server started", { port: OAUTH_PORT })
resolve()
})
oauthServer!.on("error", reject)
})
log.info("codex oauth server started", { port: OAUTH_PORT })
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
function stopOAuthServer() {
if (oauthServer) {
oauthServer.stop()
oauthServer.close(() => {
log.info("codex oauth server stopped")
})
oauthServer = undefined
log.info("codex oauth server stopped")
}
}

View File

@@ -4,13 +4,13 @@ import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { Npm } from "../npm"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { parsePluginSpecifier, resolvePluginTarget, uniqueModuleEntries } from "./shared"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
export namespace Plugin {
@@ -27,7 +27,9 @@ export namespace Plugin {
directory: Instance.directory,
fetch: async (...args) => Server.Default().fetch(...args),
})
log.info("loading config")
const config = await Config.get()
log.info("config loaded")
const hooks: Hooks[] = []
const input: PluginInput = {
client,
@@ -37,7 +39,8 @@ export namespace Plugin {
get serverUrl(): URL {
throw new Error("Server URL is no longer supported in plugins")
},
$: Bun.$,
// @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
}
for (const plugin of INTERNAL_PLUGINS) {
@@ -54,83 +57,45 @@ export namespace Plugin {
plugins = [...BUILTIN, ...plugins]
}
async function resolvePlugin(spec: string) {
const parsed = parsePluginSpecifier(spec)
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!target) return
return target
}
function isServerPlugin(value: unknown): value is PluginInstance {
return typeof value === "function"
}
function getServerPlugin(value: unknown) {
if (isServerPlugin(value)) return value
if (!value || typeof value !== "object" || !("server" in value)) return
if (!isServerPlugin(value.server)) return
return value.server
}
const prep = async (item: (typeof plugins)[number]) => {
const spec = Config.pluginSpecifier(item)
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (spec.includes("opencode-openai-codex-auth") || spec.includes("opencode-copilot-auth")) return
log.info("loading plugin", { path: spec })
const target = await resolvePlugin(spec)
if (!target) return
const mod = await import(target).catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${spec}: ${message}`,
}).toObject(),
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
plugin = await Npm.add(plugin).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { plugin, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${plugin}: ${detail}`,
}).toObject(),
})
return ""
})
return
})
if (!mod) return
return {
item,
spec,
mod,
if (!plugin) continue
}
}
const loaded = await Promise.all(plugins.map((item) => prep(item)))
for (const load of loaded) {
if (!load) continue
// Keep plugin execution sequential so hook registration and execution
// order remains deterministic across plugin runs.
// Prevent duplicate initialization when plugins export the same function
// as both a named export and default export (e.g., `export const X` and `export default X`).
// uniqueModuleEntries keeps only the first export for each shared value reference.
await (async () => {
for (const [, entry] of uniqueModuleEntries(load.mod)) {
const server = getServerPlugin(entry)
if (!server) throw new TypeError("Plugin export is not a function")
hooks.push(await server(input, Config.pluginOptions(load.item)))
}
})().catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: load.spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(),
// Object.entries(mod) would return both entries pointing to the same function reference.
await import(plugin)
.then(async (mod) => {
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
hooks.push(await fn(input))
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: plugin, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plugin}: ${message}`,
}).toObject(),
})
})
})
}
return {

View File

@@ -1,160 +0,0 @@
import path from "path"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { parsePluginSpecifier } from "./shared"
export namespace PluginMeta {
type Source = "file" | "npm"
export type Entry = {
name: string
source: Source
spec: string
target: string
requested?: string
version?: string
modified?: number
first_time: number
last_time: number
time_changed: number
load_count: number
fingerprint: string
}
export type State = "first" | "updated" | "same"
type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
const cache = {
ready: false,
path: "",
store: {} as Store,
dirty: false,
}
function storePath() {
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
}
function sourceKind(spec: string): Source {
if (spec.startsWith("file://")) return "file"
return "npm"
}
function entryKey(spec: string) {
if (spec.startsWith("file://")) return `file:${fileURLToPath(spec)}`
return `npm:${parsePluginSpecifier(spec).pkg}`
}
function entryName(spec: string) {
if (spec.startsWith("file://")) return path.parse(fileURLToPath(spec)).name
return parsePluginSpecifier(spec).pkg
}
function fileTarget(spec: string, target: string) {
if (spec.startsWith("file://")) return fileURLToPath(spec)
if (target.startsWith("file://")) return fileURLToPath(target)
return
}
function modifiedAt(file: string) {
const stat = Filesystem.stat(file)
if (!stat) return
const value = stat.mtimeMs
return Math.floor(typeof value === "bigint" ? Number(value) : value)
}
function resolvedTarget(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
return target
}
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = Filesystem.stat(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
.catch(() => undefined)
}
async function entryCore(spec: string, target: string): Promise<Core> {
const source = sourceKind(spec)
if (source === "file") {
const file = fileTarget(spec, target)
return {
name: entryName(spec),
source,
spec,
target,
modified: file ? modifiedAt(file) : undefined,
}
}
return {
name: entryName(spec),
source,
spec,
target,
requested: parsePluginSpecifier(spec).version,
version: await npmVersion(target),
}
}
function fingerprint(value: Core) {
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
}
async function load() {
const next = storePath()
if (cache.ready && cache.path === next) return
cache.path = next
cache.store = await Filesystem.readJson<Store>(next).catch(() => ({}) as Store)
cache.dirty = false
cache.ready = true
}
export async function touch(spec: string, target: string): Promise<{ state: State; entry: Entry }> {
await load()
const now = Date.now()
const id = entryKey(spec)
const prev = cache.store[id]
const core = await entryCore(spec, target)
const entry: Entry = {
...core,
first_time: prev?.first_time ?? now,
last_time: now,
time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now
cache.store[id] = entry
cache.dirty = true
return {
state,
entry,
}
}
export async function persist() {
await load()
if (!cache.dirty) return
await Filesystem.writeJson(cache.path, cache.store)
cache.dirty = false
}
export async function list(): Promise<Store> {
await load()
return { ...cache.store }
}
}

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