Compare commits

..

90 Commits

Author SHA1 Message Date
Aiden Cline 802534891c fix: ensure copilot model list filters out disabled models 2026-04-17 15:50:43 -05:00
Kit Langton 3431dfb8b8 refactor: unwrap ServerProxy namespace + self-reexport (#22954) 2026-04-16 21:40:31 +00:00
Kit Langton fefe8b500a refactor: unwrap FileWatcher namespace + self-reexport (#22941) 2026-04-16 17:39:56 -04:00
Kit Langton 5b8573e804 refactor: unwrap FileTime namespace + self-reexport (#22940) 2026-04-16 17:39:53 -04:00
Kit Langton 40123cbe2d refactor: unwrap Server namespace + self-reexport (#22955) 2026-04-16 21:39:52 +00:00
Kit Langton 016c641860 refactor: unwrap Ripgrep namespace + self-reexport (#22939) 2026-04-16 17:39:50 -04:00
Kit Langton b41b0e3d2d refactor: unwrap MDNS namespace + self-reexport (#22953) 2026-04-16 21:38:47 +00:00
Kit Langton 3d3e50ebf0 refactor: unwrap Identifier namespace + self-reexport (#22932) 2026-04-16 17:37:51 -04:00
Kit Langton 0abc0d541a refactor: unwrap BusEvent namespace + self-reexport (#22930) 2026-04-16 17:37:47 -04:00
Dax Raad 3f3989e694 fix type error 2026-04-16 17:35:56 -04:00
opencode-agent[bot] b4667d9b0d chore: generate 2026-04-16 21:33:01 +00:00
Dax Raad 3c68d75776 import performance improvements 2026-04-16 17:31:43 -04:00
Kit Langton 23ed876835 fix: narrow several from any type assertions in opencode core (#22926) 2026-04-16 17:15:18 -04:00
Kit Langton 0e86466f99 refactor: unwrap Discovery namespace to flat exports + self-reexport (#22878) 2026-04-16 16:59:30 -04:00
Kit Langton 32548bcb4a refactor: unwrap ConfigPlugin namespace to flat exports + self-reexport (#22876) 2026-04-16 16:59:17 -04:00
James Long 86c54c5acc fix(tui): minor logging cleanup (#22924) 2026-04-16 16:58:17 -04:00
Aiden Cline ae584332b3 fix: uncomment import (#22923) 2026-04-16 15:56:29 -05:00
Kit Langton 1694c5bfe1 refactor: collapse file barrel into file/index.ts (#22901) 2026-04-16 16:56:09 -04:00
Kit Langton cdfbb26c00 refactor: collapse bus barrel into bus/index.ts (#22902) 2026-04-16 16:55:57 -04:00
thakrarsagar 610c036ef1 fix(opencode): use low reasoning effort for GitHub Copilot gpt-5 models (#22824)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-04-16 15:44:58 -05:00
Kit Langton 2638e2acfa refactor: collapse plugin barrel into plugin/index.ts (#22914) 2026-04-16 20:37:13 +00:00
Kit Langton 49bbea5aed refactor: collapse snapshot barrel into snapshot/index.ts (#22916) 2026-04-16 20:36:45 +00:00
Kit Langton 5fccdc9fc7 refactor: collapse mcp barrel into mcp/index.ts (#22913) 2026-04-16 20:36:23 +00:00
Kit Langton 664b2c36e8 refactor: collapse git barrel into git/index.ts (#22909) 2026-04-16 20:36:07 +00:00
Kit Langton 964474a1b1 refactor: collapse permission barrel into permission/index.ts (#22915) 2026-04-16 20:36:04 +00:00
Kit Langton ab15fc1575 refactor: collapse npm barrel into npm/index.ts (#22911) 2026-04-16 20:36:02 +00:00
Kit Langton 99d392a4fb refactor: collapse skill barrel into skill/index.ts (#22912) 2026-04-16 20:35:43 +00:00
Kit Langton ae9a696607 refactor: collapse installation barrel into installation/index.ts (#22910) 2026-04-16 20:35:28 +00:00
Kit Langton bd51a0d35b refactor: collapse worktree barrel into worktree/index.ts (#22906) 2026-04-16 20:35:26 +00:00
Kit Langton 8c191b10c2 refactor: collapse ide barrel into ide/index.ts (#22904) 2026-04-16 20:35:04 +00:00
Kit Langton cb6a9253fe refactor: collapse sync barrel into sync/index.ts (#22907) 2026-04-16 20:34:33 +00:00
Kit Langton 23f97ac49d refactor: collapse global barrel into global/index.ts (#22905) 2026-04-16 20:33:52 +00:00
opencode-agent[bot] 021ab50fb1 chore: generate 2026-04-16 20:31:50 +00:00
Kit Langton 3fe906f517 refactor: collapse command barrel into command/index.ts (#22903) 2026-04-16 20:30:52 +00:00
James Long a8d8a35cd3 feat(core): pass auth data to workspace (#22897) 2026-04-16 16:30:11 -04:00
Kit Langton 9b77430d0d refactor: collapse env barrel into env/index.ts (#22900) 2026-04-16 16:29:54 -04:00
Kit Langton 1045a43603 refactor: collapse format barrel into format/index.ts (#22898) 2026-04-16 16:29:51 -04:00
James Long 26af77cd1e fix(core): fix detection of local installation channel (#22899) 2026-04-16 20:26:33 +00:00
Dax Raad 25a9de301a core: eager load config on startup for better traces and refactor npm install for improved error reporting
Config is now loaded eagerly during project bootstrap so users can see config loading in traces during startup. This helps diagnose configuration issues earlier in the initialization flow.

NPM installation logic has been refactored with a unified reify function and improved InstallFailedError that includes both the packages being installed and the target directory. This provides users with complete context when package installations fail, making it easier to identify which dependency or project directory caused the issue.
2026-04-16 16:23:19 -04:00
Kit Langton e0d71f124e tooling: add collapse-barrel.ts for single-namespace barrel migration (#22887) 2026-04-16 16:12:46 -04:00
Kit Langton 1c33b866ba fix: remove 10 more unnecessary as any casts in opencode core (#22882) 2026-04-16 20:11:05 +00:00
Kobi Hudson 5e650fd9e2 fix(opencode): drop max_tokens for OpenAI reasoning models on Cloudflare AI Gateway (#22864)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-16 15:01:21 -05:00
Kit Langton 76275fc3ab refactor: move Pty into pty/index.ts with self-reexport (#22881) 2026-04-16 15:49:21 -04:00
Aiden Cline 6c3b28db64 fix: ensure that double pasting doesnt happen after tui perf commit was merged (#22880) 2026-04-16 14:38:39 -05:00
Kit Langton 2fe9d94470 fix: remove 8 more unnecessary as any casts in opencode core (#22877) 2026-04-16 19:27:53 +00:00
Kit Langton 219b473e66 refactor: unwrap BashArity namespace to flat exports + self-reexport (#22874) 2026-04-16 15:24:24 -04:00
opencode-agent[bot] 7c1b30291c chore: update nix node_modules hashes 2026-04-16 19:19:52 +00:00
Aiden Cline 47e0e2342c tweak: set display 'summarized' by default for opus 4.7 thorugh messages api (#22873) 2026-04-16 14:12:43 -05:00
Kit Langton bf4c107829 fix: remove 7 unnecessary as any casts in opencode core (#22840) 2026-04-16 15:07:02 -04:00
Dax 9afbdc102c fix(test): make plugin loader theme source path separator-safe (#22870) 2026-04-16 14:45:17 -04:00
opencode-agent[bot] 370770122c chore: generate 2026-04-16 18:29:57 +00:00
Aiden Cline 143817d44e chore: bump ai sdk deps for opus 4.7 (#22869) 2026-04-16 13:28:20 -05:00
Thomas Butler c60862fc9e fix: add missing glob dependency (#22851) 2026-04-16 13:21:04 -05:00
Dax Raad bee5f919fc core: reorganize ConfigPaths module export for cleaner dependency management 2026-04-16 13:33:54 -04:00
Dax Raad cefa7f04c6 core: reorganize ConfigPaths module export for cleaner dependency management 2026-04-16 13:32:22 -04:00
Dax Raad 03e20e6ac1 core: modularize config parsing to improve maintainability
Extract error handling, parsing logic, and variable substitution into dedicated
modules. This reduces duplication between tui.json and opencode.json parsing
and makes the config system easier to extend for future config formats.
2026-04-16 13:29:03 -04:00
Aiden Cline c5deeee8c7 fix: ensure azure has store = true by default (#22764) 2026-04-16 12:19:01 -05:00
Dax Raad 8b1f0e2d90 core: add documentation comments to plugin configuration merge logic
Adds explanatory comments to config.ts and plugin.ts clarifying:

- How plugin specs are stored and normalized during config loading

- Why plugin_origins tracks provenance for location-sensitive decisions

- Why path-like specs are resolved early to prevent reinterpretation during merges

- How plugin deduplication works while keeping origin metadata for writes and diagnostics
2026-04-16 12:55:40 -04:00
Dax Raad 9bf2dfea35 core: refactor config schemas into separate modules for better maintainability 2026-04-16 12:47:09 -04:00
Dax Raad 33bb847a1d config: refactor 2026-04-16 12:40:24 -04:00
Dax Raad bfffc3c2c6 tui: ensure TUI plugins load with proper project context when multiple directories are open
Fixes potential plugin resolution issues when switching between projects by wrapping
plugin loading in Instance.provide(). This ensures each plugin resolves dependencies
relative to its correct project directory instead of inheriting context from whatever
instance happened to be active.

Also reorganizes config loading code into focused modules (command.ts, managed.ts,
plugin.ts) to make the codebase easier to maintain and test.
2026-04-16 12:40:24 -04:00
James Long b28956f0db fix(core): better global sync event structure (#22858) 2026-04-16 12:35:37 -04:00
opencode-agent[bot] d82bc3a421 chore: generate 2026-04-16 16:26:12 +00:00
James Long 06afd33291 refactor(tui): improve workspace management (#22691) 2026-04-16 12:24:40 -04:00
James Long 305460b25f fix: add a few more tests for sync and session restore (#22837) 2026-04-16 12:15:44 -04:00
Nacai 8c0205a84a fix: align stale bot message with actual 60-day threshold (#22842)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-04-16 11:01:35 -05:00
Graham Campbell 378c05f202 feat: Add support for claude opus 4.7 xhigh adaptive reasoning effort (#22833)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-16 10:57:36 -05:00
Jérôme Benoit cc7acd90ab fix(nix): add shared package to bun install filters (#22665) 2026-04-16 10:43:15 -05:00
Frank a200f6fb8b zen: opus 4.7 2026-04-16 11:32:56 -04:00
Dax Raad 2b1696f1d1 Revert "tui: fix path comparison in theme installer to handle different path formats"
This reverts commit 8ab17f5ce0.
2026-04-16 11:28:19 -04:00
Dax Raad 8ab17f5ce0 tui: fix path comparison in theme installer to handle different path formats 2026-04-16 11:18:44 -04:00
Dax Raad 6ce481e95b move useful scripts to script folder 2026-04-16 10:09:14 -04:00
opencode-agent[bot] 7341718f92 chore: generate 2026-04-16 07:15:05 +00:00
Dax ef90b93205 fix: restore .gitignore logic for config dirs and migrate to shared Npm service (#22772) 2026-04-16 03:14:06 -04:00
Dax 3f7df08be9 perf: make vcs init non-blocking by forking git branch resolution (#22771) 2026-04-16 03:02:19 -04:00
opencode-agent[bot] ef6c26c730 chore: update nix node_modules hashes 2026-04-16 06:59:47 +00:00
opencode-agent[bot] 8b3b608ba9 chore: generate 2026-04-16 06:11:24 +00:00
Brendan Allan 97918500d4 app: start migrating bootstrap data fetching to TanStack Query (#22756) 2026-04-16 06:10:23 +00:00
Brendan Allan e2c0803962 Fix desktop download asset names for beta channel (#22766) 2026-04-16 06:10:03 +00:00
Adam f418fd5632 beta badge for desktop app (#14471)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-04-16 06:03:41 +00:00
Dax 675a46e23e CLI perf: reduce deps (#22652) 2026-04-16 02:03:03 -04:00
opencode-agent[bot] 150ab07a83 chore: generate 2026-04-16 05:03:50 +00:00
Kit Langton 6b20838981 feat: unwrap provider namespaces to flat exports + barrel (#22760) 2026-04-16 05:02:50 +00:00
opencode-agent[bot] c8af8f96ce chore: generate 2026-04-16 03:57:53 +00:00
Kit Langton 5011465c81 feat: unwrap tool namespaces to flat exports + barrel (#22762) 2026-04-16 03:56:54 +00:00
Kit Langton f6cc228684 feat: unwrap cli-tui namespaces to flat exports + barrel (#22759) 2026-04-16 03:56:51 +00:00
Kit Langton 9f4b73b6a3 fix: clean up final 16 no-unused-vars warnings (#22751) 2026-04-16 03:54:21 +00:00
Kit Langton bd29004831 feat: enable type-aware no-misused-spread rule, fix 8 violations (#22749) 2026-04-16 03:50:50 +00:00
Kit Langton 8aa0f9fe95 feat: enable type-aware no-base-to-string rule, fix 56 violations (#22750) 2026-04-16 03:50:47 +00:00
Kit Langton c802695ee9 docs: add circular import rules to namespace treeshake spec (#22754) 2026-04-15 23:44:08 -04:00
318 changed files with 14418 additions and 13371 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
"Content-Type": "application/json", "Content-Type": "application/json",
...options.headers, ...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
}, },
}) })
if (!response.ok) { if (!response.ok) {
+1 -1
View File
@@ -28,7 +28,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
"Content-Type": "application/json", "Content-Type": "application/json",
...options.headers, ...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
}, },
}) })
if (!response.ok) { if (!response.ok) {
+11 -2
View File
@@ -1,9 +1,13 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json", "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
"options": {
"typeAware": true
},
"categories": { "categories": {
"suspicious": "warn" "suspicious": "warn"
}, },
"rules": { "rules": {
"typescript/no-base-to-string": "warn",
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield // Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
"require-yield": "off", "require-yield": "off",
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime // SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
@@ -33,10 +37,15 @@
"no-new": "off", "no-new": "off",
// Type-aware: catch unhandled promises // Type-aware: catch unhandled promises
"typescript/no-floating-promises": "warn" "typescript/no-floating-promises": "warn",
// Warn when spreading non-plain objects (Headers, class instances, etc.)
"typescript/no-misused-spread": "warn"
}, },
"options": { "options": {
"typeAware": true "typeAware": true
}, },
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"] "options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
} }
+21 -10
View File
@@ -322,15 +322,15 @@
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.94",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.70",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.97", "@ai-sdk/gateway": "3.0.102",
"@ai-sdk/google": "3.0.63", "@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.109", "@ai-sdk/google-vertex": "4.0.111",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53", "@ai-sdk/openai": "3.0.53",
@@ -516,6 +516,7 @@
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@npmcli/arborist": "catalog:", "@npmcli/arborist": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"glob": "13.0.5",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.2.5", "minimatch": "10.2.5",
"semver": "catalog:", "semver": "catalog:",
@@ -523,7 +524,9 @@
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:", "@types/semver": "catalog:",
}, },
}, },
@@ -735,7 +738,7 @@
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="], "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.94", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XKE7wAjXejsIfNQvn3onvGUByhGHVM6W+xlL+1DAQLmjEb+ue4sOJIRehJ96rEvTXVVHRVyA6bSXx7ayxXfn5A=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@@ -755,11 +758,11 @@
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="], "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.97", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ERHmVGX30YKTwxObuHQzNqoOf8Nb5WwYMDBn34e3TGGVn0vLEXwMimo7uRVTbhhi4gfu9WtwYTE4x1+csZok1w=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.102", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GrwDpaYJiVafrsA1MTbZtXPcQUI67g5AXiJo7Y1F8b+w+SiYHLk3ZIn1YmpQVoVAh2bjvxjj+Vo0AvfskuGH4g=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="], "@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.109", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/google": "3.0.63", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QzQ+DgOoSYlkU4mK0H+iaCaW1bl5zOimH9X2E2oylcVyUtAdCuduQ959Uw1ygW3l09J2K/ceEDtK8OUPHyOA7g=="], "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.111", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5gILpAWWI5idfal/MfoH3tlQeSnOJ9jfL8JB8m2fdc3ue/9xoXkYDpXpDL/nyJImFjMCi6eR0Fpvlo/IKEWDIg=="],
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
@@ -5149,7 +5152,11 @@
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -5163,7 +5170,9 @@
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -5681,6 +5690,8 @@
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="], "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
"ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw=="], "ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw=="],
@@ -5897,7 +5908,7 @@
"nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], "nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="], "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-VIgTxIjmZ4Bfwwdj/YFmRJdBpPHYhJSY31kh06EXX+0=", "x86_64-linux": "sha256-tYAb5Mo39UW1VEejYuo0jW0jzH2OyY/HrqgiZL3rmjY=",
"aarch64-linux": "sha256-9118AS1ED0nrliURgZYBRuF/18RqXpUouhYJRlZ6jeA=", "aarch64-linux": "sha256-3zGKV5UwokXpmY0nT1mry3IhNf2EQYLKT7ac+/trmQA=",
"aarch64-darwin": "sha256-ppo3MfSIGKQHJCdYEZiLFRc61PtcJ9J0kAXH1pNIonA=", "aarch64-darwin": "sha256-oKXAut7eu/eW5a43OT8+aFuH1F1tuIldTs+7PUXSCv4=",
"x86_64-darwin": "sha256-m+CZSOglBCTfNzbdBX6hXdDqqOzHNMzAddVp6BZVDtU=" "x86_64-darwin": "sha256-Az+9X1scOEhw3aOO8laKJoZjiuz3qlLTIk1bx25P/z4="
} }
} }
+1
View File
@@ -55,6 +55,7 @@ stdenvNoCC.mkDerivation {
--filter './packages/opencode' \ --filter './packages/opencode' \
--filter './packages/desktop' \ --filter './packages/desktop' \
--filter './packages/app' \ --filter './packages/app' \
--filter './packages/shared' \
--frozen-lockfile \ --frozen-lockfile \
--ignore-scripts \ --ignore-scripts \
--no-progress --no-progress
+16 -7
View File
@@ -121,10 +121,10 @@ function SessionProviders(props: ParentProps) {
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return ( return (
<AppShellProviders> <AppShellProviders>
<Suspense fallback={<Loading />}> {/*<Suspense fallback={<Loading />}>*/}
{props.appChildren} {props.appChildren}
{props.children} {props.children}
</Suspense> {/*</Suspense>*/}
</AppShellProviders> </AppShellProviders>
) )
} }
@@ -184,14 +184,22 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
) )
return ( return (
<Show <Suspense
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={ fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base"> <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" /> <Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div> </div>
} }
> >
{/*<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>*/}
{checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
<Show <Show
when={startupHealthCheck()} when={startupHealthCheck()}
fallback={ fallback={
@@ -209,7 +217,8 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
> >
{props.children} {props.children}
</Show> </Show>
</Show> {/*</Show>*/}
</Suspense>
) )
} }
+109 -94
View File
@@ -54,6 +54,8 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder" import { promptPlaceholder } from "./prompt-input/placeholder"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQuery } from "@tanstack/solid-query"
import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap"
interface PromptInputProps { interface PromptInputProps {
class?: string class?: string
@@ -100,6 +102,7 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/
export const PromptInput: Component<PromptInputProps> = (props) => { export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const local = useLocal() const local = useLocal()
const files = useFile() const files = useFile()
@@ -1249,6 +1252,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const agentsQuery = useQuery(() => loadAgentsQuery(sdk.directory))
const agentsLoading = () => agentsQuery.isLoading
const globalProvidersQuery = useQuery(() => loadProvidersQuery(null))
const providersQuery = useQuery(() => loadProvidersQuery(sdk.directory))
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
return ( return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-0"> <div class="relative size-full _max-h-[320px] flex flex-col gap-0">
<PromptPopover <PromptPopover
@@ -1444,53 +1455,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span> <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" /> <div class="size-4 shrink-0" />
</div> </div>
<div class="flex items-center gap-1.5 min-w-0 flex-1"> <div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
<div data-component="prompt-agent-control"> <Show when={!agentsLoading()}>
<TooltipKeybind <div data-component="prompt-agent-control">
placement="top" <TooltipKeybind
gutter={4} placement="top"
title={language.t("command.agent.cycle")} gutter={4}
keybind={command.keybind("agent.cycle")} title={language.t("command.agent.cycle")}
> keybind={command.keybind("agent.cycle")}
<Select >
size="normal" <Select
options={agentNames()} size="normal"
current={local.agent.current()?.name ?? ""} options={agentNames()}
onSelect={(value) => { current={local.agent.current()?.name ?? ""}
local.agent.set(value) onSelect={(value) => {
restoreFocus() local.agent.set(value)
}} restoreFocus()
class="capitalize max-w-[160px] text-text-base" }}
valueClass="truncate text-13-regular text-text-base" class="capitalize max-w-[160px] text-text-base"
triggerStyle={control()} valueClass="truncate text-13-regular text-text-base"
triggerProps={{ "data-action": "prompt-agent" }} triggerStyle={control()}
variant="ghost" triggerProps={{ "data-action": "prompt-agent" }}
/> variant="ghost"
</TooltipKeybind> />
</div> </TooltipKeybind>
<Show when={store.mode !== "shell"}> </div>
<div data-component="prompt-model-control"> </Show>
<Show <Show when={!providersLoading()}>
when={providers.paid().length > 0} <Show when={store.mode !== "shell"}>
fallback={ <div data-component="prompt-model-control">
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<Button <ModelSelectorPopover
data-action="prompt-model" model={local.model}
as="div" triggerAs={Button}
variant="ghost" triggerProps={{
size="normal" variant: "ghost",
class="min-w-0 max-w-[320px] text-13-regular text-text-base group" size: "normal",
style={control()} style: control(),
onClick={() => { class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
void import("@/components/dialog-select-model-unpaid").then((x) => { "data-action": "prompt-model",
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}} }}
onClose={restoreFocus}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
@@ -1503,67 +1550,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span> </span>
<Icon name="chevron-down" size="small" class="shrink-0" /> <Icon name="chevron-down" size="small" class="shrink-0" />
</Button> </ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
} </Show>
> </div>
<div data-component="prompt-variant-control">
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.choose")} title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.variant.cycle")}
> >
<ModelSelectorPopover <Select
model={local.model} size="normal"
triggerAs={Button} options={variants()}
triggerProps={{ current={local.model.variant.current() ?? "default"}
variant: "ghost", label={(x) => (x === "default" ? language.t("common.default") : x)}
size: "normal", onSelect={(value) => {
style: control(), local.model.variant.set(value === "default" ? undefined : value)
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group", restoreFocus()
"data-action": "prompt-model",
}} }}
onClose={restoreFocus} class="capitalize max-w-[160px] text-text-base"
> valueClass="truncate text-13-regular text-text-base"
<Show when={local.model.current()?.provider?.id}> triggerStyle={control()}
<ProviderIcon triggerProps={{ "data-action": "prompt-model-variant" }}
id={local.model.current()?.provider?.id ?? ""} variant="ghost"
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" />
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</Show> </div>
</div> </Show>
<div data-component="prompt-variant-control">
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show> </Show>
</div> </div>
</div> </div>
+2 -2
View File
@@ -191,7 +191,7 @@ export const Terminal = (props: TerminalProps) => {
const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
let ws: WebSocket | undefined let ws: WebSocket | undefined
let term: Term | undefined let term: Term | undefined
let ghostty: Ghostty let _ghostty: Ghostty
let serializeAddon: SerializeAddon let serializeAddon: SerializeAddon
let fitAddon: FitAddon let fitAddon: FitAddon
let handleResize: () => void let handleResize: () => void
@@ -372,7 +372,7 @@ export const Terminal = (props: TerminalProps) => {
cleanup() cleanup()
return return
} }
ghostty = g _ghostty = g
term = t term = t
output = terminalWriter((data, done) => output = terminalWriter((data, done) =>
t.write(data, () => { t.write(data, () => {
+40 -33
View File
@@ -252,41 +252,48 @@ export function Titlebar() {
</div> </div>
</div> </div>
</Show> </Show>
<Show when={hasProjects()}> <div
<div class="flex items-center shrink-0"
class="flex items-center gap-0 transition-transform" classList={{
classList={{ "translate-x-0": !layout.sidebar.opened(),
"translate-x-0": !layout.sidebar.opened(), "-translate-x-[36px]": layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened(), "duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-out": !layout.sidebar.opened(), "duration-180 ease-in": layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(), }}
}} >
> <Show when={hasProjects()}>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}> <div class="flex items-center gap-0 transition-transform">
<Button <Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
variant="ghost" <Button
icon="chevron-left" variant="ghost"
class="titlebar-icon w-6 h-6 p-0 box-border" icon="chevron-left"
disabled={!canBack()} class="titlebar-icon w-6 h-6 p-0 box-border"
onClick={back} disabled={!canBack()}
aria-label={language.t("common.goBack")} onClick={back}
/> aria-label={language.t("common.goBack")}
</Tooltip> />
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}> </Tooltip>
<Button <Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
variant="ghost" <Button
icon="chevron-right" variant="ghost"
class="titlebar-icon w-6 h-6 p-0 box-border" icon="chevron-right"
disabled={!canForward()} class="titlebar-icon w-6 h-6 p-0 box-border"
onClick={forward} disabled={!canForward()}
aria-label={language.t("common.goForward")} onClick={forward}
/> aria-label={language.t("common.goForward")}
</Tooltip> />
</div> </Tooltip>
</Show> </div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</div>
</div> </div>
</div> </div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
</div> </div>
<div class="min-w-0 flex items-center justify-center pointer-events-none"> <div class="min-w-0 flex items-center justify-center pointer-events-none">
+53 -38
View File
@@ -26,6 +26,7 @@ import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils" import { sanitizeProject } from "./global-sync/utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -41,6 +42,9 @@ type GlobalStore = {
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
} }
export const loadSessionsQuery = (directory: string) =>
queryOptions<null>({ queryKey: [directory, "loadSessions"], queryFn: skipToken })
function createGlobalSync() { function createGlobalSync() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
@@ -67,6 +71,7 @@ function createGlobalSync() {
config: {}, config: {},
reload: undefined, reload: undefined,
}) })
const queryClient = useQueryClient()
let active = true let active = true
let projectWritten = false let projectWritten = false
@@ -198,43 +203,50 @@ function createGlobalSync() {
} }
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = loadRootSessionsWithFallback({ const promise = queryClient
directory, .ensureQueryData({
limit, ...loadSessionsQuery(directory),
list: (query) => globalSDK.client.session.list(query), queryFn: () =>
}) loadRootSessionsWithFallback({
.then((x) => { directory,
const nonArchived = (x.data ?? []) limit,
.filter((s) => !!s?.id) list: (query) => globalSDK.client.session.list(query),
.filter((s) => !s.time?.archived) })
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) .then((x) => {
const limit = store.limit const nonArchived = (x.data ?? [])
const childSessions = store.session.filter((s) => !!s.parentID) .filter((s) => !!s?.id)
const sessions = trimSessions([...nonArchived, ...childSessions], { .filter((s) => !s.time?.archived)
limit, .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
permission: store.permission, const limit = store.limit
}) const childSessions = store.session.filter((s) => !!s.parentID)
setStore( const sessions = trimSessions([...nonArchived, ...childSessions], {
"sessionTotal", limit,
estimateRootSessionTotal({ permission: store.permission,
count: nonArchived.length, })
limit: x.limit, setStore(
limited: x.limited, "sessionTotal",
}), estimateRootSessionTotal({
) count: nonArchived.length,
setStore("session", reconcile(sessions, { key: "id" })) limit: x.limit,
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) limited: x.limited,
sessionMeta.set(directory, { limit }) }),
}) )
.catch((err) => { setStore("session", reconcile(sessions, { key: "id" }))
console.error("Failed to load sessions", err) cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
const project = getFilename(directory) sessionMeta.set(directory, { limit })
showToast({ })
variant: "error", .catch((err) => {
title: language.t("toast.session.listFailed.title", { project }), console.error("Failed to load sessions", err)
description: formatServerError(err, language.t), const project = getFilename(directory)
}) showToast({
variant: "error",
title: language.t("toast.session.listFailed.title", { project }),
description: formatServerError(err, language.t),
})
})
.then(() => null),
}) })
.then(() => {})
sessionLoads.set(directory, promise) sessionLoads.set(directory, promise)
void promise.finally(() => { void promise.finally(() => {
@@ -250,8 +262,9 @@ function createGlobalSync() {
if (pending) return pending if (pending) return pending
children.pin(directory) children.pin(directory)
const promise = (async () => { const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory) const child = children.ensureChild(directory)
child[1]("bootstrapPromise", promise!)
const cache = children.vcsCache.get(directory) const cache = children.vcsCache.get(directory)
if (!cache) return if (!cache) return
const sdk = sdkFor(directory) const sdk = sdkFor(directory)
@@ -269,8 +282,9 @@ function createGlobalSync() {
vcsCache: cache, vcsCache: cache,
loadSessions, loadSessions,
translate: language.t, translate: language.t,
queryClient,
}) })
})() })
booting.set(directory, promise) booting.set(directory, promise)
void promise.finally(() => { void promise.finally(() => {
@@ -346,6 +360,7 @@ function createGlobalSync() {
translate: language.t, translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore, setGlobalStore: setBootStore,
queryClient,
}) })
bootedAt = Date.now() bootedAt = Date.now()
} finally { } finally {
+148 -121
View File
@@ -18,6 +18,8 @@ import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query"
import { loadSessionsQuery } from "../global-sync"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -71,6 +73,7 @@ export async function bootstrapGlobal(input: {
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore> setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient
}) { }) {
const fast = [ const fast = [
() => () =>
@@ -80,11 +83,16 @@ export async function bootstrapGlobal(input: {
}), }),
), ),
() => () =>
retry(() => input.queryClient.fetchQuery({
input.globalSDK.provider.list().then((x) => { ...loadProvidersQuery(null),
input.setGlobalStore("provider", normalizeProviderList(x.data!)) queryFn: () =>
}), retry(() =>
), input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
return null
}),
),
}),
] ]
const slow = [ const slow = [
@@ -172,6 +180,12 @@ function warmSessions(input: {
).then(() => undefined) ).then(() => undefined)
} }
export const loadProvidersQuery = (directory: string | null) =>
queryOptions<null>({ queryKey: [directory, "providers"], queryFn: skipToken })
export const loadAgentsQuery = (directory: string | null) =>
queryOptions<null>({ queryKey: [directory, "agents"], queryFn: skipToken })
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
sdk: OpencodeClient sdk: OpencodeClient
@@ -186,6 +200,7 @@ export async function bootstrapDirectory(input: {
project: Project[] project: Project[]
provider: ProviderListResponse provider: ProviderListResponse
} }
queryClient: QueryClient
}) { }) {
const loading = input.store.status !== "complete" const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project) const seededProject = projectID(input.directory, input.global.project)
@@ -207,97 +222,7 @@ export async function bootstrapDirectory(input: {
input.setStore("lsp", []) input.setStore("lsp", [])
if (loading) input.setStore("status", "partial") if (loading) input.setStore("status", "partial")
const fast = [ const fast = [() => Promise.resolve(input.loadSessions(input.directory))]
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
]
const slow = [
() =>
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() =>
seededPath
? Promise.resolve()
: retry(() =>
input.sdk.path.get().then((x) => {
input.setStore("path", x.data!)
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
}),
),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
}),
)
}),
),
() =>
retry(() =>
input.sdk.question.list().then((x) => {
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
retry(() =>
input.sdk.mcp.status().then((x) => {
input.setStore("mcp", x.data!)
input.setStore("mcp_ready", true)
}),
),
]
const errs = errors(await runAll(fast)) const errs = errors(await runAll(fast))
if (errs.length > 0) { if (errs.length > 0) {
@@ -310,36 +235,138 @@ export async function bootstrapDirectory(input: {
}) })
} }
await waitForPaint() ;(async () => {
const slowErrs = errors(await runAll(slow)) const slow = [
if (slowErrs.length > 0) { () =>
console.error("Failed to finish bootstrap instance", slowErrs[0]) input.queryClient.ensureQueryData({
const project = getFilename(input.directory) ...loadAgentsQuery(input.directory),
showToast({ queryFn: () =>
variant: "error", retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))).then(
title: input.translate("toast.project.reloadFailed.title", { project }), () => null,
description: formatServerError(slowErrs[0], input.translate), ),
}) }),
} () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() =>
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() =>
seededPath
? Promise.resolve()
: retry(() =>
input.sdk.path.get().then((x) => {
input.setStore("path", x.data!)
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
}),
),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
}),
)
}),
),
() =>
retry(() =>
input.sdk.question.list().then((x) => {
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
retry(() =>
input.sdk.mcp.status().then((x) => {
input.setStore("mcp", x.data!)
input.setStore("mcp_ready", true)
}),
),
]
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") await waitForPaint()
const slowErrs = errors(await runAll(slow))
const rev = (providerRev.get(input.directory) ?? 0) + 1 if (slowErrs.length > 0) {
providerRev.set(input.directory, rev) console.error("Failed to finish bootstrap instance", slowErrs[0])
void retry(() => input.sdk.provider.list())
.then((x) => {
if (providerRev.get(input.directory) !== rev) return
input.setStore("provider", normalizeProviderList(x.data!))
input.setStore("provider_ready", true)
})
.catch((err) => {
if (providerRev.get(input.directory) !== rev) return
console.error("Failed to refresh provider list", err)
const project = getFilename(input.directory) const project = getFilename(input.directory)
showToast({ showToast({
variant: "error", variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }), title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate), description: formatServerError(slowErrs[0], input.translate),
}) })
}
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
const rev = (providerRev.get(input.directory) ?? 0) + 1
providerRev.set(input.directory, rev)
void input.queryClient.ensureQueryData({
...loadSessionsQuery(input.directory),
queryFn: () =>
retry(() => input.sdk.provider.list())
.then((x) => {
if (providerRev.get(input.directory) !== rev) return
input.setStore("provider", normalizeProviderList(x.data!))
input.setStore("provider_ready", true)
})
.catch((err) => {
if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err)
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
})
.then(() => null),
}) })
})()
} }
@@ -182,6 +182,7 @@ export function createChildStoreManager(input: {
limit: 5, limit: 5,
message: {}, message: {},
part: {}, part: {},
bootstrapPromise: Promise.resolve(),
}) })
children[directory] = child children[directory] = child
disposers.set(directory, dispose) disposers.set(directory, dispose)
@@ -72,6 +72,7 @@ export type State = {
part: { part: {
[messageID: string]: Part[] [messageID: string]: Part[]
} }
bootstrapPromise: Promise<void>
} }
export type VcsCache = { export type VcsCache = {
+1
View File
@@ -3,6 +3,7 @@ import "solid-js"
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_SERVER_PORT: string
readonly OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
} }
interface ImportMeta { interface ImportMeta {
-2
View File
@@ -1,5 +1,3 @@
import { dict as en } from "./en"
export const dict = { export const dict = {
"command.category.suggested": "추천", "command.category.suggested": "추천",
"command.category.view": "보기", "command.category.view": "보기",
+9 -1
View File
@@ -132,9 +132,11 @@ export default function Layout(props: ParentProps) {
if (!slug) return { slug, dir: "" } if (!slug) return { slug, dir: "" }
const dir = decode64(slug) const dir = decode64(slug)
if (!dir) return { slug, dir: "" } if (!dir) return { slug, dir: "" }
const store = globalSync.peek(dir, { bootstrap: false })
return { return {
slug, slug,
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, store,
dir: store[0].path.directory || dir,
} }
}) })
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const)) const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
@@ -2353,8 +2355,14 @@ export default function Layout(props: ParentProps) {
/> />
) )
const [loading] = createResource(
() => route()?.store?.[0]?.bootstrapPromise,
(p) => p,
)
return ( return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{(autoselecting(), loading()) ?? ""}
<Titlebar /> <Titlebar />
<div class="flex-1 min-h-0 min-w-0 flex"> <div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative"> <div class="flex-1 min-h-0 relative">
@@ -14,10 +14,11 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { sortedRootSessions, workspaceKey } from "./helpers" import { sortedRootSessions, workspaceKey } from "./helpers"
import { useQuery } from "@tanstack/solid-query"
type InlineEditorComponent = (props: { type InlineEditorComponent = (props: {
id: string id: string
@@ -454,7 +455,8 @@ export const LocalWorkspace = (props: {
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0) const count = createMemo(() => sessions()?.length ?? 0)
const loading = createMemo(() => !booted() && count() === 0) const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const loading = createMemo(() => query.isPending && count() === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > count()) const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loadMore = async () => { const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5) workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
@@ -471,7 +473,7 @@ export const LocalWorkspace = (props: {
mobile={props.mobile} mobile={props.mobile}
ctx={props.ctx} ctx={props.ctx}
showNew={() => false} showNew={() => false}
loading={loading} loading={() => query.isLoading}
sessions={sessions} sessions={sessions}
hasMore={hasMore} hasMore={hasMore}
loadMore={loadMore} loadMore={loadMore}
+9 -8
View File
@@ -13,6 +13,7 @@ import {
on, on,
onMount, onMount,
untrack, untrack,
createResource,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
@@ -432,7 +433,6 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID) const isChildSession = createMemo(() => !!info()?.parentID)
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const canReview = createMemo(() => !!sync.project) const canReview = createMemo(() => !!sync.project)
const reviewTab = createMemo(() => isDesktop()) const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({ const tabState = createSessionTabs({
@@ -805,8 +805,9 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
createEffect( const [sessionSync] = createResource(
on([() => sdk.directory, () => params.id] as const, ([, id]) => { () => [sdk.directory, params.id] as const,
([directory, id]) => {
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
refreshFrame = undefined refreshFrame = undefined
@@ -817,13 +818,10 @@ export default function Page() {
const stale = !cached const stale = !cached
? false ? false
: (() => { : (() => {
const info = getSessionPrefetch(sdk.directory, id) const info = getSessionPrefetch(directory, id)
if (!info) return true if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL return Date.now() - info.at > SESSION_PREFETCH_TTL
})() })()
untrack(() => {
void sync.session.sync(id)
})
refreshFrame = requestAnimationFrame(() => { refreshFrame = requestAnimationFrame(() => {
refreshFrame = undefined refreshFrame = undefined
@@ -835,7 +833,9 @@ export default function Page() {
}) })
}, 0) }, 0)
}) })
}),
return sync.session.sync(id)
},
) )
createEffect( createEffect(
@@ -1882,6 +1882,7 @@ export default function Page() {
return ( return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
{sessionSync() ?? ""}
<SessionHeader /> <SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row"> <div class="flex-1 min-h-0 flex flex-col md:flex-row">
<Show when={!isDesktop() && !!params.id}> <Show when={!isDesktop() && !!params.id}>
+4 -1
View File
@@ -16,7 +16,10 @@ export function createSdkForServer({
return createOpencodeClient({ return createOpencodeClient({
...config, ...config,
headers: { ...config.headers, ...auth }, headers: {
...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers),
...auth,
},
baseUrl: server.url, baseUrl: server.url,
}) })
} }
@@ -1,7 +1,7 @@
import type { APIEvent } from "@solidjs/start" import type { APIEvent } from "@solidjs/start"
import type { DownloadPlatform } from "../types" import type { DownloadPlatform } from "../types"
const assetNames: Record<string, string> = { const prodAssetNames: Record<string, string> = {
"darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg", "darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg",
"darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg", "darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg",
"windows-x64-nsis": "opencode-desktop-windows-x64.exe", "windows-x64-nsis": "opencode-desktop-windows-x64.exe",
@@ -10,6 +10,15 @@ const assetNames: Record<string, string> = {
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm", "linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
} satisfies Record<DownloadPlatform, string> } satisfies Record<DownloadPlatform, string>
const betaAssetNames: Record<string, string> = {
"darwin-aarch64-dmg": "opencode-electron-mac-arm64.dmg",
"darwin-x64-dmg": "opencode-electron-mac-x64.dmg",
"windows-x64-nsis": "opencode-electron-win-x64.exe",
"linux-x64-deb": "opencode-electron-linux-amd64.deb",
"linux-x64-appimage": "opencode-electron-linux-x86_64.AppImage",
"linux-x64-rpm": "opencode-electron-linux-x86_64.rpm",
} satisfies Record<DownloadPlatform, string>
// Doing this on the server lets us preserve the original name for platforms we don't care to rename for // Doing this on the server lets us preserve the original name for platforms we don't care to rename for
const downloadNames: Record<string, string> = { const downloadNames: Record<string, string> = {
"darwin-aarch64-dmg": "OpenCode Desktop.dmg", "darwin-aarch64-dmg": "OpenCode Desktop.dmg",
@@ -18,7 +27,7 @@ const downloadNames: Record<string, string> = {
} satisfies { [K in DownloadPlatform]?: string } } satisfies { [K in DownloadPlatform]?: string }
export async function GET({ params: { platform, channel } }: APIEvent) { export async function GET({ params: { platform, channel } }: APIEvent) {
const assetName = assetNames[platform] const assetName = channel === "stable" ? prodAssetNames[platform] : betaAssetNames[platform]
if (!assetName) return new Response(null, { status: 404 }) if (!assetName) return new Response(null, { status: 404 })
const resp = await fetch( const resp = await fetch(
@@ -37,5 +46,5 @@ export async function GET({ params: { platform, channel } }: APIEvent) {
const headers = new Headers(resp.headers) const headers = new Headers(resp.headers)
if (downloadName) headers.set("content-disposition", `attachment; filename="${downloadName}"`) if (downloadName) headers.set("content-disposition", `attachment; filename="${downloadName}"`)
return new Response(resp.body, { ...resp, headers }) return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers })
} }
+1 -2
View File
@@ -12,7 +12,6 @@ import { Header } from "~/component/header"
import { Footer } from "~/component/footer" import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal" import { Legal } from "~/component/legal"
import { github } from "~/lib/github" import { github } from "~/lib/github"
import { createMemo } from "solid-js"
import { config } from "~/config" import { config } from "~/config"
import { useI18n } from "~/context/i18n" import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language" import { useLanguage } from "~/context/language"
@@ -30,7 +29,7 @@ function CopyStatus() {
export default function Home() { export default function Home() {
const i18n = useI18n() const i18n = useI18n()
const language = useLanguage() const language = useLanguage()
const githubData = createAsync(() => github()) const _githubData = createAsync(() => github())
const handleCopyClick = (event: Event) => { const handleCopyClick = (event: Event) => {
const button = event.currentTarget as HTMLButtonElement const button = event.currentTarget as HTMLButtonElement
const text = button.textContent const text = button.textContent
@@ -116,9 +116,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
const setUseBalance = action(async (form: FormData) => { const setUseBalance = action(async (form: FormData) => {
"use server" "use server"
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
const useBalance = form.get("useBalance")?.toString() === "true" const useBalance = (form.get("useBalance") as string | null) === "true"
return json( return json(
await withActor(async () => { await withActor(async () => {
@@ -10,11 +10,11 @@ import { formError, localizeError } from "~/lib/form-error"
const setMonthlyLimit = action(async (form: FormData) => { const setMonthlyLimit = action(async (form: FormData) => {
"use server" "use server"
const limit = form.get("limit")?.toString() const limit = form.get("limit") as string | null
if (!limit) return { error: formError.limitRequired } if (!limit) return { error: formError.limitRequired }
const numericLimit = parseInt(limit) const numericLimit = parseInt(limit)
if (numericLimit < 0) return { error: formError.monthlyLimitInvalid } if (numericLimit < 0) return { error: formError.monthlyLimitInvalid }
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
return json( return json(
await withActor( await withActor(
@@ -12,7 +12,7 @@ import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localiz
const reload = action(async (form: FormData) => { const reload = action(async (form: FormData) => {
"use server" "use server"
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Billing.reload(), workspaceID), { return json(await withActor(() => Billing.reload(), workspaceID), {
revalidate: queryBillingInfo.key, revalidate: queryBillingInfo.key,
@@ -21,11 +21,11 @@ const reload = action(async (form: FormData) => {
const setReload = action(async (form: FormData) => { const setReload = action(async (form: FormData) => {
"use server" "use server"
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
const reloadValue = form.get("reload")?.toString() === "true" const reloadValue = (form.get("reload") as string | null) === "true"
const amountStr = form.get("reloadAmount")?.toString() const amountStr = form.get("reloadAmount") as string | null
const triggerStr = form.get("reloadTrigger")?.toString() const triggerStr = form.get("reloadTrigger") as string | null
const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
@@ -91,8 +91,8 @@ export function ReloadSection() {
const info = billingInfo()! const info = billingInfo()!
setStore("show", true) setStore("show", true)
setStore("reload", true) setStore("reload", true)
setStore("reloadAmount", info.reloadAmount.toString()) setStore("reloadAmount", String(info.reloadAmount))
setStore("reloadTrigger", info.reloadTrigger.toString()) setStore("reloadTrigger", String(info.reloadTrigger))
} }
function hide() { function hide() {
@@ -152,11 +152,11 @@ export function ReloadSection() {
data-component="input" data-component="input"
name="reloadAmount" name="reloadAmount"
type="number" type="number"
min={billingInfo()?.reloadAmountMin.toString()} min={String(billingInfo()?.reloadAmountMin ?? "")}
step="1" step="1"
value={store.reloadAmount} value={store.reloadAmount}
onInput={(e) => setStore("reloadAmount", e.currentTarget.value)} onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
placeholder={billingInfo()?.reloadAmount.toString()} placeholder={String(billingInfo()?.reloadAmount ?? "")}
disabled={!store.reload} disabled={!store.reload}
/> />
</div> </div>
@@ -166,11 +166,11 @@ export function ReloadSection() {
data-component="input" data-component="input"
name="reloadTrigger" name="reloadTrigger"
type="number" type="number"
min={billingInfo()?.reloadTriggerMin.toString()} min={String(billingInfo()?.reloadTriggerMin ?? "")}
step="1" step="1"
value={store.reloadTrigger} value={store.reloadTrigger}
onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)} onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
placeholder={billingInfo()?.reloadTrigger.toString()} placeholder={String(billingInfo()?.reloadTrigger ?? "")}
disabled={!store.reload} disabled={!store.reload}
/> />
</div> </div>
@@ -120,9 +120,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
const setLiteUseBalance = action(async (form: FormData) => { const setLiteUseBalance = action(async (form: FormData) => {
"use server" "use server"
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
const useBalance = form.get("useBalance")?.toString() === "true" const useBalance = (form.get("useBalance") as string | null) === "true"
return json( return json(
await withActor(async () => { await withActor(async () => {
@@ -12,18 +12,18 @@ import { formError, localizeError } from "~/lib/form-error"
const removeKey = action(async (form: FormData) => { const removeKey = action(async (form: FormData) => {
"use server" "use server"
const id = form.get("id")?.toString() const id = form.get("id") as string | null
if (!id) return { error: formError.idRequired } if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key }) return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
}, "key.remove") }, "key.remove")
const createKey = action(async (form: FormData) => { const createKey = action(async (form: FormData) => {
"use server" "use server"
const name = form.get("name")?.toString().trim() const name = (form.get("name") as string | null)?.trim()
if (!name) return { error: formError.nameRequired } if (!name) return { error: formError.nameRequired }
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
return json( return json(
await withActor( await withActor(
@@ -24,13 +24,13 @@ const listMembers = query(async (workspaceID: string) => {
const inviteMember = action(async (form: FormData) => { const inviteMember = action(async (form: FormData) => {
"use server" "use server"
const email = form.get("email")?.toString().trim() const email = (form.get("email") as string | null)?.trim()
if (!email) return { error: formError.emailRequired } if (!email) return { error: formError.emailRequired }
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
const role = form.get("role")?.toString() as (typeof UserRole)[number] const role = form.get("role") as (typeof UserRole)[number] | null
if (!role) return { error: formError.roleRequired } if (!role) return { error: formError.roleRequired }
const limit = form.get("limit")?.toString() const limit = form.get("limit") as string | null
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid } if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
return json( return json(
@@ -47,9 +47,9 @@ const inviteMember = action(async (form: FormData) => {
const removeMember = action(async (form: FormData) => { const removeMember = action(async (form: FormData) => {
"use server" "use server"
const id = form.get("id")?.toString() const id = form.get("id") as string | null
if (!id) return { error: formError.idRequired } if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
return json( return json(
await withActor( await withActor(
@@ -66,13 +66,13 @@ const removeMember = action(async (form: FormData) => {
const updateMember = action(async (form: FormData) => { const updateMember = action(async (form: FormData) => {
"use server" "use server"
const id = form.get("id")?.toString() const id = form.get("id") as string | null
if (!id) return { error: formError.idRequired } if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
const role = form.get("role")?.toString() as (typeof UserRole)[number] const role = form.get("role") as (typeof UserRole)[number] | null
if (!role) return { error: formError.roleRequired } if (!role) return { error: formError.roleRequired }
const limit = form.get("limit")?.toString() const limit = form.get("limit") as string | null
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid } if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
@@ -118,7 +118,7 @@ function MemberRow(props: {
} }
setStore("editing", true) setStore("editing", true)
setStore("selectedRole", props.member.role) setStore("selectedRole", props.member.role)
setStore("limit", props.member.monthlyLimit?.toString() ?? "") setStore("limit", props.member.monthlyLimit != null ? String(props.member.monthlyLimit) : "")
} }
function hide() { function hide() {
@@ -67,11 +67,11 @@ const getModelsInfo = query(async (workspaceID: string) => {
const updateModel = action(async (form: FormData) => { const updateModel = action(async (form: FormData) => {
"use server" "use server"
const model = form.get("model")?.toString() const model = form.get("model") as string | null
if (!model) return { error: formError.modelRequired } if (!model) return { error: formError.modelRequired }
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
const enabled = form.get("enabled")?.toString() === "true" const enabled = (form.get("enabled") as string | null) === "true"
return json( return json(
withActor(async () => { withActor(async () => {
if (enabled) { if (enabled) {
@@ -163,7 +163,7 @@ export function ModelSection() {
<form action={updateModel} method="post"> <form action={updateModel} method="post">
<input type="hidden" name="model" value={id} /> <input type="hidden" name="model" value={id} />
<input type="hidden" name="workspaceID" value={params.id} /> <input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="enabled" value={isEnabled().toString()} /> <input type="hidden" name="enabled" value={String(isEnabled())} />
<label data-slot="model-toggle-label"> <label data-slot="model-toggle-label">
<input <input
type="checkbox" type="checkbox"
@@ -21,9 +21,9 @@ function maskCredentials(credentials: string) {
const removeProvider = action(async (form: FormData) => { const removeProvider = action(async (form: FormData) => {
"use server" "use server"
const provider = form.get("provider")?.toString() const provider = form.get("provider") as string | null
if (!provider) return { error: formError.providerRequired } if (!provider) return { error: formError.providerRequired }
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Provider.remove({ provider }), workspaceID), { return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
revalidate: listProviders.key, revalidate: listProviders.key,
@@ -32,11 +32,11 @@ const removeProvider = action(async (form: FormData) => {
const saveProvider = action(async (form: FormData) => { const saveProvider = action(async (form: FormData) => {
"use server" "use server"
const provider = form.get("provider")?.toString() const provider = form.get("provider") as string | null
const credentials = form.get("credentials")?.toString() const credentials = form.get("credentials") as string | null
if (!provider) return { error: formError.providerRequired } if (!provider) return { error: formError.providerRequired }
if (!credentials) return { error: formError.apiKeyRequired } if (!credentials) return { error: formError.apiKeyRequired }
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
return json( return json(
await withActor( await withActor(
@@ -59,10 +59,13 @@ function ProviderRow(props: { provider: Provider }) {
const params = useParams() const params = useParams()
const i18n = useI18n() const i18n = useI18n()
const providers = createAsync(() => listProviders(params.id!)) const providers = createAsync(() => listProviders(params.id!))
const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key) const saveSubmission = useSubmission(
saveProvider,
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
)
const removeSubmission = useSubmission( const removeSubmission = useSubmission(
removeProvider, removeProvider,
([fd]) => fd.get("provider")?.toString() === props.provider.key, ([fd]) => (fd.get("provider") as string | null) === props.provider.key,
) )
const [store, setStore] = createStore({ editing: false }) const [store, setStore] = createStore({ editing: false })
@@ -30,10 +30,10 @@ const getWorkspaceInfo = query(async (workspaceID: string) => {
const updateWorkspace = action(async (form: FormData) => { const updateWorkspace = action(async (form: FormData) => {
"use server" "use server"
const name = form.get("name")?.toString().trim() const name = (form.get("name") as string | null)?.trim()
if (!name) return { error: formError.workspaceNameRequired } if (!name) return { error: formError.workspaceNameRequired }
if (name.length > 255) return { error: formError.nameTooLong } if (name.length > 255) return { error: formError.nameTooLong }
const workspaceID = form.get("workspaceID")?.toString() const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired } if (!workspaceID) return { error: formError.workspaceRequired }
return json( return json(
await withActor( await withActor(
@@ -60,6 +60,9 @@ export default defineConfig({
plugins: [appPlugin], plugins: [appPlugin],
publicDir: "../../../app/public", publicDir: "../../../app/public",
root: "src/renderer", root: "src/renderer",
define: {
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
build: { build: {
rollupOptions: { rollupOptions: {
input: { input: {
+3
View File
@@ -1,6 +1,9 @@
research research
dist dist
dist-*
gen gen
app.log app.log
src/provider/models-snapshot.js src/provider/models-snapshot.js
src/provider/models-snapshot.d.ts src/provider/models-snapshot.d.ts
script/build-*.ts
temporary-*.md
-31
View File
@@ -1,31 +0,0 @@
{
"name": ".opencode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "*"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.2.6",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.2.6",
"zod": "4.1.8"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.6",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+6
View File
@@ -39,6 +39,12 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top. - Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.). - Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed. - Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
- To make a service's `init()` non-blocking, fork `InstanceState.get(state)` at the `init()` call site (e.g. `Effect.forkIn(scope)`), not by forking work inside the `InstanceState.make` closure. Forking inside the closure leaves state incomplete for other methods that read it.
- `src/project/bootstrap.ts` already wraps every service `init()` in `Effect.forkDetach`, so `init()` is fire-and-forget in production. Keep `init()` methods synchronous internally; the caller controls concurrency.
## Effect v4 beta API
- `Effect.fork` and `Effect.forkDaemon` do not exist. Use `Effect.forkIn(scope)` to fork a fiber into a specific scope.
## Preferred Effect services ## Preferred Effect services
+5 -4
View File
@@ -14,6 +14,7 @@
"fix-node-pty": "bun run script/fix-node-pty.ts", "fix-node-pty": "bun run script/fix-node-pty.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
"db": "bun drizzle-kit" "db": "bun drizzle-kit"
}, },
"bin": { "bin": {
@@ -78,15 +79,15 @@
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.94",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.70",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.97", "@ai-sdk/gateway": "3.0.102",
"@ai-sdk/google": "3.0.63", "@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.109", "@ai-sdk/google-vertex": "4.0.111",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53", "@ai-sdk/openai": "3.0.53",
+1 -1
View File
@@ -213,7 +213,7 @@ for (const item of targets) {
}, },
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}, files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
entrypoints: [ entrypoints: [
"./src/index.ts", "./src/temporary.ts",
parserWorker, parserWorker,
workerPath, workerPath,
rgPath, rgPath,
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bun
/**
* Collapse a single-namespace barrel directory into a dir/index.ts module.
*
* Given a directory `src/foo/` that contains:
*
* - `index.ts` (exactly `export * as Foo from "./foo"`)
* - `foo.ts` (the real implementation)
* - zero or more sibling files
*
* this script:
*
* 1. Deletes the old `index.ts` barrel.
* 2. `git mv`s `foo.ts` → `index.ts` so the implementation IS the directory entry.
* 3. Appends `export * as Foo from "."` to the new `index.ts`.
* 4. Rewrites any same-directory sibling `*.ts` files that imported
* `./foo` (with or without the namespace name) to import `"."` instead.
*
* Consumer files outside the directory keep importing from the directory
* (`"@/foo"` / `"../foo"` / etc.) and continue to work, because
* `dir/index.ts` now provides the `Foo` named export directly.
*
* Usage:
*
* bun script/collapse-barrel.ts src/bus
* bun script/collapse-barrel.ts src/bus --dry-run
*
* Notes:
*
* - Only works on directories whose barrel is a single
* `export * as Name from "./file"` line. Refuses otherwise.
* - Refuses if the implementation file name already conflicts with
* `index.ts`.
* - Safe to run repeatedly: a second run on an already-collapsed dir
* will exit with a clear message.
*/
import fs from "node:fs"
import path from "node:path"
import { spawnSync } from "node:child_process"
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const targetArg = args.find((a) => !a.startsWith("--"))
if (!targetArg) {
console.error("Usage: bun script/collapse-barrel.ts <dir> [--dry-run]")
process.exit(1)
}
const dir = path.resolve(targetArg)
const indexPath = path.join(dir, "index.ts")
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
console.error(`Not a directory: ${dir}`)
process.exit(1)
}
if (!fs.existsSync(indexPath)) {
console.error(`No index.ts in ${dir}`)
process.exit(1)
}
// Validate barrel shape.
const indexContent = fs.readFileSync(indexPath, "utf-8").trim()
const match = indexContent.match(/^export\s+\*\s+as\s+(\w+)\s+from\s+["']\.\/([^"']+)["']\s*;?\s*$/)
if (!match) {
console.error(`Not a simple single-namespace barrel:\n${indexContent}`)
process.exit(1)
}
const namespaceName = match[1]
const implRel = match[2].replace(/\.ts$/, "")
const implPath = path.join(dir, `${implRel}.ts`)
if (!fs.existsSync(implPath)) {
console.error(`Implementation file not found: ${implPath}`)
process.exit(1)
}
if (implRel === "index") {
console.error(`Nothing to do — impl file is already index.ts`)
process.exit(0)
}
console.log(`Collapsing ${path.relative(process.cwd(), dir)}`)
console.log(` namespace: ${namespaceName}`)
console.log(` impl file: ${implRel}.ts → index.ts`)
// Figure out which sibling files need rewriting.
const siblings = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"))
.filter((f) => f !== "index.ts" && f !== `${implRel}.ts`)
.map((f) => path.join(dir, f))
type SiblingEdit = { file: string; content: string }
const siblingEdits: SiblingEdit[] = []
for (const sibling of siblings) {
const content = fs.readFileSync(sibling, "utf-8")
// Match any import or re-export referring to "./<implRel>" inside this directory.
const siblingRegex = new RegExp(`(from\\s*["'])\\.\\/${implRel.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&")}(["'])`, "g")
if (!siblingRegex.test(content)) continue
const updated = content.replace(siblingRegex, `$1.$2`)
siblingEdits.push({ file: sibling, content: updated })
}
if (siblingEdits.length > 0) {
console.log(` sibling rewrites: ${siblingEdits.length}`)
for (const edit of siblingEdits) {
console.log(` ${path.relative(process.cwd(), edit.file)}`)
}
} else {
console.log(` sibling rewrites: none`)
}
if (dryRun) {
console.log(`\n(dry run) would:`)
console.log(` - delete ${path.relative(process.cwd(), indexPath)}`)
console.log(` - git mv ${path.relative(process.cwd(), implPath)} ${path.relative(process.cwd(), indexPath)}`)
console.log(` - append \`export * as ${namespaceName} from "."\` to the new index.ts`)
for (const edit of siblingEdits) {
console.log(` - rewrite sibling: ${path.relative(process.cwd(), edit.file)}`)
}
process.exit(0)
}
// Apply: remove the old barrel, git-mv the impl onto it, then rewrite content.
// We can't git-mv on top of an existing tracked file, so we remove the barrel first.
function runGit(...cmd: string[]) {
const res = spawnSync("git", cmd, { stdio: "inherit" })
if (res.status !== 0) {
console.error(`git ${cmd.join(" ")} failed`)
process.exit(res.status ?? 1)
}
}
// Step 1: remove the barrel
runGit("rm", "-f", indexPath)
// Step 2: rename the impl file into index.ts
runGit("mv", implPath, indexPath)
// Step 3: append the self-reexport to the new index.ts
const newContent = fs.readFileSync(indexPath, "utf-8")
const trimmed = newContent.endsWith("\n") ? newContent : newContent + "\n"
fs.writeFileSync(indexPath, `${trimmed}\nexport * as ${namespaceName} from "."\n`)
console.log(` appended: export * as ${namespaceName} from "."`)
// Step 4: rewrite siblings
for (const edit of siblingEdits) {
fs.writeFileSync(edit.file, edit.content)
}
if (siblingEdits.length > 0) {
console.log(` rewrote ${siblingEdits.length} sibling file(s)`)
}
console.log(`\nDone. Verify with:`)
console.log(` cd packages/opencode`)
console.log(` bunx --bun tsgo --noEmit`)
console.log(` bun run --conditions=browser ./src/index.ts generate`)
console.log(` bun run test`)
-17
View File
@@ -68,23 +68,6 @@ function findBinary() {
} }
} }
function prepareBinDirectory(binaryName) {
const binDir = path.join(__dirname, "bin")
const targetPath = path.join(binDir, binaryName)
// Ensure bin directory exists
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true })
}
// Remove existing binary/symlink if it exists
if (fs.existsSync(targetPath)) {
fs.unlinkSync(targetPath)
}
return { binDir, targetPath }
}
async function main() { async function main() {
try { try {
if (os.platform() === "win32") { if (os.platform() === "win32") {
+2 -2
View File
@@ -2,7 +2,7 @@
import { z } from "zod" import { z } from "zod"
import { Config } from "../src/config" import { Config } from "../src/config"
import { TuiConfig } from "../src/config" import { TuiConfig } from "../src/cli/cmd/tui/config/tui"
function generate(schema: z.ZodType) { function generate(schema: z.ZodType) {
const result = z.toJSONSchema(schema, { const result = z.toJSONSchema(schema, {
@@ -33,7 +33,7 @@ function generate(schema: z.ZodType) {
schema.examples = [schema.default] schema.examples = [schema.default]
} }
schema.description = [schema.description || "", `default: \`${schema.default}\``] schema.description = [schema.description || "", `default: \`${String(schema.default)}\``]
.filter(Boolean) .filter(Boolean)
.join("\n\n") .join("\n\n")
.trim() .trim()
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bun
import path from "path"
const toDynamicallyImport = path.join(process.cwd(), process.argv[2])
await import(toDynamicallyImport)
console.log(performance.now())
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bun
import * as path from "path"
import * as ts from "typescript"
const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode"
// Get entry file from command line arg or use default
const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts"
const visited = new Set<string>()
function resolveImport(importPath: string, fromFile: string): string | null {
if (importPath.startsWith("@/")) {
return path.join(BASE_DIR, "src", importPath.slice(2))
}
if (importPath.startsWith("./") || importPath.startsWith("../")) {
const dir = path.dirname(fromFile)
return path.resolve(dir, importPath)
}
return null
}
function isInternalImport(importPath: string): boolean {
return importPath.startsWith("@/") || importPath.startsWith("./") || importPath.startsWith("../")
}
async function tryExtensions(filePath: string): Promise<string | null> {
const extensions = [".ts", ".tsx", ".js", ".jsx"]
try {
const file = Bun.file(filePath)
const stat = await file.stat()
if (stat?.isDirectory()) {
for (const ext of extensions) {
const indexPath = path.join(filePath, "index" + ext)
const indexFile = Bun.file(indexPath)
if (await indexFile.exists()) return indexPath
}
return null
}
// It's a file
return filePath
} catch {
// Path doesn't exist, try adding extensions
for (const ext of extensions) {
const withExt = filePath + ext
const extFile = Bun.file(withExt)
if (await extFile.exists()) return withExt
}
return null
}
}
function extractImports(sourceFile: ts.SourceFile): string[] {
const imports: string[] = []
function visit(node: ts.Node) {
// import x from "path" or import { x } from "path"
if (ts.isImportDeclaration(node)) {
// Skip type-only imports
if (node.importClause?.isTypeOnly) return
const moduleSpec = node.moduleSpecifier
if (ts.isStringLiteral(moduleSpec)) {
imports.push(moduleSpec.text)
}
}
// export { x } from "path"
if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
if (ts.isStringLiteral(node.moduleSpecifier)) {
imports.push(node.moduleSpecifier.text)
}
}
// Dynamic import: import("path")
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
const arg = node.arguments[0]
if (arg && ts.isStringLiteral(arg)) {
imports.push(arg.text)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return imports
}
async function traceFile(filePath: string, depth = 0): Promise<void> {
const normalizedPath = path.relative(BASE_DIR, filePath)
if (visited.has(filePath)) {
return
}
// Only trace TypeScript/JavaScript files
if (!filePath.match(/\.(ts|tsx|js|jsx)$/)) {
return
}
visited.add(filePath)
console.log("\t".repeat(depth) + normalizedPath)
let content: string
try {
content = await Bun.file(filePath).text()
} catch {
return
}
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true)
const imports = extractImports(sourceFile)
const internalImports = imports.filter(isInternalImport)
const externalImports = imports.filter((imp) => !isInternalImport(imp))
// Print external imports
for (const imp of externalImports) {
console.log("\t".repeat(depth + 1) + `[ext] ${imp}`)
}
for (const imp of internalImports) {
const resolved = resolveImport(imp, filePath)
if (!resolved) continue
const actualPath = await tryExtensions(resolved)
if (!actualPath) continue
await traceFile(actualPath, depth + 1)
}
}
async function main() {
const entryPath = path.join(BASE_DIR, ENTRY_FILE)
// Check if file exists
const file = Bun.file(entryPath)
if (!(await file.exists())) {
console.error(`File not found: ${ENTRY_FILE}`)
console.error(`Resolved to: ${entryPath}`)
process.exit(1)
}
await traceFile(entryPath)
}
main().catch(console.error)
@@ -1,444 +1,256 @@
# Namespace → flat export migration # Namespace → self-reexport migration
Migrate `export namespace` to the `export * as` / flat-export pattern used by Migrate every `export namespace Foo { ... }` to flat top-level exports plus a
effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect single self-reexport line at the bottom of the same file:
conventions, LLM-friendliness for future migrations.
## What changes and what doesn't
The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`,
`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved.
What changes is **how** the namespace is constructed — the TypeScript
`export namespace` keyword is replaced by `export * as` in a barrel file. This
is a mechanical change: unwrap the namespace body into flat exports, add a
one-line barrel. Consumers that import `{ Provider }` don't notice.
Import paths actually get **nicer**. Today most consumers import from the
explicit file (`"../provider/provider"`). After the migration, each module has a
barrel `index.ts`, so imports become `"../provider"` or `"@/provider"`:
```ts ```ts
// BEFORE — points at the file directly export * as Foo from "./foo"
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider namespace
import { Provider } from "../provider"
``` ```
## Why this matters right now No barrel `index.ts` files. No cross-directory indirection. Consumers keep the
exact same `import { Foo } from "../foo/foo"` ergonomics.
The CLI binary startup time (TOI) is too slow. Profiling shows we're loading ## Why this pattern
massive dependency graphs that are never actually used at runtime — because
bundlers cannot tree-shake TypeScript `export namespace` bodies.
### The problem in one sentence We tested three options against Bun, esbuild, Rollup (what Vite uses under the
hood), Bun's runtime, and Node's native TypeScript runner.
`cli/error.ts` needs 6 lightweight `.isInstance()` checks on error classes, but
importing `{ Provider }` from `provider.ts` forces the bundler to include **all
20+ `@ai-sdk/*` packages**, `@aws-sdk/credential-providers`,
`google-auth-library`, and every other top-level import in that 1709-line file.
### Why `export namespace` defeats tree-shaking
TypeScript compiles `export namespace Foo { ... }` to an IIFE:
```js
// TypeScript output
export var Provider;
(function (Provider) {
Provider.ModelNotFoundError = NamedError.create(...)
// ... 1600 more lines of assignments ...
})(Provider || (Provider = {}))
```
This is **opaque to static analysis**. The bundler sees one big function call
whose return value populates an object. It cannot determine which properties are
used downstream, so it keeps everything. Every `import` statement at the top of
`provider.ts` executes unconditionally — that's 20+ AI SDK packages loaded into
memory just so the CLI can check `Provider.ModelNotFoundError.isInstance(x)`.
### What `export * as` does differently
`export * as Provider from "./provider"` compiles to a static re-export. The
bundler knows the exact shape of `Provider` at compile time — it's the named
export list of `./provider.ts`. When it sees `Provider.ModelNotFoundError` used
but `Provider.layer` unused, it can trace that `ModelNotFoundError` doesn't
reference `createAnthropic` or any AI SDK import, and drop them. The namespace
object still exists at runtime — same API — but the bundler can see inside it.
### Concrete impact
The worst import chain in the codebase:
``` ```
src/index.ts (entry point) heavy.ts loaded?
A. namespace B. barrel C. self-reexport
Bun bundler YES YES no
esbuild YES YES no
Rollup (Vite) YES YES no
Bun runtime YES YES no
Node --experimental-strip-types SYNTAX ERROR YES no
```
- **`export namespace`** compiles to an IIFE. Bundlers see one opaque function
call and can't analyze what's used. Node's native TS runner rejects the
syntax outright: `SyntaxError: TypeScript namespace declaration is not
supported in strip-only mode`.
- **Barrel `index.ts`** files (`export * as Foo from "./foo"` in a separate
file) force every re-exported sibling to evaluate when you import one name.
Siblings with side effects (top-level imports of SDKs, etc.) always load.
- **Self-reexport** keeps the file as plain ESM. Bundlers see static named
exports. The module is only pulled in when something actually imports from
it. There is no barrel hop, so no sibling contamination and no circular
import hazard.
Bundle overhead for the self-reexport wrapper is roughly 240 bytes per module
(`Object.defineProperty` namespace proxy). At ~100 modules that's ~24KB —
negligible for a CLI binary.
## The pattern
### Before
```ts
// src/permission/arity.ts
export namespace BashArity {
export function prefix(tokens: string[]) { ... }
}
```
### After
```ts
// src/permission/arity.ts
export function prefix(tokens: string[]) { ... }
export * as BashArity from "./arity"
```
Consumers don't change at all:
```ts
import { BashArity } from "@/permission/arity"
BashArity.prefix(...) // still works
```
Editors still auto-import `BashArity` like any named export, because the file
does have a named `BashArity` export at the module top level.
### Odd but harmless
`BashArity.BashArity.BashArity.prefix(...)` compiles and runs because the
namespace contains a re-export of itself. Nobody would write that. Not a
problem.
## Why this is different from what we tried first
An earlier pass used sibling barrel files (`index.ts` with `export * as ...`).
That turned out to be wrong for our constraints:
1. The barrel file always loads all its sibling modules when you import
through it, even if you only need one. For our CLI this is exactly the
cost we're trying to avoid.
2. Barrel + sibling imports made it very easy to accidentally create circular
imports that only surface as `ReferenceError` at runtime, not at
typecheck.
The self-reexport has none of those issues. There is no indirection. The
file and the namespace are the same unit.
## Why this matters for startup
The worst import chain in the codebase looks like:
```
src/index.ts
└── FormatError from src/cli/error.ts └── FormatError from src/cli/error.ts
├── { Provider } from provider/provider.ts (1709 lines) ├── { Provider } from provider/provider.ts (~1700 lines)
│ ├── 20+ @ai-sdk/* packages │ ├── 20+ @ai-sdk/* packages
│ ├── @aws-sdk/credential-providers │ ├── @aws-sdk/credential-providers
│ ├── google-auth-library │ ├── google-auth-library
── gitlab-ai-provider, venice-ai-sdk-provider ── more
│ └── fuzzysort, remeda, etc. ├── { Config } from config/config.ts (~1600 lines)
── { Config } from config/config.ts (1663 lines) ── { MCP } from mcp/mcp.ts (~900 lines)
│ ├── jsonc-parser
│ ├── LSPServer (all server definitions)
│ └── Plugin, Auth, Env, Account, etc.
└── { MCP } from mcp/index.ts (930 lines)
├── @modelcontextprotocol/sdk (3 transports)
└── open (browser launcher)
``` ```
All of this gets pulled in to check `.isInstance()` on 6 error classes — code All of that currently gets pulled in just to do `.isInstance()` on a handful
that needs maybe 200 bytes total. This inflates the binary, increases startup of error classes. The namespace IIFE shape is the main reason bundlers cannot
memory, and slows down initial module evaluation. strip the unused parts. Self-reexport + flat ESM fixes it.
### Why this also hurts memory
Every module-level import is eagerly evaluated. Even with Bun's fast module
loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and
Google's auth library allocates objects, closures, and prototype chains that
persist for the lifetime of the process. Most CLI commands never use a provider
at all.
## What effect-smol does
effect-smol achieves tree-shakeable namespaced APIs via three structural choices.
### 1. Each module is a separate file with flat named exports
```ts
// Effect.ts — no namespace wrapper, just flat exports
export const gen: { ... } = internal.gen
export const fail: <E>(error: E) => Effect<never, E> = internal.fail
export const succeed: <A>(value: A) => Effect<A> = internal.succeed
// ... 230+ individual named exports
```
### 2. Barrel file uses `export * as` (not `export namespace`)
```ts
// index.ts
export * as Effect from "./Effect.ts"
export * as Schema from "./Schema.ts"
export * as Stream from "./Stream.ts"
// ~134 modules
```
This creates a namespace-like API (`Effect.gen`, `Schema.parse`) but the
bundler knows the **exact shape** at compile time — it's the static export list
of that file. It can trace property accesses (`Effect.gen` → keep `gen`,
drop `timeout` if unused). With `export namespace`, the IIFE is opaque and
nothing can be dropped.
### 3. `sideEffects: []` and deep imports
```jsonc
// package.json
{ "sideEffects": [] }
```
Plus `"./*": "./src/*.ts"` in the exports map, enabling
`import * as Effect from "effect/Effect"` to bypass the barrel entirely.
### 4. Errors as flat exports, not class declarations
```ts
// Cause.ts
export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId
export interface NoSuchElementError extends YieldableError { ... }
export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError
export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError
```
Each error is 4 independent exports: TypeId, interface, constructor (as const),
type guard. All individually shakeable.
## The plan
The core migration is **Phase 1** — convert `export namespace` to
`export * as`. Once that's done, the bundler can tree-shake individual exports
within each module. You do NOT need to break things into subfiles for
tree-shaking to work — the bundler traces which exports you actually access on
the namespace object and drops the rest, including their transitive imports.
Splitting errors/schemas into separate files (Phase 0) is optional — it's a
lower-risk warmup step that can be done before or after the main conversion, and
it provides extra resilience against bundler edge cases. But the big win comes
from Phase 1.
### Phase 0 (optional): Pre-split errors into subfiles
This is a low-risk warmup that provides immediate benefit even before the full
`export * as` conversion. It's optional because Phase 1 alone is sufficient for
tree-shaking. But it's a good starting point if you want incremental progress:
**For each namespace that defines errors** (15 files, ~30 error classes total):
1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error
definitions as top-level named exports:
```ts
// provider/errors.ts
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { ProviderID, ModelID } from "./schema"
export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError",
z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
suggestions: z.array(z.string()).optional(),
}),
)
export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod }))
```
2. In the namespace file, re-export from the errors file to maintain backward
compatibility:
```ts
// provider/provider.ts — inside the namespace
export { ModelNotFoundError, InitError } from "./errors"
```
3. Update `cli/error.ts` (and any other light consumers) to import directly:
```ts
// BEFORE
import { Provider } from "../provider/provider"
Provider.ModelNotFoundError.isInstance(input)
// AFTER
import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors"
ProviderModelNotFoundError.isInstance(input)
```
**Files to split (Phase 0):**
| Current file | New errors file | Errors to extract |
| ----------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `provider/provider.ts` | `provider/errors.ts` | ModelNotFoundError, InitError |
| `provider/auth.ts` | `provider/auth-errors.ts` | OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed |
| `config/config.ts` | (already has `config/paths.ts`) | ConfigDirectoryTypoError → move to paths.ts |
| `config/markdown.ts` | `config/markdown-errors.ts` | FrontmatterError |
| `mcp/index.ts` | `mcp/errors.ts` | Failed |
| `session/message-v2.ts` | `session/message-errors.ts` | OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError |
| `session/message.ts` | (shares with message-v2) | OutputLengthError, AuthError |
| `cli/ui.ts` | `cli/ui-errors.ts` | CancelledError |
| `skill/index.ts` | `skill/errors.ts` | InvalidError, NameMismatchError |
| `worktree/index.ts` | `worktree/errors.ts` | NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError |
| `storage/storage.ts` | `storage/errors.ts` | NotFoundError |
| `npm/index.ts` | `npm/errors.ts` | InstallFailedError |
| `ide/index.ts` | `ide/errors.ts` | AlreadyInstalledError, InstallFailedError |
| `lsp/client.ts` | `lsp/errors.ts` | InitializeError |
### Phase 1: The real migration — `export namespace` → `export * as`
This is the phase that actually fixes tree-shaking. For each module:
1. **Unwrap** the `export namespace Foo { ... }` — remove the namespace wrapper,
keep all the members as top-level `export const` / `export function` / etc.
2. **Rename** the file if it's currently `index.ts` (e.g. `bus/index.ts` →
`bus/bus.ts`), so the barrel can take `index.ts`.
3. **Create the barrel** `index.ts` with one line: `export * as Foo from "./foo"`
The file structure change for a module that's currently a single file:
```
# BEFORE
provider/
provider.ts ← 1709-line file with `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← SAME file, same name, just unwrap the namespace
```
And the code change is purely removing the wrapper:
```ts
// BEFORE: provider/provider.ts
export namespace Provider {
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
}
// AFTER: provider/provider.ts — identical exports, no namespace keyword
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
```
```ts
// NEW: provider/index.ts
export * as Provider from "./provider"
```
Consumer code barely changes — import path gets shorter:
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider object
import { Provider } from "../provider"
```
All access like `Provider.ModelNotFoundError`, `Provider.Service`,
`Provider.layer` works exactly as before. The difference is invisible to
consumers but lets the bundler see inside the namespace.
**Once this is done, you don't need to break anything into subfiles for
tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only
depends on `NamedError` + `zod` + the schema file, and drops
`Provider.layer` + all 20 AI SDK imports when they're unused. This works because
`export * as` gives the bundler a static export list it can do inner-graph
analysis on — it knows which exports reference which imports.
**Order of conversion** (by risk / size, do small modules first):
1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each)
2. Small services: `Auth`, `Env`, `BusEvent`, `SessionStatus`, `SessionRunState`, `Editor`, `Selection` (~25-91 lines)
3. Medium services: `Bus`, `Format`, `FileTime`, `FileWatcher`, `Command`, `Question`, `Permission`, `Vcs`, `Project`
4. Large services: `Config`, `Provider`, `MCP`, `Session`, `SessionProcessor`, `SessionPrompt`, `ACP`
### Phase 2: Build configuration
After the module structure supports tree-shaking:
1. Add `"sideEffects": []` to `packages/opencode/package.json` (or
`"sideEffects": false`) — this is safe because our services use explicit
layer composition, not import-time side effects.
2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is
insufficient, evaluate whether the compiled binary path needs an esbuild
pre-pass.
3. Consider adding `/*#__PURE__*/` annotations to `NamedError.create(...)` calls
— these are factory functions that return classes, and bundlers may not know
they're side-effect-free without the annotation.
## Automation ## Automation
The transformation is scripted. From `packages/opencode`: From `packages/opencode`:
```bash ```bash
bun script/unwrap-namespace.ts <file> [--dry-run] bun script/unwrap-namespace.ts <file> [--dry-run]
``` ```
The script uses ast-grep for accurate AST-based namespace boundary detection The script:
(no false matches from braces in strings/templates/comments), then:
1. Removes the `export namespace Foo {` line and its closing `}` 1. Uses ast-grep to locate the `export namespace Foo { ... }` block accurately.
2. Dedents the body by one indent level (2 spaces) 2. Removes the `export namespace Foo {` line and the matching closing `}`.
3. If the file is `index.ts`, renames it to `<name>.ts` and creates a new 3. Dedents the body by one indent level (2 spaces).
`index.ts` barrel 4. Rewrites `Foo.Bar` self-references inside the file to just `Bar`.
4. If the file is NOT `index.ts`, rewrites it in place and creates `index.ts` 5. Appends `export * as Foo from "./<basename>"` at the bottom of the file.
5. Prints the exact commands to find and rewrite import paths 6. Never creates a barrel `index.ts`.
### Walkthrough: converting a module ### Typical flow for one file
Using `Provider` as an example:
```bash ```bash
# 1. Preview what will change # 1. Preview
bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run bun script/unwrap-namespace.ts src/permission/arity.ts --dry-run
# 2. Apply the transformation # 2. Apply
bun script/unwrap-namespace.ts src/provider/provider.ts bun script/unwrap-namespace.ts src/permission/arity.ts
# 3. Rewrite import paths (script prints the exact command) # 3. Verify
rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g' cd packages/opencode
bunx --bun tsgo --noEmit
# 4. Verify bun run --conditions=browser ./src/index.ts generate
bun typecheck bun run test <affected test files>
bun run test
``` ```
**What changes on disk:** ### Consumer imports usually don't need to change
``` Most consumers already import straight from the file, e.g.:
# BEFORE
provider/
provider.ts ← 1709 lines, `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← same file, namespace unwrapped to flat exports
```
**What changes in consumer code:**
```ts ```ts
// BEFORE import { BashArity } from "@/permission/arity"
import { Provider } from "../provider/provider" import { Config } from "@/config/config"
// AFTER — shorter path, same Provider object
import { Provider } from "../provider"
``` ```
All property access (`Provider.Service`, `Provider.ModelNotFoundError`, etc.) Because the file itself now does `export * as Foo from "./foo"`, those imports
stays identical. keep working with zero edits.
### Two cases the script handles The only edits needed are when a consumer was importing through a previous
barrel (`"@/config"` or `"../config"` resolving to `config/index.ts`). In
that case, repoint it at the file:
**Case A: file is NOT `index.ts`** (e.g. `provider/provider.ts`) ```ts
// before
import { Config } from "@/config"
- Rewrites the file in place (unwrap + dedent) // after
- Creates `provider/index.ts` as the barrel import { Config } from "@/config/config"
- Import paths change: `"../provider/provider"` → `"../provider"` ```
**Case B: file IS `index.ts`** (e.g. `bus/index.ts`) ### Dynamic imports in tests
- Renames `index.ts` → `bus.ts` (kebab-case of namespace name) If a test did `const { Foo } = await import("../../src/x/y")`, the destructure
- Creates new `index.ts` as the barrel still works because of the self-reexport. No change required.
- **No import rewrites needed** — `"@/bus"` already resolves to `bus/index.ts`
## Do I need to split errors/schemas into subfiles? ## Verification checklist (per PR)
**No.** Once you do the `export * as` conversion, the bundler can tree-shake Run all of these locally before pushing:
individual exports within the file. If `cli/error.ts` only accesses
`Provider.ModelNotFoundError`, the bundler traces that `ModelNotFoundError`
doesn't reference `createAnthropic` and drops the AI SDK imports.
Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for **code ```bash
organization** — smaller files are easier to read and review. But it's not cd packages/opencode
required for tree-shaking. The `export * as` conversion alone is sufficient. bunx --bun tsgo --noEmit
bun run --conditions=browser ./src/index.ts generate
bun run test <affected test files>
```
The one case where subfile splitting provides extra tree-shake value is if an Also do a quick grep in `src/`, `test/`, and `script/` to make sure no
imported package has module-level side effects that the bundler can't prove are consumer is still importing the namespace from an old barrel path that no
unused. In practice this is rare — most npm packages are side-effect-free — and longer exports it.
adding `"sideEffects": []` to package.json handles the common cases.
## Scope The SDK build step (`bun run --conditions=browser ./src/index.ts generate`)
evaluates every module eagerly and is the most reliable way to catch circular
| Metric | Count | import regressions at runtime — the typechecker does not catch these.
| ----------------------------------------------- | --------------- |
| Files with `export namespace` | 106 |
| Total namespace declarations | 118 (12 nested) |
| Files with `NamedError.create` inside namespace | 15 |
| Total error classes to extract | ~30 |
| Files using `export * as` today | 0 |
Phase 1 (the `export * as` conversion) is the main change. It's mechanical and
LLM-friendly but touches every import site, so it should be done module by
module with type-checking between each step. Each module is an independent PR.
## Rules for new code ## Rules for new code
Going forward: - No new `export namespace`.
- Every module directory has a single canonical file — typically
`dir/index.ts` — with flat top-level exports and a self-reexport at the
bottom:
`export * as Foo from "."`
- Consumers import from the directory:
`import { Foo } from "@/dir"` or `import { Foo } from "../dir"`.
- No sibling barrel files. If a directory has multiple independent
namespaces, they each get their own file (e.g. `config/config.ts`,
`config/plugin.ts`) and their own self-reexport; the `index.ts` in that
directory stays minimal or does not exist.
- If a file needs a sibling, import the sibling file directly:
`import * as Sibling from "./sibling"`, not `from "."`.
- **No new `export namespace`**. Use a file with flat named exports and ### Why `dir/index.ts` + `"."` is fine for us
`export * as` in the barrel.
- Keep the service, layer, errors, schemas, and runtime wiring together in one A single-file module (e.g. `pty/`) can live entirely in `dir/index.ts`
file if you want — that's fine now. The `export * as` barrel makes everything with `export * as Foo from "."` at the bottom. Consumers write the
individually shakeable regardless of file structure. short form:
- If a file grows large enough that it's hard to navigate, split by concern
(errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the ```ts
bundler handles that. import { Pty } from "@/pty"
```
This works in Bun runtime, Bun build, esbuild, and Rollup. It does NOT
work under Node's `--experimental-strip-types` runner:
```
node --experimental-strip-types entry.ts
ERR_UNSUPPORTED_DIR_IMPORT: Directory import '/.../pty' is not supported
```
Node requires an explicit file or a `package.json#exports` map for ESM.
We don't care about that target right now because the opencode CLI is
built with Bun and the web apps are built with Vite/Rollup. If we ever
want to run raw `.ts` through Node, we'll need to either use explicit
`.ts` extensions everywhere or add per-directory `package.json` exports
maps.
### When NOT to collapse to `index.ts`
Some directories contain multiple independent namespaces where
`dir/index.ts` would be misleading. Examples:
- `config/` has `Config`, `ConfigPaths`, `ConfigMarkdown`, `ConfigPlugin`,
`ConfigKeybinds`. Each lives in its own file with its own self-reexport
(`config/config.ts`, `config/plugin.ts`, etc.). Consumers import the
specific one: `import { ConfigPlugin } from "@/config/plugin"`.
- Same shape for `session/`, `server/`, etc.
Collapsing one of those into `index.ts` would mean picking a single
"canonical" namespace for the directory, which breaks the symmetry and
hides the other files.
## Scope
There are still dozens of `export namespace` files left across the codebase.
Each one is its own small PR. Do them one at a time, verified locally, rather
than batching by directory.
+5 -3
View File
@@ -44,11 +44,13 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2" import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config" import { Config } from "@/config"
import { ConfigMCP } from "@/config/mcp"
import { Todo } from "@/session/todo" import { Todo } from "@/session/todo"
import { z } from "zod" import { z } from "zod"
import { LoadAPIKeyError } from "ai" import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff" import { applyPatch } from "diff"
import { InstallationVersion } from "@/installation/version"
type ModeOption = { id: string; name: string; description?: string } type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string } type ModelOption = { modelId: string; name: string }
@@ -176,7 +178,7 @@ export namespace ACP {
}) })
for await (const event of events.stream) { for await (const event of events.stream) {
if (this.eventAbort.signal.aborted) return if (this.eventAbort.signal.aborted) return
const payload = (event as any)?.payload const payload = event?.payload
if (!payload) continue if (!payload) continue
await this.handleEvent(payload as Event).catch((error) => { await this.handleEvent(payload as Event).catch((error) => {
log.error("failed to handle event", { error, type: payload.type }) log.error("failed to handle event", { error, type: payload.type })
@@ -570,7 +572,7 @@ export namespace ACP {
authMethods: [authMethod], authMethods: [authMethod],
agentInfo: { agentInfo: {
name: "OpenCode", name: "OpenCode",
version: Installation.VERSION, version: InstallationVersion,
}, },
} }
} }
@@ -1212,7 +1214,7 @@ export namespace ACP {
description: "compact the session", description: "compact the session",
}) })
const mcpServers: Record<string, Config.Mcp> = {} const mcpServers: Record<string, ConfigMCP.Info> = {}
for (const server of params.mcpServers) { for (const server of params.mcpServers) {
if ("type" in server) { if ("type" in server) {
mcpServers[server.name] = { mcpServers[server.name] = {
+2 -2
View File
@@ -4,9 +4,9 @@ import { Provider } from "../provider"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai" import { generateObject, streamObject, type ModelMessage } from "ai"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncate" import { Truncate } from "../tool"
import { Auth } from "../auth" import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform" import { ProviderTransform } from "../provider"
import PROMPT_GENERATE from "./generate.txt" import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt"
+6
View File
@@ -56,6 +56,12 @@ export const layer = Layer.effect(
const decode = Schema.decodeUnknownOption(Info) const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(function* () { const all = Effect.fn("Auth.all")(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
} catch (err) {}
}
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown> const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
}) })
+26 -26
View File
@@ -1,33 +1,33 @@
import z from "zod" import z from "zod"
import type { ZodType } from "zod" import type { ZodType } from "zod"
export namespace BusEvent { export type Definition = ReturnType<typeof define>
export type Definition = ReturnType<typeof define>
const registry = new Map<string, Definition>() const registry = new Map<string, Definition>()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) { export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = { const result = {
type, type,
properties, properties,
}
registry.set(type, result)
return result
}
export function payloads() {
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: `Event.${def.type}`,
})
})
.toArray()
} }
registry.set(type, result)
return result
} }
export function payloads() {
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: `Event.${def.type}`,
})
})
.toArray()
}
export * as BusEvent from "./bus-event"
-191
View File
@@ -1,191 +0,0 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectBridge } from "@/effect"
import { Log } from "../util"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect"
import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" })
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
}
type State = {
wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: dir,
project: context.project.id,
workspace,
payload,
})
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
bridge.fork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const s = yield* InstanceState.get(state)
return yield* on(s.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
) {
return runSync((svc) => svc.subscribeCallback(def, callback))
}
export function subscribeAll(callback: (event: any) => unknown) {
return runSync((svc) => svc.subscribeAllCallback(callback))
}
+193 -1
View File
@@ -1 +1,193 @@
export * as Bus from "./bus" import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectBridge } from "@/effect"
import { Log } from "../util"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect"
import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" })
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
}
type State = {
wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: dir,
project: context.project.id,
workspace,
payload,
})
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
bridge.fork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const s = yield* InstanceState.get(state)
return yield* on(s.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
) {
return runSync((svc) => svc.subscribeCallback(def, callback))
}
export function subscribeAll(callback: (event: any) => unknown) {
return runSync((svc) => svc.subscribeAllCallback(callback))
}
export * as Bus from "."
+1 -1
View File
@@ -6,7 +6,7 @@ import { Provider } from "../../../provider"
import { Session } from "../../../session" import { Session } from "../../../session"
import type { MessageV2 } from "../../../session/message-v2" import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema" import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry" import { ToolRegistry } from "../../../tool"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
import { Permission } from "../../../permission" import { Permission } from "../../../permission"
import { iife } from "../../../util/iife" import { iife } from "../../../util/iife"
+1 -1
View File
@@ -18,7 +18,7 @@ import type {
} from "@octokit/webhooks-types" } from "@octokit/webhooks-types"
import { UI } from "../ui" import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models" import { ModelsDev } from "../../provider"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { SessionShare } from "@/share" import { SessionShare } from "@/share"
+8 -6
View File
@@ -8,8 +8,10 @@ import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth" import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider" import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config" import { Config } from "../../config"
import { ConfigMCP } from "../../config/mcp"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { Installation } from "../../installation" import { Installation } from "../../installation"
import { InstallationVersion } from "../../installation/version"
import path from "path" import path from "path"
import { Global } from "../../global" import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser" import { modify, applyEdits } from "jsonc-parser"
@@ -42,7 +44,7 @@ function getAuthStatusText(status: MCP.AuthStatus): string {
type McpEntry = NonNullable<Config.Info["mcp"]>[string] type McpEntry = NonNullable<Config.Info["mcp"]>[string]
type McpConfigured = Config.Mcp type McpConfigured = ConfigMCP.Info
function isMcpConfigured(config: McpEntry): config is McpConfigured { function isMcpConfigured(config: McpEntry): config is McpConfigured {
return typeof config === "object" && config !== null && "type" in config return typeof config === "object" && config !== null && "type" in config
} }
@@ -425,7 +427,7 @@ async function resolveConfigPath(baseDir: string, global = false) {
return candidates[0] return candidates[0]
} }
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) { async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPath: string) {
let text = "{}" let text = "{}"
if (await Filesystem.exists(configPath)) { if (await Filesystem.exists(configPath)) {
text = await Filesystem.readText(configPath) text = await Filesystem.readText(configPath)
@@ -513,7 +515,7 @@ export const McpAddCommand = cmd({
}) })
if (prompts.isCancel(command)) throw new UI.CancelledError() if (prompts.isCancel(command)) throw new UI.CancelledError()
const mcpConfig: Config.Mcp = { const mcpConfig: ConfigMCP.Info = {
type: "local", type: "local",
command: command.split(" "), command: command.split(" "),
} }
@@ -543,7 +545,7 @@ export const McpAddCommand = cmd({
}) })
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
let mcpConfig: Config.Mcp let mcpConfig: ConfigMCP.Info
if (useOAuth) { if (useOAuth) {
const hasClientId = await prompts.confirm({ const hasClientId = await prompts.confirm({
@@ -697,7 +699,7 @@ export const McpDebugCommand = cmd({
params: { params: {
protocolVersion: "2024-11-05", protocolVersion: "2024-11-05",
capabilities: {}, capabilities: {},
clientInfo: { name: "opencode-debug", version: Installation.VERSION }, clientInfo: { name: "opencode-debug", version: InstallationVersion },
}, },
id: 1, id: 1,
}), }),
@@ -746,7 +748,7 @@ export const McpDebugCommand = cmd({
try { try {
const client = new Client({ const client = new Client({
name: "opencode-debug", name: "opencode-debug",
version: Installation.VERSION, version: InstallationVersion,
}) })
await client.connect(transport) await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)") prompts.log.success("Connection successful (already authenticated)")
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Argv } from "yargs"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { Provider } from "../../provider" import { Provider } from "../../provider"
import { ProviderID } from "../../provider/schema" import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "../../provider/models" import { ModelsDev } from "../../provider"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { UI } from "../ui" import { UI } from "../ui"
import { EOL } from "os" import { EOL } from "os"
+4 -2
View File
@@ -3,7 +3,7 @@ import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { UI } from "../ui" import { UI } from "../ui"
import { ModelsDev } from "../../provider/models" import { ModelsDev } from "../../provider"
import { map, pipe, sortBy, values } from "remeda" import { map, pipe, sortBy, values } from "remeda"
import path from "path" import path from "path"
import os from "os" import os from "os"
@@ -297,7 +297,9 @@ export const ProvidersLoginCommand = cmd({
prompts.intro("Add credential") prompts.intro("Add credential")
if (args.url) { if (args.url) {
const url = args.url.replace(/\/+$/, "") const url = args.url.replace(/\/+$/, "")
const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any) const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, { const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe", stdout: "pipe",
+1 -1
View File
@@ -12,7 +12,7 @@ import { Server } from "../../server/server"
import { Provider } from "../../provider" import { Provider } from "../../provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { Tool } from "../../tool/tool" import { Tool } from "../../tool"
import { GlobTool } from "../../tool/glob" import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep" import { GrepTool } from "../../tool/grep"
import { ReadTool } from "../../tool/read" import { ReadTool } from "../../tool/read"
+11 -10
View File
@@ -1,7 +1,7 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard" import * as Clipboard from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection" import * as Selection from "@tui/util/selection"
import { Terminal } from "@tui/util/terminal" import * as Terminal from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route" import { RouteProvider, useRoute } from "@tui/context/route"
import { import {
@@ -28,7 +28,7 @@ import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk" import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading" import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync" import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local" import { LocalProvider, parseModel, useLocal } from "@tui/context/local"
import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status" import { DialogStatus } from "@tui/component/dialog-status"
@@ -49,15 +49,13 @@ import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm" import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast" import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit" import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event" import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv" import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args" import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open" import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt" import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config" import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error" import { FormatError, FormatUnknownError } from "@/cli/error"
@@ -235,7 +233,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
renderer, renderer,
}) })
const [ready, setReady] = createSignal(false) const [ready, setReady] = createSignal(false)
TuiPluginRuntime.init(api) TuiPluginRuntime.init({
api,
config: tuiConfig,
})
.catch((error) => { .catch((error) => {
console.error("Failed to load TUI plugins", error) console.error("Failed to load TUI plugins", error)
}) })
@@ -301,7 +302,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
if (route.data.type === "session") { if (route.data.type === "session") {
const session = sync.session.get(route.data.sessionID) const session = sync.session.get(route.data.sessionID)
if (!session || SessionApi.isDefaultTitle(session.title)) { if (!session) {
renderer.setTerminalTitle("OpenCode") renderer.setTerminalTitle("OpenCode")
return return
} }
@@ -321,7 +322,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
batch(() => { batch(() => {
if (args.agent) local.agent.set(args.agent) if (args.agent) local.agent.set(args.agent)
if (args.model) { if (args.model) {
const { providerID, modelID } = Provider.parseModel(args.model) const { providerID, modelID } = parseModel(args.model)
if (!providerID || !modelID) if (!providerID || !modelID)
return toast.show({ return toast.show({
variant: "warning", variant: "warning",
+2 -7
View File
@@ -2,9 +2,7 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui" import { UI } from "@/cli/ui"
import { tui } from "./app" import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config" import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Instance } from "@/project/instance"
import { existsSync } from "fs"
export const AttachCommand = cmd({ export const AttachCommand = cmd({
command: "attach <url>", command: "attach <url>",
@@ -66,10 +64,7 @@ export const AttachCommand = cmd({
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth } return { Authorization: auth }
})() })()
const config = await Instance.provide({ const config = await TuiConfig.get()
directory: directory && existsSync(directory) ? directory : process.cwd(),
fn: () => TuiConfig.get(),
})
await tui({ await tui({
url: args.url, url: args.url,
config, config,
@@ -20,7 +20,7 @@ export function DialogAgent() {
return ( return (
<DialogSelect <DialogSelect
title="Select agent" title="Select agent"
current={local.agent.current().name} current={local.agent.current()?.name}
options={options()} options={options()}
onSelect={(option) => { onSelect={(option) => {
local.agent.set(option.value) local.agent.set(option.value)
@@ -11,7 +11,7 @@ import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2" import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
import { DialogModel } from "./dialog-model" import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard" import * as Clipboard from "@tui/util/clipboard"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "@tui/util/provider-origin" import { isConsoleManagedProvider } from "@tui/util/provider-origin"
@@ -0,0 +1,101 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export function DialogSessionDeleteFailed(props: {
session: string
workspace: string
onDelete?: () => boolean | void | Promise<boolean | void>
onRestore?: () => boolean | void | Promise<boolean | void>
onDone?: () => void
}) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
const options = [
{
id: "delete" as const,
title: "Delete workspace",
description: "Delete the workspace and all sessions attached to it.",
run: props.onDelete,
},
{
id: "restore" as const,
title: "Restore to new workspace",
description: "Try to restore this session into a new workspace.",
run: props.onRestore,
},
]
async function confirm() {
const result = await options.find((item) => item.id === store.active)?.run?.()
if (result === false) return
props.onDone?.()
if (!props.onDone) dialog.clear()
}
useKeyboard((evt) => {
if (evt.name === "return") {
void confirm()
}
if (evt.name === "left" || evt.name === "up") {
setStore("active", "delete")
}
if (evt.name === "right" || evt.name === "down") {
setStore("active", "restore")
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Failed to Delete Session
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={theme.textMuted} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
flexDirection="column"
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
}}
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? theme.selectedListItemText : theme.text}
>
{item.title}
</text>
<text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
{item.description}
</text>
</box>
)}
</For>
</box>
</box>
)
}
@@ -13,8 +13,10 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util" import { Keybind } from "@/util"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create" import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
@@ -30,7 +32,7 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
const [searchResults] = createResource(search, async (query) => { const [searchResults, { refetch }] = createResource(search, async (query) => {
if (!query) return undefined if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 }) const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? [] return result.data ?? []
@@ -56,6 +58,57 @@ export function DialogSessionList() {
)) ))
} }
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
workspace={workspace?.name ?? session.workspaceID!}
onDone={list}
onDelete={async () => {
const current = currentSessionID()
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
if (result.error) {
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return false
}
await project.workspace.sync()
await sync.session.refresh()
if (search()) await refetch()
if (info?.workspaceID === session.workspaceID) {
route.navigate({ type: "home" })
}
return true
}}
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
/>
))
return false
}}
/>
))
}
const options = createMemo(() => { const options = createMemo(() => {
const today = new Date().toDateString() const today = new Date().toDateString()
return sessions() return sessions()
@@ -145,9 +198,43 @@ export function DialogSessionList() {
title: "delete", title: "delete",
onTrigger: async (option) => { onTrigger: async (option) => {
if (toDelete() === option.value) { if (toDelete() === option.value) {
void sdk.client.session.delete({ const session = sessions().find((item) => item.id === option.value)
sessionID: option.value, const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
})
try {
const result = await sdk.client.session.delete({
sessionID: option.value,
})
if (result.error) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(result.error),
})
}
setToDelete(undefined)
return
}
} catch (err) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(err),
})
}
setToDelete(undefined)
return
}
if (status && status !== "connected") {
await sync.session.refresh()
}
if (search()) await refetch()
setToDelete(undefined) setToDelete(undefined)
return return
} }
@@ -6,6 +6,8 @@ import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project" import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js" import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { errorData, errorMessage } from "@/util/error"
import * as Log from "@/util/log"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
@@ -15,6 +17,8 @@ type Adaptor = {
description: string description: string
} }
const log = Log.Default.clone().tag("service", "tui-workspace")
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) { function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({ return createOpencodeClient({
baseUrl: sdk.url, baseUrl: sdk.url,
@@ -33,8 +37,18 @@ export async function openWorkspaceSession(input: {
workspaceID: string workspaceID: string
}) { }) {
const client = scoped(input.sdk, input.sync, input.workspaceID) const client = scoped(input.sdk, input.sync, input.workspaceID)
log.info("workspace session create requested", {
workspaceID: input.workspaceID,
})
while (true) { while (true) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
log.error("workspace session create request failed", {
workspaceID: input.workspaceID,
error: errorData(err),
})
return undefined
})
if (!result) { if (!result) {
input.toast.show({ input.toast.show({
message: "Failed to create workspace session", message: "Failed to create workspace session",
@@ -42,26 +56,113 @@ export async function openWorkspaceSession(input: {
}) })
return return
} }
if (result.response.status >= 500 && result.response.status < 600) { log.info("workspace session create response", {
workspaceID: input.workspaceID,
status: result.response?.status,
sessionID: result.data?.id,
})
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
log.warn("workspace session create retrying after server error", {
workspaceID: input.workspaceID,
status: result.response.status,
})
await sleep(1000) await sleep(1000)
continue continue
} }
if (!result.data) { if (!result.data) {
log.error("workspace session create returned no data", {
workspaceID: input.workspaceID,
status: result.response?.status,
})
input.toast.show({ input.toast.show({
message: "Failed to create workspace session", message: "Failed to create workspace session",
variant: "error", variant: "error",
}) })
return return
} }
input.route.navigate({ input.route.navigate({
type: "session", type: "session",
sessionID: result.data.id, sessionID: result.data.id,
}) })
log.info("workspace session create complete", {
workspaceID: input.workspaceID,
sessionID: result.data.id,
})
input.dialog.clear() input.dialog.clear()
return return
} }
} }
export async function restoreWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
workspaceID: string
sessionID: string
done?: () => void
}) {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
.catch((err) => {
log.error("session restore request failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
return undefined
})
if (!result?.data) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result?.response?.status,
error: result?.error ? errorData(result.error) : undefined,
})
input.toast.show({
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
log.info("session restore response", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result.response?.status,
total: result.data.total,
})
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
log.error("session restore refresh failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
})
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total: result.data.total,
})
input.toast.show({
message: "Session restored into the new workspace",
variant: "success",
})
input.done?.()
if (input.done) return
input.dialog.clear()
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) { export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog() const dialog = useDialog()
const sync = useSync() const sync = useSync()
@@ -123,18 +224,43 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const create = async (type: string) => { const create = async (type: string) => {
if (creating()) return if (creating()) return
setCreating(type) setCreating(type)
log.info("workspace create requested", {
type,
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
log.error("workspace create request failed", {
type,
error: errorData(err),
})
return undefined
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
const workspace = result?.data const workspace = result?.data
if (!workspace) { if (!workspace) {
setCreating(undefined) setCreating(undefined)
log.error("workspace create failed", {
type,
status: result?.response.status,
error: result?.error ? errorData(result.error) : undefined,
})
toast.show({ toast.show({
message: "Failed to create workspace", message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error", variant: "error",
}) })
return return
} }
log.info("workspace create response", {
type,
workspaceID: workspace.id,
status: result.response?.status,
})
await project.workspace.sync() await project.workspace.sync()
log.info("workspace create synced", {
type,
workspaceID: workspace.id,
})
await props.onSelect(workspace.id) await props.onSelect(workspace.id)
setCreating(undefined) setCreating(undefined)
} }
@@ -1,8 +1,8 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard" import * as Clipboard from "@tui/util/clipboard"
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { Installation } from "@/installation" import { InstallationVersion } from "@/installation/version"
import { win32FlushInputBuffer } from "../win32" import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll" import { getScrollAcceleration } from "../util/scroll"
@@ -53,7 +53,7 @@ export function ErrorComponent(props: {
) )
} }
issueURL.searchParams.set("opencode-version", Installation.VERSION) issueURL.searchParams.set("opencode-version", InstallationVersion)
const copyIssueURL = () => { const copyIssueURL = () => {
void Clipboard.copy(issueURL.toString()).then(() => { void Clipboard.copy(issueURL.toString()).then(() => {
@@ -1,7 +1,7 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js" import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme" import { useTheme, tint } from "@tui/context/theme"
import { Sound } from "@tui/util/sound" import * as Sound from "@tui/util/sound"
import { logo } from "@/cli/logo" import { logo } from "@/cli/logo"
// Shadow markers (rendered chars in parens): // Shadow markers (rendered chars in parens):
@@ -520,7 +520,7 @@ export function Logo() {
const shadow = tint(theme.background, ink, 0.25) const shadow = tint(theme.background, ink, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined const attrs = bold ? TextAttributes.BOLD : undefined
return [...line].map((char, i) => { return Array.from(line).map((char, i) => {
const h = field(off + i, y, frame) const h = field(off + i, y, frame)
const n = wave(off + i, y, frame, lit(char)) + h const n = wave(off + i, y, frame, lit(char)) + h
const s = wave(off + i, y, dusk, false) + h const s = wave(off + i, y, dusk, false) + h
@@ -21,9 +21,9 @@ import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command" import { useCommandDialog } from "../dialog-command"
import { useRenderer, type JSX } from "@opentui/solid" import { useRenderer, type JSX } from "@opentui/solid"
import { Editor } from "@tui/util/editor" import * as Editor from "@tui/util/editor"
import { useExit } from "../../context/exit" import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard" import * as Clipboard from "../../util/clipboard"
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event" import { TuiEvent } from "../../event"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
@@ -602,6 +602,8 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return if (props.disabled) return
if (autocomplete?.visible) return if (autocomplete?.visible) return
if (!store.prompt.input) return if (!store.prompt.input) return
const agent = local.agent.current()
if (!agent) return
const trimmed = store.prompt.input.trim() const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit() void exit()
@@ -615,9 +617,7 @@ export function Prompt(props: PromptProps) {
let sessionID = props.sessionID let sessionID = props.sessionID
if (sessionID == null) { if (sessionID == null) {
const res = await sdk.client.session.create({ const res = await sdk.client.session.create({ workspace: props.workspaceID })
workspaceID: props.workspaceID,
})
if (res.error) { if (res.error) {
console.log("Creating a session failed:", res.error) console.log("Creating a session failed:", res.error)
@@ -662,7 +662,7 @@ export function Prompt(props: PromptProps) {
if (store.mode === "shell") { if (store.mode === "shell") {
void sdk.client.session.shell({ void sdk.client.session.shell({
sessionID, sessionID,
agent: local.agent.current().name, agent: agent.name,
model: { model: {
providerID: selectedModel.providerID, providerID: selectedModel.providerID,
modelID: selectedModel.modelID, modelID: selectedModel.modelID,
@@ -689,7 +689,7 @@ export function Prompt(props: PromptProps) {
sessionID, sessionID,
command: command.slice(1), command: command.slice(1),
arguments: args, arguments: args,
agent: local.agent.current().name, agent: agent.name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`, model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID, messageID,
variant, variant,
@@ -706,7 +706,7 @@ export function Prompt(props: PromptProps) {
sessionID, sessionID,
...selectedModel, ...selectedModel,
messageID, messageID,
agent: local.agent.current().name, agent: agent.name,
model: selectedModel, model: selectedModel,
variant, variant,
parts: [ parts: [
@@ -829,7 +829,9 @@ export function Prompt(props: PromptProps) {
const highlight = createMemo(() => { const highlight = createMemo(() => {
if (keybind.leader) return theme.border if (keybind.leader) return theme.border
if (store.mode === "shell") return theme.primary if (store.mode === "shell") return theme.primary
return local.agent.color(local.agent.current().name) const agent = local.agent.current()
if (!agent) return theme.border
return local.agent.color(agent.name)
}) })
const showVariant = createMemo(() => { const showVariant = createMemo(() => {
@@ -851,7 +853,8 @@ export function Prompt(props: PromptProps) {
}) })
const spinnerDef = createMemo(() => { const spinnerDef = createMemo(() => {
const color = local.agent.color(local.agent.current().name) const agent = local.agent.current()
const color = agent ? local.agent.color(agent.name) : theme.border
return { return {
frames: createFrames({ frames: createFrames({
color, color,
@@ -1028,6 +1031,10 @@ export function Prompt(props: PromptProps) {
return return
} }
// Once we cross an async boundary below, the terminal may perform its
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
const filepath = iife(() => { const filepath = iife(() => {
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) { if (raw.startsWith("file://")) {
@@ -1041,11 +1048,10 @@ export function Prompt(props: PromptProps) {
const isUrl = /^(https?):\/\//.test(filepath) const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) { if (!isUrl) {
try { try {
const mime = Filesystem.mimeType(filepath) const mime = await Filesystem.mimeType(filepath)
const filename = path.basename(filepath) const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image // Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") { if (mime === "image/svg+xml") {
event.preventDefault()
const content = await Filesystem.readText(filepath).catch(() => {}) const content = await Filesystem.readText(filepath).catch(() => {})
if (content) { if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`) pasteText(content, `[SVG: ${filename ?? "image"}]`)
@@ -1053,7 +1059,6 @@ export function Prompt(props: PromptProps) {
} }
} }
if (mime.startsWith("image/") || mime === "application/pdf") { if (mime.startsWith("image/") || mime === "application/pdf") {
event.preventDefault()
const content = await Filesystem.readArrayBuffer(filepath) const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64")) .then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {}) .catch(() => {})
@@ -1075,11 +1080,12 @@ export function Prompt(props: PromptProps) {
(lineCount >= 3 || pastedContent.length > 150) && (lineCount >= 3 || pastedContent.length > 150) &&
!sync.data.config.experimental?.disable_paste_summary !sync.data.config.experimental?.disable_paste_summary
) { ) {
event.preventDefault()
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return return
} }
input.insertText(normalizedText)
// Force layout update and render for the pasted content // Force layout update and render for the pasted content
setTimeout(() => { setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly // setTimeout is a workaround and needs to be addressed properly
@@ -1107,22 +1113,26 @@ export function Prompt(props: PromptProps) {
/> />
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between"> <box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={highlight()}> <Show when={local.agent.current()} fallback={<box height={1} />}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} {(agent) => (
</text> <>
<Show when={store.mode === "normal"}> <text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text>
<box flexDirection="row" gap={1}> <Show when={store.mode === "normal"}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}> <box flexDirection="row" gap={1}>
{local.model.parsed().model} <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
</text> {local.model.parsed().model}
<text fg={theme.textMuted}>{currentProviderLabel()}</text> </text>
<Show when={showVariant()}> <text fg={theme.textMuted}>{currentProviderLabel()}</text>
<text fg={theme.textMuted}>·</text> <Show when={showVariant()}>
<text> <text fg={theme.textMuted}>·</text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span> <text>
</text> <span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</Show> </text>
</box> </Show>
</box>
</Show>
</>
)}
</Show> </Show>
</box> </box>
<Show when={hasRightContent()}> <Show when={hasRightContent()}>
@@ -0,0 +1,5 @@
import { Context } from "effect"
export const CurrentWorkingDirectory = Context.Reference<string>("CurrentWorkingDirectory", {
defaultValue: () => process.cwd(),
})
@@ -2,13 +2,11 @@ import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { unique } from "remeda" import { unique } from "remeda"
import z from "zod" import z from "zod"
import * as ConfigPaths from "./paths"
import { TuiInfo, TuiOptions } from "./tui-schema" import { TuiInfo, TuiOptions } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Log } from "@/util"
import { Filesystem } from "@/util"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem, Log } from "@/util"
import * as ConfigPaths from "@/config/paths"
const log = Log.create({ service: "tui.migrate" }) const log = Log.create({ service: "tui.migrate" })
@@ -26,9 +24,9 @@ const TuiLegacy = z
.strip() .strip()
interface MigrateInput { interface MigrateInput {
cwd: string
directories: string[] directories: string[]
custom?: string custom?: string
managed: string
} }
/** /**
@@ -134,16 +132,13 @@ async function backupAndStripLegacy(file: string, source: string) {
}) })
} }
async function opencodeFiles(input: { directories: string[]; managed: string }) { async function opencodeFiles(input: { directories: string[]; cwd: string }) {
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
? []
: await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")] const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
for (const dir of unique(input.directories)) { for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "opencode")) files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
} }
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG) if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
const existing = await Promise.all( const existing = await Promise.all(
unique(files).map(async (file) => { unique(files).map(async (file) => {
@@ -1,9 +1,10 @@
import z from "zod" import z from "zod"
import * as Config from "./config" import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
const KeybindOverride = z const KeybindOverride = z
.object( .object(
Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record< Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
string, string,
z.ZodOptional<z.ZodString> z.ZodOptional<z.ZodString>
>, >,
@@ -30,7 +31,7 @@ export const TuiInfo = z
$schema: z.string().optional(), $schema: z.string().optional(),
theme: z.string().optional(), theme: z.string().optional(),
keybinds: KeybindOverride.optional(), keybinds: KeybindOverride.optional(),
plugin: Config.PluginSpec.array().optional(), plugin: ConfigPlugin.Spec.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(),
}) })
.extend(TuiOptions.shape) .extend(TuiOptions.shape)
@@ -0,0 +1,213 @@
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
import { Flag } from "@/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Npm } from "@opencode-ai/shared/npm"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/cli/effect/runtime"
import { Filesystem, Log } from "@/util"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
type Acc = {
result: Info
}
type State = {
config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[]
}
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
if (Filesystem.contains(ctx.directory, file)) return "local"
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global"
}
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui
return data
}
const tui = data.tui
delete data.tui
return {
...tui,
...data,
}
}
async function resolvePlugins(config: Info, configFilepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
}
return config
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
async function loadState(ctx: { directory: string }) {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const directories = await ConfigPaths.directories(ctx.directory)
const custom = customPath()
await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const acc: Acc = {
result: {},
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx)
}
if (custom) {
await mergeFile(acc, custom, ctx)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
await mergeFile(acc, file, ctx)
}
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file, ctx)
}
}
const keybinds = { ...(acc.result.keybinds ?? {}) }
if (process.platform === "win32") {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique([
"ctrl+z",
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
}
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
const data = yield* Effect.promise(() => loadState({ directory }))
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(Effect.forkScoped),
{
concurrency: "unbounded",
},
)
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
)
return Service.of({ get, waitForDependencies })
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
export async function get() {
return runPromise((svc) => svc.get())
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
return ConfigParse.load(Info, text, {
type: "path",
path: configFilepath,
missing: "empty",
normalize: (data) => {
if (!isRecord(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.
return normalize(data)
},
})
.then((data) => resolvePlugins(data, configFilepath))
.catch((error) => {
log.warn("invalid tui config", { path: configFilepath, error })
return {}
})
}
}
@@ -1,7 +1,7 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { Keybind } from "@/util" import { Keybind } from "@/util"
import { pipe, mapValues } from "remeda" import { pipe, mapValues } from "remeda"
import type { TuiConfig } from "@/config" import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import type { ParsedKey, Renderable } from "@opentui/core" import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid" import { useKeyboard, useRenderer } from "@opentui/solid"
@@ -12,7 +12,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
const [store, setStore] = createStore<Record<string, any>>() const [store, setStore] = createStore<Record<string, any>>()
const filePath = path.join(Global.Path.state, "kv.json") const filePath = path.join(Global.Path.state, "kv.json")
Filesystem.readJson(filePath) Filesystem.readJson<Record<string, any>>(filePath)
.then((x) => { .then((x) => {
setStore(x) setStore(x)
}) })
@@ -1,4 +1,5 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { batch, createEffect, createMemo } from "solid-js" import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
@@ -6,14 +7,20 @@ import { uniqueBy } from "remeda"
import path from "path" import path from "path"
import { Global } from "@/global" import { Global } from "@/global"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { Provider } from "@/provider"
import { useArgs } from "./args" import { useArgs } from "./args"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core" import { RGBA } from "@opentui/core"
import { Filesystem } from "@/util" import { Filesystem } from "@/util"
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: providerID,
modelID: rest.join("/"),
}
}
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
init: () => { init: () => {
@@ -37,10 +44,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = iife(() => { const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden)) const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
const [agentStore, setAgentStore] = createStore<{ const [agentStore, setAgentStore] = createStore({
current: string current: undefined as string | undefined,
}>({
current: agents()[0].name,
}) })
const { theme } = useTheme() const { theme } = useTheme()
const colors = createMemo(() => [ const colors = createMemo(() => [
@@ -57,7 +62,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return agents() return agents()
}, },
current() { current() {
return agents().find((x) => x.name === agentStore.current) ?? agents()[0] return agents().find((x) => x.name === agentStore.current) ?? agents().at(0)
}, },
set(name: string) { set(name: string) {
if (!agents().some((x) => x.name === name)) if (!agents().some((x) => x.name === name))
@@ -153,7 +158,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs() const args = useArgs()
const fallbackModel = createMemo(() => { const fallbackModel = createMemo(() => {
if (args.model) { if (args.model) {
const { providerID, modelID } = Provider.parseModel(args.model) const { providerID, modelID } = parseModel(args.model)
if (isModelValid({ providerID, modelID })) { if (isModelValid({ providerID, modelID })) {
return { return {
providerID, providerID,
@@ -163,7 +168,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
if (sync.data.config.model) { if (sync.data.config.model) {
const { providerID, modelID } = Provider.parseModel(sync.data.config.model) const { providerID, modelID } = parseModel(sync.data.config.model)
if (isModelValid({ providerID, modelID })) { if (isModelValid({ providerID, modelID })) {
return { return {
providerID, providerID,
@@ -194,8 +199,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const a = agent.current() const a = agent.current()
return ( return (
getFirstValidModel( getFirstValidModel(
() => modelStore.model[a.name], () => a && modelStore.model[a.name],
() => a.model, () => a && a.model,
fallbackModel, fallbackModel,
) ?? undefined ) ?? undefined
) )
@@ -240,7 +245,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (next >= recent.length) next = 0 if (next >= recent.length) next = 0
const val = recent[next] const val = recent[next]
if (!val) return if (!val) return
setModelStore("model", agent.current().name, { ...val }) const a = agent.current()
if (!a) return
setModelStore("model", a.name, { ...val })
}, },
cycleFavorite(direction: 1 | -1) { cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item)) const favorites = modelStore.favorite.filter((item) => isModelValid(item))
@@ -266,7 +273,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const next = favorites[index] const next = favorites[index]
if (!next) return if (!next) return
setModelStore("model", agent.current().name, { ...next }) const a = agent.current()
if (!a) return
setModelStore("model", a.name, { ...next })
const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`) const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop() if (uniq.length > 10) uniq.pop()
setModelStore( setModelStore(
@@ -285,7 +294,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
return return
} }
setModelStore("model", agent.current().name, model) const a = agent.current()
if (!a) return
setModelStore("model", a.name, model)
if (options?.recent) { if (options?.recent) {
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`) const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop() if (uniq.length > 10) uniq.pop()
@@ -387,6 +398,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
// Automatically update model when agent changes // Automatically update model when agent changes
createEffect(() => { createEffect(() => {
const value = agent.current() const value = agent.current()
if (!value) return
if (value.model) { if (value.model) {
if (isModelValid(value.model)) if (isModelValid(value.model))
model.set({ model.set({
@@ -29,7 +29,7 @@ import { useExit } from "./exit"
import { useArgs } from "./args" import { useArgs } from "./args"
import { batch, createEffect, on } from "solid-js" import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util" import { Log } from "@/util"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync", name: "Sync",
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
provider: Provider[] provider: Provider[]
provider_default: Record<string, string> provider_default: Record<string, string>
provider_next: ProviderListResponse provider_next: ProviderListResponse
console_state: ConsoleStateType console_state: ConsoleState
provider_auth: Record<string, ProviderAuthMethod[]> provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[] agent: Agent[]
command: Command[] command: Command[]
@@ -363,7 +363,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true }) const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console const consoleStatePromise = sdk.client.experimental.console
.get({ workspace }, { throwOnError: true }) .get({ workspace }, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data)) .then((x) => x.data)
.catch(() => emptyConsoleState) .catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
@@ -378,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
] ]
await Promise.all(blockingRequests) await Promise.all(blockingRequests)
.then(() => { .then(async () => {
const providersResponse = providersPromise.then((x) => x.data!) const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!)
const consoleStateResponse = consoleStatePromise const consoleStateResponse = consoleStatePromise
@@ -474,6 +474,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index] if (match.found) return store.session[match.index]
return undefined return undefined
}, },
async refresh() {
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const list = await sdk.client.session
.list({ start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
setStore("session", reconcile(list))
},
status(sessionID: string) { status(sessionID: string) {
const session = result.session.get(sessionID) const session = result.session.get(sessionID)
if (!session) return "idle" if (!session) return "idle"
@@ -486,12 +493,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}, },
async sync(sessionID: string) { async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return if (fullSyncedSessions.has(sessionID)) return
const workspace = project.workspace.current()
const [session, messages, todo, diff] = await Promise.all([ const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }), sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100, workspace }), sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID, workspace }), sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID, workspace }), sdk.client.session.diff({ sessionID }),
]) ])
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -1,4 +1,4 @@
import { TuiConfig } from "@/config" import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({ export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
+1 -2
View File
@@ -1,5 +1,4 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import z from "zod" import z from "zod"
export const TuiEvent = { export const TuiEvent = {
@@ -42,7 +41,7 @@ export const TuiEvent = {
SessionSelect: BusEvent.define( SessionSelect: BusEvent.define(
"tui.session.select", "tui.session.select",
z.object({ z.object({
sessionID: SessionID.zod.describe("Session ID to navigate to"), sessionID: z.string().describe("Session ID to navigate to"),
}), }),
), ),
} }
@@ -7,7 +7,7 @@ function View(props: { api: TuiPluginApi }) {
const [open, setOpen] = createSignal(true) const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.lsp()) const list = createMemo(() => props.api.state.lsp())
const off = createMemo(() => props.api.state.config.lsp === false) const off = createMemo(() => props.api.state.config.lsp)
return ( return (
<box> <box>
@@ -0,0 +1,6 @@
import { Layer } from "effect"
import { TuiConfig } from "./config/tui"
import { Npm } from "@opencode-ai/shared/npm"
import { Observability } from "@/effect/observability"
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
@@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk"
import type { useSync } from "@tui/context/sync" import type { useSync } from "@tui/context/sync"
import type { useTheme } from "@tui/context/theme" import type { useTheme } from "@tui/context/theme"
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog" import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
import type { TuiConfig } from "@/config" import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createPluginKeybind } from "../context/plugin-keybinds" import { createPluginKeybind } from "../context/plugin-keybinds"
import type { useKV } from "../context/kv" import type { useKV } from "../context/kv"
import { DialogAlert } from "../ui/dialog-alert" import { DialogAlert } from "../ui/dialog-alert"
@@ -18,7 +18,7 @@ import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dia
import { Prompt } from "../component/prompt" import { Prompt } from "../component/prompt"
import { Slot as HostSlot } from "./slots" import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast" import type { useToast } from "../ui/toast"
import { Installation } from "@/installation" import { InstallationVersion } from "@/installation/version"
type RouteEntry = { type RouteEntry = {
key: symbol key: symbol
@@ -189,7 +189,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
function appApi(): TuiPluginApi["app"] { function appApi(): TuiPluginApi["app"] {
return { return {
get version() { get version() {
return Installation.VERSION return InstallationVersion
}, },
} }
} }
@@ -12,13 +12,10 @@ import {
} from "@opencode-ai/plugin/tui" } from "@opencode-ai/plugin/tui"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Config } from "@/config"
import { TuiConfig } from "@/config"
import { Log } from "@/util" import { Log } from "@/util"
import { errorData, errorMessage } from "@/util/error" import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import { import {
readPackageThemes, readPackageThemes,
readPluginId, readPluginId,
@@ -39,16 +36,17 @@ import { Flag } from "@/flag/flag"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots" import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots" import type { HostPluginApi, HostSlots } from "./slots"
import { ConfigPlugin } from "@/config/plugin"
type PluginLoad = { type PluginLoad = {
options: Config.PluginOptions | undefined options: ConfigPlugin.Options | undefined
spec: string spec: string
target: string target: string
retry: boolean retry: boolean
source: PluginSource | "internal" source: PluginSource | "internal"
id: string id: string
module: TuiPluginModule module: TuiPluginModule
origin: Config.PluginOrigin origin: ConfigPlugin.Origin
theme_root: string theme_root: string
theme_files: string[] theme_files: string[]
} }
@@ -77,7 +75,7 @@ type RuntimeState = {
slots: HostSlots slots: HostSlots
plugins: PluginEntry[] plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry> plugins_by_id: Map<string, PluginEntry>
pending: Map<string, Config.PluginOrigin> pending: Map<string, ConfigPlugin.Origin>
} }
const log = Log.create({ service: "tui.plugin" }) const log = Log.create({ service: "tui.plugin" })
@@ -147,7 +145,7 @@ function resolveRoot(root: string) {
} }
function createThemeInstaller( function createThemeInstaller(
meta: Config.PluginOrigin, meta: ConfigPlugin.Origin,
root: string, root: string,
spec: string, spec: string,
plugin: PluginEntry, plugin: PluginEntry,
@@ -590,7 +588,7 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
} }
} }
async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) { async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => Promise<void>) {
return PluginLoader.loadExternal({ return PluginLoader.loadExternal({
items: list, items: list,
kind: "tui", kind: "tui",
@@ -745,7 +743,7 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
return { plugins, ok } return { plugins, ok }
} }
function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin { function defaultPluginOrigin(state: RuntimeState, spec: string): ConfigPlugin.Origin {
return { return {
spec, spec,
scope: "local", scope: "local",
@@ -786,16 +784,12 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
if (!spec) return false if (!spec) return false
const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec) const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
const next = Config.pluginSpecifier(cfg.spec) const next = ConfigPlugin.pluginSpecifier(cfg.spec)
if (state.plugins.some((plugin) => plugin.load.spec === next)) { if (state.plugins.some((plugin) => plugin.load.spec === next)) {
state.pending.delete(spec) state.pending.delete(spec)
return true return true
} }
const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()).catch((error) => {
const ready = await Instance.provide({
directory: state.directory,
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
}).catch((error) => {
fail("failed to add tui plugin", { path: next, error }) fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[] return [] as PluginLoad[]
}) })
@@ -905,7 +899,7 @@ async function installPluginBySpec(
const tui = manifest.targets.find((item) => item.kind === "tui") const tui = manifest.targets.find((item) => item.kind === "tui")
if (tui) { if (tui) {
const file = patch.items.find((item) => item.kind === "tui")?.file const file = patch.items.find((item) => item.kind === "tui")?.file
const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec const next = tui.opts ? ([spec, tui.opts] as ConfigPlugin.Spec) : spec
state.pending.set(spec, { state.pending.set(spec, {
spec: next, spec: next,
scope: global ? "global" : "local", scope: global ? "global" : "local",
@@ -926,7 +920,7 @@ export namespace TuiPluginRuntime {
let runtime: RuntimeState | undefined let runtime: RuntimeState | undefined
export const Slot = View export const Slot = View
export async function init(api: HostPluginApi) { export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
const cwd = process.cwd() const cwd = process.cwd()
if (loaded) { if (loaded) {
if (dir !== cwd) { if (dir !== cwd) {
@@ -936,7 +930,7 @@ export namespace TuiPluginRuntime {
} }
dir = cwd dir = cwd
loaded = load(api) loaded = load(input)
return loaded return loaded
} }
@@ -975,7 +969,8 @@ export namespace TuiPluginRuntime {
} }
} }
async function load(api: Api) { async function load(input: { api: Api; config: TuiConfig.Info }) {
const { api, config } = input
const cwd = process.cwd() const cwd = process.cwd()
const slots = setupSlots(api) const slots = setupSlots(api)
const next: RuntimeState = { const next: RuntimeState = {
@@ -987,45 +982,40 @@ export namespace TuiPluginRuntime {
pending: new Map(), pending: new Map(),
} }
runtime = next runtime = next
try {
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
await Instance.provide({ for (const item of INTERNAL_TUI_PLUGINS) {
directory: cwd, log.info("loading internal tui plugin", { id: item.id })
fn: async () => { const entry = loadInternalPlugin(item)
const config = await TuiConfig.get() const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) addPluginEntry(next, {
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { id: entry.id,
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) load: entry,
} meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
}
for (const item of INTERNAL_TUI_PLUGINS) { const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
log.info("loading internal tui plugin", { id: item.id }) await addExternalPluginEntries(next, ready)
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
id: entry.id,
load: entry,
meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
}
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) applyInitialPluginEnabledState(next, config)
await addExternalPluginEntries(next, ready) for (const plugin of next.plugins) {
if (!plugin.enabled) continue
applyInitialPluginEnabledState(next, config) // Keep plugin execution sequential for deterministic side effects:
for (const plugin of next.plugins) { // command registration order affects keybind/command precedence,
if (!plugin.enabled) continue // route registration is last-wins when ids collide,
// Keep plugin execution sequential for deterministic side effects: // and hook chains rely on stable plugin ordering.
// command registration order affects keybind/command precedence, await activatePluginEntry(next, plugin, false)
// route registration is last-wins when ids collide, }
// and hook chains rely on stable plugin ordering. } catch (error) {
await activatePluginEntry(next, plugin, false)
}
},
}).catch((error) => {
fail("failed to load tui plugins", { directory: cwd, error }) fail("failed to load tui plugins", { directory: cwd, error })
}) }
} }
} }
@@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect } from "@tui/ui/dialog-select"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { Clipboard } from "@tui/util/clipboard" import * as Clipboard from "@tui/util/clipboard"
import type { PromptInfo } from "@tui/component/prompt/history" import type { PromptInfo } from "@tui/component/prompt/history"
import { strip } from "@tui/component/prompt/part" import { strip } from "@tui/component/prompt/part"
@@ -34,7 +34,7 @@ import type {
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { Locale } from "@/util" import { Locale } from "@/util"
import type { Tool } from "@/tool/tool" import type { Tool } from "@/tool"
import type { ReadTool } from "@/tool/read" import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write" import type { WriteTool } from "@/tool/write"
import { BashTool } from "@/tool/bash" import { BashTool } from "@/tool/bash"
@@ -44,6 +44,8 @@ import type { GrepTool } from "@/tool/grep"
import type { EditTool } from "@/tool/edit" import type { EditTool } from "@/tool/edit"
import type { ApplyPatchTool } from "@/tool/apply_patch" import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch" import type { WebFetchTool } from "@/tool/webfetch"
import type { CodeSearchTool } from "@/tool/codesearch"
import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task" import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question" import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill" import type { SkillTool } from "@/tool/skill"
@@ -66,10 +68,10 @@ import { SubagentFooter } from "./subagent-footer.tsx"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts" import parsers from "../../../../../../parsers-config.ts"
import { Clipboard } from "../../util/clipboard" import * as Clipboard from "../../util/clipboard"
import { Toast, useToast } from "../../ui/toast" import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx" import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor" import * as Editor from "../../util/editor"
import stripAnsi from "strip-ansi" import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt" import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit" import { useExit } from "../../context/exit"
@@ -597,7 +599,7 @@ export function Session() {
{ {
title: conceal() ? "Disable code concealment" : "Enable code concealment", title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal", value: "session.toggle.conceal",
keybind: "messages_toggle_conceal" as any, keybind: "messages_toggle_conceal",
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: (dialog) => {
setConceal((prev) => !prev) setConceal((prev) => !prev)
@@ -1934,28 +1936,26 @@ function Grep(props: ToolProps<typeof GrepTool>) {
function WebFetch(props: ToolProps<typeof WebFetchTool>) { function WebFetch(props: ToolProps<typeof WebFetchTool>) {
return ( return (
<InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}> <InlineTool icon="%" pending="Fetching from the web..." complete={props.input.url} part={props.part}>
WebFetch {(props.input as any).url} WebFetch {props.input.url}
</InlineTool> </InlineTool>
) )
} }
function CodeSearch(props: ToolProps<any>) { function CodeSearch(props: ToolProps<typeof CodeSearchTool>) {
const input = props.input as any const metadata = props.metadata as { results?: number }
const metadata = props.metadata as any
return ( return (
<InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part}> <InlineTool icon="◇" pending="Searching code..." complete={props.input.query} part={props.part}>
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show> Exa Code Search "{props.input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
</InlineTool> </InlineTool>
) )
} }
function WebSearch(props: ToolProps<any>) { function WebSearch(props: ToolProps<typeof WebSearchTool>) {
const input = props.input as any const metadata = props.metadata as { numResults?: number }
const metadata = props.metadata as any
return ( return (
<InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part}> <InlineTool icon="◈" pending="Searching web..." complete={props.input.query} part={props.part}>
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show> Exa Web Search "{props.input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
</InlineTool> </InlineTool>
) )
} }
@@ -1979,7 +1979,9 @@ function Task(props: ToolProps<typeof TaskTool>) {
) )
}) })
const current = createMemo(() => tools().findLast((x) => (x.state as any).title)) const current = createMemo(() =>
tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
)
const isRunning = createMemo(() => props.part.state.status === "running") const isRunning = createMemo(() => props.part.state.status === "running")
@@ -1996,8 +1998,11 @@ function Task(props: ToolProps<typeof TaskTool>) {
if (isRunning() && tools().length > 0) { if (isRunning() && tools().length > 0) {
// content[0] += ` · ${tools().length} toolcalls` // content[0] += ` · ${tools().length} toolcalls`
if (current()) content.push(`${Locale.titlecase(current()!.tool)} ${(current()!.state as any).title}`) if (current()) {
else content.push(`${tools().length} toolcalls`) const state = current()!.state
const title = state.status === "running" || state.status === "completed" ? state.title : undefined
content.push(`${Locale.titlecase(current()!.tool)} ${title}`)
} else content.push(`${tools().length} toolcalls`)
} }
if (props.part.state.status === "completed") { if (props.part.state.status === "completed") {
@@ -2,7 +2,7 @@ import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config" import { useTuiConfig } from "../../context/tui-config"
import { Installation } from "@/installation" import { InstallationVersion } from "@/installation/version"
import { TuiPluginRuntime } from "../../plugin" import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
@@ -64,7 +64,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<span style={{ fg: theme.text }}> <span style={{ fg: theme.text }}>
<b>Code</b> <b>Code</b>
</span>{" "} </span>{" "}
<span>{Installation.VERSION}</span> <span>{InstallationVersion}</span>
</text> </text>
</TuiPluginRuntime.Slot> </TuiPluginRuntime.Slot>
</box> </box>
+5 -8
View File
@@ -8,14 +8,13 @@ import { UI } from "@/cli/ui"
import { Log } from "@/util" import { Log } from "@/util"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util" import { Filesystem } from "@/util"
import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config"
import { Instance } from "@/project/instance"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
@@ -177,12 +176,9 @@ export const TuiThreadCommand = cmd({
} }
const prompt = await input(args.prompt) const prompt = await input(args.prompt)
const config = await Instance.provide({ const config = await TuiConfig.get()
directory: cwd,
fn: () => TuiConfig.get(),
})
const network = await resolveNetworkOptions(args) const network = resolveNetworkOptionsNoConfig(args)
const external = const external =
process.argv.includes("--port") || process.argv.includes("--port") ||
process.argv.includes("--hostname") || process.argv.includes("--hostname") ||
@@ -237,3 +233,4 @@ export const TuiThreadCommand = cmd({
process.exit(0) process.exit(0)
}, },
}) })
// scratch
@@ -5,7 +5,7 @@ import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useToast } from "./toast" import { useToast } from "./toast"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Selection } from "@tui/util/selection" import * as Selection from "@tui/util/selection"
export function Dialog( export function Dialog(
props: ParentProps<{ props: ParentProps<{
@@ -5,7 +5,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "../component/border" import { SplitBorder } from "../component/border"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import z from "zod" import z from "zod"
import { TuiEvent } from "../event" import { type TuiEvent } from "../event"
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties> export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
@@ -56,8 +56,7 @@ function init() {
const toast = { const toast = {
show(options: ToastOptions) { show(options: ToastOptions) {
const parsedOptions = TuiEvent.ToastShow.properties.parse(options) const { duration, ...currentToast } = options
const { duration, ...currentToast } = parsedOptions
setStore("currentToast", currentToast) setStore("currentToast", currentToast)
if (timeoutHandle) clearTimeout(timeoutHandle) if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => { timeoutHandle = setTimeout(() => {
@@ -1,12 +1,21 @@
import { platform, release } from "os" import { platform, release } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js" import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os" import { tmpdir } from "os"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { Filesystem } from "../../../../util" import * as Filesystem from "../../../../util/filesystem"
import { Process } from "../../../../util" import * as Process from "../../../../util/process"
import { which } from "../../../../util/which"
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
const getWhich = lazy(async () => {
const { which } = await import("../../../../util/which")
return which
})
const getClipboardy = lazy(async () => {
const { default: clipboardy } = await import("clipboardy")
return clipboardy
})
/** /**
* Writes text to clipboard via OSC 52 escape sequence. * Writes text to clipboard via OSC 52 escape sequence.
@@ -22,171 +31,173 @@ function writeOsc52(text: string): void {
process.stdout.write(sequence) process.stdout.write(sequence)
} }
export namespace Clipboard { export interface Content {
export interface Content { data: string
data: string mime: string
mime: string }
}
// Checks clipboard for images first, then falls back to text. // Checks clipboard for images first, then falls back to text.
// //
// On Windows prompt/ can call this from multiple paste signals because // On Windows prompt/ can call this from multiple paste signals because
// terminals surface image paste differently: // terminals surface image paste differently:
// 1. A forwarded Ctrl+V keypress // 1. A forwarded Ctrl+V keypress
// 2. An empty bracketed-paste hint for image-only clipboard in Windows // 2. An empty bracketed-paste hint for image-only clipboard in Windows
// Terminal <1.25 // Terminal <1.25
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+ // 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
export async function read(): Promise<Content | undefined> { export async function read(): Promise<Content | undefined> {
const os = platform() const os = platform()
if (os === "darwin") { if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png") const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try { try {
await Process.run( await Process.run(
[ [
"osascript", "osascript",
"-e", "-e",
'set imageData to the clipboard as "PNGf"', 'set imageData to the clipboard as "PNGf"',
"-e", "-e",
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`, `set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
"-e", "-e",
"set eof fileRef to 0", "set eof fileRef to 0",
"-e", "-e",
"write imageData to fileRef", "write imageData to fileRef",
"-e", "-e",
"close access fileRef", "close access fileRef",
], ],
{ nothrow: true }, { nothrow: true },
) )
const buffer = await Filesystem.readBytes(tmpfile) const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" } return { data: buffer.toString("base64"), mime: "image/png" }
} catch { } catch {
} finally { } finally {
await fs.rm(tmpfile, { force: true }).catch(() => {}) await fs.rm(tmpfile, { force: true }).catch(() => {})
}
}
// Windows/WSL: probe clipboard for images via PowerShell.
// Bracketed paste can't carry image data so we read it directly.
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
if (os === "linux") {
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
}
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
}
}
const text = await clipboardy.read().catch(() => {})
if (text) {
return { data: text, mime: "text/plain" }
} }
} }
const getCopyMethod = lazy(() => { // Windows/WSL: probe clipboard for images via PowerShell.
const os = platform() // Bracketed paste can't carry image data so we read it directly.
if (os === "win32" || release().includes("WSL")) {
if (os === "darwin" && which("osascript")) { const script =
console.log("clipboard: using osascript") "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
return async (text: string) => { const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') nothrow: true,
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true }) })
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
} }
} }
}
if (os === "linux") { if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) { const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
console.log("clipboard: using wl-copy") if (wayland.stdout.byteLength > 0) {
return async (text: string) => { return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
} }
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
}
}
if (os === "win32") { const clipboardy = await getClipboardy()
console.log("clipboard: using powershell") const text = await clipboardy.read().catch(() => {})
if (text) {
return { data: text, mime: "text/plain" }
}
}
const getCopyMethod = lazy(async () => {
const os = platform()
const which = await getWhich()
if (os === "darwin" && which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => { return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
const proc = Process.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
if (!proc.stdin) return if (!proc.stdin) return
proc.stdin.write(text) proc.stdin.write(text)
proc.stdin.end() proc.stdin.end()
await proc.exited.catch(() => {}) await proc.exited.catch(() => {})
} }
} }
if (which("xclip")) {
console.log("clipboard: no native support") console.log("clipboard: using xclip")
return async (text: string) => { return async (text: string) => {
await clipboardy.write(text).catch(() => {}) const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
} }
})
export async function copy(text: string): Promise<void> {
writeOsc52(text)
await getCopyMethod()(text)
} }
if (os === "win32") {
console.log("clipboard: using powershell")
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Process.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
console.log("clipboard: no native support")
return async (text: string) => {
const clipboardy = await getClipboardy()
await clipboardy.write(text).catch(() => {})
}
})
export async function copy(text: string): Promise<void> {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
} }
@@ -6,32 +6,30 @@ import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util" import { Filesystem } from "@/util"
import { Process } from "@/util" import { Process } from "@/util"
export namespace Editor { export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> { const editor = process.env["VISUAL"] || process.env["EDITOR"]
const editor = process.env["VISUAL"] || process.env["EDITOR"] if (!editor) return
if (!editor) return
const filepath = join(tmpdir(), `${Date.now()}.md`) const filepath = join(tmpdir(), `${Date.now()}.md`)
await using _ = defer(async () => rm(filepath, { force: true })) await using _ = defer(async () => rm(filepath, { force: true }))
await Filesystem.write(filepath, opts.value) await Filesystem.write(filepath, opts.value)
opts.renderer.suspend() opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear()
try {
const parts = editor.split(" ")
const proc = Process.spawn([...parts, filepath], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
shell: process.platform === "win32",
})
await proc.exited
const content = await Filesystem.readText(filepath)
return content || undefined
} finally {
opts.renderer.currentRenderBuffer.clear() opts.renderer.currentRenderBuffer.clear()
try { opts.renderer.resume()
const parts = editor.split(" ") opts.renderer.requestRender()
const proc = Process.spawn([...parts, filepath], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
shell: process.platform === "win32",
})
await proc.exited
const content = await Filesystem.readText(filepath)
return content || undefined
} finally {
opts.renderer.currentRenderBuffer.clear()
opts.renderer.resume()
opts.renderer.requestRender()
}
} }
} }
@@ -0,0 +1,5 @@
export * as Editor from "./editor"
export * as Selection from "./selection"
export * as Sound from "./sound"
export * as Terminal from "./terminal"
export * as Clipboard from "./clipboard"
@@ -1,5 +1,5 @@
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core" import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
import type { TuiConfig } from "@/config" import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
export class CustomSpeedScroll implements ScrollAcceleration { export class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {} constructor(private speed: number) {}
@@ -1,4 +1,4 @@
import { Clipboard } from "./clipboard" import * as Clipboard from "./clipboard"
type Toast = { type Toast = {
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
@@ -10,16 +10,14 @@ type Renderer = {
clearSelection: () => void clearSelection: () => void
} }
export namespace Selection { export function copy(renderer: Renderer, toast: Toast): boolean {
export function copy(renderer: Renderer, toast: Toast): boolean { const text = renderer.getSelection()?.getSelectedText()
const text = renderer.getSelection()?.getSelectedText() if (!text) return false
if (!text) return false
Clipboard.copy(text) Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error) .catch(toast.error)
renderer.clearSelection() renderer.clearSelection()
return true return true
}
} }
+106 -108
View File
@@ -43,114 +43,112 @@ function args(kind: Kind, file: string, volume: number) {
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`] return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
} }
export namespace Sound { let item: Player | null | undefined
let item: Player | null | undefined let kind: Kind | null | undefined
let kind: Kind | null | undefined let proc: Process.Child | undefined
let proc: Process.Child | undefined let tail: ReturnType<typeof setTimeout> | undefined
let tail: ReturnType<typeof setTimeout> | undefined let cache: Promise<{ hum: string; pulse: string[] }> | undefined
let cache: Promise<{ hum: string; pulse: string[] }> | undefined let seq = 0
let seq = 0 let shot = 0
let shot = 0
function load() { function load() {
if (item !== undefined) return item if (item !== undefined) return item
try { try {
item = new Player({ volume: 0.35 }) item = new Player({ volume: 0.35 })
} catch { } catch {
item = null item = null
}
return item
}
async function file(path: string) {
mkdirSync(DIR, { recursive: true })
const next = join(DIR, basename(path))
const out = Bun.file(next)
if (await out.exists()) return next
await Bun.write(out, Bun.file(path))
return next
}
function asset() {
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
return cache
}
function pick() {
if (kind !== undefined) return kind
kind = LIST.find((item) => which(item)) ?? null
return kind
}
function run(file: string, volume: number) {
const kind = pick()
if (!kind) return
return Process.spawn(args(kind, file, volume), {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
})
}
function clear() {
if (!tail) return
clearTimeout(tail)
tail = undefined
}
function play(file: string, volume: number) {
const item = load()
if (!item) return run(file, volume)?.exited
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
}
export function start() {
stop()
const id = ++seq
void asset().then(({ hum }) => {
if (id !== seq) return
const next = run(hum, 0.24)
if (!next) return
proc = next
void next.exited.then(
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
)
})
}
export function stop(delay = 0) {
seq++
clear()
if (!proc) return
const next = proc
if (delay <= 0) {
proc = undefined
void Process.stop(next).catch(() => undefined)
return
}
tail = setTimeout(() => {
tail = undefined
if (proc === next) proc = undefined
void Process.stop(next).catch(() => undefined)
}, delay)
}
export function pulse(scale = 1) {
stop(140)
const index = shot++ % FILE.length
void asset()
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
.catch(() => undefined)
}
export function dispose() {
stop()
} }
return item
}
async function file(path: string) {
mkdirSync(DIR, { recursive: true })
const next = join(DIR, basename(path))
const out = Bun.file(next)
if (await out.exists()) return next
await Bun.write(out, Bun.file(path))
return next
}
function asset() {
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
return cache
}
function pick() {
if (kind !== undefined) return kind
kind = LIST.find((item) => which(item)) ?? null
return kind
}
function run(file: string, volume: number) {
const kind = pick()
if (!kind) return
return Process.spawn(args(kind, file, volume), {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
})
}
function clear() {
if (!tail) return
clearTimeout(tail)
tail = undefined
}
function play(file: string, volume: number) {
const item = load()
if (!item) return run(file, volume)?.exited
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
}
export function start() {
stop()
const id = ++seq
void asset().then(({ hum }) => {
if (id !== seq) return
const next = run(hum, 0.24)
if (!next) return
proc = next
void next.exited.then(
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
() => {
if (id !== seq) return
if (proc === next) proc = undefined
},
)
})
}
export function stop(delay = 0) {
seq++
clear()
if (!proc) return
const next = proc
if (delay <= 0) {
proc = undefined
void Process.stop(next).catch(() => undefined)
return
}
tail = setTimeout(() => {
tail = undefined
if (proc === next) proc = undefined
void Process.stop(next).catch(() => undefined)
}, delay)
}
export function pulse(scale = 1) {
stop(140)
const index = shot++ % FILE.length
void asset()
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
.catch(() => undefined)
}
export function dispose() {
stop()
} }
+114 -116
View File
@@ -1,137 +1,135 @@
import { RGBA } from "@opentui/core" import { RGBA } from "@opentui/core"
export namespace Terminal { export type Colors = Awaited<ReturnType<typeof colors>>
export type Colors = Awaited<ReturnType<typeof colors>>
function parse(color: string): RGBA | null { function parse(color: string): RGBA | null {
if (color.startsWith("rgb:")) { if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/") const parts = color.substring(4).split("/")
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255) return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
}
if (color.startsWith("#")) {
return RGBA.fromHex(color)
}
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
} }
if (color.startsWith("#")) {
function mode(bg: RGBA | null): "dark" | "light" { return RGBA.fromHex(color)
if (!bg) return "dark"
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
return luminance > 0.5 ? "light" : "dark"
} }
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
/** function mode(bg: RGBA | null): "dark" | "light" {
* Query terminal colors including background, foreground, and palette (0-15). if (!bg) return "dark"
* Uses OSC escape sequences to retrieve actual terminal color values. const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
* return luminance > 0.5 ? "light" : "dark"
* Note: OSC 4 (palette) queries may not work through tmux as responses are filtered. }
* OSC 10/11 (foreground/background) typically work in most environments.
*
* Returns an object with background, foreground, and colors array.
* Any query that fails will be null/empty.
*/
export async function colors(): Promise<{
background: RGBA | null
foreground: RGBA | null
colors: RGBA[]
}> {
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
return new Promise((resolve) => { /**
let background: RGBA | null = null * Query terminal colors including background, foreground, and palette (0-15).
let foreground: RGBA | null = null * Uses OSC escape sequences to retrieve actual terminal color values.
const paletteColors: RGBA[] = [] *
let timeout: NodeJS.Timeout * Note: OSC 4 (palette) queries may not work through tmux as responses are filtered.
* OSC 10/11 (foreground/background) typically work in most environments.
*
* Returns an object with background, foreground, and colors array.
* Any query that fails will be null/empty.
*/
export async function colors(): Promise<{
background: RGBA | null
foreground: RGBA | null
colors: RGBA[]
}> {
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
const cleanup = () => { return new Promise((resolve) => {
process.stdin.setRawMode(false) let background: RGBA | null = null
process.stdin.removeListener("data", handler) let foreground: RGBA | null = null
clearTimeout(timeout) const paletteColors: RGBA[] = []
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const str = data.toString()
// Match OSC 11 (background color)
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) {
background = parse(bgMatch[1])
} }
const handler = (data: Buffer) => { // Match OSC 10 (foreground color)
const str = data.toString() const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) {
// Match OSC 11 (background color) foreground = parse(fgMatch[1])
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) {
background = parse(bgMatch[1])
}
// Match OSC 10 (foreground color)
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) {
foreground = parse(fgMatch[1])
}
// Match OSC 4 (palette colors)
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) {
const index = parseInt(match[1])
const color = parse(match[2])
if (color) paletteColors[index] = color
}
// Return immediately if we have all 16 palette colors
if (paletteColors.filter((c) => c !== undefined).length === 16) {
cleanup()
resolve({ background, foreground, colors: paletteColors })
}
} }
process.stdin.setRawMode(true) // Match OSC 4 (palette colors)
process.stdin.on("data", handler) const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) {
// Query background (OSC 11) const index = parseInt(match[1])
process.stdout.write("\x1b]11;?\x07") const color = parse(match[2])
// Query foreground (OSC 10) if (color) paletteColors[index] = color
process.stdout.write("\x1b]10;?\x07")
// Query palette colors 0-15 (OSC 4)
for (let i = 0; i < 16; i++) {
process.stdout.write(`\x1b]4;${i};?\x07`)
} }
timeout = setTimeout(() => { // Return immediately if we have all 16 palette colors
if (paletteColors.filter((c) => c !== undefined).length === 16) {
cleanup() cleanup()
resolve({ background, foreground, colors: paletteColors }) resolve({ background, foreground, colors: paletteColors })
}, 1000)
})
}
// Keep startup mode detection separate from `colors()`: the TUI boot path only
// needs OSC 11 and should resolve on the first background response instead of
// waiting on the full palette query used by system theme generation.
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
} }
}
const handler = (data: Buffer) => { process.stdin.setRawMode(true)
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/) process.stdin.on("data", handler)
if (!match) return
cleanup()
resolve(mode(parse(match[1])))
}
process.stdin.setRawMode(true) // Query background (OSC 11)
process.stdin.on("data", handler) process.stdout.write("\x1b]11;?\x07")
process.stdout.write("\x1b]11;?\x07") // Query foreground (OSC 10)
process.stdout.write("\x1b]10;?\x07")
// Query palette colors 0-15 (OSC 4)
for (let i = 0; i < 16; i++) {
process.stdout.write(`\x1b]4;${i};?\x07`)
}
timeout = setTimeout(() => { timeout = setTimeout(() => {
cleanup() cleanup()
resolve("dark") resolve({ background, foreground, colors: paletteColors })
}, 1000) }, 1000)
}) })
} }
// Keep startup mode detection separate from `colors()`: the TUI boot path only
// needs OSC 11 and should resolve on the first background response instead of
// waiting on the full palette query used by system theme generation.
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
if (!match) return
cleanup()
resolve(mode(parse(match[1])))
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
} }
+3 -2
View File
@@ -1,4 +1,5 @@
import { dlopen, ptr } from "bun:ffi" import { dlopen, ptr } from "bun:ffi"
import type { ReadStream } from "node:tty"
const STD_INPUT_HANDLE = -10 const STD_INPUT_HANDLE = -10
const ENABLE_PROCESSED_INPUT = 0x0001 const ENABLE_PROCESSED_INPUT = 0x0001
@@ -71,7 +72,7 @@ export function win32InstallCtrlCGuard() {
if (!load()) return if (!load()) return
if (unhook) return unhook if (unhook) return unhook
const stdin = process.stdin as any const stdin = process.stdin as ReadStream
const original = stdin.setRawMode const original = stdin.setRawMode
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
@@ -93,7 +94,7 @@ export function win32InstallCtrlCGuard() {
setImmediate(enforce) setImmediate(enforce)
} }
let wrapped: ((mode: boolean) => unknown) | undefined let wrapped: ReadStream["setRawMode"] | undefined
if (typeof original === "function") { if (typeof original === "function") {
wrapped = (mode: boolean) => { wrapped = (mode: boolean) => {
+3 -2
View File
@@ -3,6 +3,7 @@ import { UI } from "../ui"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation" import { Installation } from "../../installation"
import { InstallationVersion } from "../../installation/version"
export const UpgradeCommand = { export const UpgradeCommand = {
command: "upgrade [target]", command: "upgrade [target]",
@@ -47,13 +48,13 @@ export const UpgradeCommand = {
? args.target.replace(/^v/, "") ? args.target.replace(/^v/, "")
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest())) : await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
if (Installation.VERSION === target) { if (InstallationVersion === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`) prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
prompts.outro("Done") prompts.outro("Done")
return return
} }
prompts.log.info(`From ${Installation.VERSION}${target}`) prompts.log.info(`From ${InstallationVersion}${target}`)
const spinner = prompts.spinner() const spinner = prompts.spinner()
spinner.start("Upgrading...") spinner.start("Upgrading...")
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch( const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
@@ -0,0 +1,20 @@
import { Observability } from "@/effect/observability"
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
export const memoMap = Layer.makeMemoMapUnsafe()
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () =>
(rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
return {
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromiseExit(service.use(fn), options),
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromise(service.use(fn), options),
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
}
}
+67 -35
View File
@@ -1,48 +1,80 @@
import { AccountServiceError, AccountTransportError } from "@/account" import { NamedError } from "@opencode-ai/shared/util/error"
import { ConfigMarkdown } from "@/config"
import { errorFormat } from "@/util/error" import { errorFormat } from "@/util/error"
import { Config } from "../config"
import { MCP } from "../mcp" interface ErrorLike {
import { Provider } from "../provider" name?: string
import { UI } from "./ui" _tag?: string
message?: string
data?: Record<string, any>
}
function isTaggedError(error: unknown, tag: string): boolean {
return (
typeof error === "object" && error !== null && "_tag" in error && (error as Record<string, unknown>)._tag === tag
)
}
export function FormatError(input: unknown) { export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input)) // MCPFailed: { name: string }
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` if (NamedError.hasName(input, "MCPFailed")) {
if (input instanceof AccountTransportError || input instanceof AccountServiceError) { return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
return input.message
} }
if (Provider.ModelNotFoundError.isInstance(input)) {
const { providerID, modelID, suggestions } = input.data // AccountServiceError, AccountTransportError: TaggedErrorClass
if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) {
return (input as ErrorLike).message ?? ""
}
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
const data = (input as ErrorLike).data
const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
return [ return [
`Model not found: ${providerID}/${modelID}`, `Model not found: ${data?.providerID}/${data?.modelID}`,
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`, `Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`, `Or check your config (opencode.json) provider/model names`,
].join("\n") ].join("\n")
} }
if (Provider.InitError.isInstance(input)) {
return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.`
}
if (Config.JsonError.isInstance(input)) {
return (
`Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
)
}
if (Config.ConfigDirectoryTypoError.isInstance(input)) {
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
}
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
return input.data.message
}
if (Config.InvalidError.isInstance(input))
return [
`Configuration is invalid${input.data.path && input.data.path !== "config" ? ` at ${input.data.path}` : ""}` +
(input.data.message ? `: ${input.data.message}` : ""),
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
].join("\n")
if (UI.CancelledError.isInstance(input)) return "" // ProviderInitError: { providerID: string }
if (NamedError.hasName(input, "ProviderInitError")) {
return `Failed to initialize provider "${(input as ErrorLike).data?.providerID}". Check credentials and configuration.`
}
// ConfigJsonError: { path: string, message?: string }
if (NamedError.hasName(input, "ConfigJsonError")) {
const data = (input as ErrorLike).data
return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "")
}
// ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string }
if (NamedError.hasName(input, "ConfigDirectoryTypoError")) {
const data = (input as ErrorLike).data
return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.`
}
// ConfigFrontmatterError: { message: string }
if (NamedError.hasName(input, "ConfigFrontmatterError")) {
return (input as ErrorLike).data?.message ?? ""
}
// ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> }
if (NamedError.hasName(input, "ConfigInvalidError")) {
const data = (input as ErrorLike).data
const path = data?.path
const message = data?.message
const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
return [
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
].join("\n")
}
// UICancelledError: void (no data)
if (NamedError.hasName(input, "UICancelledError")) {
return ""
}
} }
export function FormatUnknownError(input: unknown): string { export function FormatUnknownError(input: unknown): string {
+4 -1
View File
@@ -36,9 +36,12 @@ export type NetworkOptions = InferredOptionTypes<typeof options>
export function withNetworkOptions<T>(yargs: Argv<T>) { export function withNetworkOptions<T>(yargs: Argv<T>) {
return yargs.options(options) return yargs.options(options)
} }
export async function resolveNetworkOptions(args: NetworkOptions) { export async function resolveNetworkOptions(args: NetworkOptions) {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
return resolveNetworkOptionsNoConfig(args, config)
}
export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) {
const portExplicitlySet = process.argv.includes("--port") const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname") const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsExplicitlySet = process.argv.includes("--mdns")
+3 -2
View File
@@ -3,6 +3,7 @@ import { Config } from "@/config"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version"
export async function upgrade() { export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
@@ -15,10 +16,10 @@ export async function upgrade() {
return return
} }
if (Installation.VERSION === latest) return if (InstallationVersion === latest) return
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const kind = Installation.getReleaseType(Installation.VERSION, latest) const kind = Installation.getReleaseType(InstallationVersion, latest)
if (config.autoupdate === "notify" || kind !== "patch") { if (config.autoupdate === "notify" || kind !== "patch") {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest }) await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
-186
View File
@@ -1,186 +0,0 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect"
import { EffectBridge } from "@/effect"
import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context } from "effect"
import z from "zod"
import { Config } from "../config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
type State = {
commands: Record<string, Info>
}
export const Event = {
Executed: BusEvent.define(
"command.executed",
z.object({
name: z.string(),
sessionID: SessionID.zod,
arguments: z.string(),
messageID: MessageID.zod,
}),
),
}
export const Info = z
.object({
name: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
source: z.enum(["command", "mcp", "skill"]).optional(),
// workaround for zod not supporting async functions natively so we use getters
// https://zod.dev/v4/changelog?id=zfunction
template: z.promise(z.string()).or(z.string()),
subtask: z.boolean().optional(),
hints: z.array(z.string()),
})
.meta({
ref: "Command",
})
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
export function hints(template: string) {
const result: string[] = []
const numbered = template.match(/\$\d+/g)
if (numbered) {
for (const match of [...new Set(numbered)].sort()) result.push(match)
}
if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
return result
}
export const Default = {
INIT: "init",
REVIEW: "review",
} as const
export interface Interface {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const mcp = yield* MCP.Service
const skill = yield* Skill.Service
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
const cfg = yield* config.get()
const bridge = yield* EffectBridge.make()
const commands: Record<string, Info> = {}
commands[Default.INIT] = {
name: Default.INIT,
description: "guided AGENTS.md setup",
source: "command",
get template() {
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
},
hints: hints(PROMPT_INITIALIZE),
}
commands[Default.REVIEW] = {
name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted",
source: "command",
get template() {
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
},
subtask: true,
hints: hints(PROMPT_REVIEW),
}
for (const [name, command] of Object.entries(cfg.command ?? {})) {
commands[name] = {
name,
agent: command.agent,
model: command.model,
description: command.description,
source: "command",
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
}
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
commands[name] = {
name,
source: "mcp",
description: prompt.description,
get template() {
return bridge.promise(
mcp
.getPrompt(
prompt.client,
prompt.name,
prompt.arguments
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
: {},
)
.pipe(
Effect.map(
(template) =>
template?.messages
.map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "",
),
),
)
},
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
for (const item of yield* skill.all()) {
if (commands[item.name]) continue
commands[item.name] = {
name: item.name,
description: item.description,
source: "skill",
get template() {
return item.content
},
hints: [],
}
}
return {
commands,
}
})
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
const get = Effect.fn("Command.get")(function* (name: string) {
const s = yield* InstanceState.get(state)
return s.commands[name]
})
const list = Effect.fn("Command.list")(function* () {
const s = yield* InstanceState.get(state)
return Object.values(s.commands)
})
return Service.of({ get, list })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(Skill.defaultLayer),
)

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