From b6856bd593402fef816a4ed9f6abfd411f5f463d Mon Sep 17 00:00:00 2001 From: Charles Cooper Date: Fri, 12 Dec 2025 20:45:28 -0500 Subject: [PATCH 001/575] fix: add --session flag to attach command (#5460) Co-authored-by: Claude Opus 4.5 Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/cli/cmd/tui/attach.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 7da6507ea..5d1a4ded2 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -14,12 +14,17 @@ export const AttachCommand = cmd({ .option("dir", { type: "string", description: "directory to run in", + }) + .option("session", { + alias: ["s"], + type: "string", + describe: "session id to continue", }), handler: async (args) => { if (args.dir) process.chdir(args.dir) await tui({ url: args.url, - args: {}, + args: { sessionID: args.session }, }) }, }) From 613e082358d2f8cd589ace285248017ba73e9e94 Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Fri, 12 Dec 2025 20:55:46 -0500 Subject: [PATCH 002/575] github: support GITHUB_TOKEN + skip OIDC (#5459) --- github/action.yml | 6 +++++ packages/opencode/src/cli/cmd/github.ts | 33 +++++++++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/github/action.yml b/github/action.yml index f52f14d80..fe6a32206 100644 --- a/github/action.yml +++ b/github/action.yml @@ -17,6 +17,11 @@ inputs: description: "Custom prompt to override the default prompt" required: false + use_github_token: + description: "Use GITHUB_TOKEN directly instead of OpenCode App token exchange. When true, skips OIDC and uses the GITHUB_TOKEN env var." + required: false + default: "false" + runs: using: "composite" steps: @@ -51,3 +56,4 @@ runs: MODEL: ${{ inputs.model }} SHARE: ${{ inputs.share }} PROMPT: ${{ inputs.prompt }} + USE_GITHUB_TOKEN: ${{ inputs.use_github_token }} diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 55d9fb19d..dab1196f4 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -411,17 +411,30 @@ export const GithubRunCommand = cmd({ let exitCode = 0 type PromptFiles = Awaited>["promptFiles"] const triggerCommentId = payload.comment.id + const useGithubToken = normalizeUseGithubToken() try { - const actionToken = isMock ? args.token! : await getOidcToken() - appToken = await exchangeForAppToken(actionToken) + if (useGithubToken) { + const githubToken = process.env["GITHUB_TOKEN"] + if (!githubToken) { + throw new Error( + "GITHUB_TOKEN environment variable is not set. When using use_github_token, you must provide GITHUB_TOKEN.", + ) + } + appToken = githubToken + } else { + const actionToken = isMock ? args.token! : await getOidcToken() + appToken = await exchangeForAppToken(actionToken) + } octoRest = new Octokit({ auth: appToken }) octoGraph = graphql.defaults({ headers: { authorization: `token ${appToken}` }, }) const { userPrompt, promptFiles } = await getUserPrompt() - await configureGit(appToken) + if (!useGithubToken) { + await configureGit(appToken) + } await assertPermissions() await addReaction() @@ -514,8 +527,10 @@ export const GithubRunCommand = cmd({ // Also output the clean error message for the action to capture //core.setOutput("prepare_error", e.message); } finally { - await restoreGitConfig() - await revokeAppToken() + if (!useGithubToken) { + await restoreGitConfig() + await revokeAppToken() + } } process.exit(exitCode) @@ -544,6 +559,14 @@ export const GithubRunCommand = cmd({ throw new Error(`Invalid share value: ${value}. Share must be a boolean.`) } + function normalizeUseGithubToken() { + const value = process.env["USE_GITHUB_TOKEN"] + if (!value) return false + if (value === "true") return true + if (value === "false") return false + throw new Error(`Invalid use_github_token value: ${value}. Must be a boolean.`) + } + function isIssueCommentEvent( event: IssueCommentEvent | PullRequestReviewCommentEvent, ): event is IssueCommentEvent { From b7b827c5bd9c4aea11e6c6e008ab19c7cfa8c60d Mon Sep 17 00:00:00 2001 From: "Jan-Niklas W." <6104311+niklas-wortmann@users.noreply.github.com> Date: Fri, 12 Dec 2025 20:09:08 -0600 Subject: [PATCH 003/575] docs: JetBrains IDEs to ACP config docs page (#5465) --- packages/web/src/content/docs/acp.mdx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/web/src/content/docs/acp.mdx b/packages/web/src/content/docs/acp.mdx index 9d421235c..5e6be152d 100644 --- a/packages/web/src/content/docs/acp.mdx +++ b/packages/web/src/content/docs/acp.mdx @@ -67,6 +67,26 @@ You can also bind a keyboard shortcut by editing your `keymap.json`: --- +### JetBrains IDEs + +Add to your [JetBrains IDE](https://www.jetbrains.com/) acp.json according to the [documentation](https://www.jetbrains.com/help/ai-assistant/acp.html): + +```json title="~/.config/zed/settings.json" +{ + "agent_servers": { + "OpenCode": { + "command": "/absolute/path/bin/opencode", + "args": ["acp"] + } + } +} +``` + +To open it, use the new 'OpenCode' agent in the AI Chat agent selector. + +--- + + ### Avante.nvim Add to your [Avante.nvim](https://github.com/yetone/avante.nvim) configuration: From 5d7a52f8b8d2334348467778a049a4cc96a0dca4 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 13 Dec 2025 02:09:41 +0000 Subject: [PATCH 004/575] chore: format code --- packages/web/src/content/docs/acp.mdx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/web/src/content/docs/acp.mdx b/packages/web/src/content/docs/acp.mdx index 5e6be152d..eb171b28c 100644 --- a/packages/web/src/content/docs/acp.mdx +++ b/packages/web/src/content/docs/acp.mdx @@ -73,12 +73,12 @@ Add to your [JetBrains IDE](https://www.jetbrains.com/) acp.json according to th ```json title="~/.config/zed/settings.json" { - "agent_servers": { - "OpenCode": { - "command": "/absolute/path/bin/opencode", - "args": ["acp"] - } + "agent_servers": { + "OpenCode": { + "command": "/absolute/path/bin/opencode", + "args": ["acp"] } + } } ``` @@ -86,7 +86,6 @@ To open it, use the new 'OpenCode' agent in the AI Chat agent selector. --- - ### Avante.nvim Add to your [Avante.nvim](https://github.com/yetone/avante.nvim) configuration: From 8917a4c609362d407a8a4e2f25423d474215edda Mon Sep 17 00:00:00 2001 From: rari404 <138394996+edlsh@users.noreply.github.com> Date: Sat, 13 Dec 2025 00:50:09 -0500 Subject: [PATCH 005/575] feat: add texlab language server and latexindent formatter (#5251) --- packages/opencode/src/format/formatter.ts | 9 +++ packages/opencode/src/lsp/server.ts | 84 +++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index bcde6e7aa..4e49bb324 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -275,3 +275,12 @@ export const terraform: Info = { return Bun.which("terraform") !== null }, } + +export const latexindent: Info = { + name: "latexindent", + command: ["latexindent", "-w", "-s", "$FILE"], + extensions: [".tex"], + async enabled() { + return Bun.which("latexindent") !== null + }, +} diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index daee7db73..5230117ee 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1386,4 +1386,88 @@ export namespace LSPServer { } }, } + + export const TexLab: Info = { + id: "texlab", + extensions: [".tex", ".bib"], + root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), + async spawn(root) { + let bin = Bun.which("texlab", { + PATH: process.env["PATH"] + ":" + Global.Path.bin, + }) + + if (!bin) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading texlab from GitHub releases") + + const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest") + if (!response.ok) { + log.error("Failed to fetch texlab release info") + return + } + + const release = (await response.json()) as { + tag_name?: string + assets?: { name?: string; browser_download_url?: string }[] + } + const version = release.tag_name?.replace("v", "") + if (!version) { + log.error("texlab release did not include a version tag") + return + } + + const platform = process.platform + const arch = process.arch + + const texArch = arch === "arm64" ? "aarch64" : "x86_64" + const texPlatform = platform === "darwin" ? "macos" : platform === "win32" ? "windows" : "linux" + const ext = platform === "win32" ? "zip" : "tar.gz" + const assetName = `texlab-${texArch}-${texPlatform}.${ext}` + + const assets = release.assets ?? [] + const asset = assets.find((a) => a.name === assetName) + if (!asset?.browser_download_url) { + log.error(`Could not find asset ${assetName} in texlab release`) + return + } + + const downloadResponse = await fetch(asset.browser_download_url) + if (!downloadResponse.ok) { + log.error("Failed to download texlab") + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + await Bun.file(tempPath).write(downloadResponse) + + if (ext === "zip") { + await $`unzip -o -q ${tempPath}`.cwd(Global.Path.bin).nothrow() + } + if (ext === "tar.gz") { + await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).nothrow() + } + + await fs.rm(tempPath, { force: true }) + + bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : "")) + + if (!(await Bun.file(bin).exists())) { + log.error("Failed to extract texlab binary") + return + } + + if (platform !== "win32") { + await $`chmod +x ${bin}`.nothrow() + } + + log.info("installed texlab", { bin }) + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, + } } From d8663a44c285a9165b9aa28061e24ba1fad02d66 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 13 Dec 2025 12:04:19 +0000 Subject: [PATCH 006/575] ignore: update download stats 2025-12-13 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index 8a3e0d553..93ab445e6 100644 --- a/STATS.md +++ b/STATS.md @@ -168,3 +168,4 @@ | 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) | | 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) | | 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) | +| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) | From decf2452c4474069b02931295170dcc695d80f4e Mon Sep 17 00:00:00 2001 From: rari404 <138394996+edlsh@users.noreply.github.com> Date: Sat, 13 Dec 2025 12:30:15 -0500 Subject: [PATCH 007/575] feat: add dockerfile language server (#5252) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/lsp/server.ts | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 5230117ee..353d4272d 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1470,4 +1470,49 @@ export namespace LSPServer { } }, } + + export const DockerfileLS: Info = { + id: "dockerfile", + extensions: [".dockerfile", "Dockerfile"], + root: async () => Instance.directory, + async spawn(root) { + let binary = Bun.which("docker-langserver") + const args: string[] = [] + if (!binary) { + const js = path.join( + Global.Path.bin, + "node_modules", + "dockerfile-language-server-nodejs", + "lib", + "server.js", + ) + if (!(await Bun.file(js).exists())) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { + cwd: Global.Path.bin, + env: { + ...process.env, + BUN_BE_BUN: "1", + }, + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }).exited + } + binary = BunProc.which() + args.push("run", js) + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + BUN_BE_BUN: "1", + }, + }) + return { + process: proc, + } + }, + } } From 199bd8a9a2bb53fa17c86d15b4fe27d1fdceb807 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 13 Dec 2025 17:30:48 +0000 Subject: [PATCH 008/575] chore: format code --- packages/opencode/src/lsp/server.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 353d4272d..8076edc24 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1479,13 +1479,7 @@ export namespace LSPServer { let binary = Bun.which("docker-langserver") const args: string[] = [] if (!binary) { - const js = path.join( - Global.Path.bin, - "node_modules", - "dockerfile-language-server-nodejs", - "lib", - "server.js", - ) + const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js") if (!(await Bun.file(js).exists())) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { From b46d4789fc26b36b4c2c3849253de6dc26931ebb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 14 Dec 2025 02:33:10 +0900 Subject: [PATCH 009/575] docs: add oh-my-opencode to plugins list (#5481) --- packages/web/src/content/docs/ecosystem.mdx | 23 +++++++++++---------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 42df490cf..63fe8766f 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -15,17 +15,18 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Plugins -| Name | Description | -| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | -| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities | -| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | -| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | -| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | -| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing | -| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | -| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | -| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | +| Name | Description | +| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | +| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities | +| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | +| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | +| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | +| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing | +| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | +| [opencode-wakatime](https://github.com/angrister/opencode-wakatime) | Track OpenCode usage with Wakatime | +| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | +| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | --- From b7581e01ea58b25708dd9a0d6b8cf30177a00119 Mon Sep 17 00:00:00 2001 From: "Jan-Niklas W." <6104311+niklas-wortmann@users.noreply.github.com> Date: Sat, 13 Dec 2025 11:33:31 -0600 Subject: [PATCH 010/575] docs: fix title for JetBrains ACP config file (#5479) --- packages/web/src/content/docs/acp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/acp.mdx b/packages/web/src/content/docs/acp.mdx index eb171b28c..9129db135 100644 --- a/packages/web/src/content/docs/acp.mdx +++ b/packages/web/src/content/docs/acp.mdx @@ -71,7 +71,7 @@ You can also bind a keyboard shortcut by editing your `keymap.json`: Add to your [JetBrains IDE](https://www.jetbrains.com/) acp.json according to the [documentation](https://www.jetbrains.com/help/ai-assistant/acp.html): -```json title="~/.config/zed/settings.json" +```json title="acp.json" { "agent_servers": { "OpenCode": { From 7434fbba8e4313449cd0e15b9b7c337f0a514efd Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 13 Dec 2025 17:34:07 +0000 Subject: [PATCH 011/575] chore: format code --- packages/web/src/content/docs/ecosystem.mdx | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 63fe8766f..03af6fd8c 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -15,17 +15,17 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Plugins -| Name | Description | -| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | -| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities | -| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | -| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | -| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | -| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing | -| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | -| [opencode-wakatime](https://github.com/angrister/opencode-wakatime) | Track OpenCode usage with Wakatime | -| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | +| Name | Description | +| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | +| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities | +| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | +| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | +| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | +| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing | +| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | +| [opencode-wakatime](https://github.com/angrister/opencode-wakatime) | Track OpenCode usage with Wakatime | +| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | | [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | --- From 7bf6f264e48b8e4fccb8310551ce7d6928c0a2c2 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 13 Dec 2025 13:00:03 -0600 Subject: [PATCH 012/575] bump bun version & set flags this time --- bun.lock | 6 +++--- package.json | 4 ++-- packages/opencode/script/build.ts | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 5bc89cf56..d6bcc4e4d 100644 --- a/bun.lock +++ b/bun.lock @@ -477,7 +477,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.3", + "@types/bun": "1.3.4", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@typescript/native-preview": "7.0.0-dev.20251207.1", @@ -1703,7 +1703,7 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], - "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -2009,7 +2009,7 @@ "bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="], - "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], diff --git a/package.json b/package.json index 39733b931..ca2a10f78 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.3", + "packageManager": "bun@1.3.4", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", @@ -20,7 +20,7 @@ "packages/slack" ], "catalog": { - "@types/bun": "1.3.3", + "@types/bun": "1.3.4", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", "@kobalte/core": "0.13.11", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 5a6ac2584..a85fde9e2 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -117,6 +117,9 @@ for (const item of targets) { compile: { autoloadBunfig: false, autoloadDotenv: false, + //@ts-ignore (bun types aren't up to date) + autoloadTsconfig: true, + autoloadPackageJson: true, target: name.replace(pkg.name, "bun") as any, outfile: `dist/${name}/bin/opencode`, execArgv: [`--user-agent=opencode/${Script.version}`, "--"], From b4ffaa21ec0576936fa24c72d6548bba6bdd9239 Mon Sep 17 00:00:00 2001 From: Github Action Date: Sat, 13 Dec 2025 19:01:20 +0000 Subject: [PATCH 013/575] Update Nix flake.lock and hashes --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index e28f98d05..4232eea86 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-nWSAnQEm/t1ESZe23dr4JnIOJQ0JLN0w4NVoMJajbVQ=" + "nodeModules": "sha256-pLxjuCudEQilu9YAJ9lcyZIrX7THaDjQnI+TLhMuc3w=" } From f254cf76d919a96559706c92fa5af009ce699d63 Mon Sep 17 00:00:00 2001 From: Felipe Oduardo Sierra <420001+felipe@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:01:59 +0100 Subject: [PATCH 014/575] add ARM64 Docker image support (#5483) --- .github/workflows/publish.yml | 6 ++++++ packages/opencode/Dockerfile | 12 ++++++++++-- packages/opencode/script/publish.ts | 8 ++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 96b9280fb..9c44efe1b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -64,6 +64,12 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - uses: actions/setup-node@v4 with: node-version: "24" diff --git a/packages/opencode/Dockerfile b/packages/opencode/Dockerfile index 99f593581..f92b48a6d 100644 --- a/packages/opencode/Dockerfile +++ b/packages/opencode/Dockerfile @@ -1,10 +1,18 @@ -FROM alpine +FROM alpine AS base # Disable the runtime transpiler cache by default inside Docker containers. # On ephemeral containers, the cache is not useful ARG BUN_RUNTIME_TRANSPILER_CACHE_PATH=0 ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=${BUN_RUNTIME_TRANSPILER_CACHE_PATH} RUN apk add libgcc libstdc++ ripgrep -ADD ./dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode + +FROM base AS build-amd64 +COPY dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode + +FROM base AS build-arm64 +COPY dist/opencode-linux-arm64-musl/bin/opencode /usr/local/bin/opencode + +ARG TARGETARCH +FROM build-${TARGETARCH} RUN opencode --version ENTRYPOINT ["opencode"] diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index ff75bbb8d..72632992f 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -244,8 +244,8 @@ if (!Script.preview) { await $`cd ./dist/homebrew-tap && git push` const image = "ghcr.io/sst/opencode" - await $`docker build -t ${image}:${Script.version} .` - await $`docker push ${image}:${Script.version}` - await $`docker tag ${image}:${Script.version} ${image}:latest` - await $`docker push ${image}:latest` + const platforms = "linux/amd64,linux/arm64" + const tags = [`${image}:${Script.version}`, `${image}:latest`] + const tagFlags = tags.flatMap((t) => ["-t", t]) + await $`docker buildx build --platform ${platforms} ${tagFlags} --push .` } From 307af10c8bad1eb90288df5447275bb1b65cebc7 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 13 Dec 2025 06:15:11 -0600 Subject: [PATCH 015/575] fix: session turn scroll --- packages/ui/src/components/session-turn.css | 29 ++++--- packages/ui/src/components/session-turn.tsx | 87 +++++++++++---------- 2 files changed, 62 insertions(+), 54 deletions(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index c4dd2b839..24eb1563b 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -1,5 +1,6 @@ [data-component="session-turn"] { /* flex: 1; */ + --scroll-y: 0px; height: 100%; min-height: 0; min-width: 0; @@ -26,18 +27,26 @@ align-items: flex-start; align-self: stretch; min-width: 0; - gap: 32px; + gap: clamp(8px, calc(42px - var(--scroll-y) * 0.48), 42px); } - [data-slot="session-turn-sticky-header"] { + [data-slot="session-turn-sticky-title"] { width: 100%; position: sticky; top: 0; background-color: var(--background-stronger); + z-index: 21; + /* padding-bottom: clamp(0px, calc(8px - var(--scroll-y) * 0.16), 8px); */ + } + + [data-slot="session-turn-response-trigger"] { + position: sticky; + top: 32px; + background-color: var(--background-stronger); z-index: 20; - display: flex; - flex-direction: column; - gap: 8px; + width: calc(100% + 9px); + margin-left: -9px; + padding-left: 9px; padding-bottom: 8px; } @@ -49,13 +58,8 @@ height: 32px; } - /* [data-slot="session-turn-message-content"] { */ - /* } */ - - [data-slot="session-turn-response-trigger"] { - width: calc(100% + 9px); - margin-left: -9px; - padding-left: 9px; + [data-slot="session-turn-message-content"] { + margin-top: -24px; } [data-slot="session-turn-message-title"] { @@ -292,6 +296,7 @@ [data-slot="session-turn-collapsible"] { gap: 32px; overflow: visible; + /* margin-top: clamp(8px, calc(24px - var(--scroll-y) * 0.32), 24px); */ } [data-slot="session-turn-collapsible-trigger-content"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 708ac5b83..361a5cac0 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,18 +3,7 @@ import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" -import { - createEffect, - createMemo, - createSignal, - For, - Match, - onCleanup, - onMount, - ParentProps, - Show, - Switch, -} from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" @@ -61,12 +50,15 @@ export function SessionTurn( let scrollRef: HTMLDivElement | undefined const [contentRef, setContentRef] = createSignal() - const [stickyHeaderRef, setStickyHeaderRef] = createSignal() + const [stickyTitleRef, setStickyTitleRef] = createSignal() + const [stickyTriggerRef, setStickyTriggerRef] = createSignal() const [userScrolled, setUserScrolled] = createSignal(false) const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0) + const [scrollY, setScrollY] = createSignal(0) function handleScroll() { if (!scrollRef) return + setScrollY(scrollRef.scrollTop) const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { @@ -88,15 +80,24 @@ export function SessionTurn( createResizeObserver(contentRef, () => { if (!scrollRef || userScrolled() || !working()) return - scrollRef.scrollTop = scrollRef.scrollHeight + requestAnimationFrame(() => { + if (!scrollRef) return + scrollRef.scrollTop = scrollRef.scrollHeight + }) }) - createResizeObserver(stickyHeaderRef, ({ height }) => { - setStickyHeaderHeight(height + 8) + createResizeObserver(stickyTitleRef, ({ height }) => { + const triggerHeight = stickyTriggerRef()?.offsetHeight ?? 0 + setStickyHeaderHeight(height + triggerHeight + 8) + }) + + createResizeObserver(stickyTriggerRef, ({ height }) => { + const titleHeight = stickyTitleRef()?.offsetHeight ?? 0 + setStickyHeaderHeight(titleHeight + height + 8) }) return ( -
+
@@ -250,8 +251,8 @@ export function SessionTurn( class={props.classes?.container} style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }} > - {/* Sticky Header */} -
+ {/* Title (sticky) */} +
@@ -264,29 +265,31 @@ export function SessionTurn(
-
- -
-
- -
+
+ {/* User Message (non-sticky, scrolls under sticky header) */} +
+ +
+ {/* Trigger (sticky) */} +
+
{/* Response */} From a6e297baadc6c19a566a69da92c7fb2010f40977 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 13 Dec 2025 06:51:24 -0600 Subject: [PATCH 016/575] feat(desktop): message history --- .../desktop/src/components/prompt-input.tsx | 161 ++++++++++++++++++ packages/ui/src/components/button.css | 2 +- packages/ui/src/components/session-turn.tsx | 3 +- packages/ui/src/styles/animations.css | 4 +- 4 files changed, 165 insertions(+), 5 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 70ee0a739..ec8267bf7 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -13,6 +13,7 @@ import { createMemo, } from "solid-js" import { createStore } from "solid-js/store" +import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" @@ -85,6 +86,69 @@ export const PromptInput: Component = (props) => { popoverIsOpen: false, }) + const MAX_HISTORY = 100 + const [history, setHistory] = makePersisted( + createStore<{ + entries: Prompt[] + }>({ + entries: [], + }), + { + name: "prompt-history.v1", + }, + ) + const [historyIndex, setHistoryIndex] = createSignal(-1) + const [savedPrompt, setSavedPrompt] = createSignal(null) + + const clonePromptParts = (prompt: Prompt): Prompt => + prompt.map((part) => + part.type === "text" + ? { ...part } + : { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + }, + ) + + const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + + const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => { + const length = position === "start" ? 0 : promptLength(prompt) + session.prompt.set(prompt, length) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, length) + }) + } + + const getCaretLineState = () => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false } + const range = selection.getRangeAt(0) + const rect = range.getBoundingClientRect() + const editorRect = editorRef.getBoundingClientRect() + const style = window.getComputedStyle(editorRef) + const paddingTop = parseFloat(style.paddingTop) || 0 + const paddingBottom = parseFloat(style.paddingBottom) || 0 + let lineHeight = parseFloat(style.lineHeight) + if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16 + const scrollTop = editorRef.scrollTop + let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop + if (!Number.isFinite(relativeTop)) relativeTop = scrollTop + relativeTop = Math.max(0, relativeTop) + let caretHeight = rect.height + if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight + const relativeBottom = relativeTop + caretHeight + const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom) + const threshold = Math.max(2, lineHeight / 2) + + return { + collapsed: selection.isCollapsed, + onFirstLine: relativeTop <= threshold, + onLastLine: contentHeight - relativeBottom <= threshold, + } + } + const [placeholder, setPlaceholder] = createSignal(Math.floor(Math.random() * PLACEHOLDERS.length)) onMount(() => { @@ -221,6 +285,11 @@ export const PromptInput: Component = (props) => { setStore("popoverIsOpen", false) } + if (historyIndex() >= 0) { + setHistoryIndex(-1) + setSavedPrompt(null) + } + session.prompt.set(rawParts, cursorPosition) } @@ -296,12 +365,100 @@ export const PromptInput: Component = (props) => { sessionID: session.id!, }) + const addToHistory = (prompt: Prompt) => { + const text = prompt + .map((p) => p.content) + .join("") + .trim() + if (!text) return + + const entry = clonePromptParts(prompt) + const lastEntry = history.entries[0] + if (lastEntry) { + const lastText = lastEntry.map((p) => p.content).join("") + if (lastText === text) return + } + + setHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY)) + } + + const navigateHistory = (direction: "up" | "down") => { + const entries = history.entries + const current = historyIndex() + + if (direction === "up") { + if (entries.length === 0) return false + if (current === -1) { + setSavedPrompt(clonePromptParts(session.prompt.current())) + setHistoryIndex(0) + applyHistoryPrompt(entries[0], "start") + return true + } + if (current < entries.length - 1) { + const next = current + 1 + setHistoryIndex(next) + applyHistoryPrompt(entries[next], "start") + return true + } + return false + } + + if (current > 0) { + const next = current - 1 + setHistoryIndex(next) + applyHistoryPrompt(entries[next], "end") + return true + } + if (current === 0) { + setHistoryIndex(-1) + const saved = savedPrompt() + if (saved) { + applyHistoryPrompt(saved, "end") + setSavedPrompt(null) + return true + } + applyHistoryPrompt(DEFAULT_PROMPT, "end") + return true + } + + return false + } + const handleKeyDown = (event: KeyboardEvent) => { if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { onKeyDown(event) event.preventDefault() return } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + const { collapsed, onFirstLine, onLastLine } = getCaretLineState() + if (!collapsed) return + const cursorPos = getCursorPosition(editorRef) + const textLength = promptLength(session.prompt.current()) + const inHistory = historyIndex() >= 0 + const isStart = cursorPos === 0 + const isEnd = cursorPos === textLength + const atAbsoluteStart = onFirstLine && isStart + const atAbsoluteEnd = onLastLine && isEnd + const allowUp = (inHistory && isEnd) || atAbsoluteStart + const allowDown = (inHistory && isStart) || atAbsoluteEnd + + if (event.key === "ArrowUp") { + if (!allowUp) return + if (navigateHistory("up")) { + event.preventDefault() + } + return + } + + if (!allowDown) return + if (navigateHistory("down")) { + event.preventDefault() + } + return + } + if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } @@ -323,6 +480,10 @@ export const PromptInput: Component = (props) => { return } + addToHistory(prompt) + setHistoryIndex(-1) + setSavedPrompt(null) + let existing = session.info() if (!existing) { const created = await sdk.client.session.create() diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index c5bd2c696..7aba89b03 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -148,7 +148,7 @@ padding: 0 12px 0 8px; } - gap: 4px; + gap: 8px; /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 361a5cac0..07946ed79 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -81,7 +81,6 @@ export function SessionTurn( createResizeObserver(contentRef, () => { if (!scrollRef || userScrolled() || !working()) return requestAnimationFrame(() => { - if (!scrollRef) return scrollRef.scrollTop = scrollRef.scrollHeight }) }) @@ -266,7 +265,7 @@ export function SessionTurn(
- {/* User Message (non-sticky, scrolls under sticky header) */} + {/* User Message */}
diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index 0ae3493eb..3480976dd 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -5,7 +5,7 @@ @keyframes pulse-opacity { 0%, 100% { - opacity: 0; + opacity: 0.4; } 50% { opacity: 1; @@ -18,7 +18,7 @@ opacity: 0; } 50% { - opacity: 0.3; + opacity: 0.2; } } From d0789632b4cf8d29d67faf652d955868fc447208 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 13 Dec 2025 15:12:32 -0600 Subject: [PATCH 017/575] fix(desktop): terminal light mode --- packages/desktop/src/components/terminal.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx index 15302f152..865d9b30f 100644 --- a/packages/desktop/src/components/terminal.tsx +++ b/packages/desktop/src/components/terminal.tsx @@ -1,8 +1,9 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" -import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js" +import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" import { LocalPTY } from "@/context/session" +import { usePrefersDark } from "@solid-primitives/media" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY @@ -21,6 +22,7 @@ export const Terminal = (props: TerminalProps) => { let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void + const prefersDark = usePrefersDark() onMount(async () => { ghostty = await Ghostty.load() @@ -31,10 +33,17 @@ export const Terminal = (props: TerminalProps) => { fontSize: 14, fontFamily: "TX-02, monospace", allowTransparency: true, - theme: { - background: "#191515", - foreground: "#d4d4d4", - }, + theme: prefersDark() + ? { + background: "#191515", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + } + : { + background: "#fcfcfc", + foreground: "#211e1e", + cursor: "#211e1e", + }, scrollback: 10_000, ghostty, }) From 5bcc93851c59829c00c5801c039170cc6e1f3d7a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 13 Dec 2025 15:12:41 -0600 Subject: [PATCH 018/575] chore: cleanup --- bun.lock | 1 + packages/desktop/package.json | 1 + packages/ui/src/components/session-turn.tsx | 73 ++++++++++++--------- 3 files changed, 44 insertions(+), 31 deletions(-) diff --git a/bun.lock b/bun.lock index d6bcc4e4d..096d45aeb 100644 --- a/bun.lock +++ b/bun.lock @@ -133,6 +133,7 @@ "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "4.3.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 91e04af08..6753308c9 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -37,6 +37,7 @@ "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "4.3.3", diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 07946ed79..692e869fe 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,7 +3,7 @@ import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" -import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" @@ -49,56 +49,67 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - const [contentRef, setContentRef] = createSignal() - const [stickyTitleRef, setStickyTitleRef] = createSignal() - const [stickyTriggerRef, setStickyTriggerRef] = createSignal() - const [userScrolled, setUserScrolled] = createSignal(false) - const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0) - const [scrollY, setScrollY] = createSignal(0) + const [state, setState] = createStore({ + contentRef: undefined as HTMLDivElement | undefined, + stickyTitleRef: undefined as HTMLDivElement | undefined, + stickyTriggerRef: undefined as HTMLDivElement | undefined, + userScrolled: false, + stickyHeaderHeight: 0, + scrollY: 0, + }) function handleScroll() { if (!scrollRef) return - setScrollY(scrollRef.scrollTop) + setState("scrollY", scrollRef.scrollTop) const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setUserScrolled(true) + setState("userScrolled", true) } } function handleInteraction() { if (working()) { - setUserScrolled(true) + setState("userScrolled", true) } } createEffect(() => { if (!working()) { - setUserScrolled(false) + setState("userScrolled", false) } }) - createResizeObserver(contentRef, () => { - if (!scrollRef || userScrolled() || !working()) return - requestAnimationFrame(() => { - scrollRef.scrollTop = scrollRef.scrollHeight - }) - }) + createResizeObserver( + () => state.contentRef, + () => { + if (!scrollRef || state.userScrolled || !working()) return + requestAnimationFrame(() => { + scrollRef.scrollTop = scrollRef.scrollHeight + }) + }, + ) - createResizeObserver(stickyTitleRef, ({ height }) => { - const triggerHeight = stickyTriggerRef()?.offsetHeight ?? 0 - setStickyHeaderHeight(height + triggerHeight + 8) - }) + createResizeObserver( + () => state.stickyTitleRef, + ({ height }) => { + const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", height + triggerHeight + 8) + }, + ) - createResizeObserver(stickyTriggerRef, ({ height }) => { - const titleHeight = stickyTitleRef()?.offsetHeight ?? 0 - setStickyHeaderHeight(titleHeight + height + 8) - }) + createResizeObserver( + () => state.stickyTriggerRef, + ({ height }) => { + const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", titleHeight + height + 8) + }, + ) return ( -
+
-
+
setState("contentRef", el)} onClick={handleInteraction}> {(message) => { const assistantMessages = createMemo(() => { @@ -237,7 +248,7 @@ export function SessionTurn( createEffect((prev) => { const isWorking = working() - if (prev && !isWorking && !userScrolled()) { + if (prev && !isWorking && !state.userScrolled) { setStore("stepsExpanded", false) } return isWorking @@ -248,10 +259,10 @@ export function SessionTurn( data-message={message().id} data-slot="session-turn-message-container" class={props.classes?.container} - style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }} + style={{ "--sticky-header-height": `${state.stickyHeaderHeight}px` }} > {/* Title (sticky) */} -
+
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
@@ -270,7 +281,7 @@ export function SessionTurn(
{/* Trigger (sticky) */} -
+
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
@@ -154,7 +146,7 @@ export default function Download() { Windows (x64)
- + Download
@@ -170,7 +162,7 @@ export default function Download() { Linux (.deb)
- + Download
@@ -186,7 +178,7 @@ export default function Download() { Linux (.rpm)
- + Download
From 7368342bab50f1ffc71be59a54348b8246893141 Mon Sep 17 00:00:00 2001 From: Martijn Baay Date: Mon, 15 Dec 2025 00:13:32 +0100 Subject: [PATCH 054/575] feat: add experimental.continue_loop_on_deny config option (#4729) Co-authored-by: Aiden Cline --- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/session/processor.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 42f6b11e9..333e19848 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -783,6 +783,7 @@ export namespace Config { .array(z.string()) .optional() .describe("Tools that should only be available to primary agents."), + continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"), }) .optional(), }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f1f7dd096..a1244d1df 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -12,6 +12,7 @@ import { SessionRetry } from "./retry" import { SessionStatus } from "./status" import { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" +import { Config } from "@/config/config" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -49,6 +50,7 @@ export namespace SessionProcessor { }, async process(streamInput: StreamInput) { log.info("process") + const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true while (true) { try { let currentText: MessageV2.TextPart | undefined @@ -228,7 +230,7 @@ export namespace SessionProcessor { }) if (value.error instanceof Permission.RejectedError) { - blocked = true + blocked = shouldBreak } delete toolcalls[value.toolCallId] } From fc3ffb2bf9d194d05803aeae3fb766fe74bc4bf8 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 14 Dec 2025 23:14:05 +0000 Subject: [PATCH 055/575] chore: format code --- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ packages/sdk/openapi.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9d0bbcc92..9dc057ba5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1518,6 +1518,10 @@ export type Config = { * Tools that should only be available to primary agents. */ primary_tools?: Array + /** + * Continue the agent loop when a tool call is denied + */ + continue_loop_on_deny?: boolean } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 98c8b3586..372b0a63d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8279,6 +8279,10 @@ "items": { "type": "string" } + }, + "continue_loop_on_deny": { + "description": "Continue the agent loop when a tool call is denied", + "type": "boolean" } } } From fdf560c3434032dee4e078548019049813239cc5 Mon Sep 17 00:00:00 2001 From: Ravi Kumar <82090231+Raviguntakala@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:20:54 +0530 Subject: [PATCH 056/575] fix(tui): --continue selects wrong session (#5513) --- packages/opencode/src/cli/cmd/tui/app.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 18a342818..69db202ee 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -218,7 +218,9 @@ function App() { let continued = false createEffect(() => { if (continued || sync.status !== "complete" || !args.continue) return - const match = sync.data.session.find((x) => x.parentID === undefined)?.id + const match = sync.data.session + .toSorted((a, b) => b.time.updated - a.time.updated) + .find((x) => x.parentID === undefined)?.id if (match) { continued = true route.navigate({ type: "session", sessionID: match }) From fed4776451eab0bd2490f2438094f0919e4fb677 Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 14 Dec 2025 21:11:30 -0500 Subject: [PATCH 057/575] LLM cleanup (#5462) Co-authored-by: GitHub Action Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- .opencode/opencode.jsonc | 1 + packages/desktop/src/context/local.tsx | 2 +- packages/opencode/src/agent/agent.ts | 74 ++-- .../{session => agent}/prompt/compaction.txt | 0 .../opencode/src/agent/prompt/explore.txt | 18 + .../prompt/summary.txt} | 0 .../src/{session => agent}/prompt/title.txt | 4 +- packages/opencode/src/cli/cmd/agent.ts | 4 +- .../cli/cmd/tui/component/dialog-agent.tsx | 2 +- .../cmd/tui/component/prompt/autocomplete.tsx | 2 +- .../src/cli/cmd/tui/context/local.tsx | 2 +- packages/opencode/src/provider/provider.ts | 2 +- packages/opencode/src/session/compaction.ts | 96 ++--- packages/opencode/src/session/llm.ts | 184 ++++++++++ packages/opencode/src/session/message-v2.ts | 20 +- packages/opencode/src/session/processor.ts | 15 +- packages/opencode/src/session/prompt.ts | 339 ++++-------------- packages/opencode/src/session/summary.ts | 92 ++--- packages/opencode/src/session/system.ts | 30 +- packages/opencode/src/tool/bash.ts | 1 - packages/opencode/src/tool/registry.ts | 14 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 8 +- packages/sdk/js/src/v2/gen/types.gen.ts | 80 +++-- packages/sdk/openapi.json | 167 ++++----- 24 files changed, 548 insertions(+), 609 deletions(-) rename packages/opencode/src/{session => agent}/prompt/compaction.txt (100%) create mode 100644 packages/opencode/src/agent/prompt/explore.txt rename packages/opencode/src/{session/prompt/summarize.txt => agent/prompt/summary.txt} (100%) rename packages/opencode/src/{session => agent}/prompt/title.txt (84%) create mode 100644 packages/opencode/src/session/llm.ts diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index fe70e35fa..d5d97f4c9 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -10,4 +10,5 @@ "options": {}, }, }, + "mcp": {}, } diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 39fd1f987..181a4d247 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -78,7 +78,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const agent = (() => { - const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) + const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const [store, setStore] = createStore<{ current: string }>({ diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 94127e51c..ef007df13 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -2,18 +2,24 @@ import { Config } from "../config/config" import z from "zod" import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" -import PROMPT_GENERATE from "./generate.txt" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" import { mergeDeep } from "remeda" +import PROMPT_GENERATE from "./generate.txt" +import PROMPT_COMPACTION from "./prompt/compaction.txt" +import PROMPT_EXPLORE from "./prompt/explore.txt" +import PROMPT_SUMMARY from "./prompt/summary.txt" +import PROMPT_TITLE from "./prompt/title.txt" + export namespace Agent { export const Info = z .object({ name: z.string(), description: z.string().optional(), mode: z.enum(["subagent", "primary", "all"]), - builtIn: z.boolean(), + native: z.boolean().optional(), + hidden: z.boolean().optional(), topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), @@ -112,7 +118,8 @@ export namespace Agent { options: {}, permission: agentPermission, mode: "subagent", - builtIn: true, + native: true, + hidden: true, }, explore: { name: "explore", @@ -124,30 +131,23 @@ export namespace Agent { ...defaultTools, }, description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, - prompt: [ - `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.`, - ``, - `Your strengths:`, - `- Rapidly finding files using glob patterns`, - `- Searching code and text with powerful regex patterns`, - `- Reading and analyzing file contents`, - ``, - `Guidelines:`, - `- Use Glob for broad file pattern matching`, - `- Use Grep for searching file contents with regex`, - `- Use Read when you know the specific file path you need to read`, - `- Use Bash for file operations like copying, moving, or listing directory contents`, - `- Adapt your search approach based on the thoroughness level specified by the caller`, - `- Return file paths as absolute paths in your final response`, - `- For clear communication, avoid using emojis`, - `- Do not create any files, or run bash commands that modify the user's system state in any way`, - ``, - `Complete the user's search request efficiently and report your findings clearly.`, - ].join("\n"), + prompt: PROMPT_EXPLORE, options: {}, permission: agentPermission, mode: "subagent", - builtIn: true, + native: true, + }, + compaction: { + name: "compaction", + mode: "primary", + native: true, + hidden: true, + prompt: PROMPT_COMPACTION, + tools: { + "*": false, + }, + options: {}, + permission: agentPermission, }, build: { name: "build", @@ -155,7 +155,27 @@ export namespace Agent { options: {}, permission: agentPermission, mode: "primary", - builtIn: true, + native: true, + }, + title: { + name: "title", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: agentPermission, + prompt: PROMPT_TITLE, + tools: {}, + }, + summary: { + name: "summary", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: agentPermission, + prompt: PROMPT_SUMMARY, + tools: {}, }, plan: { name: "plan", @@ -165,7 +185,7 @@ export namespace Agent { ...defaultTools, }, mode: "primary", - builtIn: true, + native: true, }, } for (const [key, value] of Object.entries(cfg.agent ?? {})) { @@ -181,7 +201,7 @@ export namespace Agent { permission: agentPermission, options: {}, tools: {}, - builtIn: false, + native: false, } const { name, diff --git a/packages/opencode/src/session/prompt/compaction.txt b/packages/opencode/src/agent/prompt/compaction.txt similarity index 100% rename from packages/opencode/src/session/prompt/compaction.txt rename to packages/opencode/src/agent/prompt/compaction.txt diff --git a/packages/opencode/src/agent/prompt/explore.txt b/packages/opencode/src/agent/prompt/explore.txt new file mode 100644 index 000000000..5761077cb --- /dev/null +++ b/packages/opencode/src/agent/prompt/explore.txt @@ -0,0 +1,18 @@ +You are a file search specialist. You excel at thoroughly navigating and exploring codebases. + +Your strengths: +- Rapidly finding files using glob patterns +- Searching code and text with powerful regex patterns +- Reading and analyzing file contents + +Guidelines: +- Use Glob for broad file pattern matching +- Use Grep for searching file contents with regex +- Use Read when you know the specific file path you need to read +- Use Bash for file operations like copying, moving, or listing directory contents +- Adapt your search approach based on the thoroughness level specified by the caller +- Return file paths as absolute paths in your final response +- For clear communication, avoid using emojis +- Do not create any files, or run bash commands that modify the user's system state in any way + +Complete the user's search request efficiently and report your findings clearly. diff --git a/packages/opencode/src/session/prompt/summarize.txt b/packages/opencode/src/agent/prompt/summary.txt similarity index 100% rename from packages/opencode/src/session/prompt/summarize.txt rename to packages/opencode/src/agent/prompt/summary.txt diff --git a/packages/opencode/src/session/prompt/title.txt b/packages/opencode/src/agent/prompt/title.txt similarity index 84% rename from packages/opencode/src/session/prompt/title.txt rename to packages/opencode/src/agent/prompt/title.txt index e297dc460..f67aaa95b 100644 --- a/packages/opencode/src/session/prompt/title.txt +++ b/packages/opencode/src/agent/prompt/title.txt @@ -22,8 +22,8 @@ Your output must be: - The title should NEVER include "summarizing" or "generating" when generating a title - DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT - Always output something meaningful, even if the input is minimal. -- If the user message is short or conversational (e.g. “hello”, “lol”, “whats up”, “hey”): - → create a title that reflects the user’s tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) +- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"): + → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 812e97423..2cbcfbfe9 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -227,8 +227,8 @@ const AgentListCommand = cmd({ async fn() { const agents = await Agent.list() const sortedAgents = agents.sort((a, b) => { - if (a.builtIn !== b.builtIn) { - return a.builtIn ? -1 : 1 + if (a.native !== b.native) { + return a.native ? -1 : 1 } return a.name.localeCompare(b.name) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx index 65aaeb22b..365a22445 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx @@ -12,7 +12,7 @@ export function DialogAgent() { return { value: item.name, title: item.name, - description: item.builtIn ? "native" : item.description, + description: item.native ? "native" : item.description, } }), ) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index c40aa114a..37e6ccda5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -184,7 +184,7 @@ export function Autocomplete(props: { const agents = createMemo(() => { const agents = sync.data.agent return agents - .filter((agent) => !agent.builtIn && agent.mode !== "primary") + .filter((agent) => !agent.hidden && agent.mode !== "primary") .map( (agent): AutocompleteOption => ({ display: "@" + agent.name, diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 6cc97e041..f04b79685 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -52,7 +52,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const agent = iife(() => { - const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) + const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const [agentStore, setAgentStore] = createStore<{ current: string }>({ diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 60ce2297b..d4755af17 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -858,7 +858,7 @@ export namespace Provider { return info } - export async function getLanguage(model: Model) { + export async function getLanguage(model: Model): Promise { const s = await state() const key = `${model.providerID}/${model.id}` if (s.models.has(key)) return s.models.get(key)! diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index f9d1b1c04..f8ed149ba 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,22 +1,18 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { wrapLanguageModel, type ModelMessage } from "ai" import { Session } from "." import { Identifier } from "../id/id" import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { MessageV2 } from "./message-v2" -import { SystemPrompt } from "./system" import z from "zod" import { SessionPrompt } from "./prompt" import { Flag } from "../flag/flag" import { Token } from "../util/token" -import { Config } from "../config/config" import { Log } from "../util/log" -import { ProviderTransform } from "@/provider/transform" import { SessionProcessor } from "./processor" import { fn } from "@/util/fn" -import { mergeDeep, pipe } from "remeda" +import { Agent } from "@/agent/agent" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -90,24 +86,21 @@ export namespace SessionCompaction { parentID: string messages: MessageV2.WithParts[] sessionID: string - model: { - providerID: string - modelID: string - } - agent: string abort: AbortSignal auto: boolean }) { - const cfg = await Config.get() - const model = await Provider.getModel(input.model.providerID, input.model.modelID) - const language = await Provider.getLanguage(model) - const system = [...SystemPrompt.compaction(model.providerID)] + const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User + const agent = await Agent.get("compaction") + const model = agent.model + ? await Provider.getModel(agent.model.providerID, agent.model.modelID) + : await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID) const msg = (await Session.updateMessage({ id: Identifier.ascending("message"), role: "assistant", parentID: input.parentID, sessionID: input.sessionID, - mode: input.agent, + mode: "compaction", + agent: "compaction", summary: true, path: { cwd: Instance.directory, @@ -120,7 +113,7 @@ export namespace SessionCompaction { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: input.model.modelID, + modelID: model.id, providerID: model.providerID, time: { created: Date.now(), @@ -129,46 +122,18 @@ export namespace SessionCompaction { const processor = SessionProcessor.create({ assistantMessage: msg, sessionID: input.sessionID, - model: model, + model, abort: input.abort, }) const result = await processor.process({ - onError(error) { - log.error("stream error", { - error, - }) - }, - // set to 0, we handle loop - maxRetries: 0, - providerOptions: ProviderTransform.providerOptions( - model, - pipe({}, mergeDeep(ProviderTransform.options(model, input.sessionID)), mergeDeep(model.options)), - ), - headers: model.headers, - abortSignal: input.abort, - tools: model.capabilities.toolcall ? {} : undefined, + user: userMessage, + agent, + abort: input.abort, + sessionID: input.sessionID, + tools: {}, + system: [], messages: [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...MessageV2.toModelMessage( - input.messages.filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } - - return false - }), - ), + ...MessageV2.toModelMessage(input.messages), { role: "user", content: [ @@ -179,28 +144,9 @@ export namespace SessionCompaction { ], }, ], - model: wrapLanguageModel({ - model: language, - middleware: [ - { - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, model) - } - return args.params - }, - }, - ], - }), - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: input.sessionID, - }, - }, + model, }) + if (result === "continue" && input.auto) { const continueMsg = await Session.updateMessage({ id: Identifier.ascending("message"), @@ -209,8 +155,8 @@ export namespace SessionCompaction { time: { created: Date.now(), }, - agent: input.agent, - model: input.model, + agent: userMessage.agent, + model: userMessage.model, }) await Session.updatePart({ id: Identifier.ascending("part"), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts new file mode 100644 index 000000000..97b8aae2b --- /dev/null +++ b/packages/opencode/src/session/llm.ts @@ -0,0 +1,184 @@ +import { Provider } from "@/provider/provider" +import { Log } from "@/util/log" +import { streamText, wrapLanguageModel, type ModelMessage, type StreamTextResult, type Tool, type ToolSet } from "ai" +import { mergeDeep, pipe } from "remeda" +import { ProviderTransform } from "@/provider/transform" +import { Config } from "@/config/config" +import { Instance } from "@/project/instance" +import type { Agent } from "@/agent/agent" +import type { MessageV2 } from "./message-v2" +import { Plugin } from "@/plugin" +import { SystemPrompt } from "./system" +import { ToolRegistry } from "@/tool/registry" +import { Flag } from "@/flag/flag" + +export namespace LLM { + const log = Log.create({ service: "llm" }) + + export const OUTPUT_TOKEN_MAX = 32_000 + + export type StreamInput = { + user: MessageV2.User + sessionID: string + model: Provider.Model + agent: Agent.Info + system: string[] + abort: AbortSignal + messages: ModelMessage[] + small?: boolean + tools: Record + retries?: number + } + + export type StreamOutput = StreamTextResult + + export async function stream(input: StreamInput) { + const l = log + .clone() + .tag("providerID", input.model.providerID) + .tag("modelID", input.model.id) + .tag("sessionID", input.sessionID) + .tag("small", (input.small ?? false).toString()) + .tag("agent", input.agent.name) + l.info("stream", { + modelID: input.model.id, + providerID: input.model.providerID, + }) + const [language, cfg] = await Promise.all([Provider.getLanguage(input.model), Config.get()]) + + const system = SystemPrompt.header(input.model.providerID) + system.push( + [ + // use agent prompt otherwise provider prompt + ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), + // any custom prompt passed into this call + ...input.system, + // any custom prompt from last user message + ...(input.user.system ? [input.user.system] : []), + ] + .filter((x) => x) + .join("\n"), + ) + + const params = await Plugin.trigger( + "chat.params", + { + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + provider: Provider.getProvider(input.model.providerID), + message: input.user, + }, + { + temperature: input.model.capabilities.temperature + ? (input.agent.temperature ?? ProviderTransform.temperature(input.model)) + : undefined, + topP: input.agent.topP ?? ProviderTransform.topP(input.model), + options: pipe( + {}, + mergeDeep(ProviderTransform.options(input.model, input.sessionID)), + input.small ? mergeDeep(ProviderTransform.smallOptions(input.model)) : mergeDeep({}), + mergeDeep(input.model.options), + mergeDeep(input.agent.options), + ), + }, + ) + + l.info("params", { + params, + }) + + const maxOutputTokens = ProviderTransform.maxOutputTokens( + input.model.api.npm, + params.options, + input.model.limit.output, + OUTPUT_TOKEN_MAX, + ) + + const tools = await resolveTools(input) + + return streamText({ + onError(error) { + l.error("stream error", { + error, + }) + }, + async experimental_repairToolCall(failed) { + const lower = failed.toolCall.toolName.toLowerCase() + if (lower !== failed.toolCall.toolName && tools[lower]) { + l.info("repairing tool call", { + tool: failed.toolCall.toolName, + repaired: lower, + }) + return { + ...failed.toolCall, + toolName: lower, + } + } + return { + ...failed.toolCall, + input: JSON.stringify({ + tool: failed.toolCall.toolName, + error: failed.error.message, + }), + toolName: "invalid", + } + }, + temperature: params.temperature, + topP: params.topP, + providerOptions: ProviderTransform.providerOptions(input.model, params.options), + activeTools: Object.keys(tools).filter((x) => x !== "invalid"), + tools, + maxOutputTokens, + abortSignal: input.abort, + headers: { + ...(input.model.providerID.startsWith("opencode") + ? { + "x-opencode-project": Instance.project.id, + "x-opencode-session": input.sessionID, + "x-opencode-request": input.user.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, + } + : undefined), + ...input.model.headers, + }, + maxRetries: input.retries ?? 0, + messages: [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...input.messages, + ], + model: wrapLanguageModel({ + model: language, + middleware: [ + { + async transformParams(args) { + if (args.type === "stream") { + // @ts-expect-error + args.params.prompt = ProviderTransform.message(args.params.prompt, input.model) + } + return args.params + }, + }, + ], + }), + experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry }, + }) + } + + async function resolveTools(input: Pick) { + const enabled = pipe( + input.agent.tools, + mergeDeep(await ToolRegistry.enabled(input.agent)), + mergeDeep(input.user.tools ?? {}), + ) + for (const [key, value] of Object.entries(enabled)) { + if (value === false) delete input.tools[key] + } + return input.tools + } +} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1f4fffaa6..76162c797 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -348,7 +348,11 @@ export namespace MessageV2 { parentID: z.string(), modelID: z.string(), providerID: z.string(), + /** + * @deprecated + */ mode: z.string(), + agent: z.string(), path: z.object({ cwd: z.string(), root: z.string(), @@ -412,12 +416,7 @@ export namespace MessageV2 { }) export type WithParts = z.infer - export function toModelMessage( - input: { - info: Info - parts: Part[] - }[], - ): ModelMessage[] { + export function toModelMessage(input: WithParts[]): ModelMessage[] { const result: UIMessage[] = [] for (const msg of input) { @@ -461,6 +460,15 @@ export namespace MessageV2 { } if (msg.info.role === "assistant") { + if ( + msg.info.error && + !( + MessageV2.AbortedError.isInstance(msg.info.error) && + msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) + ) { + continue + } const assistantMessage: UIMessage = { id: msg.info.id, role: "assistant", diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index a1244d1df..1d4d24303 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,5 +1,4 @@ import { MessageV2 } from "./message-v2" -import { streamText } from "ai" import { Log } from "@/util/log" import { Identifier } from "@/id/id" import { Session } from "." @@ -12,6 +11,7 @@ import { SessionRetry } from "./retry" import { SessionStatus } from "./status" import { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" +import { LLM } from "./llm" import { Config } from "@/config/config" export namespace SessionProcessor { @@ -21,15 +21,6 @@ export namespace SessionProcessor { export type Info = Awaited> export type Result = Awaited> - export type StreamInput = Parameters[0] - - export type TBD = { - model: { - modelID: string - providerID: string - } - } - export function create(input: { assistantMessage: MessageV2.Assistant sessionID: string @@ -48,14 +39,14 @@ export namespace SessionProcessor { partFromToolCall(toolCallID: string) { return toolcalls[toolCallID] }, - async process(streamInput: StreamInput) { + async process(streamInput: LLM.StreamInput) { log.info("process") const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true while (true) { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} - const stream = streamText(streamInput) + const stream = await LLM.stream(streamInput) for await (const value of stream.fullStream) { input.abort.throwIfAborted() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 51fb49af4..9a36c5c62 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -5,32 +5,22 @@ import z from "zod" import { Identifier } from "../id/id" import { MessageV2 } from "./message-v2" import { Log } from "../util/log" -import { Flag } from "../flag/flag" import { SessionRevert } from "./revert" import { Session } from "." import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" -import { - generateText, - type ModelMessage, - type Tool as AITool, - tool, - wrapLanguageModel, - stepCountIs, - jsonSchema, -} from "ai" +import { type Tool as AITool, tool, jsonSchema } from "ai" import { SessionCompaction } from "./compaction" import { Instance } from "../project/instance" import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" import { Plugin } from "../plugin" - import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { defer } from "../util/defer" -import { clone, mergeDeep, pipe } from "remeda" +import { mergeDeep, pipe } from "remeda" import { ToolRegistry } from "../tool/registry" import { Wildcard } from "../util/wildcard" import { MCP } from "../mcp" @@ -44,12 +34,13 @@ import { Command } from "../command" import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" -import { Config } from "../config/config" import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { SessionStatus } from "./status" +import { LLM } from "./llm" +import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" // @ts-ignore @@ -96,8 +87,8 @@ export namespace SessionPrompt { .optional(), agent: z.string().optional(), noReply: z.boolean().optional(), - system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + system: z.string().optional(), parts: z.array( z.discriminatedUnion("type", [ MessageV2.TextPart.omit({ @@ -145,6 +136,20 @@ export namespace SessionPrompt { }) export type PromptInput = z.infer + export const prompt = fn(PromptInput, async (input) => { + const session = await Session.get(input.sessionID) + await SessionRevert.cleanup(session) + + const message = await createUserMessage(input) + await Session.touch(input.sessionID) + + if (input.noReply === true) { + return message + } + + return loop(input.sessionID) + }) + export async function resolvePromptParts(template: string): Promise { const parts: PromptInput["parts"] = [ { @@ -196,20 +201,6 @@ export namespace SessionPrompt { return parts } - export const prompt = fn(PromptInput, async (input) => { - const session = await Session.get(input.sessionID) - await SessionRevert.cleanup(session) - - const message = await createUserMessage(input) - await Session.touch(input.sessionID) - - if (input.noReply === true) { - return message - } - - return loop(input.sessionID) - }) - function start(sessionID: string) { const s = state() if (s[sessionID]) return @@ -291,7 +282,6 @@ export namespace SessionPrompt { }) const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) - const language = await Provider.getLanguage(model) const task = tasks.pop() // pending subtask @@ -304,6 +294,7 @@ export namespace SessionPrompt { parentID: lastUser.id, sessionID, mode: task.agent, + agent: task.agent, path: { cwd: Instance.directory, root: Instance.worktree, @@ -414,11 +405,6 @@ export namespace SessionPrompt { messages: msgs, parentID: lastUser.id, abort, - agent: lastUser.agent, - model: { - providerID: model.providerID, - modelID: model.id, - }, sessionID, auto: task.auto, }) @@ -442,7 +428,6 @@ export namespace SessionPrompt { } // normal processing - const cfg = await Config.get() const agent = await Agent.get(lastUser.agent) const maxSteps = agent.maxSteps ?? Infinity const isLastStep = step >= maxSteps @@ -450,12 +435,14 @@ export namespace SessionPrompt { messages: msgs, agent, }) + const processor = SessionProcessor.create({ assistantMessage: (await Session.updateMessage({ id: Identifier.ascending("message"), parentID: lastUser.id, role: "assistant", mode: agent.name, + agent: agent.name, path: { cwd: Instance.directory, root: Instance.worktree, @@ -478,12 +465,6 @@ export namespace SessionPrompt { model, abort, }) - const system = await resolveSystemPrompt({ - model, - agent, - system: lastUser.system, - isLastStep, - }) const tools = await resolveTools({ agent, sessionID, @@ -491,30 +472,6 @@ export namespace SessionPrompt { tools: lastUser.tools, processor, }) - const provider = await Provider.getProvider(model.providerID) - const params = await Plugin.trigger( - "chat.params", - { - sessionID: sessionID, - agent: lastUser.agent, - model: model, - provider, - message: lastUser, - }, - { - temperature: model.capabilities.temperature - ? (agent.temperature ?? ProviderTransform.temperature(model)) - : undefined, - topP: agent.topP ?? ProviderTransform.topP(model), - topK: ProviderTransform.topK(model), - options: pipe( - {}, - mergeDeep(ProviderTransform.options(model, sessionID, provider?.options)), - mergeDeep(model.options), - mergeDeep(agent.options), - ), - }, - ) if (step === 1) { SessionSummary.summarize({ @@ -523,135 +480,25 @@ export namespace SessionPrompt { }) } - // Deep copy message history so that modifications made by plugins do not - // affect the original messages - const sessionMessages = clone( - msgs.filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } - return false - }), - ) - - await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) - - const messages: ModelMessage[] = [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...MessageV2.toModelMessage(sessionMessages), - ...(isLastStep - ? [ - { - role: "assistant" as const, - content: MAX_STEPS, - }, - ] - : []), - ] - const result = await processor.process({ - onError(error) { - log.error("stream error", { - error, - }) - }, - async experimental_repairToolCall(input) { - const lower = input.toolCall.toolName.toLowerCase() - if (lower !== input.toolCall.toolName && tools[lower]) { - log.info("repairing tool call", { - tool: input.toolCall.toolName, - repaired: lower, - }) - return { - ...input.toolCall, - toolName: lower, - } - } - return { - ...input.toolCall, - input: JSON.stringify({ - tool: input.toolCall.toolName, - error: input.error.message, - }), - toolName: "invalid", - } - }, - headers: { - ...(model.providerID.startsWith("opencode") - ? { - "x-opencode-project": Instance.project.id, - "x-opencode-session": sessionID, - "x-opencode-request": lastUser.id, - "x-opencode-client": Flag.OPENCODE_CLIENT, - } - : undefined), - ...model.headers, - }, - // set to 0, we handle loop - maxRetries: 0, - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), - maxOutputTokens: ProviderTransform.maxOutputTokens( - model.api.npm, - params.options, - model.limit.output, - OUTPUT_TOKEN_MAX, - ), - abortSignal: abort, - providerOptions: ProviderTransform.providerOptions(model, params.options), - stopWhen: stepCountIs(1), - temperature: params.temperature, - topP: params.topP, - topK: params.topK, - toolChoice: isLastStep ? "none" : undefined, - messages, - tools: model.capabilities.toolcall === false ? undefined : tools, - model: wrapLanguageModel({ - model: language, - middleware: [ - { - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - prompt types are compatible at runtime - args.params.prompt = ProviderTransform.message(args.params.prompt, model) - } - // Transform tool schemas for provider compatibility - if (args.params.tools && Array.isArray(args.params.tools)) { - args.params.tools = args.params.tools.map((tool: any) => { - // Tools at middleware level have inputSchema, not parameters - if (tool.inputSchema && typeof tool.inputSchema === "object") { - // Transform the inputSchema for provider compatibility - return { - ...tool, - inputSchema: ProviderTransform.schema(model, tool.inputSchema), - } - } - // If no inputSchema, return tool unchanged - return tool - }) - } - return args.params - }, - }, - ], - }), - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: sessionID, - }, - }, + user: lastUser, + agent, + abort, + sessionID, + system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())], + messages: [ + ...MessageV2.toModelMessage(msgs), + ...(isLastStep + ? [ + { + role: "assistant" as const, + content: MAX_STEPS, + }, + ] + : []), + ], + tools, + model, }) if (result === "stop") break continue @@ -675,33 +522,6 @@ export namespace SessionPrompt { return Provider.defaultModel() } - async function resolveSystemPrompt(input: { - system?: string - agent: Agent.Info - model: Provider.Model - isLastStep?: boolean - }) { - let system = SystemPrompt.header(input.model.providerID) - system.push( - ...(() => { - if (input.system) return [input.system] - if (input.agent.prompt) return [input.agent.prompt] - return SystemPrompt.provider(input.model) - })(), - ) - system.push(...(await SystemPrompt.environment())) - system.push(...(await SystemPrompt.custom())) - - if (input.isLastStep) { - system.push(MAX_STEPS) - } - - // max 2 system prompt messages for caching purposes - const [first, ...rest] = system - system = [first, rest.join("\n")] - return system - } - async function resolveTools(input: { agent: Agent.Info model: Provider.Model @@ -709,6 +529,7 @@ export namespace SessionPrompt { tools?: Record processor: SessionProcessor.Info }) { + using _ = log.time("resolveTools") const tools: Record = {} const enabledTools = pipe( input.agent.tools, @@ -778,7 +599,6 @@ export namespace SessionPrompt { }, }) } - for (const [key, item] of Object.entries(await MCP.tools())) { if (Wildcard.all(key, enabledTools) === false) continue const execute = item.execute @@ -857,7 +677,6 @@ export namespace SessionPrompt { created: Date.now(), }, tools: input.tools, - system: input.system, agent: agent.name, model: input.model ?? agent.model ?? (await lastModel(input.sessionID)), } @@ -1148,7 +967,7 @@ export namespace SessionPrompt { synthetic: true, }) } - const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.mode === "plan") + const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan") if (wasPlan && input.agent.name === "build") { userMessage.parts.push({ id: Identifier.ascending("part"), @@ -1216,6 +1035,7 @@ export namespace SessionPrompt { sessionID: input.sessionID, parentID: userMsg.id, mode: input.agent, + agent: input.agent, cost: 0, path: { cwd: Instance.directory, @@ -1510,28 +1330,24 @@ export namespace SessionPrompt { input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)) .length === 1 if (!isFirst) return - const cfg = await Config.get() - const small = - (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) - const language = await Provider.getLanguage(small) - const provider = await Provider.getProvider(small.providerID) - const options = pipe( - {}, - mergeDeep(ProviderTransform.options(small, input.session.id, provider?.options)), - mergeDeep(ProviderTransform.smallOptions(small)), - mergeDeep(small.options), - ) - await generateText({ - // use higher # for reasoning models since reasoning tokens eat up a lot of the budget - maxOutputTokens: small.capabilities.reasoning ? 3000 : 20, - providerOptions: ProviderTransform.providerOptions(small, options), + const agent = await Agent.get("title") + if (!agent) return + const result = await LLM.stream({ + agent, + user: input.message.info as MessageV2.User, + system: [], + small: true, + tools: {}, + model: await iife(async () => { + if (agent.model) return await Provider.getModel(agent.model.providerID, agent.model.modelID) + return ( + (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) + ) + }), + abort: new AbortController().signal, + sessionID: input.session.id, + retries: 2, messages: [ - ...SystemPrompt.title(small.providerID).map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), { role: "user", content: "Generate a title for this conversation:\n", @@ -1555,32 +1371,19 @@ export namespace SessionPrompt { }, ]), ], - headers: small.headers, - model: language, - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: input.session.id, - }, - }, }) - .then((result) => { - if (result.text) - return Session.update(input.session.id, (draft) => { - const cleaned = result.text - .replace(/[\s\S]*?<\/think>\s*/g, "") - .split("\n") - .map((line) => line.trim()) - .find((line) => line.length > 0) - if (!cleaned) return + const text = await result.text.catch((err) => log.error("failed to generate title", { error: err })) + if (text) + return Session.update(input.session.id, (draft) => { + const cleaned = text + .replace(/[\s\S]*?<\/think>\s*/g, "") + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) + if (!cleaned) return - const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - draft.title = title - }) - }) - .catch((error) => { - log.error("failed to generate title", { error, model: small.id }) + const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned + draft.title = title }) } } diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 4761c9d2f..83519307a 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -1,20 +1,21 @@ import { Provider } from "@/provider/provider" -import { Config } from "@/config/config" + import { fn } from "@/util/fn" import z from "zod" import { Session } from "." -import { generateText, type ModelMessage } from "ai" + import { MessageV2 } from "./message-v2" import { Identifier } from "@/id/id" import { Snapshot } from "@/snapshot" -import { ProviderTransform } from "@/provider/transform" -import { SystemPrompt } from "./system" + import { Log } from "@/util/log" import path from "path" import { Instance } from "@/project/instance" import { Storage } from "@/storage/storage" import { Bus } from "@/bus" -import { mergeDeep, pipe } from "remeda" + +import { LLM } from "./llm" +import { Agent } from "@/agent/agent" export namespace SessionSummary { const log = Log.create({ service: "session.summary" }) @@ -61,7 +62,6 @@ export namespace SessionSummary { } async function summarizeMessage(input: { messageID: string; messages: MessageV2.WithParts[] }) { - const cfg = await Config.get() const messages = input.messages.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), ) @@ -78,27 +78,17 @@ export namespace SessionSummary { const small = (await Provider.getSmallModel(assistantMsg.providerID)) ?? (await Provider.getModel(assistantMsg.providerID, assistantMsg.modelID)) - const language = await Provider.getLanguage(small) - - const options = pipe( - {}, - mergeDeep(ProviderTransform.options(small, assistantMsg.sessionID)), - mergeDeep(ProviderTransform.smallOptions(small)), - mergeDeep(small.options), - ) const textPart = msgWithParts.parts.find((p) => p.type === "text" && !p.synthetic) as MessageV2.TextPart if (textPart && !userMsg.summary?.title) { - const result = await generateText({ - maxOutputTokens: small.capabilities.reasoning ? 1500 : 20, - providerOptions: ProviderTransform.providerOptions(small, options), + const agent = await Agent.get("title") + const stream = await LLM.stream({ + agent, + user: userMsg, + tools: {}, + model: agent.model ? await Provider.getModel(agent.model.providerID, agent.model.modelID) : small, + small: true, messages: [ - ...SystemPrompt.title(small.providerID).map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), { role: "user" as const, content: ` @@ -109,18 +99,14 @@ export namespace SessionSummary { `, }, ], - headers: small.headers, - model: language, - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: assistantMsg.sessionID, - }, - }, + abort: new AbortController().signal, + sessionID: userMsg.sessionID, + system: [], + retries: 3, }) - log.info("title", { title: result.text }) - userMsg.summary.title = result.text + const result = await stream.text + log.info("title", { title: result }) + userMsg.summary.title = result await Session.updateMessage(userMsg) } @@ -138,34 +124,30 @@ export namespace SessionSummary { } } } - const result = await generateText({ - model: language, - maxOutputTokens: 100, - providerOptions: ProviderTransform.providerOptions(small, options), + const summaryAgent = await Agent.get("summary") + const stream = await LLM.stream({ + agent: summaryAgent, + user: userMsg, + tools: {}, + model: summaryAgent.model + ? await Provider.getModel(summaryAgent.model.providerID, summaryAgent.model.modelID) + : small, + small: true, messages: [ - ...SystemPrompt.summarize(small.providerID).map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), ...MessageV2.toModelMessage(messages), { - role: "user", + role: "user" as const, content: `Summarize the above conversation according to your system prompts.`, }, ], - headers: small.headers, - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: assistantMsg.sessionID, - }, - }, - }).catch(() => {}) + abort: new AbortController().signal, + sessionID: userMsg.sessionID, + system: [], + retries: 3, + }) + const result = await stream.text if (result) { - userMsg.summary.body = result.text + userMsg.summary.body = result } } await Session.updateMessage(userMsg) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 3146110cf..e15185b38 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -14,8 +14,7 @@ import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" -import PROMPT_SUMMARIZE from "./prompt/summarize.txt" -import PROMPT_TITLE from "./prompt/title.txt" + import PROMPT_CODEX from "./prompt/codex.txt" import type { Provider } from "@/provider/provider" @@ -118,31 +117,4 @@ export namespace SystemPrompt { ) return Promise.all(found).then((result) => result.filter(Boolean)) } - - export function compaction(providerID: string) { - switch (providerID) { - case "anthropic": - return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_COMPACTION] - default: - return [PROMPT_COMPACTION] - } - } - - export function summarize(providerID: string) { - switch (providerID) { - case "anthropic": - return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_SUMMARIZE] - default: - return [PROMPT_SUMMARIZE] - } - } - - export function title(providerID: string) { - switch (providerID) { - case "anthropic": - return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_TITLE] - default: - return [PROMPT_TITLE] - } - } } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 6b84d1bff..115d8f8b2 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -50,7 +50,6 @@ const parser = lazy(async () => { }) // TODO: we may wanna rename this tool so it works better on other shells - export const BashTool = Tool.define("bash", async () => { const shell = Shell.acceptable() log.info("bash tool using shell", { shell }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 7e440a78a..647c74267 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -21,8 +21,11 @@ import { Plugin } from "../plugin" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" +import { Log } from "@/util/log" export namespace ToolRegistry { + const log = Log.create({ service: "tool.registry" }) + export const state = Instance.state(async () => { const custom = [] as Tool.Info[] const glob = new Bun.Glob("tool/*.{js,ts}") @@ -119,10 +122,13 @@ export namespace ToolRegistry { } return true }) - .map(async (t) => ({ - id: t.id, - ...(await t.init()), - })), + .map(async (t) => { + using _ = log.time(t.id) + return { + id: t.id, + ...(await t.init()), + } + }), ) return result } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 90df76c22..16fe07ae4 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1203,10 +1203,10 @@ export class Session extends HeyApiClient { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts?: Array }, options?: Options, @@ -1222,8 +1222,8 @@ export class Session extends HeyApiClient { { in: "body", key: "model" }, { in: "body", key: "agent" }, { in: "body", key: "noReply" }, - { in: "body", key: "system" }, { in: "body", key: "tools" }, + { in: "body", key: "system" }, { in: "body", key: "parts" }, ], }, @@ -1289,10 +1289,10 @@ export class Session extends HeyApiClient { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts?: Array }, options?: Options, @@ -1308,8 +1308,8 @@ export class Session extends HeyApiClient { { in: "body", key: "model" }, { in: "body", key: "agent" }, { in: "body", key: "noReply" }, - { in: "body", key: "system" }, { in: "body", key: "tools" }, + { in: "body", key: "system" }, { in: "body", key: "parts" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9dc057ba5..31d5b8561 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -147,6 +147,7 @@ export type AssistantMessage = { modelID: string providerID: string mode: string + agent: string path: { cwd: string root: string @@ -475,6 +476,40 @@ export type EventPermissionReplied = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string + /** + * Unique identifier for the todo item + */ + id: string +} + +export type EventTodoUpdated = { + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + export type SessionStatus = | { type: "idle" @@ -511,40 +546,6 @@ export type EventSessionCompacted = { } } -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string - /** - * Unique identifier for the todo item - */ - id: string -} - -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - export type EventCommandExecuted = { type: "command.executed" properties: { @@ -745,11 +746,11 @@ export type Event = | EventMessagePartRemoved | EventPermissionUpdated | EventPermissionReplied + | EventFileEdited + | EventTodoUpdated | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventFileEdited - | EventTodoUpdated | EventCommandExecuted | EventSessionCreated | EventSessionUpdated @@ -1738,7 +1739,8 @@ export type Agent = { name: string description?: string mode: "subagent" | "primary" | "all" - builtIn: boolean + native?: boolean + hidden?: boolean topP?: number temperature?: number color?: string @@ -2801,10 +2803,10 @@ export type SessionPromptData = { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts: Array } path: { @@ -2896,10 +2898,10 @@ export type SessionPromptAsyncData = { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts: Array } path: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 372b0a63d..21928684a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1997,9 +1997,6 @@ "noReply": { "type": "boolean" }, - "system": { - "type": "string" - }, "tools": { "type": "object", "propertyNames": { @@ -2009,6 +2006,9 @@ "type": "boolean" } }, + "system": { + "type": "string" + }, "parts": { "type": "array", "items": { @@ -2202,9 +2202,6 @@ "noReply": { "type": "boolean" }, - "system": { - "type": "string" - }, "tools": { "type": "object", "propertyNames": { @@ -2214,6 +2211,9 @@ "type": "boolean" } }, + "system": { + "type": "string" + }, "parts": { "type": "array", "items": { @@ -5193,6 +5193,9 @@ "mode": { "type": "string" }, + "agent": { + "type": "string" + }, "path": { "type": "object", "properties": { @@ -5251,6 +5254,7 @@ "modelID", "providerID", "mode", + "agent", "path", "cost", "tokens" @@ -6152,6 +6156,72 @@ }, "required": ["type", "properties"] }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Todo": { + "type": "object", + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string" + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string" + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string" + }, + "id": { + "description": "Unique identifier for the todo item", + "type": "string" + } + }, + "required": ["content", "status", "priority", "id"] + }, + "Event.todo.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "todo.updated" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"] + } + }, + "required": ["type", "properties"] + }, "SessionStatus": { "anyOf": [ { @@ -6255,72 +6325,6 @@ }, "required": ["type", "properties"] }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Todo": { - "type": "object", - "properties": { - "content": { - "description": "Brief description of the task", - "type": "string" - }, - "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" - }, - "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - }, - "id": { - "description": "Unique identifier for the todo item", - "type": "string" - } - }, - "required": ["content", "status", "priority", "id"] - }, - "Event.todo.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "todo.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"] - } - }, - "required": ["type", "properties"] - }, "Event.command.executed": { "type": "object", "properties": { @@ -6886,6 +6890,12 @@ { "$ref": "#/components/schemas/Event.permission.replied" }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" + }, { "$ref": "#/components/schemas/Event.session.status" }, @@ -6895,12 +6905,6 @@ { "$ref": "#/components/schemas/Event.session.compacted" }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.todo.updated" - }, { "$ref": "#/components/schemas/Event.command.executed" }, @@ -8920,7 +8924,10 @@ "type": "string", "enum": ["subagent", "primary", "all"] }, - "builtIn": { + "native": { + "type": "boolean" + }, + "hidden": { "type": "boolean" }, "topP": { @@ -9001,7 +9008,7 @@ "maximum": 9007199254740991 } }, - "required": ["name", "mode", "builtIn", "permission", "tools", "options"] + "required": ["name", "mode", "permission", "tools", "options"] }, "MCPStatusConnected": { "type": "object", From 622caae9c99bd182d62ea016fafbacd211e113d6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 14 Dec 2025 21:12:23 -0500 Subject: [PATCH 058/575] fix desktop updater --- packages/tauri/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index a47b4a619..607b94134 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -30,7 +30,7 @@ "plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYwMDM5Nzg5OUMzOUExMDQKUldRRW9UbWNpWmNEOENYT01CV0lhOXR1UFhpaXJsK1Z3aU9lZnNtNzE0TDROWVMwVW9XQnFOelkK", - "endpoints": ["https://github.com/brendonovich/opencode/releases/latest/download/latest.json"] + "endpoints": ["https://github.com/sst/opencode/releases/latest/download/latest.json"] } } } From e22af250760e99c3f4cc88f48ef430e60e43d0a7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 14 Dec 2025 21:23:45 -0500 Subject: [PATCH 059/575] ci: fix test failures in CI by pre-populating models cache Tests were failing in CI because the models.json cache file doesn't exist and the data() macro fallback only works at build time, not runtime. The preload now pre-fetches models.json and disables the background refresh to prevent race conditions during test execution. --- packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/provider/models.ts | 2 ++ packages/opencode/test/preload.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index ca1af6d84..d7a24708a 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -11,6 +11,7 @@ export namespace Flag { export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD") export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS") export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT") + export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH") export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index c523725ec..c58638d28 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -4,6 +4,7 @@ import path from "path" import z from "zod" import { data } from "./models-macro" with { type: "macro" } import { Installation } from "../installation" +import { Flag } from "../flag/flag" export namespace ModelsDev { const log = Log.create({ service: "models.dev" }) @@ -83,6 +84,7 @@ export namespace ModelsDev { } export async function refresh() { + if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return const file = Bun.file(filepath) log.info("refreshing", { file, diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index c2a294ab2..08316a23f 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -11,6 +11,18 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") +// Pre-fetch models.json so tests don't need the macro fallback +// Also write the cache version file to prevent global/index.ts from clearing the cache +const cacheDir = path.join(dir, "cache", "opencode") +await fs.mkdir(cacheDir, { recursive: true }) +await fs.writeFile(path.join(cacheDir, "version"), "14") +const response = await fetch("https://models.dev/api.json") +if (response.ok) { + await fs.writeFile(path.join(cacheDir, "models.json"), await response.text()) +} +// Disable models.dev refresh to avoid race conditions during tests +process.env["OPENCODE_DISABLE_MODELS_FETCH"] = "true" + // Clear provider env vars to ensure clean test state delete process.env["ANTHROPIC_API_KEY"] delete process.env["OPENAI_API_KEY"] From 2d63c22d1a3de36476c2cd8f948b985672374ddd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 14 Dec 2025 22:05:53 -0500 Subject: [PATCH 060/575] fix share link --- .../enterprise/src/routes/share/[shareID].tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 7cce15906..8fe6f0c1b 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -138,18 +138,13 @@ const getData = query(async (shareID) => { export default function () { const params = useParams() - const data = createAsync( - async () => { - if (!params.shareID) throw new Error("Missing shareID") - const now = Date.now() - const data = getData(params.shareID) - console.log("getData", Date.now() - now) - return data - }, - { - deferStream: true, - }, - ) + const data = createAsync(async () => { + if (!params.shareID) throw new Error("Missing shareID") + const now = Date.now() + const data = getData(params.shareID) + console.log("getData", Date.now() - now) + return data + }) createEffect(() => { console.log(data()) From ed33d82535cf23eb48a0c41dce96317aca85ef6e Mon Sep 17 00:00:00 2001 From: Mark Jaquith Date: Sun, 14 Dec 2025 22:06:04 -0500 Subject: [PATCH 061/575] feat(cli): auto-submit prompt when using --prompt flag (#4510) --- .../opencode/src/cli/cmd/tui/component/prompt/index.tsx | 6 +++++- packages/opencode/src/cli/cmd/tui/routes/home.tsx | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 5cc757ac2..784c8648e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -44,6 +44,7 @@ export type PromptRef = { reset(): void blur(): void focus(): void + submit(): void } const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] @@ -447,11 +448,14 @@ export function Prompt(props: PromptProps) { }) setStore("extmarkToPartIndex", new Map()) }, + submit() { + submit() + }, }) async function submit() { if (props.disabled) return - if (autocomplete.visible) return + if (autocomplete?.visible) return if (!store.prompt.input) return const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index d0bb296eb..ecdf93c43 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -57,6 +57,7 @@ export function Home() { } else if (args.prompt) { prompt.set({ input: args.prompt, parts: [] }) once = true + prompt.submit() } }) const directory = useDirectory() From a68bee7878d78ac7d480d6fbbd3225759e695c61 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 03:46:40 -0600 Subject: [PATCH 062/575] fix(desktop): layout fixes --- packages/desktop/src/pages/layout.tsx | 4 ++-- packages/ui/src/components/button.css | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 52a3bd6ab..ef51361ba 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -321,7 +321,7 @@ export default function Layout(props: ParentProps) { } return (
@@ -379,7 +379,7 @@ export default function Layout(props: ParentProps) {
diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 7aba89b03..800795e87 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -9,6 +9,7 @@ user-select: none; cursor: default; outline: none; + white-space: nowrap; &[data-variant="primary"] { background-color: var(--icon-strong-base); From 4a8e8f537ca688cca52674a619065f577cbd3f9b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 05:40:43 -0600 Subject: [PATCH 063/575] wip(desktop): progress --- .../desktop/src/components/dialog-connect.tsx | 406 +++++++++++++++ .../desktop/src/components/dialog-model.tsx | 212 ++++++++ .../src/components/dialog-provider.tsx | 68 +++ .../desktop/src/components/prompt-input.tsx | 208 +------- packages/desktop/src/context/layout.tsx | 9 +- packages/desktop/src/context/local.tsx | 93 +++- packages/desktop/src/pages/layout.tsx | 480 +----------------- packages/opencode/src/provider/provider.ts | 4 + packages/ui/src/components/select-dialog.tsx | 11 +- packages/ui/src/components/session-turn.tsx | 23 +- 10 files changed, 784 insertions(+), 730 deletions(-) create mode 100644 packages/desktop/src/components/dialog-connect.tsx create mode 100644 packages/desktop/src/components/dialog-model.tsx create mode 100644 packages/desktop/src/components/dialog-provider.tsx diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx new file mode 100644 index 000000000..a44365069 --- /dev/null +++ b/packages/desktop/src/components/dialog-connect.tsx @@ -0,0 +1,406 @@ +import { Component, createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useLayout } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { useGlobalSDK } from "@/context/global-sdk" +import { usePlatform } from "@/context/platform" +import { ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TextField } from "@opencode-ai/ui/text-field" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { iife } from "@opencode-ai/util/iife" +import { Link } from "@/components/link" + +export const DialogConnect: Component = () => { + const layout = useLayout() + const globalSync = useGlobalSync() + const globalSDK = useGlobalSDK() + const platform = usePlatform() + + const providerID = createMemo(() => layout.connect.provider()!) + const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!) + const methods = createMemo( + () => + globalSync.data.provider_auth[providerID()] ?? [ + { + type: "api", + label: "API key", + }, + ], + ) + const [store, setStore] = createStore({ + method: undefined as undefined | ProviderAuthMethod, + authorization: undefined as undefined | ProviderAuthAuthorization, + state: "pending" as undefined | "pending" | "complete" | "error", + error: undefined as string | undefined, + }) + + const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label)) + + async function selectMethod(index: number) { + const method = methods()[index] + setStore( + produce((draft) => { + draft.method = method + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + }), + ) + + if (method.type === "oauth") { + setStore("state", "pending") + const start = Date.now() + await globalSDK.client.provider.oauth + .authorize( + { + providerID: providerID(), + method: index, + }, + { throwOnError: true }, + ) + .then((x) => { + const elapsed = Date.now() - start + const delay = 1000 - elapsed + + if (delay > 0) { + setTimeout(() => { + setStore("state", "complete") + setStore("authorization", x.data!) + }, delay) + return + } + setStore("state", "complete") + setStore("authorization", x.data!) + }) + .catch((e) => { + setStore("state", "error") + setStore("error", String(e)) + }) + } + } + + let listRef: ListRef | undefined + function handleKey(e: KeyboardEvent) { + if (e.key === "Enter" && e.target instanceof HTMLInputElement) { + return + } + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + if (methods().length === 1) { + selectMethod(0) + } + + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + async function complete() { + await globalSDK.client.global.dispose() + setTimeout(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: `${provider().name} connected`, + description: `${provider().name} models are now available to use.`, + }) + layout.connect.complete() + }, 500) + } + + return ( + { + if (open) { + layout.dialog.open("connect") + } else { + layout.dialog.close("connect") + } + }} + > + + + { + if (methods().length === 1) { + layout.dialog.open("provider") + return + } + if (store.authorization) { + setStore("authorization", undefined) + setStore("method", undefined) + return + } + if (store.method) { + setStore("method", undefined) + return + } + layout.dialog.open("provider") + }} + /> + + + + +
+
+ +
+ + + Login with Claude Pro/Max + + Connect {provider().name} + +
+
+
+ + +
Select login method for {provider().name}.
+
+ (listRef = ref)} + items={methods} + key={(m) => m?.label} + onSelect={async (method, index) => { + if (!method) return + selectMethod(index) + }} + > + {(i) => ( +
+
+ + {i.label} +
+ )} + +
+ + +
+
+ + Authorization in progress... +
+
+
+ +
+
+ + Authorization failed: {store.error} +
+
+
+ + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string + + if (!apiKey?.trim()) { + setFormStore("error", "API key is required") + return + } + + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: providerID(), + auth: { + type: "api", + key: apiKey, + }, + }) + await complete() + } + + return ( +
+ + +
+
+ OpenCode Zen gives you access to a curated set of reliable optimized models for coding + agents. +
+
+ With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and + more. +
+
+ Visit{" "} + + opencode.ai/zen + {" "} + to collect your API key. +
+
+
+ +
+ Enter your {provider().name} API key to connect your account and use {provider().name}{" "} + models in OpenCode. +
+
+
+
+ + + +
+ ) + })} +
+ + + + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + onMount(() => { + if (store.authorization?.method === "code" && store.authorization?.url) { + platform.openLink(store.authorization.url) + } + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const code = formData.get("code") as string + + if (!code?.trim()) { + setFormStore("error", "Authorization code is required") + return + } + + setFormStore("error", undefined) + const { error } = await globalSDK.client.provider.oauth.callback({ + providerID: providerID(), + method: methodIndex(), + code, + }) + if (!error) { + await complete() + return + } + setFormStore("error", "Invalid authorization code") + } + + return ( +
+
+ Visit this link to collect your authorization + code to connect your account and use {provider().name} models in OpenCode. +
+
+ + + +
+ ) + })} +
+ + {iife(() => { + const code = createMemo(() => { + const instructions = store.authorization?.instructions + if (instructions?.includes(":")) { + return instructions?.split(":")[1]?.trim() + } + return instructions + }) + + onMount(async () => { + const result = await globalSDK.client.provider.oauth.callback({ + providerID: providerID(), + method: methodIndex(), + }) + if (result.error) { + // TODO: show error + layout.dialog.close("connect") + return + } + await complete() + }) + + return ( +
+
+ Visit this link and enter the code below to + connect your account and use {provider().name} models in OpenCode. +
+ +
+ + Waiting for authorization... +
+
+ ) + })} +
+
+
+ +
+
+ +
+ ) +} diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx new file mode 100644 index 000000000..9d36e0797 --- /dev/null +++ b/packages/desktop/src/components/dialog-model.tsx @@ -0,0 +1,212 @@ +import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { useLocal } from "@/context/local" +import { useLayout } from "@/context/layout" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { iife } from "@opencode-ai/util/iife" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" + +export const DialogModel: Component = () => { + const local = useLocal() + const layout = useLayout() + const providers = useProviders() + + return ( + + 0}> + {iife(() => { + const models = createMemo(() => + local.model + .list() + .filter((m) => m.visible) + .filter((m) => + layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true, + ), + ) + return ( + { + if (open) { + layout.dialog.open("model") + } else { + layout.dialog.close("model") + } + }} + title="Select model" + placeholder="Search models" + emptyMessage="No model results" + key={(x) => `${x.provider.id}:${x.id}`} + items={models} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + if (a.category === "Recent" && b.category !== "Recent") return -1 + if (b.category === "Recent" && a.category !== "Recent") return 1 + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + } + actions={ + + } + > + {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} +
+ ) + })} +
+ + {iife(() => { + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + return ( + { + if (open) { + layout.dialog.open("model") + } else { + layout.dialog.close("model") + } + }} + > + + Select model + + + +
+
Free models provided by OpenCode
+ (listRef = ref)} + items={local.model.list} + current={local.model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + layout.dialog.close("model") + }} + > + {(i) => ( +
+ {i.name} + Free + + Latest + +
+ )} +
+
+
+
+
+
+
+
Add more models from popular providers
+
+ x?.id} + items={providers.popular} + activeIcon="plus-small" + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + onSelect={(x) => { + if (!x) return + layout.dialog.connect(x.id) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+ +
+
+
+
+ +
+ ) + })} +
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-provider.tsx b/packages/desktop/src/components/dialog-provider.tsx new file mode 100644 index 000000000..56c791479 --- /dev/null +++ b/packages/desktop/src/components/dialog-provider.tsx @@ -0,0 +1,68 @@ +import { Component, Show } from "solid-js" +import { useLayout } from "@/context/layout" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { Tag } from "@opencode-ai/ui/tag" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" + +export const DialogProvider: Component = () => { + const layout = useLayout() + const providers = useProviders() + + return ( + x?.id} + items={providers.all} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + if (a.category === "Popular" && b.category !== "Popular") return -1 + if (b.category === "Popular" && a.category !== "Popular") return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + layout.dialog.connect(x.id) + }} + onOpenChange={(open) => { + if (open) { + layout.dialog.open("provider") + } else { + layout.dialog.close("provider") + } + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+ ) +} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 114e6d49d..7c60a6d01 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,5 +1,5 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" +import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" @@ -9,21 +9,14 @@ import { useSDK } from "@/context/sdk" import { useNavigate } from "@solidjs/router" import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { Tag } from "@opencode-ai/ui/tag" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useLayout } from "@/context/layout" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List, ListRef } from "@opencode-ai/ui/list" -import { iife } from "@opencode-ai/util/iife" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogModel } from "@/components/dialog-model" interface PromptInputProps { class?: string @@ -65,7 +58,6 @@ export const PromptInput: Component = (props) => { const local = useLocal() const session = useSession() const layout = useLayout() - const providers = useProviders() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ @@ -624,201 +616,7 @@ export const PromptInput: Component = (props) => { - - 0}> - {iife(() => { - const models = createMemo(() => - local.model - .list() - .filter((m) => - layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true, - ), - ) - return ( - { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") - } - }} - title="Select model" - placeholder="Search models" - emptyMessage="No model results" - key={(x) => `${x.provider.id}:${x.id}`} - items={models} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - if (a.category === "Recent" && b.category !== "Recent") return -1 - if (b.category === "Recent" && a.category !== "Recent") return 1 - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - onSelect={(x) => - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - } - actions={ - - } - > - {(i) => ( -
- {i.name} - - Free - - - Latest - -
- )} -
- ) - })} -
- - {iife(() => { - let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - - return ( - { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") - } - }} - > - - Select model - - - -
-
Free models provided by OpenCode
- (listRef = ref)} - items={local.model.list} - current={local.model.current()} - key={(x) => `${x.provider.id}:${x.id}`} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - layout.dialog.close("model") - }} - > - {(i) => ( -
- {i.name} - Free - - Latest - -
- )} -
-
-
-
-
-
-
-
- Add more models from popular providers -
-
- x?.id} - items={providers.popular} - activeIcon="plus-small" - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - onSelect={(x) => { - if (!x) return - layout.dialog.connect(x.id) - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
- Connect with Claude Pro/Max or API key -
-
-
- )} -
- -
-
-
-
- -
- ) - })} -
-
+
ephemeral.dialog?.open), open(dialog: Dialog) { - batch(() => { - // if (dialog !== "connect") { - // setEphemeral("connect", {}) - // } - setEphemeral("dialog", "open", dialog) - }) + setEphemeral("dialog", "open", dialog) }, close(dialog: Dialog) { if (ephemeral.dialog.open === dialog) { diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 181a4d247..f841da1cc 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -1,12 +1,14 @@ import { createStore, produce, reconcile } from "solid-js/store" import { batch, createEffect, createMemo } from "solid-js" -import { uniqueBy } from "remeda" +import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" import { useSDK } from "./sdk" import { useSync } from "./sync" import { base64Encode } from "@opencode-ai/util/encode" import { useProviders } from "@/hooks/use-providers" +import { makePersisted } from "@solid-primitives/storage" +import { DateTime } from "luxon" export type LocalFile = FileNode & Partial<{ @@ -108,30 +110,66 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ })() const model = (() => { - const [store, setStore] = createStore<{ + const [store, setStore] = makePersisted( + createStore<{ + user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[] + recent: ModelKey[] + }>({ + user: [], + recent: [], + }), + { name: "model.v1" }, + ) + + const [ephemeral, setEphemeral] = createStore<{ model: Record - recent: ModelKey[] }>({ model: {}, - recent: [], }) - const value = localStorage.getItem("model") - setStore("recent", JSON.parse(value ?? "[]")) - createEffect(() => { - localStorage.setItem("model", JSON.stringify(store.recent)) - }) - - const list = createMemo(() => + const available = createMemo(() => providers.connected().flatMap((p) => Object.values(p.models).map((m) => ({ ...m, - name: m.name.replace("(latest)", "").trim(), provider: p, - latest: m.name.includes("(latest)"), + user: store.user.find((x) => x.modelID === m.id && x.providerID === p.id), })), ), ) + const latest = createMemo(() => + pipe( + available(), + filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6), + groupBy((x) => x.provider.id), + mapValues((models) => + pipe( + models, + groupBy((x) => x.family), + values(), + (groups) => + groups.flatMap((g) => { + const first = firstBy(g, [(x) => x.release_date, "desc"]) + return first ? [{ modelID: first.id, providerID: first.provider.id }] : [] + }), + ), + ), + values(), + flat(), + ), + ) + + const list = createMemo(() => + available().map((m) => ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + latest: m.name.includes("(latest)"), + visible: + m.user?.visibility !== "hide" && + (latest().find((x) => x.modelID === m.id && x.providerID === m.provider.id) || + store.user.find((x) => x.modelID === m.id && x.providerID === m.provider.id)?.visibility === "show"), + })), + ) + const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) const fallbackModel = createMemo(() => { @@ -163,10 +201,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ throw new Error("No default model found") }) - const currentModel = createMemo(() => { + const current = createMemo(() => { const a = agent.current() const key = getFirstValidModel( - () => store.model[a.name], + () => ephemeral.model[a.name], () => a.model, fallbackModel, )! @@ -177,10 +215,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const cycle = (direction: 1 | -1) => { const recentList = recent() - const current = currentModel() - if (!current) return + const currentModel = current() + if (!currentModel) return - const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id) + const index = recentList.findIndex( + (x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id, + ) if (index === -1) return let next = index + direction @@ -196,14 +236,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) } + function updateVisibility(model: ModelKey, visibility: "show" | "hide") { + const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID) + if (index >= 0) { + setStore("user", index, { visibility: visibility }) + } + } + return { - current: currentModel, + current, recent, list, cycle, set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { - setStore("model", agent.current().name, model ?? fallbackModel()) + setEphemeral("model", agent.current().name, model ?? fallbackModel()) if (options?.recent && model) { const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) if (uniq.length > 5) uniq.pop() @@ -211,6 +258,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) }, + show(model: ModelKey) { + updateVisibility(model, "show") + }, + hide(model: ModelKey) { + updateVisibility(model, "hide") + }, } })() diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index ef51361ba..63ee5b2aa 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,16 +1,4 @@ -import { - createEffect, - createMemo, - createSignal, - For, - Match, - onCleanup, - onMount, - ParentProps, - Show, - Switch, - type JSX, -} from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js" import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors } from "@/context/layout" @@ -20,14 +8,13 @@ import { Avatar } from "@opencode-ai/ui/avatar" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { Session, Project } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { createStore, produce } from "solid-js/store" import { @@ -40,21 +27,14 @@ import { useDragDropContext, } from "@thisbeyond/solid-dnd" import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" -import { Tag } from "@opencode-ai/ui/tag" -import { IconName } from "@opencode-ai/ui/icons/provider" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { Dialog } from "@opencode-ai/ui/dialog" -import { iife } from "@opencode-ai/util/iife" -import { Link } from "@/components/link" -import { List, ListRef } from "@opencode-ai/ui/list" -import { TextField } from "@opencode-ai/ui/text-field" -import { showToast, Toast } from "@opencode-ai/ui/toast" +import { useProviders } from "@/hooks/use-providers" +import { Toast } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" -import { Spinner } from "@opencode-ai/ui/spinner" import { useNotification } from "@/context/notification" import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" +import { DialogProvider } from "@/components/dialog-provider" +import { DialogConnect } from "@/components/dialog-connect" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -576,454 +556,10 @@ export default function Layout(props: ParentProps) {
{props.children}
- x?.id} - items={providers.all} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - if (a.category === "Popular" && b.category !== "Popular") return -1 - if (b.category === "Popular" && a.category !== "Popular") return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - layout.dialog.connect(x.id) - }} - onOpenChange={(open) => { - if (open) { - layout.dialog.open("provider") - } else { - layout.dialog.close("provider") - } - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
+
- {iife(() => { - const providerID = createMemo(() => layout.connect.provider()!) - const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!) - const methods = createMemo( - () => - globalSync.data.provider_auth[providerID()] ?? [ - { - type: "api", - label: "API key", - }, - ], - ) - const [store, setStore] = createStore({ - method: undefined as undefined | ProviderAuthMethod, - authorization: undefined as undefined | ProviderAuthAuthorization, - state: "pending" as undefined | "pending" | "complete" | "error", - error: undefined as string | undefined, - }) - - const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label)) - - async function selectMethod(index: number) { - const method = methods()[index] - setStore( - produce((draft) => { - draft.method = method - draft.authorization = undefined - draft.state = undefined - draft.error = undefined - }), - ) - - if (method.type === "oauth") { - setStore("state", "pending") - const start = Date.now() - await globalSDK.client.provider.oauth - .authorize( - { - providerID: providerID(), - method: index, - }, - { throwOnError: true }, - ) - .then((x) => { - const elapsed = Date.now() - start - const delay = 1000 - elapsed - - if (delay > 0) { - setTimeout(() => { - setStore("state", "complete") - setStore("authorization", x.data!) - }, delay) - return - } - setStore("state", "complete") - setStore("authorization", x.data!) - }) - .catch((e) => { - setStore("state", "error") - setStore("error", String(e)) - }) - } - } - - let listRef: ListRef | undefined - function handleKey(e: KeyboardEvent) { - if (e.key === "Enter" && e.target instanceof HTMLInputElement) { - return - } - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - if (methods().length === 1) { - selectMethod(0) - } - - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - - async function complete() { - await globalSDK.client.global.dispose() - setTimeout(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: `${provider().name} connected`, - description: `${provider().name} models are now available to use.`, - }) - layout.connect.complete() - }, 500) - } - - return ( - { - if (open) { - layout.dialog.open("connect") - } else { - layout.dialog.close("connect") - } - }} - > - - - { - if (methods().length === 1) { - layout.dialog.open("provider") - return - } - if (store.authorization) { - setStore("authorization", undefined) - setStore("method", undefined) - return - } - if (store.method) { - setStore("method", undefined) - return - } - layout.dialog.open("provider") - }} - /> - - - - -
-
- -
- - - Login with Claude Pro/Max - - Connect {provider().name} - -
-
-
- - -
Select login method for {provider().name}.
-
- (listRef = ref)} - items={methods} - key={(m) => m?.label} - onSelect={async (method, index) => { - if (!method) return - selectMethod(index) - }} - > - {(i) => ( -
-
- - {i.label} -
- )} - -
- - -
-
- - Authorization in progress... -
-
-
- -
-
- - Authorization failed: {store.error} -
-
-
- - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string - - if (!apiKey?.trim()) { - setFormStore("error", "API key is required") - return - } - - setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID: providerID(), - auth: { - type: "api", - key: apiKey, - }, - }) - await complete() - } - - return ( -
- - -
-
- OpenCode Zen gives you access to a curated set of reliable optimized models for - coding agents. -
-
- With a single API key you’ll get access to models such as Claude, GPT, Gemini, - GLM and more. -
-
- Visit{" "} - - opencode.ai/zen - {" "} - to collect your API key. -
-
-
- -
- Enter your {provider().name} API key to connect your account and use{" "} - {provider().name} models in OpenCode. -
-
-
-
- - - -
- ) - })} -
- - - - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - onMount(() => { - if (store.authorization?.method === "code" && store.authorization?.url) { - platform.openLink(store.authorization.url) - } - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const code = formData.get("code") as string - - if (!code?.trim()) { - setFormStore("error", "Authorization code is required") - return - } - - setFormStore("error", undefined) - const { error } = await globalSDK.client.provider.oauth.callback({ - providerID: providerID(), - method: methodIndex(), - code, - }) - if (!error) { - await complete() - return - } - setFormStore("error", "Invalid authorization code") - } - - return ( -
-
- Visit this link to collect your - authorization code to connect your account and use {provider().name} models in - OpenCode. -
-
- - - -
- ) - })} -
- - {iife(() => { - const code = createMemo(() => { - const instructions = store.authorization?.instructions - if (instructions?.includes(":")) { - return instructions?.split(":")[1]?.trim() - } - return instructions - }) - - onMount(async () => { - const result = await globalSDK.client.provider.oauth.callback({ - providerID: providerID(), - method: methodIndex(), - }) - if (result.error) { - // TODO: show error - layout.dialog.close("connect") - return - } - await complete() - }) - - return ( -
-
- Visit this link and enter the code - below to connect your account and use {provider().name} models in OpenCode. -
- -
- - Waiting for authorization... -
-
- ) - })} -
-
-
- -
-
- -
- ) - })} +
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d4755af17..b8d4dadbd 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -392,6 +392,7 @@ export namespace Provider { status: z.enum(["alpha", "beta", "deprecated", "active"]), options: z.record(z.string(), z.any()), headers: z.record(z.string(), z.string()), + release_date: z.string(), }) .meta({ ref: "Model", @@ -470,6 +471,7 @@ export namespace Provider { }, interleaved: model.interleaved ?? false, }, + release_date: model.release_date, } } @@ -602,6 +604,8 @@ export namespace Provider { output: model.limit?.output ?? existingModel?.limit?.output ?? 0, }, headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), + family: model.family ?? existingModel?.family ?? "", + release_date: model.release_date ?? existingModel?.release_date ?? "", } parsed.models[modelID] = parsedModel } diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index 06953168c..68707536a 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -1,4 +1,4 @@ -import { createEffect, Show, type JSX, splitProps, createSignal } from "solid-js" +import { Show, type JSX, splitProps, createSignal } from "solid-js" import { Dialog, DialogProps } from "./dialog" import { Icon } from "./icon" import { IconButton } from "./icon-button" @@ -20,15 +20,6 @@ export function SelectDialog(props: SelectDialogProps) { const [filter, setFilter] = createSignal("") let listRef: ListRef | undefined - createEffect(() => { - if (!props.current) return - const key = props.key(props.current) - requestAnimationFrame(() => { - const element = document.querySelector(`[data-key="${key}"]`) - element?.scrollIntoView({ block: "center" }) - }) - }) - const handleSelect = (item: T | undefined, index: number) => { others.onSelect?.(item, index) closeButton.click() diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index b50e4c8a0..a4a762fc6 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -50,19 +50,16 @@ export function SessionTurn( let scrollRef: HTMLDivElement | undefined const [state, setState] = createStore({ - contentRef: undefined as HTMLDivElement | undefined, stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, - autoScrolling: false, }) function handleScroll() { if (!scrollRef) return setState("scrollY", scrollRef.scrollTop) - if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { @@ -77,13 +74,9 @@ export function SessionTurn( } function scrollToBottom() { - if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return - setState("autoScrolling", true) + if (!scrollRef || state.userScrolled || !working()) return requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" }) - requestAnimationFrame(() => { - setState("autoScrolling", false) - }) }) } @@ -93,13 +86,6 @@ export function SessionTurn( } }) - createResizeObserver( - () => state.contentRef, - () => { - scrollToBottom() - }, - ) - createResizeObserver( () => state.stickyTitleRef, ({ height }) => { @@ -119,7 +105,7 @@ export function SessionTurn( return (
-
setState("contentRef", el)} onClick={handleInteraction}> +
{(message) => { const assistantMessages = createMemo(() => { @@ -221,6 +207,11 @@ export function SessionTurn( }) } + createEffect(() => { + lastPart() + scrollToBottom() + }) + const [store, setStore] = createStore({ status: rawStatus(), stepsExpanded: true, From 62ffeb3987ad1188e37141513bee7d1f3ce0dcd8 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 06:06:07 -0600 Subject: [PATCH 064/575] fix(desktop): auto scroll --- packages/ui/src/components/session-turn.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a4a762fc6..196e0bdb6 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -55,11 +55,13 @@ export function SessionTurn( userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, + autoScrolling: false, }) function handleScroll() { if (!scrollRef) return setState("scrollY", scrollRef.scrollTop) + if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { @@ -74,9 +76,13 @@ export function SessionTurn( } function scrollToBottom() { - if (!scrollRef || state.userScrolled || !working()) return + if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return + setState("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" }) + requestAnimationFrame(() => { + setState("autoScrolling", false) + }) }) } From 2613f44961a73bc57db6662bfa1d0407515c497a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 06:39:08 -0600 Subject: [PATCH 065/575] wip(desktop): progress --- .../desktop/src/components/dialog-connect.tsx | 74 ++++++++--------- .../desktop/src/components/dialog-model.tsx | 32 ++++---- ...rovider.tsx => dialog-select-provider.tsx} | 15 ++-- .../desktop/src/components/prompt-input.tsx | 9 +-- packages/desktop/src/context/dialog.tsx | 80 +++++++++++++++++++ packages/desktop/src/context/layout.tsx | 69 +--------------- .../desktop/src/pages/directory-layout.tsx | 7 +- packages/desktop/src/pages/layout.tsx | 16 +--- 8 files changed, 151 insertions(+), 151 deletions(-) rename packages/desktop/src/components/{dialog-provider.tsx => dialog-select-provider.tsx} (87%) create mode 100644 packages/desktop/src/context/dialog.tsx diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx index a44365069..d482b3f50 100644 --- a/packages/desktop/src/components/dialog-connect.tsx +++ b/packages/desktop/src/components/dialog-connect.tsx @@ -1,6 +1,6 @@ import { Component, createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" -import { useLayout } from "@/context/layout" +import { useDialog } from "@/context/dialog" import { useGlobalSync } from "@/context/global-sync" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" @@ -17,18 +17,19 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" import { iife } from "@opencode-ai/util/iife" import { Link } from "@/components/link" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogModel } from "./dialog-model" -export const DialogConnect: Component = () => { - const layout = useLayout() +export const DialogConnect: Component<{ provider: string }> = (props) => { + const dialog = useDialog() const globalSync = useGlobalSync() const globalSDK = useGlobalSDK() const platform = usePlatform() - const providerID = createMemo(() => layout.connect.provider()!) - const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!) + const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) const methods = createMemo( () => - globalSync.data.provider_auth[providerID()] ?? [ + globalSync.data.provider_auth[props.provider] ?? [ { type: "api", label: "API key", @@ -61,7 +62,7 @@ export const DialogConnect: Component = () => { await globalSDK.client.provider.oauth .authorize( { - providerID: providerID(), + providerID: props.provider, method: index, }, { throwOnError: true }, @@ -116,55 +117,50 @@ export const DialogConnect: Component = () => { title: `${provider().name} connected`, description: `${provider().name} models are now available to use.`, }) - layout.connect.complete() + dialog.replace(() => ) }, 500) } + function goBack() { + if (methods().length === 1) { + dialog.replace(() => ) + return + } + if (store.authorization) { + setStore("authorization", undefined) + setStore("method", undefined) + return + } + if (store.method) { + setStore("method", undefined) + return + } + dialog.replace(() => ) + } + return ( { - if (open) { - layout.dialog.open("connect") - } else { - layout.dialog.close("connect") + if (!open) { + dialog.clear() } }} > - { - if (methods().length === 1) { - layout.dialog.open("provider") - return - } - if (store.authorization) { - setStore("authorization", undefined) - setStore("method", undefined) - return - } - if (store.method) { - setStore("method", undefined) - return - } - layout.dialog.open("provider") - }} - /> +
- +
- + Login with Claude Pro/Max Connect {provider().name} @@ -233,7 +229,7 @@ export const DialogConnect: Component = () => { setFormStore("error", undefined) await globalSDK.client.auth.set({ - providerID: providerID(), + providerID: props.provider, auth: { type: "api", key: apiKey, @@ -320,7 +316,7 @@ export const DialogConnect: Component = () => { setFormStore("error", undefined) const { error } = await globalSDK.client.provider.oauth.callback({ - providerID: providerID(), + providerID: props.provider, method: methodIndex(), code, }) @@ -369,12 +365,12 @@ export const DialogConnect: Component = () => { onMount(async () => { const result = await globalSDK.client.provider.oauth.callback({ - providerID: providerID(), + providerID: props.provider, method: methodIndex(), }) if (result.error) { // TODO: show error - layout.dialog.close("connect") + dialog.clear() return } await complete() diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx index 9d36e0797..7f90e1a78 100644 --- a/packages/desktop/src/components/dialog-model.tsx +++ b/packages/desktop/src/components/dialog-model.tsx @@ -1,6 +1,6 @@ import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" import { useLocal } from "@/context/local" -import { useLayout } from "@/context/layout" +import { useDialog } from "@/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { Button } from "@opencode-ai/ui/button" @@ -10,10 +10,12 @@ import { List, ListRef } from "@opencode-ai/ui/list" import { iife } from "@opencode-ai/util/iife" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnect } from "./dialog-connect" -export const DialogModel: Component = () => { +export const DialogModel: Component<{ connectedProvider?: string }> = (props) => { const local = useLocal() - const layout = useLayout() + const dialog = useDialog() const providers = useProviders() return ( @@ -24,18 +26,14 @@ export const DialogModel: Component = () => { local.model .list() .filter((m) => m.visible) - .filter((m) => - layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true, - ), + .filter((m) => (props.connectedProvider ? m.provider.id === props.connectedProvider : true)), ) return ( { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") + if (!open) { + dialog.clear() } }} title="Select model" @@ -66,7 +64,7 @@ export const DialogModel: Component = () => { class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} - onClick={() => layout.dialog.open("provider")} + onClick={() => dialog.replace(() => )} > Connect provider @@ -107,10 +105,8 @@ export const DialogModel: Component = () => { modal defaultOpen onOpenChange={(open) => { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") + if (!open) { + dialog.clear() } }} > @@ -130,7 +126,7 @@ export const DialogModel: Component = () => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, }) - layout.dialog.close("model") + dialog.clear() }} > {(i) => ( @@ -163,7 +159,7 @@ export const DialogModel: Component = () => { }} onSelect={(x) => { if (!x) return - layout.dialog.connect(x.id) + dialog.replace(() => ) }} > {(i) => ( @@ -193,7 +189,7 @@ export const DialogModel: Component = () => { class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium" icon="dot-grid" onClick={() => { - layout.dialog.open("provider") + dialog.replace(() => ) }} > View all providers diff --git a/packages/desktop/src/components/dialog-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx similarity index 87% rename from packages/desktop/src/components/dialog-provider.tsx rename to packages/desktop/src/components/dialog-select-provider.tsx index 56c791479..6dabdb8b4 100644 --- a/packages/desktop/src/components/dialog-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -1,13 +1,14 @@ import { Component, Show } from "solid-js" -import { useLayout } from "@/context/layout" +import { useDialog } from "@/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { Tag } from "@opencode-ai/ui/tag" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogConnect } from "./dialog-connect" -export const DialogProvider: Component = () => { - const layout = useLayout() +export const DialogSelectProvider: Component = () => { + const dialog = useDialog() const providers = useProviders() return ( @@ -32,13 +33,11 @@ export const DialogProvider: Component = () => { }} onSelect={(x) => { if (!x) return - layout.dialog.connect(x.id) + dialog.replace(() => ) }} onOpenChange={(open) => { - if (open) { - layout.dialog.open("provider") - } else { - layout.dialog.close("provider") + if (!open) { + dialog.clear() } }} > diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 7c60a6d01..ca0ccf96a 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -15,7 +15,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useLayout } from "@/context/layout" +import { useDialog } from "@/context/dialog" import { DialogModel } from "@/components/dialog-model" interface PromptInputProps { @@ -57,7 +57,7 @@ export const PromptInput: Component = (props) => { const sync = useSync() const local = useLocal() const session = useSession() - const layout = useLayout() + const dialog = useDialog() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ @@ -610,14 +610,11 @@ export const PromptInput: Component = (props) => { class="capitalize" variant="ghost" /> - - - -
JSX.Element) + +export const { use: useDialog, provider: DialogProvider } = createSimpleContext({ + name: "Dialog", + init: () => { + const [store, setStore] = createStore({ + stack: [] as { + element: DialogElement + onClose?: () => void + }[], + }) + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape" && store.stack.length > 0) { + const current = store.stack.at(-1)! + current.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + e.preventDefault() + e.stopPropagation() + } + } + + createEffect(() => { + document.addEventListener("keydown", handleKeyDown, true) + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown, true) + }) + }) + + return { + get stack() { + return store.stack + }, + push(element: DialogElement, onClose?: () => void) { + setStore("stack", (s) => [...s, { element, onClose }]) + }, + pop() { + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + }, + replace(element: DialogElement, onClose?: () => void) { + for (const item of store.stack) { + item.onClose?.() + } + setStore("stack", [{ element, onClose }]) + }, + clear() { + for (const item of store.stack) { + item.onClose?.() + } + setStore("stack", []) + }, + } + }, +}) + +export function DialogRoot(props: { children?: JSX.Element }) { + const dialog = useDialog() + return ( + <> + {props.children} + 0}> +
+ + {(item, index) => ( + + {typeof item.element === "function" ? item.element() : item.element} + + )} + +
+
+ + ) +} diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 587276c53..925bf4d4c 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -1,5 +1,5 @@ -import { createStore, produce } from "solid-js/store" -import { batch, createMemo, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { createMemo, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSync } from "./global-sync" @@ -22,8 +22,6 @@ export function getAvatarColors(key?: string) { } } -type Dialog = "provider" | "model" | "connect" | "manage-models" - export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -45,22 +43,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }), { - name: "layout.v1", + name: "layout.v2", }, ) - const [ephemeral, setEphemeral] = createStore<{ - connect: { - provider?: string - state?: "pending" | "complete" | "error" - error?: string - } - dialog: { - open?: Dialog - } - }>({ - connect: {}, - dialog: {}, - }) + const usedColors = new Set() function pickAvailableColor(): AvatarColorKey { @@ -169,53 +155,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, - dialog: { - opened: createMemo(() => ephemeral.dialog?.open), - open(dialog: Dialog) { - setEphemeral("dialog", "open", dialog) - }, - close(dialog: Dialog) { - if (ephemeral.dialog.open === dialog) { - setEphemeral( - produce((state) => { - state.dialog.open = undefined - state.connect = {} - }), - ) - } - }, - connect(provider: string) { - setEphemeral( - produce((state) => { - state.dialog.open = "connect" - state.connect = { provider, state: "pending" } - }), - ) - }, - }, - connect: { - provider: createMemo(() => ephemeral.connect.provider), - state: createMemo(() => ephemeral.connect.state), - complete() { - setEphemeral( - produce((state) => { - state.dialog.open = "model" - state.connect.state = "complete" - }), - ) - }, - error(message: string) { - setEphemeral( - produce((state) => { - state.connect.state = "error" - state.connect.error = message - }), - ) - }, - clear() { - setEphemeral("connect", {}) - }, - }, } }, }) diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx index c909a373d..1349f6ec0 100644 --- a/packages/desktop/src/pages/directory-layout.tsx +++ b/packages/desktop/src/pages/directory-layout.tsx @@ -6,6 +6,7 @@ import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" +import { DialogProvider, DialogRoot } from "@/context/dialog" export default function Layout(props: ParentProps) { const params = useParams() @@ -20,7 +21,11 @@ export default function Layout(props: ParentProps) { const sync = useSync() return ( - {props.children} + + + {props.children} + + ) })} diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 63ee5b2aa..7b1d0e45a 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -33,8 +33,6 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useNotification } from "@/context/notification" import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" -import { DialogProvider } from "@/components/dialog-provider" -import { DialogConnect } from "@/components/dialog-connect" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -90,10 +88,6 @@ export default function Layout(props: ParentProps) { } } - async function connectProvider() { - layout.dialog.open("provider") - } - createEffect(() => { if (!params.dir || !params.id) return const directory = base64Decode(params.dir) @@ -494,7 +488,7 @@ export default function Layout(props: ParentProps) { class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px" size="large" icon="plus" - onClick={connectProvider} + // onClick={connectProvider} > Connect provider @@ -508,7 +502,7 @@ export default function Layout(props: ParentProps) { variant="ghost" size="large" icon="plus" - onClick={connectProvider} + // onClick={connectProvider} > Connect provider @@ -555,12 +549,6 @@ export default function Layout(props: ParentProps) {
{props.children}
- - - - - -
From 7ade6d386daeea120415b69f9df522001350db7b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:51:36 -0600 Subject: [PATCH 066/575] wip(desktop): progress --- packages/desktop/src/app.tsx | 57 ++++++++++--------- .../desktop/src/pages/directory-layout.tsx | 6 +- packages/desktop/src/pages/layout.tsx | 11 +++- packages/ui/src/components/dialog.tsx | 2 +- 4 files changed, 42 insertions(+), 34 deletions(-) diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index bf9dfd3b7..fd55b228e 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -1,20 +1,21 @@ import "@/index.css" +import { Show } from "solid-js" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" import { Diff } from "@opencode-ai/ui/diff" -import { GlobalSyncProvider } from "./context/global-sync" +import { GlobalSyncProvider } from "@/context/global-sync" +import { LayoutProvider } from "@/context/layout" +import { GlobalSDKProvider } from "@/context/global-sdk" +import { SessionProvider } from "@/context/session" +import { NotificationProvider } from "@/context/notification" +import { DialogProvider } from "@/context/dialog" import Layout from "@/pages/layout" import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" import Session from "@/pages/session" -import { LayoutProvider } from "./context/layout" -import { GlobalSDKProvider } from "./context/global-sdk" -import { SessionProvider } from "./context/session" -import { Show } from "solid-js" -import { NotificationProvider } from "./context/notification" declare global { interface Window { @@ -38,27 +39,29 @@ export function App() { - - - - - - - } /> - ( - - - - - - )} - /> - - - - + + + + + + + + } /> + ( + + + + + + )} + /> + + + + + diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx index 1349f6ec0..7b8d2ab9e 100644 --- a/packages/desktop/src/pages/directory-layout.tsx +++ b/packages/desktop/src/pages/directory-layout.tsx @@ -6,7 +6,7 @@ import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" -import { DialogProvider, DialogRoot } from "@/context/dialog" +import { DialogRoot } from "@/context/dialog" export default function Layout(props: ParentProps) { const params = useParams() @@ -22,9 +22,7 @@ export default function Layout(props: ParentProps) { return ( - - {props.children} - + {props.children} ) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 7b1d0e45a..c36cc234e 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -33,6 +33,8 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useNotification } from "@/context/notification" import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" +import { useDialog } from "@/context/dialog" +import { DialogSelectProvider } from "@/components/dialog-select-provider" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -48,6 +50,11 @@ export default function Layout(props: ParentProps) { const notification = useNotification() const navigate = useNavigate() const providers = useProviders() + const dialog = useDialog() + + function connectProvider() { + dialog.replace(() => ) + } function navigateToProject(directory: string | undefined) { if (!directory) return @@ -488,7 +495,7 @@ export default function Layout(props: ParentProps) { class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px" size="large" icon="plus" - // onClick={connectProvider} + onClick={connectProvider} > Connect provider @@ -502,7 +509,7 @@ export default function Layout(props: ParentProps) { variant="ghost" size="large" icon="plus" - // onClick={connectProvider} + onClick={connectProvider} > Connect provider diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 56053278d..aebb77885 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -14,7 +14,7 @@ export interface DialogProps extends DialogRootProps { classList?: ComponentProps<"div">["classList"] } -export function DialogRoot(props: DialogProps) { +function DialogRoot(props: DialogProps) { let trigger!: HTMLElement const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"]) From 4246cdb069502c96ab11e260eb36a07a0370b710 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:33:40 -0600 Subject: [PATCH 067/575] wip(desktop): progress --- .../desktop/src/components/dialog-connect.tsx | 2 +- .../src/components/dialog-file-select.tsx | 52 ++++ .../src/components/dialog-manage-models.tsx | 65 +++++ .../src/components/dialog-model-unpaid.tsx | 133 +++++++++ .../desktop/src/components/dialog-model.tsx | 275 ++++++------------ .../src/components/dialog-select-provider.tsx | 101 ++++--- .../desktop/src/components/prompt-input.tsx | 9 +- packages/desktop/src/context/local.tsx | 7 +- packages/desktop/src/pages/session.tsx | 38 +-- packages/ui/src/components/dialog.css | 27 +- packages/ui/src/components/list.css | 38 +++ packages/ui/src/components/list.tsx | 141 +++++---- packages/ui/src/components/select-dialog.css | 44 --- packages/ui/src/components/select-dialog.tsx | 93 ------ packages/ui/src/components/session-turn.css | 2 - packages/ui/src/components/session-turn.tsx | 4 +- packages/ui/src/components/switch.css | 131 +++++++++ packages/ui/src/components/switch.tsx | 30 ++ packages/ui/src/hooks/use-filtered-list.tsx | 11 +- packages/ui/src/styles/index.css | 2 +- 20 files changed, 726 insertions(+), 479 deletions(-) create mode 100644 packages/desktop/src/components/dialog-file-select.tsx create mode 100644 packages/desktop/src/components/dialog-manage-models.tsx create mode 100644 packages/desktop/src/components/dialog-model-unpaid.tsx delete mode 100644 packages/ui/src/components/select-dialog.css delete mode 100644 packages/ui/src/components/select-dialog.tsx create mode 100644 packages/ui/src/components/switch.css create mode 100644 packages/ui/src/components/switch.tsx diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx index d482b3f50..3a1e05f27 100644 --- a/packages/desktop/src/components/dialog-connect.tsx +++ b/packages/desktop/src/components/dialog-connect.tsx @@ -117,7 +117,7 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { title: `${provider().name} connected`, description: `${provider().name} models are now available to use.`, }) - dialog.replace(() => ) + dialog.replace(() => ) }, 500) } diff --git a/packages/desktop/src/components/dialog-file-select.tsx b/packages/desktop/src/components/dialog-file-select.tsx new file mode 100644 index 000000000..3afe06062 --- /dev/null +++ b/packages/desktop/src/components/dialog-file-select.tsx @@ -0,0 +1,52 @@ +import { Component } from "solid-js" +import { useLocal } from "@/context/local" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" + +export const DialogFileSelect: Component<{ + onOpenChange?: (open: boolean) => void + onSelect?: (path: string) => void +}> = (props) => { + const local = useLocal() + let closeButton!: HTMLButtonElement + + return ( + + + Select file + + + + x} + onSelect={(x) => { + if (x) { + props.onSelect?.(x) + } + closeButton.click() + }} + > + {(i) => ( +
+
+ +
+ + {getDirectory(i)} + + {getFilename(i)} +
+
+
+ )} +
+
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx new file mode 100644 index 000000000..2904f9a5b --- /dev/null +++ b/packages/desktop/src/components/dialog-manage-models.tsx @@ -0,0 +1,65 @@ +import { Component } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@/context/dialog" +import { popularProviders } from "@/hooks/use-providers" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" + +export const DialogManageModels: Component = () => { + const local = useLocal() + const dialog = useDialog() + + return ( + { + if (!open) { + dialog.clear() + } + }} + > + + Manage models + + + Customize which models appear in the model selector. + + `${x?.provider?.id}:${x?.id}`} + items={local.model.list()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => { + if (!x) return + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible) + }} + > + {(i) => ( +
+ {i.name} + { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> +
+ )} +
+
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-model-unpaid.tsx b/packages/desktop/src/components/dialog-model-unpaid.tsx new file mode 100644 index 000000000..d218770d9 --- /dev/null +++ b/packages/desktop/src/components/dialog-model-unpaid.tsx @@ -0,0 +1,133 @@ +import { Component, onCleanup, onMount, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@/context/dialog" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnect } from "./dialog-connect" + +export const DialogModelUnpaid: Component = () => { + const local = useLocal() + const dialog = useDialog() + const providers = useProviders() + + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + return ( + { + if (!open) { + dialog.clear() + } + }} + > + + Select model + + + +
+
Free models provided by OpenCode
+ (listRef = ref)} + items={local.model.list} + current={local.model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + dialog.clear() + }} + > + {(i) => ( +
+ {i.name} + Free + + Latest + +
+ )} +
+
+
+
+
+
+
+
Add more models from popular providers
+
+ x?.id} + items={providers.popular} + activeIcon="plus-small" + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+ +
+
+
+
+ +
+ ) +} diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx index 7f90e1a78..e8f9df055 100644 --- a/packages/desktop/src/components/dialog-model.tsx +++ b/packages/desktop/src/components/dialog-model.tsx @@ -1,208 +1,95 @@ -import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { Component, createMemo, Show } from "solid-js" import { useLocal } from "@/context/local" import { useDialog } from "@/context/dialog" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { popularProviders } from "@/hooks/use-providers" import { Button } from "@opencode-ai/ui/button" import { Tag } from "@opencode-ai/ui/tag" import { Dialog } from "@opencode-ai/ui/dialog" -import { List, ListRef } from "@opencode-ai/ui/list" -import { iife } from "@opencode-ai/util/iife" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { IconName } from "@opencode-ai/ui/icons/provider" +import { List } from "@opencode-ai/ui/list" import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogConnect } from "./dialog-connect" +import { DialogManageModels } from "./dialog-manage-models" -export const DialogModel: Component<{ connectedProvider?: string }> = (props) => { +export const DialogModel: Component<{ provider?: string }> = (props) => { const local = useLocal() const dialog = useDialog() - const providers = useProviders() + + let closeButton!: HTMLButtonElement + const models = createMemo(() => + local.model + .list() + .filter((m) => m.visible) + .filter((m) => (props.provider ? m.provider.id === props.provider : true)), + ) return ( - - 0}> - {iife(() => { - const models = createMemo(() => - local.model - .list() - .filter((m) => m.visible) - .filter((m) => (props.connectedProvider ? m.provider.id === props.connectedProvider : true)), - ) - return ( - { - if (!open) { - dialog.clear() - } - }} - title="Select model" - placeholder="Search models" - emptyMessage="No model results" - key={(x) => `${x.provider.id}:${x.id}`} - items={models} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - if (a.category === "Recent" && b.category !== "Recent") return -1 - if (b.category === "Recent" && a.category !== "Recent") return 1 - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - onSelect={(x) => - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - } - actions={ - - } - > - {(i) => ( -
- {i.name} - - Free - - - Latest - -
- )} -
- ) - })} -
- - {iife(() => { - let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) + { + if (!open) { + dialog.clear() + } + }} + > + + Select model + + + + + `${x.provider.id}:${x.id}`} + items={models} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + if (a.category === "Recent" && b.category !== "Recent") return -1 + if (b.category === "Recent" && a.category !== "Recent") return 1 + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, }) - }) - - return ( - { - if (!open) { - dialog.clear() - } - }} - > - - Select model - - - -
-
Free models provided by OpenCode
- (listRef = ref)} - items={local.model.list} - current={local.model.current()} - key={(x) => `${x.provider.id}:${x.id}`} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - dialog.clear() - }} - > - {(i) => ( -
- {i.name} - Free - - Latest - -
- )} -
-
-
-
-
-
-
-
Add more models from popular providers
-
- x?.id} - items={providers.popular} - activeIcon="plus-small" - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - onSelect={(x) => { - if (!x) return - dialog.replace(() => ) - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
- -
-
-
-
- -
- ) - })} -
-
+ closeButton.click() + }} + > + {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} + + + + ) } diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx index 6dabdb8b4..1c54184bd 100644 --- a/packages/desktop/src/components/dialog-select-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -1,7 +1,8 @@ import { Component, Show } from "solid-js" import { useDialog } from "@/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" import { Tag } from "@opencode-ai/ui/tag" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" @@ -12,56 +13,66 @@ export const DialogSelectProvider: Component = () => { const providers = useProviders() return ( - x?.id} - items={providers.all} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - if (a.category === "Popular" && b.category !== "Popular") return -1 - if (b.category === "Popular" && a.category !== "Popular") return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - dialog.replace(() => ) - }} onOpenChange={(open) => { if (!open) { dialog.clear() } }} > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
+ + Connect provider + + + + x?.id} + items={providers.all} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + if (a.category === "Popular" && b.category !== "Popular") return -1 + if (b.category === "Popular" && a.category !== "Popular") return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+
+ ) } diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index ca0ccf96a..faecd9520 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -17,6 +17,8 @@ import { Select } from "@opencode-ai/ui/select" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useDialog } from "@/context/dialog" import { DialogModel } from "@/components/dialog-model" +import { DialogModelUnpaid } from "@/components/dialog-model-unpaid" +import { useProviders } from "@/hooks/use-providers" interface PromptInputProps { class?: string @@ -58,6 +60,7 @@ export const PromptInput: Component = (props) => { const local = useLocal() const session = useSession() const dialog = useDialog() + const providers = useProviders() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ @@ -610,7 +613,11 @@ export const PromptInput: Component = (props) => { class="capitalize" variant="ghost" /> -
- x} + setStore("fileSelectOpen", open)} - onSelect={(x) => { - if (x) { - return session.layout.openTab("file://" + x) - } - return undefined - }} - > - {(i) => ( -
-
- -
- - {getDirectory(i)} - - {getFilename(i)} -
-
-
-
- )} -
+ onSelect={(path) => session.layout.openTab("file://" + path)} + />
diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 979906e26..fa5e1171e 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -59,9 +59,7 @@ [data-slot="dialog-header"] { display: flex; - /* height: 40px; */ - /* padding: 4px 4px 4px 8px; */ - padding: 20px; + padding: 16px; justify-content: space-between; align-items: center; flex-shrink: 0; @@ -80,7 +78,28 @@ } /* [data-slot="dialog-close-button"] {} */ } - /* [data-slot="dialog-description"] {} */ + + [data-slot="dialog-description"] { + display: flex; + padding: 16px; + padding-top: 0; + margin-top: -8px; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + align-self: stretch; + + color: var(--text-base); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + } + [data-slot="dialog-body"] { width: 100%; position: relative; diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 132824164..cd9e73d1d 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -2,6 +2,43 @@ display: flex; flex-direction: column; gap: 20px; + overflow: hidden; + + [data-slot="list-search"] { + display: flex; + height: 40px; + flex-shrink: 0; + padding: 4px 10px 4px 16px; + align-items: center; + gap: 12px; + align-self: stretch; + + border-radius: var(--radius-md); + background: var(--surface-base); + + [data-slot="list-search-container"] { + display: flex; + align-items: center; + gap: 16px; + flex: 1 0 0; + + [data-slot="list-search-input"] { + width: 100%; + } + } + } + + [data-slot="list-scroll"] { + display: flex; + flex-direction: column; + gap: 20px; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } + } [data-slot="list-empty-state"] { display: flex; @@ -41,6 +78,7 @@ [data-slot="list-header"] { display: flex; + z-index: 10; height: 28px; padding: 0 10px; justify-content: space-between; diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 013767e60..2923956a9 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -2,6 +2,13 @@ import { createEffect, Show, For, type JSX, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Icon, IconProps } from "./icon" +import { IconButton } from "./icon-button" +import { TextField } from "./text-field" + +export interface ListSearchProps { + placeholder?: string + autofocus?: boolean +} export interface ListProps extends FilteredListProps { class?: string @@ -10,6 +17,7 @@ export interface ListProps extends FilteredListProps { onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void activeIcon?: IconProps["name"] filter?: string + search?: ListSearchProps | boolean } export interface ListRef { @@ -19,23 +27,22 @@ export interface ListRef { export function List(props: ListProps & { ref?: (ref: ListRef) => void }) { const [scrollRef, setScrollRef] = createSignal(undefined) + const [internalFilter, setInternalFilter] = createSignal("") const [store, setStore] = createStore({ mouseActive: false, }) - const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList({ - items: props.items, - key: props.key, - filterKeys: props.filterKeys, - current: props.current, - groupBy: props.groupBy, - sortBy: props.sortBy, - sortGroupsBy: props.sortGroupsBy, - }) + const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList(props) + + const searchProps = () => (typeof props.search === "object" ? props.search : {}) + const hasSearch = () => !!props.search createEffect(() => { - if (props.filter === undefined) return - onInput(props.filter) + if (props.filter !== undefined) { + onInput(props.filter) + } else if (hasSearch()) { + onInput(internalFilter()) + } }) createEffect(() => { @@ -92,52 +99,78 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) }) return ( -
- 0} - fallback={ -
-
- {props.emptyMessage ?? "No results"} for "{filter()}" -
+
+ +
+
+ +
- } - > - - {(group) => ( -
- -
{group.category}
-
-
- - {(item, i) => ( - - )} - + + setInternalFilter("")} /> + +
+ +
+ 0} + fallback={ +
+
+ {props.emptyMessage ?? "No results"} for "{filter()}"
- )} - -
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+
+ + {(item, i) => ( + + )} + +
+
+ )} +
+ +
) } diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css deleted file mode 100644 index 9759174a6..000000000 --- a/packages/ui/src/components/select-dialog.css +++ /dev/null @@ -1,44 +0,0 @@ -[data-slot="select-dialog-content"] { - width: 100%; - display: flex; - flex-direction: column; - overflow: hidden; - gap: 20px; - padding: 0 10px; - - [data-slot="dialog-body"] { - scrollbar-width: none; - -ms-overflow-style: none; - &::-webkit-scrollbar { - display: none; - } - } -} - -[data-component="select-dialog-input"] { - display: flex; - height: 40px; - flex-shrink: 0; - padding: 4px 10px 4px 16px; - align-items: center; - gap: 12px; - align-self: stretch; - - border-radius: var(--radius-md); - background: var(--surface-base); - - [data-slot="select-dialog-input-container"] { - display: flex; - align-items: center; - gap: 16px; - flex: 1 0 0; - - /* [data-slot="select-dialog-icon"] {} */ - - [data-slot="select-dialog-input"] { - width: 100%; - } - } - - /* [data-slot="select-dialog-clear-button"] {} */ -} diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx deleted file mode 100644 index 68707536a..000000000 --- a/packages/ui/src/components/select-dialog.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Show, type JSX, splitProps, createSignal } from "solid-js" -import { Dialog, DialogProps } from "./dialog" -import { Icon } from "./icon" -import { IconButton } from "./icon-button" -import { List, ListRef, ListProps } from "./list" -import { TextField } from "./text-field" - -interface SelectDialogProps - extends Omit, "filter">, - Pick { - title: string - placeholder?: string - actions?: JSX.Element -} - -export function SelectDialog(props: SelectDialogProps) { - const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"]) - let closeButton!: HTMLButtonElement - let inputRef: HTMLInputElement | undefined - const [filter, setFilter] = createSignal("") - let listRef: ListRef | undefined - - const handleSelect = (item: T | undefined, index: number) => { - others.onSelect?.(item, index) - closeButton.click() - } - - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - const handleOpenChange = (open: boolean) => { - if (!open) setFilter("") - props.onOpenChange?.(open) - } - - return ( - - - {others.title} - {others.actions} - - -
-
-
- - -
- - setFilter("")} /> - -
- - { - listRef = ref - }} - items={others.items} - key={others.key} - filterKeys={others.filterKeys} - current={others.current} - groupBy={others.groupBy} - sortBy={others.sortBy} - sortGroupsBy={others.sortGroupsBy} - emptyMessage={others.emptyMessage} - activeIcon={others.activeIcon} - filter={filter()} - onSelect={handleSelect} - onKeyEvent={others.onKeyEvent} - > - {others.children} - - -
-
- ) -} diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index bc61318e3..0f218b515 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -37,7 +37,6 @@ top: 0; background-color: var(--background-stronger); z-index: 21; - /* padding-bottom: clamp(0px, calc(8px - var(--scroll-y) * 0.16), 8px); */ } [data-slot="session-turn-response-trigger"] { @@ -297,7 +296,6 @@ [data-slot="session-turn-collapsible"] { gap: 32px; overflow: visible; - /* margin-top: clamp(8px, calc(24px - var(--scroll-y) * 0.32), 24px); */ } [data-slot="session-turn-collapsible-trigger-content"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 196e0bdb6..ad2e6c36e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -60,6 +60,8 @@ export function SessionTurn( function handleScroll() { if (!scrollRef) return + // prevents scroll loops + if (working() && scrollRef.scrollTop < 100) return setState("scrollY", scrollRef.scrollTop) if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef @@ -79,7 +81,7 @@ export function SessionTurn( if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return setState("autoScrolling", true) requestAnimationFrame(() => { - scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" }) + scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { setState("autoScrolling", false) }) diff --git a/packages/ui/src/components/switch.css b/packages/ui/src/components/switch.css new file mode 100644 index 000000000..c01e45d5f --- /dev/null +++ b/packages/ui/src/components/switch.css @@ -0,0 +1,131 @@ +[data-component="switch"] { + display: flex; + align-items: center; + gap: 8px; + cursor: default; + + [data-slot="switch-input"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + [data-slot="switch-control"] { + display: inline-flex; + align-items: center; + width: 28px; + height: 16px; + flex-shrink: 0; + border-radius: 3px; + border: 1px solid var(--border-weak-base); + background: var(--surface-base); + transition: + background-color 150ms, + border-color 150ms; + } + + [data-slot="switch-thumb"] { + width: 14px; + height: 14px; + box-sizing: content-box; + + border-radius: 2px; + border: 1px solid var(--border-base); + background: var(--icon-invert-base); + + /* shadows/shadow-xs */ + box-shadow: + 0 1px 2px -1px rgba(19, 16, 16, 0.04), + 0 1px 2px 0 rgba(19, 16, 16, 0.06), + 0 1px 3px 0 rgba(19, 16, 16, 0.08); + + transform: translateX(-1px); + transition: + transform 150ms, + background-color 150ms; + } + + [data-slot="switch-label"] { + user-select: none; + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="switch-description"] { + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="switch-error"] { + color: var(--text-error); + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-normal); + } + + &:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-hover); + background-color: var(--surface-hover); + } + + &:focus-within:not([data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-focus); + box-shadow: 0 0 0 2px var(--surface-focus); + } + + &[data-checked] [data-slot="switch-control"] { + box-sizing: border-box; + border-color: var(--icon-strong-base); + background-color: var(--icon-strong-base); + } + + &[data-checked] [data-slot="switch-thumb"] { + border: none; + transform: translateX(12px); + background-color: var(--icon-invert-base); + } + + &[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-hover); + background-color: var(--surface-hover); + } + + &[data-disabled] { + cursor: not-allowed; + } + + &[data-disabled] [data-slot="switch-control"] { + border-color: var(--border-disabled); + background-color: var(--surface-disabled); + } + + &[data-disabled] [data-slot="switch-thumb"] { + background-color: var(--icon-disabled); + } + + &[data-invalid] [data-slot="switch-control"] { + border-color: var(--border-error); + } + + &[data-readonly] { + cursor: default; + pointer-events: none; + } +} diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx new file mode 100644 index 000000000..af70dfb5c --- /dev/null +++ b/packages/ui/src/components/switch.tsx @@ -0,0 +1,30 @@ +import { Switch as Kobalte } from "@kobalte/core/switch" +import { children, Show, splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface SwitchProps extends ParentProps> { + hideLabel?: boolean + description?: string +} + +export function Switch(props: SwitchProps) { + const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"]) + const resolved = children(() => local.children) + return ( + + + + + {resolved()} + + + + {local.description} + + + + + + + ) +} diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index e3b373d4d..76a5ae84f 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -5,7 +5,7 @@ import { createStore } from "solid-js/store" import { createList } from "solid-list" export interface FilteredListProps { - items: (filter: string) => T[] | Promise + items: T[] | ((filter: string) => T[] | Promise) key: (item: T) => string filterKeys?: string[] current?: T @@ -19,10 +19,13 @@ export function useFilteredList(props: FilteredListProps) { const [store, setStore] = createStore<{ filter: string }>({ filter: "" }) const [grouped, { refetch }] = createResource( - () => store.filter, - async (filter) => { + () => ({ + filter: store.filter, + items: typeof props.items === "function" ? undefined : props.items, + }), + async ({ filter, items }) => { const needle = filter?.toLowerCase() - const all = (await props.items(needle)) || [] + const all = (items ?? (await (props.items as (filter: string) => T[] | Promise)(needle))) || [] const result = pipe( all, (x) => { diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index ba2c954bc..3f8838a7a 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -30,8 +30,8 @@ @import "../components/progress-circle.css" layer(components); @import "../components/resize-handle.css" layer(components); @import "../components/select.css" layer(components); -@import "../components/select-dialog.css" layer(components); @import "../components/spinner.css" layer(components); +@import "../components/switch.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); From dda579c8ad30f81ade458769971d85ff7afee64c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:32:14 -0600 Subject: [PATCH 068/575] wip(desktop): progress --- packages/desktop/src/app.tsx | 2 +- .../desktop/src/components/dialog-connect.tsx | 517 +++++++++--------- .../src/components/dialog-file-select.tsx | 52 -- .../src/components/dialog-manage-models.tsx | 86 ++- .../src/components/dialog-model-unpaid.tsx | 133 ----- .../desktop/src/components/dialog-model.tsx | 95 ---- .../src/components/dialog-select-file.tsx | 44 ++ .../components/dialog-select-model-unpaid.tsx | 119 ++++ .../src/components/dialog-select-model.tsx | 85 +++ .../src/components/dialog-select-provider.tsx | 108 ++-- .../desktop/src/components/prompt-input.tsx | 10 +- .../desktop/src/pages/directory-layout.tsx | 2 +- packages/desktop/src/pages/layout.tsx | 2 +- packages/desktop/src/pages/session.tsx | 15 +- packages/ui/src/components/dialog.css | 2 + packages/ui/src/components/dialog.tsx | 123 ++--- .../{desktop => ui}/src/context/dialog.tsx | 37 +- packages/ui/src/context/index.ts | 1 + 18 files changed, 649 insertions(+), 784 deletions(-) delete mode 100644 packages/desktop/src/components/dialog-file-select.tsx delete mode 100644 packages/desktop/src/components/dialog-model-unpaid.tsx delete mode 100644 packages/desktop/src/components/dialog-model.tsx create mode 100644 packages/desktop/src/components/dialog-select-file.tsx create mode 100644 packages/desktop/src/components/dialog-select-model-unpaid.tsx create mode 100644 packages/desktop/src/components/dialog-select-model.tsx rename packages/{desktop => ui}/src/context/dialog.tsx (70%) diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index fd55b228e..a49dac9aa 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -11,7 +11,7 @@ import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" import { SessionProvider } from "@/context/session" import { NotificationProvider } from "@/context/notification" -import { DialogProvider } from "@/context/dialog" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" import Layout from "@/pages/layout" import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx index 3a1e05f27..f61221f72 100644 --- a/packages/desktop/src/components/dialog-connect.tsx +++ b/packages/desktop/src/components/dialog-connect.tsx @@ -1,10 +1,10 @@ -import { Component, createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" -import { useDialog } from "@/context/dialog" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { useGlobalSync } from "@/context/global-sync" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" -import { ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" import { Dialog } from "@opencode-ai/ui/dialog" import { List, ListRef } from "@opencode-ai/ui/list" import { Button } from "@opencode-ai/ui/button" @@ -18,14 +18,13 @@ import { IconName } from "@opencode-ai/ui/icons/provider" import { iife } from "@opencode-ai/util/iife" import { Link } from "@/components/link" import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogModel } from "./dialog-model" +import { DialogSelectModel } from "./dialog-select-model" -export const DialogConnect: Component<{ provider: string }> = (props) => { +export function DialogConnect(props: { provider: string }) { const dialog = useDialog() const globalSync = useGlobalSync() const globalSDK = useGlobalSDK() const platform = usePlatform() - const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) const methods = createMemo( () => @@ -37,19 +36,19 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { ], ) const [store, setStore] = createStore({ - method: undefined as undefined | ProviderAuthMethod, + methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, state: "pending" as undefined | "pending" | "complete" | "error", error: undefined as string | undefined, }) - const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label)) + const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) async function selectMethod(index: number) { const method = methods()[index] setStore( produce((draft) => { - draft.method = method + draft.methodIndex = index draft.authorization = undefined draft.state = undefined draft.error = undefined @@ -101,7 +100,6 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { if (methods().length === 1) { selectMethod(0) } - document.addEventListener("keydown", handleKey) onCleanup(() => { document.removeEventListener("keydown", handleKey) @@ -117,8 +115,8 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { title: `${provider().name} connected`, description: `${provider().name} models are now available to use.`, }) - dialog.replace(() => ) - }, 500) + dialog.replace(() => ) + }, 1000) } function goBack() { @@ -128,275 +126,258 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { } if (store.authorization) { setStore("authorization", undefined) - setStore("method", undefined) + setStore("methodIndex", undefined) return } - if (store.method) { - setStore("method", undefined) + if (store.methodIndex) { + setStore("methodIndex", undefined) return } dialog.replace(() => ) } return ( - { - if (!open) { - dialog.clear() - } - }} - > - - - - - - - -
-
- -
- - - Login with Claude Pro/Max - - Connect {provider().name} - -
-
-
+ }> +
+
+ +
- -
Select login method for {provider().name}.
-
- (listRef = ref)} - items={methods} - key={(m) => m?.label} - onSelect={async (method, index) => { - if (!method) return - selectMethod(index) - }} - > - {(i) => ( -
-
- - {i.label} -
- )} - -
- - -
-
- - Authorization in progress... -
-
-
- -
-
- - Authorization failed: {store.error} -
-
-
- - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string - - if (!apiKey?.trim()) { - setFormStore("error", "API key is required") - return - } - - setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - }, - }) - await complete() - } - - return ( -
- - -
-
- OpenCode Zen gives you access to a curated set of reliable optimized models for coding - agents. -
-
- With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and - more. -
-
- Visit{" "} - - opencode.ai/zen - {" "} - to collect your API key. -
-
-
- -
- Enter your {provider().name} API key to connect your account and use {provider().name}{" "} - models in OpenCode. -
-
-
-
- - - -
- ) - })} -
- - - - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - onMount(() => { - if (store.authorization?.method === "code" && store.authorization?.url) { - platform.openLink(store.authorization.url) - } - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const code = formData.get("code") as string - - if (!code?.trim()) { - setFormStore("error", "Authorization code is required") - return - } - - setFormStore("error", undefined) - const { error } = await globalSDK.client.provider.oauth.callback({ - providerID: props.provider, - method: methodIndex(), - code, - }) - if (!error) { - await complete() - return - } - setFormStore("error", "Invalid authorization code") - } - - return ( -
-
- Visit this link to collect your authorization - code to connect your account and use {provider().name} models in OpenCode. -
-
- - - -
- ) - })} -
- - {iife(() => { - const code = createMemo(() => { - const instructions = store.authorization?.instructions - if (instructions?.includes(":")) { - return instructions?.split(":")[1]?.trim() - } - return instructions - }) - - onMount(async () => { - const result = await globalSDK.client.provider.oauth.callback({ - providerID: props.provider, - method: methodIndex(), - }) - if (result.error) { - // TODO: show error - dialog.clear() - return - } - await complete() - }) - - return ( -
-
- Visit this link and enter the code below to - connect your account and use {provider().name} models in OpenCode. -
- -
- - Waiting for authorization... -
-
- ) - })} -
-
+ + Login with Claude Pro/Max + Connect {provider().name}
- +
+ + +
Select login method for {provider().name}.
+
+ (listRef = ref)} + items={methods} + key={(m) => m?.label} + onSelect={async (method, index) => { + if (!method) return + selectMethod(index) + }} + > + {(i) => ( +
+
+ + {i.label} +
+ )} + +
+ + +
+
+ + Authorization in progress... +
+
+
+ +
+
+ + Authorization failed: {store.error} +
+
+
+ + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string + + if (!apiKey?.trim()) { + setFormStore("error", "API key is required") + return + } + + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: props.provider, + auth: { + type: "api", + key: apiKey, + }, + }) + await complete() + } + + return ( +
+ + +
+
+ OpenCode Zen gives you access to a curated set of reliable optimized models for coding + agents. +
+
+ With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more. +
+
+ Visit{" "} + + opencode.ai/zen + {" "} + to collect your API key. +
+
+
+ +
+ Enter your {provider().name} API key to connect your account and use {provider().name} models + in OpenCode. +
+
+
+
+ + + +
+ ) + })} +
+ + + + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + onMount(() => { + if (store.authorization?.method === "code" && store.authorization?.url) { + platform.openLink(store.authorization.url) + } + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const code = formData.get("code") as string + + if (!code?.trim()) { + setFormStore("error", "Authorization code is required") + return + } + + setFormStore("error", undefined) + const { error } = await globalSDK.client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, + code, + }) + if (!error) { + await complete() + return + } + setFormStore("error", "Invalid authorization code") + } + + return ( +
+
+ Visit this link to collect your authorization + code to connect your account and use {provider().name} models in OpenCode. +
+
+ + + +
+ ) + })} +
+ + {iife(() => { + const code = createMemo(() => { + const instructions = store.authorization?.instructions + if (instructions?.includes(":")) { + return instructions?.split(":")[1]?.trim() + } + return instructions + }) + + onMount(async () => { + const result = await globalSDK.client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, + }) + if (result.error) { + // TODO: show error + dialog.clear() + return + } + await complete() + }) + + return ( +
+
+ Visit this link and enter the code below to + connect your account and use {provider().name} models in OpenCode. +
+ +
+ + Waiting for authorization... +
+
+ ) + })} +
+
+
+ +
+
) } diff --git a/packages/desktop/src/components/dialog-file-select.tsx b/packages/desktop/src/components/dialog-file-select.tsx deleted file mode 100644 index 3afe06062..000000000 --- a/packages/desktop/src/components/dialog-file-select.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Component } from "solid-js" -import { useLocal } from "@/context/local" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { getDirectory, getFilename } from "@opencode-ai/util/path" - -export const DialogFileSelect: Component<{ - onOpenChange?: (open: boolean) => void - onSelect?: (path: string) => void -}> = (props) => { - const local = useLocal() - let closeButton!: HTMLButtonElement - - return ( - - - Select file - - - - x} - onSelect={(x) => { - if (x) { - props.onSelect?.(x) - } - closeButton.click() - }} - > - {(i) => ( -
-
- -
- - {getDirectory(i)} - - {getFilename(i)} -
-
-
- )} -
-
-
- ) -} diff --git a/packages/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx index 2904f9a5b..de1c3cb15 100644 --- a/packages/desktop/src/components/dialog-manage-models.tsx +++ b/packages/desktop/src/components/dialog-manage-models.tsx @@ -1,6 +1,5 @@ import { Component } from "solid-js" import { useLocal } from "@/context/local" -import { useDialog } from "@/context/dialog" import { popularProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" @@ -8,58 +7,41 @@ import { Switch } from "@opencode-ai/ui/switch" export const DialogManageModels: Component = () => { const local = useLocal() - const dialog = useDialog() - return ( - { - if (!open) { - dialog.clear() - } - }} - > - - Manage models - - - Customize which models appear in the model selector. - - `${x?.provider?.id}:${x?.id}`} - items={local.model.list()} - filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - onSelect={(x) => { - if (!x) return - local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible) - }} - > - {(i) => ( -
- {i.name} - { - local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) - }} - /> -
- )} -
-
+ + `${x?.provider?.id}:${x?.id}`} + items={local.model.list()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => { + if (!x) return + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible) + }} + > + {(i) => ( +
+ {i.name} + { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> +
+ )} +
) } diff --git a/packages/desktop/src/components/dialog-model-unpaid.tsx b/packages/desktop/src/components/dialog-model-unpaid.tsx deleted file mode 100644 index d218770d9..000000000 --- a/packages/desktop/src/components/dialog-model-unpaid.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { Component, onCleanup, onMount, Show } from "solid-js" -import { useLocal } from "@/context/local" -import { useDialog } from "@/context/dialog" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { Button } from "@opencode-ai/ui/button" -import { Tag } from "@opencode-ai/ui/tag" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List, ListRef } from "@opencode-ai/ui/list" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { IconName } from "@opencode-ai/ui/icons/provider" -import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogConnect } from "./dialog-connect" - -export const DialogModelUnpaid: Component = () => { - const local = useLocal() - const dialog = useDialog() - const providers = useProviders() - - let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - - return ( - { - if (!open) { - dialog.clear() - } - }} - > - - Select model - - - -
-
Free models provided by OpenCode
- (listRef = ref)} - items={local.model.list} - current={local.model.current()} - key={(x) => `${x.provider.id}:${x.id}`} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - dialog.clear() - }} - > - {(i) => ( -
- {i.name} - Free - - Latest - -
- )} -
-
-
-
-
-
-
-
Add more models from popular providers
-
- x?.id} - items={providers.popular} - activeIcon="plus-small" - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - onSelect={(x) => { - if (!x) return - dialog.replace(() => ) - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
- -
-
-
-
- -
- ) -} diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx deleted file mode 100644 index e8f9df055..000000000 --- a/packages/desktop/src/components/dialog-model.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Component, createMemo, Show } from "solid-js" -import { useLocal } from "@/context/local" -import { useDialog } from "@/context/dialog" -import { popularProviders } from "@/hooks/use-providers" -import { Button } from "@opencode-ai/ui/button" -import { Tag } from "@opencode-ai/ui/tag" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogManageModels } from "./dialog-manage-models" - -export const DialogModel: Component<{ provider?: string }> = (props) => { - const local = useLocal() - const dialog = useDialog() - - let closeButton!: HTMLButtonElement - const models = createMemo(() => - local.model - .list() - .filter((m) => m.visible) - .filter((m) => (props.provider ? m.provider.id === props.provider : true)), - ) - - return ( - { - if (!open) { - dialog.clear() - } - }} - > - - Select model - - - - - `${x.provider.id}:${x.id}`} - items={models} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - if (a.category === "Recent" && b.category !== "Recent") return -1 - if (b.category === "Recent" && a.category !== "Recent") return 1 - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - closeButton.click() - }} - > - {(i) => ( -
- {i.name} - - Free - - - Latest - -
- )} -
- -
-
- ) -} diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx new file mode 100644 index 000000000..0250963b0 --- /dev/null +++ b/packages/desktop/src/components/dialog-select-file.tsx @@ -0,0 +1,44 @@ +import { useLocal } from "@/context/local" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { useSession } from "@/context/session" +import { useDialog } from "@opencode-ai/ui/context/dialog" + +export function DialogSelectFile() { + const session = useSession() + const local = useLocal() + const dialog = useDialog() + return ( + + x} + onSelect={(path) => { + if (path) { + session.layout.openTab("file://" + path) + } + dialog.clear() + }} + > + {(i) => ( +
+
+ +
+ + {getDirectory(i)} + + {getFilename(i)} +
+
+
+ )} +
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx new file mode 100644 index 000000000..1c9e9cc75 --- /dev/null +++ b/packages/desktop/src/components/dialog-select-model-unpaid.tsx @@ -0,0 +1,119 @@ +import { Component, onCleanup, onMount, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnect } from "./dialog-connect" + +export const DialogSelectModelUnpaid: Component = () => { + const local = useLocal() + const dialog = useDialog() + const providers = useProviders() + + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + return ( + +
+
Free models provided by OpenCode
+ (listRef = ref)} + items={local.model.list} + current={local.model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + dialog.clear() + }} + > + {(i) => ( +
+ {i.name} + Free + + Latest + +
+ )} +
+
+
+
+
+
+
+
Add more models from popular providers
+
+ x?.id} + items={providers.popular} + activeIcon="plus-small" + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+ +
+
+
+
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx new file mode 100644 index 000000000..805db47fe --- /dev/null +++ b/packages/desktop/src/components/dialog-select-model.tsx @@ -0,0 +1,85 @@ +import { Component, createMemo, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogManageModels } from "./dialog-manage-models" + +export const DialogSelectModel: Component<{ provider?: string }> = (props) => { + const local = useLocal() + const dialog = useDialog() + + let closeButton!: HTMLButtonElement + const models = createMemo(() => + local.model + .list() + .filter((m) => m.visible) + .filter((m) => (props.provider ? m.provider.id === props.provider : true)), + ) + + return ( + dialog.replace(() => )} + > + Connect provider + + } + > + `${x.provider.id}:${x.id}`} + items={models} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + if (a.category === "Recent" && b.category !== "Recent") return -1 + if (b.category === "Recent" && a.category !== "Recent") return 1 + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + closeButton.click() + }} + > + {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} +
+ +
+ ) +} diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx index 1c54184bd..292d5fccb 100644 --- a/packages/desktop/src/components/dialog-select-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -1,5 +1,5 @@ import { Component, Show } from "solid-js" -import { useDialog } from "@/context/dialog" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" @@ -13,66 +13,52 @@ export const DialogSelectProvider: Component = () => { const providers = useProviders() return ( - { - if (!open) { - dialog.clear() - } - }} - > - - Connect provider - - - - x?.id} - items={providers.all} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - if (a.category === "Popular" && b.category !== "Popular") return -1 - if (b.category === "Popular" && a.category !== "Popular") return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - dialog.replace(() => ) - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
-
+ + x?.id} + items={providers.all} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + if (a.category === "Popular" && b.category !== "Popular") return -1 + if (b.category === "Popular" && a.category !== "Popular") return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
) } diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index faecd9520..296fe8b2f 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -15,9 +15,9 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useDialog } from "@/context/dialog" -import { DialogModel } from "@/components/dialog-model" -import { DialogModelUnpaid } from "@/components/dialog-model-unpaid" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogSelectModel } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" interface PromptInputProps { @@ -616,7 +616,9 @@ export const PromptInput: Component = (props) => {
@@ -610,12 +611,6 @@ export default function Page() {
- - setStore("fileSelectOpen", open)} - onSelect={(path) => session.layout.openTab("file://" + path)} - /> -
["class"] classList?: ComponentProps<"div">["classList"] } -function DialogRoot(props: DialogProps) { - let trigger!: HTMLElement - const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"]) - - const resetTabIndex = () => { - trigger.tabIndex = 0 - } - - const handleTriggerFocus = (e: FocusEvent & { currentTarget: HTMLElement | null }) => { - const firstChild = e.currentTarget?.firstElementChild as HTMLElement - if (!firstChild) return - - firstChild.focus() - trigger.tabIndex = -1 - - firstChild.addEventListener("focusout", resetTabIndex) - onCleanup(() => { - firstChild.removeEventListener("focusout", resetTabIndex) - }) - } - - onMount(() => { - // @ts-ignore - document?.activeElement?.blur?.() - }) - +export function Dialog(props: DialogProps) { return ( - - - - {props.trigger} - - - - -
-
- - {local.children} - -
-
-
-
+
+
+ + +
+ + {props.title} + + + {props.action} + + + + +
+
+ + {props.description} + +
{props.children}
+
+
+
) } - -function DialogHeader(props: ComponentProps<"div">) { - return
-} - -function DialogBody(props: ComponentProps<"div">) { - return
-} - -function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) { - return -} - -function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) { - return -} - -function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) { - return -} - -export const Dialog = Object.assign(DialogRoot, { - Header: DialogHeader, - Title: DialogTitle, - Description: DialogDescription, - CloseButton: DialogCloseButton, - Body: DialogBody, -}) diff --git a/packages/desktop/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx similarity index 70% rename from packages/desktop/src/context/dialog.tsx rename to packages/ui/src/context/dialog.tsx index cc49764fe..af5da06f9 100644 --- a/packages/desktop/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -1,4 +1,4 @@ -import { createEffect, For, onCleanup, Show, type JSX } from "solid-js" +import { For, Show, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" @@ -14,23 +14,6 @@ export const { use: useDialog, provider: DialogProvider } = createSimpleContext( }[], }) - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape" && store.stack.length > 0) { - const current = store.stack.at(-1)! - current.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - e.preventDefault() - e.stopPropagation() - } - } - - createEffect(() => { - document.addEventListener("keydown", handleKeyDown, true) - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown, true) - }) - }) - return { get stack() { return store.stack @@ -59,6 +42,8 @@ export const { use: useDialog, provider: DialogProvider } = createSimpleContext( }, }) +import { Dialog as Kobalte } from "@kobalte/core/dialog" + export function DialogRoot(props: { children?: JSX.Element }) { const dialog = useDialog() return ( @@ -69,7 +54,21 @@ export function DialogRoot(props: { children?: JSX.Element }) { {(item, index) => ( - {typeof item.element === "function" ? item.element() : item.element} + { + if (!open) { + item.onClose?.() + dialog.pop() + } + }} + > + + + {typeof item.element === "function" ? item.element() : item.element} + + )} diff --git a/packages/ui/src/context/index.ts b/packages/ui/src/context/index.ts index 3e0f5de74..499cb74d4 100644 --- a/packages/ui/src/context/index.ts +++ b/packages/ui/src/context/index.ts @@ -1,3 +1,4 @@ export * from "./helper" export * from "./data" export * from "./diff" +export * from "./dialog" From ad5614bbb91004855608fc98f3d0e75033d52ccf Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:33:18 -0600 Subject: [PATCH 069/575] wip(desktop): progress --- .../{dialog-connect.tsx => dialog-connect-provider.tsx} | 2 +- .../desktop/src/components/dialog-select-model-unpaid.tsx | 4 ++-- packages/desktop/src/components/dialog-select-provider.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename packages/desktop/src/components/{dialog-connect.tsx => dialog-connect-provider.tsx} (99%) diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect-provider.tsx similarity index 99% rename from packages/desktop/src/components/dialog-connect.tsx rename to packages/desktop/src/components/dialog-connect-provider.tsx index f61221f72..4660e1398 100644 --- a/packages/desktop/src/components/dialog-connect.tsx +++ b/packages/desktop/src/components/dialog-connect-provider.tsx @@ -20,7 +20,7 @@ import { Link } from "@/components/link" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectModel } from "./dialog-select-model" -export function DialogConnect(props: { provider: string }) { +export function DialogConnectProvider(props: { provider: string }) { const dialog = useDialog() const globalSync = useGlobalSync() const globalSDK = useGlobalSDK() diff --git a/packages/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx index 1c9e9cc75..7cdb24915 100644 --- a/packages/desktop/src/components/dialog-select-model-unpaid.tsx +++ b/packages/desktop/src/components/dialog-select-model-unpaid.tsx @@ -9,7 +9,7 @@ import { List, ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogConnect } from "./dialog-connect" +import { DialogConnectProvider } from "./dialog-connect-provider" export const DialogSelectModelUnpaid: Component = () => { const local = useLocal() @@ -75,7 +75,7 @@ export const DialogSelectModelUnpaid: Component = () => { }} onSelect={(x) => { if (!x) return - dialog.replace(() => ) + dialog.replace(() => ) }} > {(i) => ( diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx index 292d5fccb..8da10b1d5 100644 --- a/packages/desktop/src/components/dialog-select-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -6,7 +6,7 @@ import { List } from "@opencode-ai/ui/list" import { Tag } from "@opencode-ai/ui/tag" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" -import { DialogConnect } from "./dialog-connect" +import { DialogConnectProvider } from "./dialog-connect-provider" export const DialogSelectProvider: Component = () => { const dialog = useDialog() @@ -34,7 +34,7 @@ export const DialogSelectProvider: Component = () => { }} onSelect={(x) => { if (!x) return - dialog.replace(() => ) + dialog.replace(() => ) }} > {(i) => ( From ba16bfdf3d52da982dc984c77bbf2ec7768b07d6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:35:13 -0600 Subject: [PATCH 070/575] wip(desktop): progress --- .../src/components/dialog-manage-models.tsx | 17 ++++++---- .../src/components/dialog-select-model.tsx | 5 ++- packages/desktop/src/context/local.tsx | 20 ++++++------ packages/ui/src/components/list.tsx | 32 ++++++++++++------- 4 files changed, 42 insertions(+), 32 deletions(-) diff --git a/packages/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx index de1c3cb15..5765a8e1a 100644 --- a/packages/desktop/src/components/dialog-manage-models.tsx +++ b/packages/desktop/src/components/dialog-manage-models.tsx @@ -27,18 +27,21 @@ export const DialogManageModels: Component = () => { }} onSelect={(x) => { if (!x) return - local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible) + const visible = local.model.visible({ modelID: x.id, providerID: x.provider.id }) + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible) }} > {(i) => (
{i.name} - { - local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) - }} - /> +
e.stopPropagation()}> + { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> +
)} diff --git a/packages/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx index 805db47fe..f0b2e6db9 100644 --- a/packages/desktop/src/components/dialog-select-model.tsx +++ b/packages/desktop/src/components/dialog-select-model.tsx @@ -13,11 +13,10 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { const local = useLocal() const dialog = useDialog() - let closeButton!: HTMLButtonElement const models = createMemo(() => local.model .list() - .filter((m) => m.visible) + .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id })) .filter((m) => (props.provider ? m.provider.id === props.provider : true)), ) @@ -58,7 +57,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, }) - closeButton.click() + dialog.clear() }} > {(i) => ( diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 0970178ea..56154c5ba 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -132,10 +132,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ Object.values(p.models).map((m) => ({ ...m, provider: p, - user: store.user.find((x) => x.modelID === m.id && x.providerID === p.id), })), ), ) + const latest = createMemo(() => pipe( available(), @@ -163,10 +163,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ ...m, name: m.name.replace("(latest)", "").trim(), latest: m.name.includes("(latest)"), - visible: - m.user?.visibility !== "hide" && - (latest().find((x) => x.modelID === m.id && x.providerID === m.provider.id) || - store.user.find((x) => x.modelID === m.id && x.providerID === m.provider.id)?.visibility === "show"), })), ) @@ -241,7 +237,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (index >= 0) { setStore("user", index, { visibility }) } else { - setStore("user", (prev) => [...prev, { ...model, visibility }]) + setStore("user", store.user.length, { ...model, visibility }) } } @@ -260,11 +256,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) }, - show(model: ModelKey) { - updateVisibility(model, "show") - }, - hide(model: ModelKey) { - updateVisibility(model, "hide") + visible(model: ModelKey) { + const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID) + return ( + user?.visibility !== "hide" && + (latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) || + user?.visibility === "show") + ) }, setVisibility(model: ModelKey, visible: boolean) { updateVisibility(model, visible ? "show" : "hide") diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 2923956a9..7ec6e159d 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -1,4 +1,4 @@ -import { createEffect, Show, For, type JSX, createSignal } from "solid-js" +import { createEffect, on, Show, For, type JSX, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Icon, IconProps } from "./icon" @@ -32,24 +32,34 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) mouseActive: false, }) - const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList(props) + const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList(props) const searchProps = () => (typeof props.search === "object" ? props.search : {}) - const hasSearch = () => !!props.search createEffect(() => { if (props.filter !== undefined) { onInput(props.filter) - } else if (hasSearch()) { - onInput(internalFilter()) } }) - createEffect(() => { - filter() - scrollRef()?.scrollTo(0, 0) - reset() - }) + createEffect((prev) => { + if (!props.search) return + const current = internalFilter() + if (prev !== current) { + onInput(current) + } + return current + }, "") + + createEffect( + on( + filter, + () => { + scrollRef()?.scrollTo(0, 0) + }, + { defer: true }, + ), + ) createEffect(() => { if (!scrollRef()) return @@ -100,7 +110,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return (
- +
From 654534ac716622aa3eb2d9c8b96db4a371252a46 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:41:20 -0600 Subject: [PATCH 071/575] fix: update sdk --- packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 31d5b8561..c466e78dc 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1662,6 +1662,7 @@ export type Model = { headers: { [key: string]: string } + release_date: string } export type Provider = { From 79a4c6531371433b0e778aeacd4a5f9a991c77d3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:42:17 -0600 Subject: [PATCH 072/575] fix: test --- packages/opencode/test/provider/transform.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4e202a63c..eecf43783 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -144,6 +144,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { status: "active", options: {}, headers: {}, + release_date: "2023-04-01", }) expect(result).toHaveLength(1) @@ -204,6 +205,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { status: "active", options: {}, headers: {}, + release_date: "2023-04-01", }) expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Thinking...") @@ -250,6 +252,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { status: "active", options: {}, headers: {}, + release_date: "2023-04-01", }) expect(result[0].content).toEqual([ From 6a09861806fa6b2068f8251e598c14e2b756ab00 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 15 Dec 2025 03:42:56 +0000 Subject: [PATCH 073/575] chore: format code --- packages/sdk/openapi.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 21928684a..71f1df312 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8681,9 +8681,24 @@ "additionalProperties": { "type": "string" } + }, + "release_date": { + "type": "string" } }, - "required": ["id", "providerID", "api", "name", "capabilities", "cost", "limit", "status", "options", "headers"] + "required": [ + "id", + "providerID", + "api", + "name", + "capabilities", + "cost", + "limit", + "status", + "options", + "headers", + "release_date" + ] }, "Provider": { "type": "object", From 54569b55525de3c25c78468299261a9d94517450 Mon Sep 17 00:00:00 2001 From: Ravi Kumar <82090231+Raviguntakala@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:35:06 +0530 Subject: [PATCH 074/575] fix(session): fix unshare command not clearing share state (#5523) --- packages/opencode/src/cli/cmd/run.ts | 8 ++++---- .../src/cli/cmd/tui/routes/session/index.tsx | 11 +++++++---- packages/opencode/src/session/index.ts | 16 +++------------- packages/opencode/src/share/share-next.ts | 2 +- 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c57711b4c..23456c75e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -277,8 +277,8 @@ export const RunCommand = cmd({ } return { error } }) - if (!shareResult.error) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8)) + if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) } } @@ -330,8 +330,8 @@ export const RunCommand = cmd({ } return { error } }) - if (!shareResult.error) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8)) + if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) } } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1c1e4b65e..48f7db054 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -323,10 +323,13 @@ export function Session() { keybind: "session_unshare", disabled: !session()?.share?.url, category: "Session", - onSelect: (dialog) => { - sdk.client.session.unshare({ - sessionID: route.sessionID, - }) + onSelect: async (dialog) => { + await sdk.client.session + .unshare({ + sessionID: route.sessionID, + }) + .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) + .catch(() => toast.show({ message: "Failed to unshare session", variant: "error" })) dialog.clear() }, }, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index bf3135284..b1a193904 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -234,22 +234,12 @@ export namespace Session { }) export const unshare = fn(Identifier.schema("session"), async (id) => { - const cfg = await Config.get() - if (cfg.enterprise?.url) { - const { ShareNext } = await import("@/share/share-next") - await ShareNext.remove(id) - await update(id, (draft) => { - draft.share = undefined - }) - } - const share = await getShare(id) - if (!share) return - await Storage.remove(["share", id]) + // Use ShareNext to remove the share (same as share function uses ShareNext to create) + const { ShareNext } = await import("@/share/share-next") + await ShareNext.remove(id) await update(id, (draft) => { draft.share = undefined }) - const { Share } = await import("../share/share") - await Share.remove(id, share.secret) }) export async function update(id: string, editor: (session: Info) => void) { diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index fea9c3bb9..37ecdf7ea 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -157,7 +157,7 @@ export namespace ShareNext { secret: share.secret, }), }) - await Storage.remove(["session_share", share.id]) + await Storage.remove(["session_share", sessionID]) } async function fullSync(sessionID: string) { From 543dbe71d28e28c409fdd4ce40717b9f5535d1b3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 14 Dec 2025 22:36:46 -0600 Subject: [PATCH 075/575] ci: smart oc --- .github/workflows/opencode.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 4c75ad2e0..e6148acbd 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -32,3 +32,29 @@ jobs: OPENCODE_PERMISSION: '{"bash": "deny"}' with: model: opencode/claude-haiku-4-5 + + opencode-smart: + if: | + contains(github.event.comment.body, ' /soc') || + startsWith(github.event.comment.body, '/soc') || + contains(github.event.comment.body, ' /smart-opencode') || + startsWith(github.event.comment.body, '/smart-opencode') + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-bun + + - name: Run opencode + uses: sst/opencode/github@latest + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + OPENCODE_PERMISSION: '{"bash": "deny"}' + with: + model: opencode/claude-opus-4-5 From cf5c0129ac2663950b049d89367c35f4f13d3a2d Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 15 Dec 2025 12:17:41 +0800 Subject: [PATCH 076/575] tauri: rename sidecar to opencode-cli --- packages/tauri/scripts/utils.ts | 2 +- packages/tauri/src-tauri/tauri.conf.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tauri/scripts/utils.ts b/packages/tauri/scripts/utils.ts index b2885d00a..3e74346c8 100644 --- a/packages/tauri/scripts/utils.ts +++ b/packages/tauri/scripts/utils.ts @@ -36,7 +36,7 @@ export function getCurrentSidecar(target = RUST_TARGET) { export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET) { await $`mkdir -p src-tauri/sidecars` - const dest = `src-tauri/sidecars/opencode-${target}${process.platform === "win32" ? ".exe" : ""}` + const dest = `src-tauri/sidecars/opencode-cli-${target}${process.platform === "win32" ? ".exe" : ""}` await $`cp ${source} ${dest}` console.log(`Copied ${source} to ${dest}`) diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index 607b94134..6813a218b 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ "active": true, "targets": ["deb", "rpm", "dmg", "nsis"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], - "externalBin": ["sidecars/opencode"], + "externalBin": ["sidecars/opencode-cli"], "createUpdaterArtifacts": true, "macOS": { "entitlements": "./entitlements.plist" From 220c564047946a96ac604a668926d38b99cfec88 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 15 Dec 2025 12:40:49 +0800 Subject: [PATCH 077/575] tauri: use correct sidecar name --- packages/tauri/src-tauri/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tauri/src-tauri/src/lib.rs b/packages/tauri/src-tauri/src/lib.rs index a275fab78..b06ccd06c 100644 --- a/packages/tauri/src-tauri/src/lib.rs +++ b/packages/tauri/src-tauri/src/lib.rs @@ -4,9 +4,9 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, Instant}, }; -use tauri::{AppHandle, LogicalSize, Manager, Monitor, RunEvent, WebviewUrl, WebviewWindow}; #[cfg(target_os = "macos")] use tauri::TitleBarStyle; +use tauri::{AppHandle, LogicalSize, Manager, Monitor, RunEvent, WebviewUrl, WebviewWindow}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; @@ -66,7 +66,7 @@ fn find_and_kill_process_on_port(port: u16) -> Result<(), Box CommandChild { let (mut rx, child) = app .shell() - .sidecar("opencode") + .sidecar("opencode-cli") .unwrap() .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") .env("OPENCODE_CLIENT", "desktop") From 9555d348de8d6fdc389f8f554dd28d3eb79e504d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 14 Dec 2025 22:41:39 -0600 Subject: [PATCH 078/575] ci: switch model --- .github/workflows/opencode.yml | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index e6148acbd..37210191e 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -25,32 +25,6 @@ jobs: - uses: ./.github/actions/setup-bun - - name: Run opencode - uses: sst/opencode/github@latest - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - OPENCODE_PERMISSION: '{"bash": "deny"}' - with: - model: opencode/claude-haiku-4-5 - - opencode-smart: - if: | - contains(github.event.comment.body, ' /soc') || - startsWith(github.event.comment.body, '/soc') || - contains(github.event.comment.body, ' /smart-opencode') || - startsWith(github.event.comment.body, '/smart-opencode') - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - id-token: write - contents: read - pull-requests: read - issues: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: ./.github/actions/setup-bun - - name: Run opencode uses: sst/opencode/github@latest env: From b021b26e77b4982e27b77a33d51535bfd9d0ff76 Mon Sep 17 00:00:00 2001 From: DS <78942835+Tarquinen@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:51:11 -0500 Subject: [PATCH 079/575] feat: restore experimental.chat.messages.transform and add experimental.chat.system.transform hooks (#5542) --- packages/opencode/src/session/llm.ts | 8 +++++++- packages/opencode/src/session/prompt.ts | 8 ++++++-- packages/plugin/src/index.ts | 6 ++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 97b8aae2b..565d037f4 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,7 +1,7 @@ import { Provider } from "@/provider/provider" import { Log } from "@/util/log" import { streamText, wrapLanguageModel, type ModelMessage, type StreamTextResult, type Tool, type ToolSet } from "ai" -import { mergeDeep, pipe } from "remeda" +import { clone, mergeDeep, pipe } from "remeda" import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" import { Instance } from "@/project/instance" @@ -60,6 +60,12 @@ export namespace LLM { .join("\n"), ) + const original = clone(system) + await Plugin.trigger("experimental.chat.system.transform", {}, { system }) + if (system.length === 0) { + system.push(...original) + } + const params = await Plugin.trigger( "chat.params", { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9a36c5c62..e71162d0b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -20,7 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { defer } from "../util/defer" -import { mergeDeep, pipe } from "remeda" +import { clone, mergeDeep, pipe } from "remeda" import { ToolRegistry } from "../tool/registry" import { Wildcard } from "../util/wildcard" import { MCP } from "../mcp" @@ -480,6 +480,10 @@ export namespace SessionPrompt { }) } + const sessionMessages = clone(msgs) + + await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) + const result = await processor.process({ user: lastUser, agent, @@ -487,7 +491,7 @@ export namespace SessionPrompt { sessionID, system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())], messages: [ - ...MessageV2.toModelMessage(msgs), + ...MessageV2.toModelMessage(sessionMessages), ...(isLastStep ? [ { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 57ca75d60..9dd4820b9 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -185,6 +185,12 @@ export interface Hooks { }[] }, ) => Promise + "experimental.chat.system.transform"?: ( + input: {}, + output: { + system: string[] + }, + ) => Promise "experimental.text.complete"?: ( input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, From ae1bf92c815b32a2eb9808e813cb7f9a593f20dd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:58:26 -0600 Subject: [PATCH 080/575] Add dismiss button to Getting Started box (#5543) Co-authored-by: opencode-agent[bot] Co-authored-by: rekram1-node Co-authored-by: Aiden Cline --- .../src/cli/cmd/tui/routes/session/sidebar.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index b5208cd1c..d79200d5b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -9,6 +9,7 @@ import { Global } from "@/global" import { Installation } from "@/installation" import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" +import { useKV } from "../../context/kv" export function Sidebar(props: { sessionID: string }) { const sync = useSync() @@ -48,12 +49,13 @@ export function Sidebar(props: { sessionID: string }) { } }) - const keybind = useKeybind() const directory = useDirectory() + const kv = useKV() const hasProviders = createMemo(() => sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) + const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false)) return ( @@ -249,7 +251,7 @@ export function Sidebar(props: { sessionID: string }) { - + - - Getting started - + + + Getting started + + kv.set("dismissed_getting_started", true)}> + ✕ + + OpenCode includes free models so you can start immediately. Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc From 509f7d961768e56b6b3862adf19779518b3e0d0f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 14 Dec 2025 22:59:30 -0600 Subject: [PATCH 081/575] ignore: fix debug var in last commit --- packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index d79200d5b..b64a18ae2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -251,7 +251,7 @@ export function Sidebar(props: { sessionID: string }) { - + Date: Mon, 15 Dec 2025 06:01:50 +0100 Subject: [PATCH 082/575] fix(edit): add per-file lock to prevent read-before-write race (#4388) --- packages/opencode/src/file/time.ts | 26 ++++++++++++++++++++++++++ packages/opencode/src/tool/edit.ts | 8 ++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 5cba5e820..770427abe 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -3,14 +3,20 @@ import { Log } from "../util/log" export namespace FileTime { const log = Log.create({ service: "file.time" }) + // Per-session read times plus per-file write locks. + // All tools that overwrite existing files should run their + // assert/read/write/update sequence inside withLock(filepath, ...) + // so concurrent writes to the same file are serialized. export const state = Instance.state(() => { const read: { [sessionID: string]: { [path: string]: Date | undefined } } = {} + const locks = new Map>() return { read, + locks, } }) @@ -25,6 +31,26 @@ export namespace FileTime { return state().read[sessionID]?.[file] } + export async function withLock(filepath: string, fn: () => Promise): Promise { + const current = state() + const currentLock = current.locks.get(filepath) ?? Promise.resolve() + let release: () => void = () => {} + const nextLock = new Promise((resolve) => { + release = resolve + }) + const chained = currentLock.then(() => nextLock) + current.locks.set(filepath, chained) + await currentLock + try { + return await fn() + } finally { + release() + if (current.locks.get(filepath) === chained) { + current.locks.delete(filepath) + } + } + } + export async function assert(sessionID: string, filepath: string) { const time = get(sessionID, filepath) if (!time) throw new Error(`You must read the file ${filepath} before overwriting it. Use the Read tool first`) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 62814dbf9..fdf115ac4 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -76,7 +76,7 @@ export const EditTool = Tool.define("edit", { let diff = "" let contentOld = "" let contentNew = "" - await (async () => { + await FileTime.withLock(filePath, async () => { if (params.oldString === "") { contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) @@ -97,6 +97,7 @@ export const EditTool = Tool.define("edit", { await Bus.publish(File.Event.Edited, { file: filePath, }) + FileTime.read(ctx.sessionID, filePath) return } @@ -133,9 +134,8 @@ export const EditTool = Tool.define("edit", { diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), ) - })() - - FileTime.read(ctx.sessionID, filePath) + FileTime.read(ctx.sessionID, filePath) + }) let output = "" await LSP.touchFile(filePath, true) From 7c1124199ecba33750c9b536c907359f40ed91de Mon Sep 17 00:00:00 2001 From: Nalin Singh <38408670+nalin-singh@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:40:33 +0530 Subject: [PATCH 083/575] fix: input lip visibility for transparent themes (#5544) --- .../src/cli/cmd/tui/component/prompt/index.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 784c8648e..eefe43d1f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -873,17 +873,24 @@ export function Prompt(props: PromptProps) { borderColor={highlight()} customBorderChars={{ ...EmptyBorder, - vertical: "╹", + vertical: theme.backgroundElement.a !== 0 ? "╹" : " ", }} > From 9eefcd1b41db29e5142bce800790f559f01f2877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= Date: Mon, 15 Dec 2025 06:56:47 +0100 Subject: [PATCH 084/575] Provider fix, anthropic Errorhandling if empty image file is read (#5521) --- packages/opencode/src/provider/transform.ts | 14 +++ .../opencode/test/provider/transform.test.ts | 103 ++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 728c26a27..9af5589e8 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -171,6 +171,20 @@ export namespace ProviderTransform { const filtered = msg.content.map((part) => { if (part.type !== "file" && part.type !== "image") return part + // Check for empty base64 image data + if (part.type === "image") { + const imageStr = part.image.toString() + if (imageStr.startsWith("data:")) { + const match = imageStr.match(/^data:([^;]+);base64,(.*)$/) + if (match && (!match[2] || match[2].length === 0)) { + return { + type: "text" as const, + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + } + } + } + } + const mime = part.type === "image" ? part.image.toString().split(";")[0].replace("data:", "") : part.mediaType const filename = part.type === "file" ? part.filename : undefined const modality = mimeToModality(mime) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index eecf43783..b040f24f4 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -262,3 +262,106 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() }) }) + +describe("ProviderTransform.message - empty image handling", () => { + const mockModel = { + id: "anthropic/claude-3-5-sonnet", + providerID: "anthropic", + api: { + id: "claude-3-5-sonnet-20241022", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude 3.5 Sonnet", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.003, + output: 0.015, + cache: { read: 0.0003, write: 0.00375 }, + }, + limit: { + context: 200000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + } as any + + test("should replace empty base64 image with error text", () => { + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", image: "data:image/png;base64," }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "text", text: "What is in this image?" }) + expect(result[0].content[1]).toEqual({ + type: "text", + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + }) + }) + + test("should keep valid base64 images unchanged", () => { + const validBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", image: `data:image/png;base64,${validBase64}` }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "text", text: "What is in this image?" }) + expect(result[0].content[1]).toEqual({ type: "image", image: `data:image/png;base64,${validBase64}` }) + }) + + test("should handle mixed valid and empty images", () => { + const validBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "Compare these images" }, + { type: "image", image: `data:image/png;base64,${validBase64}` }, + { type: "image", image: "data:image/jpeg;base64," }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(3) + expect(result[0].content[0]).toEqual({ type: "text", text: "Compare these images" }) + expect(result[0].content[1]).toEqual({ type: "image", image: `data:image/png;base64,${validBase64}` }) + expect(result[0].content[2]).toEqual({ + type: "text", + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + }) + }) +}) From ed6d74910443c809f96eaed38a7b3147f0c47c2b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 15 Dec 2025 12:05:05 +0000 Subject: [PATCH 085/575] ignore: update download stats 2025-12-15 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index 1a1081d3a..9d60266d2 100644 --- a/STATS.md +++ b/STATS.md @@ -170,3 +170,4 @@ | 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) | | 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) | | 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) | +| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) | From ea65a91b2ed06294f781cca9822b4d58c08fa301 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 15 Dec 2025 13:11:09 +0000 Subject: [PATCH 086/575] fix: remove blue border from prompt input --- packages/desktop/src/components/prompt-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 296fe8b2f..3d595840b 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -580,7 +580,7 @@ export const PromptInput: Component = (props) => { onSubmit={handleSubmit} classList={{ "bg-surface-raised-stronger-non-alpha border border-border-strong-base": true, - "rounded-md overflow-clip focus-within:border-transparent focus-within:shadow-xs-border-select": true, + "rounded-md overflow-clip focus-within:border-transparent focus-within:shadow-xs-border": true, [props.class ?? ""]: !!props.class, }} > From b913eb7acc543ba4eb08af7af601e46ea8db23c5 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 15 Dec 2025 13:21:59 +0000 Subject: [PATCH 087/575] fix: avatar and icon size in sidebar --- packages/desktop/src/pages/layout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 7af562d57..965ade9f8 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -173,7 +173,7 @@ export default function Layout(props: ParentProps) { const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( -
+
From d2217bb825639da0848a0397b187805554696cf7 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 15 Dec 2025 14:05:24 +0000 Subject: [PATCH 089/575] fix: fix width and padding on agent select - conditionally hide the role=presentation element because it was adding extra gap before the first agent --- packages/ui/src/components/select.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index 96ddf174c..8d0ce44fd 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -47,11 +47,14 @@ overflow-x: hidden; display: flex; flex-direction: column; - gap: 2px; &:focus { outline: none; } + + > *:not([role="presentation"]) + *:not([role="presentation"]) { + margin-top: 2px; + } } /* [data-slot="select-section"] { */ @@ -62,6 +65,7 @@ display: flex; align-items: center; padding: 0 6px 0 6px; + gap: 12px; border-radius: var(--radius-sm); /* text-12-medium */ From bb426112ed5a87b5a91199a0d7be43ded391ca67 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 15 Dec 2025 14:09:47 +0000 Subject: [PATCH 090/575] fix: replace agents dropdown with shadow border --- packages/ui/src/components/select.css | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index 8d0ce44fd..482f7e9c0 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -23,12 +23,9 @@ max-width: 23rem; overflow: hidden; border-radius: var(--radius-md); - border-width: 1px; - border-style: solid; - border-color: var(--border-weak-base); background-color: var(--surface-raised-stronger-non-alpha); padding: 2px; - box-shadow: var(--shadow-xs); + box-shadow: var(--shadow-xs-border); z-index: 50; &[data-closed] { From d43fbec12d30542428b3bd1cc967e362ac058258 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 15 Dec 2025 14:14:10 +0000 Subject: [PATCH 091/575] fix: prompt input using border shadow --- packages/desktop/src/components/prompt-input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index e99f89c46..0c1be77db 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -579,8 +579,8 @@ export const PromptInput: Component = (props) => {
From a0c0e2b5c3b5041389ba025ed7858cb9f2b3fe00 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 15 Dec 2025 14:22:57 +0000 Subject: [PATCH 092/575] fix: add tooltip to close review tab --- packages/desktop/src/pages/session.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index a21135f76..bef12fbd8 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -280,7 +280,11 @@ export default function Page() {
props.onTabClose(props.tab)} />} + closeButton={ + + props.onTabClose(props.tab)} /> + + } hideCloseButton onClick={() => props.onTabClick(props.tab)} > @@ -358,7 +362,9 @@ export default function Page() { + + + } >
From 2ca118db59f9beda1a30ae7fe8b2ae67e0738133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= Date: Mon, 15 Dec 2025 15:32:06 +0100 Subject: [PATCH 093/575] docs: Fix Wakatime repository link in ecosystem.mdx (#5552) Co-authored-by: Github Action --- flake.lock | 6 +++--- packages/web/src/content/docs/ecosystem.mdx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.lock b/flake.lock index 58344d82c..0d77a07fd 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1765425892, - "narHash": "sha256-jlQpSkg2sK6IJVzTQBDyRxQZgKADC2HKMRfGCSgNMHo=", + "lastModified": 1765644376, + "narHash": "sha256-yqHBL2wYGwjGL2GUF2w3tofWl8qO9tZEuI4wSqbCrtE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5d6bdbddb4695a62f0d00a3620b37a15275a5093", + "rev": "23735a82a828372c4ef92c660864e82fbe2f5fbe", "type": "github" }, "original": { diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index c62f12cb3..6a3119549 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -27,7 +27,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | | [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style | | [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. | -| [opencode-wakatime](https://github.com/angrister/opencode-wakatime) | Track OpenCode usage with Wakatime | +| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | | [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | | [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | From 274b86b19bcd2b6d27fb91212ebe29a0b1b3ba57 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 15 Dec 2025 09:47:19 -0500 Subject: [PATCH 094/575] ci: fix AppImage build hanging by using portable appimage format - Add appimage target back to tauri.conf.json - Force reinstall tauri-cli from feat/truly-portable-appimage branch - Add 10 minute timeout to prevent indefinite hangs - Add logging to verify correct tauri-cli version is installed This fixes the issue where AppImage builds would hang at 'Running input plugin: gtk' by using the new portable appimage format that bypasses linuxdeploy entirely. --- .github/workflows/publish.yml | 7 ++++++- packages/tauri/src-tauri/tauri.conf.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9c44efe1b..3120a02f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -166,10 +166,15 @@ jobs: GH_TOKEN: ${{ github.token }} # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released - - run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage + - name: Install tauri-cli from portable appimage branch if: contains(matrix.settings.host, 'ubuntu') + run: | + cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force + echo "Installed tauri-cli version:" + cargo tauri --version - name: Build and upload artifacts + timeout-minutes: 10 uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index 6813a218b..e2d1f239a 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ }, "bundle": { "active": true, - "targets": ["deb", "rpm", "dmg", "nsis"], + "targets": ["appimage", "deb", "rpm", "dmg", "nsis"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "externalBin": ["sidecars/opencode-cli"], "createUpdaterArtifacts": true, From b0f77da56c0238667c897d084f4c51eab263df6f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 15 Dec 2025 09:58:23 -0500 Subject: [PATCH 095/575] core: reorganize agent configuration to separate primary agents (build, plan) from subagents --- .github/workflows/publish.yml | 2 +- packages/opencode/src/agent/agent.ts | 36 +++++++++++++------------- packages/opencode/src/config/config.ts | 6 +++++ 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3120a02f3..4e35af4e2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -102,7 +102,7 @@ jobs: target: aarch64-apple-darwin - host: blacksmith-4vcpu-windows-2025 target: x86_64-pc-windows-msvc - - host: blacksmith-4vcpu-ubuntu-2404 + - host: blacksmith-4vcpu-ubuntu-2204 target: x86_64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ef007df13..a2ff825d9 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -107,6 +107,24 @@ export namespace Agent { ) const result: Record = { + build: { + name: "build", + tools: { ...defaultTools }, + options: {}, + permission: agentPermission, + mode: "primary", + native: true, + }, + plan: { + name: "plan", + options: {}, + permission: planPermission, + tools: { + ...defaultTools, + }, + mode: "primary", + native: true, + }, general: { name: "general", description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, @@ -149,14 +167,6 @@ export namespace Agent { options: {}, permission: agentPermission, }, - build: { - name: "build", - tools: { ...defaultTools }, - options: {}, - permission: agentPermission, - mode: "primary", - native: true, - }, title: { name: "title", mode: "primary", @@ -177,16 +187,6 @@ export namespace Agent { prompt: PROMPT_SUMMARY, tools: {}, }, - plan: { - name: "plan", - options: {}, - permission: planPermission, - tools: { - ...defaultTools, - }, - mode: "primary", - native: true, - }, } for (const [key, value] of Object.entries(cfg.agent ?? {})) { if (value.disable) { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 333e19848..d0fb12c58 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -668,10 +668,16 @@ export namespace Config { .describe("@deprecated Use `agent` field instead."), agent: z .object({ + // primary plan: Agent.optional(), build: Agent.optional(), + // subagent general: Agent.optional(), explore: Agent.optional(), + // specialized + title: Agent.optional(), + summary: Agent.optional(), + compaction: Agent.optional(), }) .catchall(Agent) .optional() From f492122d590030d20ae8445596f77ebfc00eb9ec Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 15 Dec 2025 14:59:05 +0000 Subject: [PATCH 096/575] chore: format code --- packages/sdk/js/src/v2/gen/types.gen.ts | 3 +++ packages/sdk/openapi.json | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c466e78dc..a92709561 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1413,6 +1413,9 @@ export type Config = { build?: AgentConfig general?: AgentConfig explore?: AgentConfig + title?: AgentConfig + summary?: AgentConfig + compaction?: AgentConfig [key: string]: AgentConfig | undefined } /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 71f1df312..5a978c69c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7992,6 +7992,15 @@ }, "explore": { "$ref": "#/components/schemas/AgentConfig" + }, + "title": { + "$ref": "#/components/schemas/AgentConfig" + }, + "summary": { + "$ref": "#/components/schemas/AgentConfig" + }, + "compaction": { + "$ref": "#/components/schemas/AgentConfig" } }, "additionalProperties": { From d2ce368a3fc15a740da097385990b60efb2b6e68 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 15 Dec 2025 10:14:18 -0500 Subject: [PATCH 097/575] ci: update publish workflow concurrency to include version inputs and upgrade ubuntu runner to 24.04 --- .github/workflows/publish.yml | 4 ++-- packages/tauri/src-tauri/tauri.conf.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4e35af4e2..4e3df621a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ on: required: false type: string -concurrency: ${{ github.workflow }}-${{ github.ref }} +concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }} permissions: id-token: write @@ -102,7 +102,7 @@ jobs: target: aarch64-apple-darwin - host: blacksmith-4vcpu-windows-2025 target: x86_64-pc-windows-msvc - - host: blacksmith-4vcpu-ubuntu-2204 + - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index e2d1f239a..6813a218b 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ }, "bundle": { "active": true, - "targets": ["appimage", "deb", "rpm", "dmg", "nsis"], + "targets": ["deb", "rpm", "dmg", "nsis"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "externalBin": ["sidecars/opencode-cli"], "createUpdaterArtifacts": true, From 56dde2cc835f509f77cbd800d080d6dbb2b8edc6 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 15 Dec 2025 16:01:15 +0000 Subject: [PATCH 098/575] release: v1.0.154 --- bun.lock | 30 +++++++++++++------------- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 4 ++-- packages/sdk/js/package.json | 4 ++-- packages/slack/package.json | 2 +- packages/tauri/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index c709bd83b..d7e87dd5e 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -48,7 +48,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -75,7 +75,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -99,7 +99,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -123,7 +123,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -170,7 +170,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -199,7 +199,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "22.0.0", @@ -215,7 +215,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.153", + "version": "1.0.154", "bin": { "opencode": "./bin/opencode", }, @@ -307,7 +307,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -327,7 +327,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.153", + "version": "1.0.154", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -338,7 +338,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -351,7 +351,7 @@ }, "packages/tauri": { "name": "@opencode-ai/tauri", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@opencode-ai/desktop": "workspace:*", "@tauri-apps/api": "^2", @@ -376,7 +376,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -411,7 +411,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "zod": "catalog:", }, @@ -422,7 +422,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 4fcaff701..e3d371d3d 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f68f70436..329ad92f9 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.153", + "version": "1.0.154", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 53a41670d..b1aa5e080 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.153", + "version": "1.0.154", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index a03e3843b..e9de718de 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.153", + "version": "1.0.154", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 0f5afeaa9..90f00980d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/desktop", - "version": "1.0.153", + "version": "1.0.154", "description": "", "type": "module", "exports": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index d0589587b..481b893ed 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.153", + "version": "1.0.154", "private": true, "type": "module", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 52f948655..124bb4433 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.0.153" +version = "1.0.154" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index c2ee790ce..9320bedc5 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.153", + "version": "1.0.154", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index bbeb9ae0a..7a63b3532 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.153", + "version": "1.0.154", "name": "opencode", "type": "module", "private": true, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 4a7841908..509d00949 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a18040830..ac9e2f050 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", @@ -29,4 +29,4 @@ "publishConfig": { "directory": "dist" } -} +} \ No newline at end of file diff --git a/packages/slack/package.json b/packages/slack/package.json index 7a5c339e8..b38bad972 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/tauri/package.json b/packages/tauri/package.json index 22fa35023..5cec6c0cd 100644 --- a/packages/tauri/package.json +++ b/packages/tauri/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/tauri", "private": true, - "version": "1.0.153", + "version": "1.0.154", "type": "module", "scripts": { "typecheck": "tsgo -b", diff --git a/packages/ui/package.json b/packages/ui/package.json index 2632f6961..739953777 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.153", + "version": "1.0.154", "type": "module", "exports": { "./*": "./src/components/*.tsx", diff --git a/packages/util/package.json b/packages/util/package.json index c77d99e7c..16149e204 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.153", + "version": "1.0.154", "private": true, "type": "module", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 9d028edbb..7c8ca723d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.153", + "version": "1.0.154", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 6291f7177..753f8526f 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.0.153", + "version": "1.0.154", "publisher": "sst-dev", "repository": { "type": "git", From e9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:09:57 -0600 Subject: [PATCH 099/575] wip(desktop): progress --- packages/desktop/src/app.tsx | 45 ++-- .../desktop/src/components/prompt-input.tsx | 203 +++++++++----- packages/desktop/src/context/command.tsx | 255 ++++++++++++++++++ packages/desktop/src/pages/session.tsx | 117 ++++---- 4 files changed, 475 insertions(+), 145 deletions(-) create mode 100644 packages/desktop/src/context/command.tsx diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index a49dac9aa..6414d0d49 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -12,6 +12,7 @@ import { GlobalSDKProvider } from "@/context/global-sdk" import { SessionProvider } from "@/context/session" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { CommandProvider } from "@/context/command" import Layout from "@/pages/layout" import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" @@ -40,27 +41,29 @@ export function App() { - - - - - - - } /> - ( - - - - - - )} - /> - - - - + + + + + + + + } /> + ( + + + + + + )} + /> + + + + + diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 0c1be77db..6ab280fa6 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,5 +1,5 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" +import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" @@ -19,6 +19,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" +import { useCommand, formatKeybind } from "@/context/command" interface PromptInputProps { class?: string @@ -53,6 +54,14 @@ const PLACEHOLDERS = [ "How do environment variables work here?", ] +interface SlashCommand { + id: string + trigger: string + title: string + description?: string + keybind?: string +} + export const PromptInput: Component = (props) => { const navigate = useNavigate() const sdk = useSDK() @@ -61,18 +70,21 @@ export const PromptInput: Component = (props) => { const session = useSession() const dialog = useDialog() const providers = useProviders() + const command = useCommand() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ - popoverIsOpen: boolean + popover: "file" | "slash" | null historyIndex: number savedPrompt: Prompt | null placeholder: number + slashFilter: string }>({ - popoverIsOpen: false, + popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + slashFilter: "", }) const MAX_HISTORY = 100 @@ -157,17 +169,17 @@ export const PromptInput: Component = (props) => { } onMount(() => { - editorRef.addEventListener("paste", handlePaste) + editorRef?.addEventListener("paste", handlePaste) }) onCleanup(() => { - editorRef.removeEventListener("paste", handlePaste) + editorRef?.removeEventListener("paste", handlePaste) }) createEffect(() => { if (isFocused()) { handleInput() } else { - setStore("popoverIsOpen", false) + setStore("popover", null) } }) @@ -182,6 +194,53 @@ export const PromptInput: Component = (props) => { onSelect: handleFileSelect, }) + // Get slash commands from registered commands (only those with explicit slash trigger) + const slashCommands = createMemo(() => + command.options + .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) + .map((opt) => ({ + id: opt.id, + trigger: opt.slash!, + title: opt.title, + description: opt.description, + keybind: opt.keybind, + })), + ) + + const handleSlashSelect = (cmd: SlashCommand | undefined) => { + if (!cmd) return + // Since slash commands only trigger from start, just clear the input + editorRef.innerHTML = "" + session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("popover", null) + command.trigger(cmd.id, "slash") + } + + const { + flat: slashFlat, + active: slashActive, + onInput: slashOnInput, + onKeyDown: slashOnKeyDown, + } = useFilteredList({ + items: () => { + const filter = store.slashFilter.toLowerCase() + return slashCommands().filter( + (cmd) => + cmd.trigger.toLowerCase().includes(filter) || + cmd.title.toLowerCase().includes(filter) || + cmd.description?.toLowerCase().includes(filter) || + false, + ) + }, + key: (x) => x?.id, + onSelect: handleSlashSelect, + }) + + // Update slash filter when store changes + createEffect(() => { + slashOnInput(store.slashFilter) + }) + createEffect( on( () => session.prompt.current(), @@ -256,11 +315,17 @@ export const PromptInput: Component = (props) => { const rawText = rawParts.map((p) => p.content).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) + // Slash commands only trigger when / is at the start of input + const slashMatch = rawText.match(/^\/(\S*)$/) + if (atMatch) { onInput(atMatch[1]) - setStore("popoverIsOpen", true) - } else if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) + setStore("popover", "file") + } else if (slashMatch) { + setStore("slashFilter", slashMatch[1]) + setStore("popover", "slash") + } else { + setStore("popover", null) } if (store.historyIndex >= 0) { @@ -294,8 +359,6 @@ export const PromptInput: Component = (props) => { const range = selection.getRangeAt(0) if (atMatch) { - // let node: Node | null = range.startContainer - // let offset = range.startOffset let runningLength = 0 const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null) @@ -335,7 +398,7 @@ export const PromptInput: Component = (props) => { } handleInput() - setStore("popoverIsOpen", false) + setStore("popover", null) } const abort = () => @@ -403,8 +466,13 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { - onKeyDown(event) + // Handle popover navigation + if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { + if (store.popover === "file") { + onKeyDown(event) + } else { + slashOnKeyDown(event) + } event.preventDefault() return } @@ -441,8 +509,8 @@ export const PromptInput: Component = (props) => { handleSubmit(event) } if (event.key === "Escape") { - if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) + if (store.popover) { + setStore("popover", null) } else if (session.working()) { abort() } @@ -470,31 +538,9 @@ export const PromptInput: Component = (props) => { } if (!existing) return - // if (!session.id) { - // session.layout.setOpenedTabs( - // session.layout.copyTabs("", session.id) - // } - const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) const attachments = prompt.filter((part) => part.type === "file") - // const activeFile = local.context.active() - // if (activeFile) { - // registerAttachment( - // activeFile.path, - // activeFile.selection, - // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection), - // ) - // } - - // for (const contextFile of local.context.all()) { - // registerAttachment( - // contextFile.path, - // contextFile.selection, - // formatAttachmentLabel(contextFile.path, contextFile.selection), - // ) - // } - const attachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection @@ -519,7 +565,6 @@ export const PromptInput: Component = (props) => { session.layout.setActiveTab(undefined) session.messages.setActive(undefined) - // Clear the editor DOM directly to ensure it's empty editorRef.innerHTML = "" session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) @@ -542,38 +587,66 @@ export const PromptInput: Component = (props) => { return (
- + {/* Popover for file mentions and slash commands */} +
- 0} fallback={
No matching files
}> - - {(i) => ( - + )} + +
+ + + 0} + fallback={
No matching commands
} + > + + {(cmd) => ( +
-
-
- - )} - - + + )} + + + +
void +} + +export function parseKeybind(config: string): Keybind[] { + if (!config || config === "none") return [] + + return config.split(",").map((combo) => { + const parts = combo.trim().toLowerCase().split("+") + const keybind: Keybind = { + key: "", + ctrl: false, + meta: false, + shift: false, + alt: false, + } + + for (const part of parts) { + switch (part) { + case "ctrl": + case "control": + keybind.ctrl = true + break + case "meta": + case "cmd": + case "command": + keybind.meta = true + break + case "mod": + if (IS_MAC) keybind.meta = true + else keybind.ctrl = true + break + case "alt": + case "option": + keybind.alt = true + break + case "shift": + keybind.shift = true + break + default: + keybind.key = part + break + } + } + + return keybind + }) +} + +export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean { + const eventKey = event.key.toLowerCase() + + for (const kb of keybinds) { + const keyMatch = kb.key === eventKey + const ctrlMatch = kb.ctrl === (event.ctrlKey || false) + const metaMatch = kb.meta === (event.metaKey || false) + const shiftMatch = kb.shift === (event.shiftKey || false) + const altMatch = kb.alt === (event.altKey || false) + + if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) { + return true + } + } + + return false +} + +export function formatKeybind(config: string): string { + if (!config || config === "none") return "" + + const keybinds = parseKeybind(config) + if (keybinds.length === 0) return "" + + const kb = keybinds[0] + const parts: string[] = [] + + if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl") + if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt") + if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift") + if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") + + if (kb.key) { + const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1) + parts.push(displayKey) + } + + return IS_MAC ? parts.join("") : parts.join("+") +} + +function DialogCommand(props: { options: CommandOption[] }) { + const dialog = useDialog() + + return ( + + props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)} + key={(x) => x.id} + groupBy={(x) => x.category ?? ""} + onSelect={(option) => { + if (option) { + dialog.clear() + option.onSelect?.("palette") + } + }} + > + {(option) => ( +
+
+ {option.title} + + {option.description} + +
+ + {formatKeybind(option.keybind!)} + +
+ )} +
+
+ ) +} + +export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ + name: "Command", + init: () => { + const [registrations, setRegistrations] = createSignal[]>([]) + const [suspendCount, setSuspendCount] = createSignal(0) + const dialog = useDialog() + + const options = createMemo(() => { + const all = registrations().flatMap((x) => x()) + const suggested = all.filter((x) => x.suggested && !x.disabled) + return [ + ...suggested.map((x) => ({ + ...x, + id: "suggested." + x.id, + category: "Suggested", + })), + ...all, + ] + }) + + const suspended = () => suspendCount() > 0 + + const showPalette = () => { + if (dialog.stack.length === 0) { + dialog.replace(() => !x.disabled)} />) + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (suspended()) return + + // Check for command palette keybind (mod+shift+p) + const paletteKeybinds = parseKeybind("mod+shift+p") + if (matchKeybind(paletteKeybinds, event)) { + event.preventDefault() + showPalette() + return + } + + // Check registered command keybinds + for (const option of options()) { + if (option.disabled) continue + if (!option.keybind) continue + + const keybinds = parseKeybind(option.keybind) + if (matchKeybind(keybinds, event)) { + event.preventDefault() + option.onSelect?.("keybind") + return + } + } + } + + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + + return { + register(cb: () => CommandOption[]) { + const results = createMemo(cb) + setRegistrations((arr) => [results, ...arr]) + onCleanup(() => { + setRegistrations((arr) => arr.filter((x) => x !== results)) + }) + }, + trigger(id: string, source?: "palette" | "keybind" | "slash") { + for (const option of options()) { + if (option.id === id || option.id === "suggested." + id) { + option.onSelect?.(source) + return + } + } + }, + show: showPalette, + keybinds(enabled: boolean) { + setSuspendCount((count) => count + (enabled ? -1 : 1)) + }, + suspended, + get options() { + return options() + }, + } + }, +}) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index bef12fbd8..e3cac4842 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -34,6 +34,7 @@ import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" +import { useCommand } from "@/context/command" export default function Page() { const layout = useLayout() @@ -41,6 +42,7 @@ export default function Page() { const sync = useSync() const session = useSession() const dialog = useDialog() + const command = useCommand() const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, @@ -48,16 +50,6 @@ export default function Page() { }) let inputRef!: HTMLDivElement - const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" - - onMount(() => { - document.addEventListener("keydown", handleKeyDown) - }) - - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown) - }) - createEffect(() => { if (layout.terminal.opened()) { if (session.terminal.all().length === 0) { @@ -66,35 +58,54 @@ export default function Page() { } }) - const handleKeyDown = (event: KeyboardEvent) => { - if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { - event.preventDefault() - return - } - if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { - event.preventDefault() - dialog.replace(() => ) - return - } - if (event.ctrlKey && event.key.toLowerCase() === "t") { - event.preventDefault() - const currentTheme = localStorage.getItem("theme") ?? "oc-1" - const themes = ["oc-1", "oc-2-paper"] - const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length] - localStorage.setItem("theme", nextTheme) - document.documentElement.setAttribute("data-theme", nextTheme) - return - } - if (event.ctrlKey && event.key.toLowerCase() === "`") { - event.preventDefault() - if (event.shiftKey) { - session.terminal.new() - return - } - layout.terminal.toggle() - return - } + // Register commands for this page + command.register(() => [ + { + id: "file.open", + title: "Open file", + description: "Search and open a file", + category: "File", + keybind: "mod+p", + slash: "open", + onSelect: () => dialog.replace(() => ), + }, + { + id: "theme.toggle", + title: "Toggle theme", + description: "Switch between themes", + category: "View", + keybind: "ctrl+t", + slash: "theme", + onSelect: () => { + const currentTheme = localStorage.getItem("theme") ?? "oc-1" + const themes = ["oc-1", "oc-2-paper"] + const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length] + localStorage.setItem("theme", nextTheme) + document.documentElement.setAttribute("data-theme", nextTheme) + }, + }, + { + id: "terminal.toggle", + title: "Toggle terminal", + description: "Show or hide the terminal", + category: "View", + keybind: "ctrl+`", + slash: "terminal", + onSelect: () => layout.terminal.toggle(), + }, + { + id: "terminal.new", + title: "New terminal", + description: "Create a new terminal tab", + category: "Terminal", + keybind: "ctrl+shift+`", + onSelect: () => session.terminal.new(), + }, + ]) + // Handle keyboard events that aren't commands + const handleKeyDown = (event: KeyboardEvent) => { + // Don't interfere with terminal // @ts-expect-error if (document.activeElement?.dataset?.component === "terminal") { return @@ -108,32 +119,20 @@ export default function Page() { return } - // if (local.file.active()) { - // const active = local.file.active()! - // if (event.key === "Enter" && active.selection) { - // local.context.add({ - // type: "file", - // path: active.path, - // selection: { ...active.selection }, - // }) - // return - // } - // - // if (event.getModifierState(MOD)) { - // if (event.key.toLowerCase() === "a") { - // return - // } - // if (event.key.toLowerCase() === "c") { - // return - // } - // } - // } - + // Focus input when typing characters if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } } + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + const resetClickTimer = () => { if (!store.clickTimer) return clearTimeout(store.clickTimer) From d66d806700de9ad9fb0f3997a076ad23a815e6ea Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:37:14 -0600 Subject: [PATCH 100/575] wip(desktop): progress --- packages/desktop/src/app.tsx | 11 +- .../src/components/dialog-select-file.tsx | 11 +- .../desktop/src/components/prompt-input.tsx | 78 +++-- packages/desktop/src/components/terminal.tsx | 2 +- packages/desktop/src/context/command.tsx | 2 +- packages/desktop/src/context/layout.tsx | 92 ++++- packages/desktop/src/context/prompt.tsx | 100 ++++++ packages/desktop/src/context/session.tsx | 321 ------------------ packages/desktop/src/context/terminal.tsx | 106 ++++++ packages/desktop/src/pages/session.tsx | 184 ++++++---- 10 files changed, 483 insertions(+), 424 deletions(-) create mode 100644 packages/desktop/src/context/prompt.tsx delete mode 100644 packages/desktop/src/context/session.tsx create mode 100644 packages/desktop/src/context/terminal.tsx diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index 6414d0d49..2530f92dd 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -9,7 +9,8 @@ import { Diff } from "@opencode-ai/ui/diff" import { GlobalSyncProvider } from "@/context/global-sync" import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" -import { SessionProvider } from "@/context/session" +import { TerminalProvider } from "@/context/terminal" +import { PromptProvider } from "@/context/prompt" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" @@ -53,9 +54,11 @@ export function App() { path="/session/:id?" component={(p) => ( - - - + + + + + )} /> diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx index 0250963b0..b719e15d2 100644 --- a/packages/desktop/src/components/dialog-select-file.tsx +++ b/packages/desktop/src/components/dialog-select-file.tsx @@ -3,13 +3,18 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { FileIcon } from "@opencode-ai/ui/file-icon" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useSession } from "@/context/session" +import { useLayout } from "@/context/layout" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useParams } from "@solidjs/router" +import { createMemo } from "solid-js" export function DialogSelectFile() { - const session = useSession() + const layout = useLayout() const local = useLocal() const dialog = useDialog() + const params = useParams() + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) return ( x} onSelect={(path) => { if (path) { - session.layout.openTab("file://" + path) + tabs().open("file://" + path) } dialog.clear() }} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 6ab280fa6..a498593bd 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -4,9 +4,10 @@ import { createStore } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt" +import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useNavigate } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" @@ -67,12 +68,26 @@ export const PromptInput: Component = (props) => { const sdk = useSDK() const sync = useSync() const local = useLocal() - const session = useSession() + const prompt = usePrompt() + const layout = useLayout() + const params = useParams() const dialog = useDialog() const providers = useProviders() const command = useCommand() let editorRef!: HTMLDivElement + // Session-derived state + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const status = createMemo( + () => + sync.data.session_status[params.id ?? ""] ?? { + type: "idle", + }, + ) + const working = createMemo(() => status()?.type !== "idle") + const [store, setStore] = createStore<{ popover: "file" | "slash" | null historyIndex: number @@ -111,9 +126,9 @@ export const PromptInput: Component = (props) => { const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) - const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => { - const length = position === "start" ? 0 : promptLength(prompt) - session.prompt.set(prompt, length) + const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { + const length = position === "start" ? 0 : promptLength(p) + prompt.set(p, length) requestAnimationFrame(() => { editorRef.focus() setCursorPosition(editorRef, length) @@ -149,9 +164,9 @@ export const PromptInput: Component = (props) => { } createEffect(() => { - session.id + params.id editorRef.focus() - if (session.id) return + if (params.id) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length) }, 6500) @@ -211,7 +226,7 @@ export const PromptInput: Component = (props) => { if (!cmd) return // Since slash commands only trigger from start, just clear the input editorRef.innerHTML = "" - session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) setStore("popover", null) command.trigger(cmd.id, "slash") } @@ -243,7 +258,7 @@ export const PromptInput: Component = (props) => { createEffect( on( - () => session.prompt.current(), + () => prompt.current(), (currentParts) => { const domParts = parseFromDOM() if (isPromptEqual(currentParts, domParts)) return @@ -255,7 +270,7 @@ export const PromptInput: Component = (props) => { } editorRef.innerHTML = "" - currentParts.forEach((part) => { + currentParts.forEach((part: ContentPart) => { if (part.type === "text") { editorRef.appendChild(document.createTextNode(part.content)) } else if (part.type === "file") { @@ -333,7 +348,7 @@ export const PromptInput: Component = (props) => { setStore("savedPrompt", null) } - session.prompt.set(rawParts, cursorPosition) + prompt.set(rawParts, cursorPosition) } const addPart = (part: ContentPart) => { @@ -341,8 +356,8 @@ export const PromptInput: Component = (props) => { if (!selection || selection.rangeCount === 0) return const cursorPosition = getCursorPosition(editorRef) - const prompt = session.prompt.current() - const rawText = prompt.map((p) => p.content).join("") + const currentPrompt = prompt.current() + const rawText = currentPrompt.map((p: ContentPart) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -403,7 +418,7 @@ export const PromptInput: Component = (props) => { const abort = () => sdk.client.session.abort({ - sessionID: session.id!, + sessionID: params.id!, }) const addToHistory = (prompt: Prompt) => { @@ -430,7 +445,7 @@ export const PromptInput: Component = (props) => { if (direction === "up") { if (entries.length === 0) return false if (current === -1) { - setStore("savedPrompt", clonePromptParts(session.prompt.current())) + setStore("savedPrompt", clonePromptParts(prompt.current())) setStore("historyIndex", 0) applyHistoryPrompt(entries[0], "start") return true @@ -481,7 +496,7 @@ export const PromptInput: Component = (props) => { const { collapsed, onFirstLine, onLastLine } = getCaretLineState() if (!collapsed) return const cursorPos = getCursorPosition(editorRef) - const textLength = promptLength(session.prompt.current()) + const textLength = promptLength(prompt.current()) const inHistory = store.historyIndex >= 0 const isStart = cursorPos === 0 const isEnd = cursorPos === textLength @@ -511,7 +526,7 @@ export const PromptInput: Component = (props) => { if (event.key === "Escape") { if (store.popover) { setStore("popover", null) - } else if (session.working()) { + } else if (working()) { abort() } } @@ -519,18 +534,18 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() - const prompt = session.prompt.current() - const text = prompt.map((part) => part.content).join("") + const currentPrompt = prompt.current() + const text = currentPrompt.map((part: ContentPart) => part.content).join("") if (text.trim().length === 0) { - if (session.working()) abort() + if (working()) abort() return } - addToHistory(prompt) + addToHistory(currentPrompt) setStore("historyIndex", -1) setStore("savedPrompt", null) - let existing = session.info() + let existing = info() if (!existing) { const created = await sdk.client.session.create() existing = created.data ?? undefined @@ -539,7 +554,9 @@ export const PromptInput: Component = (props) => { if (!existing) return const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) - const attachments = prompt.filter((part) => part.type === "file") + const attachments = currentPrompt.filter( + (part: ContentPart) => part.type === "file", + ) as import("@/context/prompt").FileAttachmentPart[] const attachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) @@ -563,10 +580,9 @@ export const PromptInput: Component = (props) => { } }) - session.layout.setActiveTab(undefined) - session.messages.setActive(undefined) + tabs().setActive(undefined) editorRef.innerHTML = "" - session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) sdk.client.session.prompt({ sessionID: existing.id, @@ -671,7 +687,7 @@ export const PromptInput: Component = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - +
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
@@ -703,7 +719,7 @@ export const PromptInput: Component = (props) => { inactive={!session.prompt.dirty() && !session.working()} value={ - +
Stop ESC @@ -720,8 +736,8 @@ export const PromptInput: Component = (props) => { > diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx index 865d9b30f..082525e28 100644 --- a/packages/desktop/src/components/terminal.tsx +++ b/packages/desktop/src/components/terminal.tsx @@ -2,7 +2,7 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" -import { LocalPTY } from "@/context/session" +import { LocalPTY } from "@/context/terminal" import { usePrefersDark } from "@solid-primitives/media" export interface TerminalProps extends ComponentProps<"div"> { diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx index b17a98270..26b03f980 100644 --- a/packages/desktop/src/context/command.tsx +++ b/packages/desktop/src/context/command.tsx @@ -138,7 +138,7 @@ function DialogCommand(props: { options: CommandOption[] }) { search={{ placeholder: "Search commands", autofocus: true }} emptyMessage="No commands found" items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)} - key={(x) => x.id} + key={(x) => x?.id} groupBy={(x) => x.category ?? ""} onSelect={(option) => { if (option) { diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 925bf4d4c..af71c6a00 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -1,5 +1,5 @@ -import { createStore } from "solid-js/store" -import { createMemo, onMount } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { batch, createMemo, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSync } from "./global-sync" @@ -22,6 +22,11 @@ export function getAvatarColors(key?: string) { } } +type SessionTabs = { + active?: string + all: string[] +} + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -41,9 +46,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, + sessionTabs: {} as Record, }), { - name: "layout.v2", + name: "layout.v3", }, ) @@ -155,6 +161,86 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, + tabs(sessionKey: string) { + const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) + return { + tabs, + active: createMemo(() => tabs().active), + all: createMemo(() => tabs().all), + setActive(tab: string | undefined) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + setAll(all: string[]) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all, active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "all", all) + } + }, + async open(tab: string) { + if (tab === "chat") { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "active", undefined) + } + return + } + const current = store.sessionTabs[sessionKey] ?? { all: [] } + if (tab !== "review") { + if (!current.all.includes(tab)) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [tab], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "all", [...current.all, tab]) + setStore("sessionTabs", sessionKey, "active", tab) + } + return + } + } + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + close(tab: string) { + const current = store.sessionTabs[sessionKey] + if (!current) return + batch(() => { + setStore( + "sessionTabs", + sessionKey, + "all", + current.all.filter((x) => x !== tab), + ) + if (current.active === tab) { + const index = current.all.findIndex((f) => f === tab) + const previous = current.all[Math.max(0, index - 1)] + setStore("sessionTabs", sessionKey, "active", previous) + } + }) + }, + move(tab: string, to: number) { + const current = store.sessionTabs[sessionKey] + if (!current) return + const index = current.all.findIndex((f) => f === tab) + if (index === -1) return + setStore( + "sessionTabs", + sessionKey, + "all", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + }, + } + }, } }, }) diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx new file mode 100644 index 000000000..c3b3bbace --- /dev/null +++ b/packages/desktop/src/context/prompt.tsx @@ -0,0 +1,100 @@ +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { TextSelection } from "./local" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: TextSelection +} + +export type ContentPart = TextPart | FileAttachmentPart +export type Prompt = ContentPart[] + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + const partA = promptA[i] + const partB = promptB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB as TextPart).content) { + return false + } + if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { + return false + } + } + return true +} + +function cloneSelection(selection?: TextSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ + name: "Prompt", + init: () => { + const params = useParams() + const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + prompt: Prompt + cursor?: number + }>({ + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, + }), + { + name: name(), + }, + ) + + return { + current: createMemo(() => store.prompt), + cursor: createMemo(() => store.cursor), + dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } + }, +}) diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx deleted file mode 100644 index 860c1a14f..000000000 --- a/packages/desktop/src/context/session.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { createStore, produce } from "solid-js/store" -import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo } from "solid-js" -import { useSync } from "./sync" -import { makePersisted } from "@solid-primitives/storage" -import { TextSelection } from "./local" -import { pipe, sumBy } from "remeda" -import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" -import { useParams } from "@solidjs/router" -import { useSDK } from "./sdk" - -export type LocalPTY = { - id: string - title: string - rows?: number - cols?: number - buffer?: string - scrollY?: number -} - -export const { use: useSession, provider: SessionProvider } = createSimpleContext({ - name: "Session", - init: () => { - const sdk = useSDK() - const params = useParams() - const sync = useSync() - const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`) - - const [store, setStore] = makePersisted( - createStore<{ - messageId?: string - tabs: { - active?: string - all: string[] - } - prompt: Prompt - cursor?: number - terminals: { - active?: string - all: LocalPTY[] - } - }>({ - tabs: { - all: [], - }, - prompt: clonePrompt(DEFAULT_PROMPT), - cursor: undefined, - terminals: { all: [] }, - }), - { - name: name(), - }, - ) - - createEffect(() => { - if (!params.id) return - sync.session.sync(params.id) - }) - - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const userMessages = createMemo(() => - messages() - .filter((m) => m.role === "user") - .sort((a, b) => a.id.localeCompare(b.id)), - ) - const lastUserMessage = createMemo(() => { - return userMessages()?.at(-1) - }) - const activeMessage = createMemo(() => { - if (!store.messageId) return lastUserMessage() - return userMessages()?.find((m) => m.id === store.messageId) - }) - const status = createMemo( - () => - sync.data.session_status[params.id ?? ""] ?? { - type: "idle", - }, - ) - const working = createMemo(() => status()?.type !== "idle") - - const cost = createMemo(() => { - const total = pipe( - messages(), - sumBy((x) => (x.role === "assistant" ? x.cost : 0)), - ) - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(total) - }) - - const last = createMemo( - () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, - ) - const model = createMemo(() => - last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, - ) - const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - - const tokens = createMemo(() => { - if (!last()) return - const tokens = last().tokens - return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write - }) - - const context = createMemo(() => { - const total = tokens() - const limit = model()?.limit.context - if (!total || !limit) return 0 - return Math.round((total / limit) * 100) - }) - - return { - get id() { - return params.id - }, - info, - status, - working, - diffs, - prompt: { - current: createMemo(() => store.prompt), - cursor: createMemo(() => store.cursor), - dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - }, - messages: { - all: messages, - user: userMessages, - last: lastUserMessage, - active: activeMessage, - setActive(message: UserMessage | undefined) { - setStore("messageId", message?.id) - }, - }, - usage: { - tokens, - cost, - context, - }, - layout: { - tabs: store.tabs, - setActiveTab(tab: string | undefined) { - setStore("tabs", "active", tab) - }, - setOpenedTabs(tabs: string[]) { - setStore("tabs", "all", tabs) - }, - async openTab(tab: string) { - if (tab === "chat") { - setStore("tabs", "active", undefined) - return - } - if (tab !== "review") { - if (!store.tabs.all.includes(tab)) { - setStore("tabs", "all", [...store.tabs.all, tab]) - } - } - setStore("tabs", "active", tab) - }, - closeTab(tab: string) { - batch(() => { - setStore( - "tabs", - "all", - store.tabs.all.filter((x) => x !== tab), - ) - if (store.tabs.active === tab) { - const index = store.tabs.all.findIndex((f) => f === tab) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("tabs", "active", previous) - } - }) - }, - moveTab(tab: string, to: number) { - const index = store.tabs.all.findIndex((f) => f === tab) - if (index === -1) return - setStore( - "tabs", - "all", - produce((opened) => { - opened.splice(to, 0, opened.splice(index, 1)[0]) - }), - ) - }, - }, - terminal: { - all: createMemo(() => Object.values(store.terminals.all)), - active: createMemo(() => store.terminals.active), - new() { - sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("terminals", "all", [ - ...store.terminals.all, - { - id, - title: pty.data?.title ?? "Terminal", - }, - ]) - setStore("terminals", "active", id) - }) - }, - update(pty: Partial & { id: string }) { - setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty.update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - }, - async clone(id: string) { - const index = store.terminals.all.findIndex((x) => x.id === id) - const pty = store.terminals.all[index] - if (!pty) return - const clone = await sdk.client.pty.create({ - title: pty.title, - }) - if (!clone.data) return - setStore("terminals", "all", index, { - ...pty, - ...clone.data, - }) - if (store.terminals.active === pty.id) { - setStore("terminals", "active", clone.data.id) - } - }, - open(id: string) { - setStore("terminals", "active", id) - }, - async close(id: string) { - batch(() => { - setStore( - "terminals", - "all", - store.terminals.all.filter((x) => x.id !== id), - ) - if (store.terminals.active === id) { - const index = store.terminals.all.findIndex((f) => f.id === id) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("terminals", "active", previous) - } - }) - await sdk.client.pty.remove({ ptyID: id }) - }, - move(id: string, to: number) { - const index = store.terminals.all.findIndex((f) => f.id === id) - if (index === -1) return - setStore( - "terminals", - "all", - produce((all) => { - all.splice(to, 0, all.splice(index, 1)[0]) - }), - ) - }, - }, - } - }, -}) - -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: TextSelection -} - -export type ContentPart = TextPart | FileAttachmentPart -export type Prompt = ContentPart[] - -export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] - -export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - const partA = promptA[i] - const partB = promptB[i] - if (partA.type !== partB.type) return false - if (partA.type === "text" && partA.content !== (partB as TextPart).content) { - return false - } - if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { - return false - } - } - return true -} - -function cloneSelection(selection?: TextSelection) { - if (!selection) return undefined - return { ...selection } -} - -function clonePart(part: ContentPart): ContentPart { - if (part.type === "text") return { ...part } - return { - ...part, - selection: cloneSelection(part.selection), - } -} - -function clonePrompt(prompt: Prompt): Prompt { - return prompt.map(clonePart) -} diff --git a/packages/desktop/src/context/terminal.tsx b/packages/desktop/src/context/terminal.tsx new file mode 100644 index 000000000..cf9b5a5b9 --- /dev/null +++ b/packages/desktop/src/context/terminal.tsx @@ -0,0 +1,106 @@ +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { useSDK } from "./sdk" + +export type LocalPTY = { + id: string + title: string + rows?: number + cols?: number + buffer?: string + scrollY?: number +} + +export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ + name: "Terminal", + init: () => { + const sdk = useSDK() + const params = useParams() + const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + active?: string + all: LocalPTY[] + }>({ + all: [], + }), + { + name: name(), + }, + ) + + return { + all: createMemo(() => Object.values(store.all)), + active: createMemo(() => store.active), + new() { + sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { + const id = pty.data?.id + if (!id) return + setStore("all", [ + ...store.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("active", id) + }) + }, + update(pty: Partial & { id: string }) { + setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) + sdk.client.pty.update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + }, + async clone(id: string) { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const clone = await sdk.client.pty.create({ + title: pty.title, + }) + if (!clone.data) return + setStore("all", index, { + ...pty, + ...clone.data, + }) + if (store.active === pty.id) { + setStore("active", clone.data.id) + } + }, + open(id: string) { + setStore("active", id) + }, + async close(id: string) { + batch(() => { + setStore( + "all", + store.all.filter((x) => x.id !== id), + ) + if (store.active === id) { + const index = store.all.findIndex((f) => f.id === id) + const previous = store.all[Math.max(0, index - 1)] + setStore("active", previous?.id) + } + }) + await sdk.client.pty.remove({ ptyID: id }) + }, + move(id: string, to: number) { + const index = store.all.findIndex((f) => f.id === id) + if (index === -1) return + setStore( + "all", + produce((all) => { + all.splice(to, 0, all.splice(index, 1)[0]) + }), + ) + }, + } + }, +}) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index e3cac4842..48e01239c 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -27,22 +27,91 @@ import { import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" -import { useSession, type LocalPTY } from "@/context/session" +import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" +import { usePrompt } from "@/context/prompt" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import { useCommand } from "@/context/command" +import { useParams } from "@solidjs/router" +import { pipe, sumBy } from "remeda" +import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" export default function Page() { const layout = useLayout() const local = useLocal() const sync = useSync() - const session = useSession() + const terminal = useTerminal() + const prompt = usePrompt() const dialog = useDialog() const command = useCommand() + const params = useParams() + + // Session-specific derived state + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const userMessages = createMemo(() => + messages() + .filter((m) => m.role === "user") + .sort((a, b) => a.id.localeCompare(b.id)), + ) + const lastUserMessage = createMemo(() => userMessages()?.at(-1)) + + const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({}) + const activeMessage = createMemo(() => { + if (!messageStore.messageId) return lastUserMessage() + return userMessages()?.find((m) => m.id === messageStore.messageId) + }) + const setActiveMessage = (message: UserMessage | undefined) => { + setMessageStore("messageId", message?.id) + } + + const status = createMemo( + () => + sync.data.session_status[params.id ?? ""] ?? { + type: "idle", + }, + ) + const working = createMemo(() => status()?.type !== "idle") + + const cost = createMemo(() => { + const total = pipe( + messages(), + sumBy((x) => (x.role === "assistant" ? x.cost : 0)), + ) + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(total) + }) + + const last = createMemo( + () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, + ) + const model = createMemo(() => + last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, + ) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + + const tokens = createMemo(() => { + if (!last()) return + const t = last().tokens + return t.input + t.output + t.reasoning + t.cache.read + t.cache.write + }) + + const context = createMemo(() => { + const total = tokens() + const limit = model()?.limit.context + if (!total || !limit) return 0 + return Math.round((total / limit) * 100) + }) + const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, @@ -50,10 +119,15 @@ export default function Page() { }) let inputRef!: HTMLDivElement + createEffect(() => { + if (!params.id) return + sync.session.sync(params.id) + }) + createEffect(() => { if (layout.terminal.opened()) { - if (session.terminal.all().length === 0) { - session.terminal.new() + if (terminal.all().length === 0) { + terminal.new() } } }) @@ -99,7 +173,7 @@ export default function Page() { description: "Create a new terminal tab", category: "Terminal", keybind: "ctrl+shift+`", - onSelect: () => session.terminal.new(), + onSelect: () => terminal.new(), }, ]) @@ -166,11 +240,11 @@ export default function Page() { const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const currentTabs = session.layout.tabs.all + const currentTabs = tabs().all() const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== undefined) { - session.layout.moveTab(draggable.id.toString(), toIndex) + tabs().move(draggable.id.toString(), toIndex) } } } @@ -188,11 +262,11 @@ export default function Page() { const handleTerminalDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const terminals = session.terminal.all() - const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString()) - const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString()) + const terminals = terminal.all() + const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) + const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { - session.terminal.move(draggable.id.toString(), toIndex) + terminal.move(draggable.id.toString(), toIndex) } } } @@ -210,8 +284,8 @@ export default function Page() { 1 && ( - session.terminal.close(props.terminal.id)} /> + terminal.all().length > 1 && ( + terminal.close(props.terminal.id)} /> ) } > @@ -326,7 +400,7 @@ export default function Page() { return typeof draggable.id === "string" ? draggable.id : undefined } - const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) + const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length) return (
@@ -339,7 +413,7 @@ export default function Page() { > - +
@@ -349,15 +423,15 @@ export default function Page() { value={`${new Intl.NumberFormat("en-US", { notation: "compact", compactDisplay: "short", - }).format(session.usage.tokens() ?? 0)} Tokens`} + }).format(tokens() ?? 0)} Tokens`} class="flex items-center gap-1.5" > - -
{session.usage.context() ?? 0}%
+ +
{context() ?? 0}%
- +
- - + +
Review
- +
- {session.info()?.summary?.files ?? 0} + {info()?.summary?.files ?? 0}
- - - {(tab) => ( - - )} + + + {(tab) => }
@@ -415,27 +487,23 @@ export default function Page() { }} > - +
1 - ? "pr-6 pl-18" - : "px-6"), + (wide() ? "max-w-146 mx-auto px-6" : userMessages().length > 1 ? "pr-6 pl-18" : "px-6"), }} />
@@ -476,7 +544,7 @@ export default function Page() {
- +
{ layout.review.tab() - session.layout.setActiveTab("review") + tabs().setActive("review") }} /> @@ -506,7 +574,7 @@ export default function Page() {
- +
- + {(tab) => { const [file] = createResource( () => tab, @@ -579,7 +647,7 @@ export default function Page() {
- +
{ @@ -639,25 +707,21 @@ export default function Page() { > - + - t.id)}> - {(terminal) => } + t.id)}> + {(pty) => }
- +
- - {(terminal) => ( - - session.terminal.clone(terminal.id)} - /> + + {(pty) => ( + + terminal.clone(pty.id)} /> )} @@ -665,9 +729,9 @@ export default function Page() { {(draggedId) => { - const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId())) + const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) return ( - + {(t) => (
{t().title} From 3a14ca044ca521d1ab24c04da8e9e3bab9e2de58 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:43:01 -0600 Subject: [PATCH 101/575] wip(desktop): progress --- packages/desktop/src/context/local.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 56154c5ba..6ec9778cc 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -249,6 +249,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { setEphemeral("model", agent.current().name, model ?? fallbackModel()) + if (model) updateVisibility(model, "show") if (options?.recent && model) { const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) if (uniq.length > 5) uniq.pop() From acd91bddf7e83591d60f6801b7d34685a7f3f256 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:58:01 -0600 Subject: [PATCH 102/575] wip(desktop): progress --- packages/desktop/src/pages/session.tsx | 5 +++++ packages/ui/src/components/dialog.tsx | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 48e01239c..caea614c9 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -185,6 +185,11 @@ export default function Page() { return } + // Don't interfere with dialogs + if (dialog.stack.length > 0) { + return + } + const focused = document.activeElement === inputRef if (focused) { if (event.key === "Escape") { diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 47d6af42e..40a6ac83d 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -20,6 +20,14 @@ export function Dialog(props: DialogProps) { ...(props.classList ?? {}), [props.class ?? ""]: !!props.class, }} + onOpenAutoFocus={(e) => { + const target = e.currentTarget as HTMLElement | null + const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null + if (autofocusEl) { + e.preventDefault() + autofocusEl.focus() + } + }} >
From ece3bfd93d88584dbab4e680a1cc56efc5113b9b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:06:14 -0600 Subject: [PATCH 103/575] wip(desktop): progress --- packages/ui/src/components/message-part.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 9d4214bae..b66ef1d27 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -107,15 +107,19 @@ display: flex; align-items: center; justify-content: space-between; + gap: 8px; width: 100%; [data-slot="message-part-title-area"] { + flex-grow: 1; display: flex; align-items: center; gap: 8px; + min-width: 0; } [data-slot="message-part-title"] { + flex-shrink: 0; font-family: var(--font-family-sans); font-size: var(--font-size-small); font-style: normal; @@ -128,14 +132,22 @@ [data-slot="message-part-path"] { display: flex; + flex-grow: 1; + min-width: 0; } [data-slot="message-part-directory"] { color: var(--text-weak); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + direction: rtl; + text-align: left; } [data-slot="message-part-filename"] { color: var(--text-strong); + flex-shrink: 0; } [data-slot="message-part-actions"] { From d81d63045ac619bc9201df4ae2e003a2d08596d1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:18:39 -0600 Subject: [PATCH 104/575] wip(desktop): session turn state consolidation --- packages/ui/src/components/session-turn.tsx | 626 ++++++++++---------- 1 file changed, 306 insertions(+), 320 deletions(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index ad2e6c36e..e6654f480 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -40,6 +40,9 @@ export function SessionTurn( .sort((a, b) => a.id.localeCompare(b.id)), ) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) + + if (!message()) return null + const status = createMemo( () => data.store.session_status[props.sessionID] ?? { @@ -49,379 +52,362 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - const [state, setState] = createStore({ + + const assistantMessages = createMemo(() => { + return messages()?.filter((m) => m.role === "assistant" && m.parentID == message()!.id) as AssistantMessage[] + }) + const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) + const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const parts = createMemo(() => data.store.part[message()!.id]) + const lastTextPart = createMemo(() => + assistantMessageParts() + .filter((p) => p?.type === "text") + ?.at(-1), + ) + const summary = createMemo(() => message()!.summary?.body ?? lastTextPart()?.text) + const lastTextPartShown = createMemo(() => !message()!.summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) + + const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) + const currentTask = createMemo( + () => + assistantParts().findLast( + (p) => + p && + p.type === "tool" && + p.tool === "task" && + p.state && + "metadata" in p.state && + p.state.metadata && + p.state.metadata.sessionId && + p.state.status === "running", + ) as ToolPart, + ) + const resolvedParts = createMemo(() => { + let resolved = assistantParts() + const task = currentTask() + if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { + const msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant") + resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() + } + return resolved + }) + const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) + const rawStatus = createMemo(() => { + const last = lastPart() + if (!last) return undefined + + if (last.type === "tool") { + switch (last.tool) { + case "task": + return "Delegating work" + case "todowrite": + case "todoread": + return "Planning next steps" + case "read": + return "Gathering context" + case "list": + case "grep": + case "glob": + return "Searching the codebase" + case "webfetch": + return "Searching the web" + case "edit": + case "write": + return "Making edits" + case "bash": + return "Running commands" + default: + break + } + } else if (last.type === "reasoning") { + const text = last.text ?? "" + const match = text.trimStart().match(/^\*\*(.+?)\*\*/) + if (match) return `Thinking · ${match[1].trim()}` + return "Thinking" + } else if (last.type === "text") { + return "Gathering thoughts" + } + return undefined + }) + + function duration() { + const completed = lastAssistantMessage()?.time.completed + const from = DateTime.fromMillis(message()!.time.created) + const to = completed ? DateTime.fromMillis(completed) : DateTime.now() + const interval = Interval.fromDateTimes(from, to) + const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] + + return interval.toDuration(unit).normalize().toHuman({ + notation: "compact", + unitDisplay: "narrow", + compactDisplay: "short", + showZeros: false, + }) + } + + const [store, setStore] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, autoScrolling: false, + status: rawStatus(), + stepsExpanded: true, + duration: duration(), + lastStatusChange: Date.now(), + statusTimeout: undefined as number | undefined, }) function handleScroll() { if (!scrollRef) return // prevents scroll loops if (working() && scrollRef.scrollTop < 100) return - setState("scrollY", scrollRef.scrollTop) - if (state.autoScrolling) return + setStore("scrollY", scrollRef.scrollTop) + if (store.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setState("userScrolled", true) + setStore("userScrolled", true) } } function handleInteraction() { if (working()) { - setState("userScrolled", true) + setStore("userScrolled", true) } } function scrollToBottom() { - if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return - setState("autoScrolling", true) + if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return + setStore("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { - setState("autoScrolling", false) + setStore("autoScrolling", false) }) }) } createEffect(() => { if (!working()) { - setState("userScrolled", false) + setStore("userScrolled", false) } }) createResizeObserver( - () => state.stickyTitleRef, + () => store.stickyTitleRef, ({ height }) => { - const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 - setState("stickyHeaderHeight", height + triggerHeight + 8) + const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0 + setStore("stickyHeaderHeight", height + triggerHeight + 8) }, ) createResizeObserver( - () => state.stickyTriggerRef, + () => store.stickyTriggerRef, ({ height }) => { - const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 - setState("stickyHeaderHeight", titleHeight + height + 8) + const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0 + setStore("stickyHeaderHeight", titleHeight + height + 8) }, ) + createEffect(() => { + lastPart() + scrollToBottom() + }) + + createEffect(() => { + const timer = setInterval(() => { + setStore("duration", duration()) + }, 1000) + onCleanup(() => clearInterval(timer)) + }) + + createEffect(() => { + const newStatus = rawStatus() + if (newStatus === store.status || !newStatus) return + + const timeSinceLastChange = Date.now() - store.lastStatusChange + + if (timeSinceLastChange >= 2500) { + setStore("status", newStatus) + setStore("lastStatusChange", Date.now()) + if (store.statusTimeout) { + clearTimeout(store.statusTimeout) + setStore("statusTimeout", undefined) + } + } else { + if (store.statusTimeout) clearTimeout(store.statusTimeout) + setStore( + "statusTimeout", + setTimeout(() => { + setStore("status", rawStatus()) + setStore("lastStatusChange", Date.now()) + setStore("statusTimeout", undefined) + }, 2500 - timeSinceLastChange) as unknown as number, + ) + } + }) + + createEffect((prev) => { + const isWorking = working() + if (prev && !isWorking && !store.userScrolled) { + setStore("stepsExpanded", false) + } + return isWorking + }, working()) + return ( -
+
- - {(message) => { - const assistantMessages = createMemo(() => { - return messages()?.filter( - (m) => m.role === "assistant" && m.parentID == message().id, - ) as AssistantMessage[] - }) - const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) - const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const parts = createMemo(() => data.store.part[message().id]) - const lastTextPart = createMemo(() => - assistantMessageParts() - .filter((p) => p?.type === "text") - ?.at(-1), - ) - const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text) - const lastTextPartShown = createMemo( - () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0, - ) - - const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) - const currentTask = createMemo( - () => - assistantParts().findLast( - (p) => - p && - p.type === "tool" && - p.tool === "task" && - p.state && - "metadata" in p.state && - p.state.metadata && - p.state.metadata.sessionId && - p.state.status === "running", - ) as ToolPart, - ) - const resolvedParts = createMemo(() => { - let resolved = assistantParts() - const task = currentTask() - if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { - const messages = data.store.message[task.state.metadata.sessionId as string]?.filter( - (m) => m.role === "assistant", - ) - resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() - } - return resolved - }) - const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) - const rawStatus = createMemo(() => { - const last = lastPart() - if (!last) return undefined - - if (last.type === "tool") { - switch (last.tool) { - case "task": - return "Delegating work" - case "todowrite": - case "todoread": - return "Planning next steps" - case "read": - return "Gathering context" - case "list": - case "grep": - case "glob": - return "Searching the codebase" - case "webfetch": - return "Searching the web" - case "edit": - case "write": - return "Making edits" - case "bash": - return "Running commands" - default: - break - } - } else if (last.type === "reasoning") { - const text = last.text ?? "" - const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return `Thinking · ${match[1].trim()}` - return "Thinking" - } else if (last.type === "text") { - return "Gathering thoughts" - } - return undefined - }) - - function duration() { - const completed = lastAssistantMessage()?.time.completed - const from = DateTime.fromMillis(message()!.time.created) - const to = completed ? DateTime.fromMillis(completed) : DateTime.now() - const interval = Interval.fromDateTimes(from, to) - const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - - return interval.toDuration(unit).normalize().toHuman({ - notation: "compact", - unitDisplay: "narrow", - compactDisplay: "short", - showZeros: false, - }) - } - - createEffect(() => { - lastPart() - scrollToBottom() - }) - - const [store, setStore] = createStore({ - status: rawStatus(), - stepsExpanded: true, - duration: duration(), - }) - - createEffect(() => { - const timer = setInterval(() => { - setStore("duration", duration()) - }, 1000) - onCleanup(() => clearInterval(timer)) - }) - - let lastStatusChange = Date.now() - let statusTimeout: number | undefined - createEffect(() => { - const newStatus = rawStatus() - if (newStatus === store.status || !newStatus) return - - const timeSinceLastChange = Date.now() - lastStatusChange - - if (timeSinceLastChange >= 2500) { - setStore("status", newStatus) - lastStatusChange = Date.now() - if (statusTimeout) { - clearTimeout(statusTimeout) - statusTimeout = undefined - } - } else { - if (statusTimeout) clearTimeout(statusTimeout) - statusTimeout = setTimeout(() => { - setStore("status", rawStatus()) - lastStatusChange = Date.now() - statusTimeout = undefined - }, 2500 - timeSinceLastChange) as unknown as number - } - }) - - createEffect((prev) => { - const isWorking = working() - if (prev && !isWorking && !state.userScrolled) { - setStore("stepsExpanded", false) - } - return isWorking - }, working()) - - return ( -
- {/* Title (sticky) */} -
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> -
-
- - - - - -

{message().summary?.title}

-
-
-
-
-
- {/* User Message */} -
- -
- {/* Trigger (sticky) */} -
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - +
+ {/* Response */} + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( - {store.status ?? "Considering next steps"} - Hide steps - Show steps + + p?.id !== last()?.id)} /> + + + + - · - {store.duration} - - -
- {/* Response */} - -
- - {(assistantMessage) => { - const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) - const last = createMemo(() => - parts() - .filter((p) => p?.type === "text") - .at(-1), - ) - return ( - - - p?.id !== last()?.id)} - /> - - - - - - ) - }} - - - - {error()?.data?.message as string} - - -
-
- {/* Summary */} - -
-
-

- - Summary - Response - -

- - {(summary) => ( - - )} - -
- - - {(diff) => ( - - - -
-
- -
- - {getDirectory(diff.file)}‎ - - {getFilename(diff.file)} -
-
-
- - -
-
-
-
- - - -
- )} -
-
-
-
- - - {error()?.data?.message as string} - + ) + }} + + + + {error()?.data?.message as string} + + +
+
+ {/* Summary */} + +
+
+

+ + Summary + Response + +

+ + {(summary) => ( + + )}
- ) - }} - + + + {(diff) => ( + + + +
+
+ +
+ + {getDirectory(diff.file)}‎ + + {getFilename(diff.file)} +
+
+
+ + +
+
+
+
+ + + +
+ )} +
+
+
+
+ + + {error()?.data?.message as string} + + +
{props.children}
From 40572eeba4b94d411934c7ff95ce51ba88c9562f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:19:20 -0600 Subject: [PATCH 105/575] wip(desktop): progress --- packages/ui/src/components/list.css | 1 + packages/ui/src/components/list.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index cd9e73d1d..368065e53 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -112,6 +112,7 @@ padding: 4px 10px; align-items: center; color: var(--text-strong); + scroll-margin-top: 28px; /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 7ec6e159d..0ed745f32 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -79,7 +79,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return } const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) - element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + element?.scrollIntoView({ block: "center", behavior: "smooth" }) }) const handleSelect = (item: T | undefined, index: number) => { From 82c4755fb01f3ae821569af80d1b8670fe69fe56 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:26:32 -0600 Subject: [PATCH 106/575] wip(desktop): progress --- packages/desktop/src/pages/session.tsx | 24 +++++++++++---------- packages/ui/src/components/session-turn.tsx | 3 --- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index caea614c9..66a2fcec7 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -500,17 +500,19 @@ export default function Page() { onMessageSelect={setActiveMessage} wide={wide()} /> - 1 ? "pr-6 pl-18" : "px-6"), - }} - /> + + 1 ? "pr-6 pl-18" : "px-6"), + }} + /> +
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index e6654f480..281b2a03b 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -40,9 +40,6 @@ export function SessionTurn( .sort((a, b) => a.id.localeCompare(b.id)), ) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) - - if (!message()) return null - const status = createMemo( () => data.store.session_status[props.sessionID] ?? { From 88c06751487291416eb473831cda70455f67412a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:41:37 -0600 Subject: [PATCH 107/575] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 21 +++---------------- packages/desktop/src/pages/layout.tsx | 3 +++ 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index a498593bd..fd5574990 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -93,13 +93,11 @@ export const PromptInput: Component = (props) => { historyIndex: number savedPrompt: Prompt | null placeholder: number - slashFilter: string }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), - slashFilter: "", }) const MAX_HISTORY = 100 @@ -237,25 +235,12 @@ export const PromptInput: Component = (props) => { onInput: slashOnInput, onKeyDown: slashOnKeyDown, } = useFilteredList({ - items: () => { - const filter = store.slashFilter.toLowerCase() - return slashCommands().filter( - (cmd) => - cmd.trigger.toLowerCase().includes(filter) || - cmd.title.toLowerCase().includes(filter) || - cmd.description?.toLowerCase().includes(filter) || - false, - ) - }, + items: slashCommands, key: (x) => x?.id, + filterKeys: ["trigger", "title", "description"], onSelect: handleSlashSelect, }) - // Update slash filter when store changes - createEffect(() => { - slashOnInput(store.slashFilter) - }) - createEffect( on( () => prompt.current(), @@ -337,7 +322,7 @@ export const PromptInput: Component = (props) => { onInput(atMatch[1]) setStore("popover", "file") } else if (slashMatch) { - setStore("slashFilter", slashMatch[1]) + slashOnInput(slashMatch[1]) setStore("popover", "slash") } else { setStore("popover", null) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 965ade9f8..08d24dc6f 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -299,6 +299,9 @@ export default function Layout(props: ParentProps) { if (match.found) draft.session.splice(match.index, 1) }), ) + if (session.id === params.id) { + navigate(`/${params.dir}/session`) + } } return (
Date: Mon, 15 Dec 2025 05:42:25 -0600 Subject: [PATCH 108/575] Revert "wip(desktop): session turn state consolidation" This reverts commit 453f862616dc4d3ac90680581cde279e118b0da1. --- packages/ui/src/components/session-turn.tsx | 623 ++++++++++---------- 1 file changed, 320 insertions(+), 303 deletions(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 281b2a03b..ad2e6c36e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -49,362 +49,379 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - - const assistantMessages = createMemo(() => { - return messages()?.filter((m) => m.role === "assistant" && m.parentID == message()!.id) as AssistantMessage[] - }) - const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) - const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const parts = createMemo(() => data.store.part[message()!.id]) - const lastTextPart = createMemo(() => - assistantMessageParts() - .filter((p) => p?.type === "text") - ?.at(-1), - ) - const summary = createMemo(() => message()!.summary?.body ?? lastTextPart()?.text) - const lastTextPartShown = createMemo(() => !message()!.summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) - - const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) - const currentTask = createMemo( - () => - assistantParts().findLast( - (p) => - p && - p.type === "tool" && - p.tool === "task" && - p.state && - "metadata" in p.state && - p.state.metadata && - p.state.metadata.sessionId && - p.state.status === "running", - ) as ToolPart, - ) - const resolvedParts = createMemo(() => { - let resolved = assistantParts() - const task = currentTask() - if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { - const msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant") - resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() - } - return resolved - }) - const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) - const rawStatus = createMemo(() => { - const last = lastPart() - if (!last) return undefined - - if (last.type === "tool") { - switch (last.tool) { - case "task": - return "Delegating work" - case "todowrite": - case "todoread": - return "Planning next steps" - case "read": - return "Gathering context" - case "list": - case "grep": - case "glob": - return "Searching the codebase" - case "webfetch": - return "Searching the web" - case "edit": - case "write": - return "Making edits" - case "bash": - return "Running commands" - default: - break - } - } else if (last.type === "reasoning") { - const text = last.text ?? "" - const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return `Thinking · ${match[1].trim()}` - return "Thinking" - } else if (last.type === "text") { - return "Gathering thoughts" - } - return undefined - }) - - function duration() { - const completed = lastAssistantMessage()?.time.completed - const from = DateTime.fromMillis(message()!.time.created) - const to = completed ? DateTime.fromMillis(completed) : DateTime.now() - const interval = Interval.fromDateTimes(from, to) - const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - - return interval.toDuration(unit).normalize().toHuman({ - notation: "compact", - unitDisplay: "narrow", - compactDisplay: "short", - showZeros: false, - }) - } - - const [store, setStore] = createStore({ + const [state, setState] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, autoScrolling: false, - status: rawStatus(), - stepsExpanded: true, - duration: duration(), - lastStatusChange: Date.now(), - statusTimeout: undefined as number | undefined, }) function handleScroll() { if (!scrollRef) return // prevents scroll loops if (working() && scrollRef.scrollTop < 100) return - setStore("scrollY", scrollRef.scrollTop) - if (store.autoScrolling) return + setState("scrollY", scrollRef.scrollTop) + if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setStore("userScrolled", true) + setState("userScrolled", true) } } function handleInteraction() { if (working()) { - setStore("userScrolled", true) + setState("userScrolled", true) } } function scrollToBottom() { - if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return - setStore("autoScrolling", true) + if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return + setState("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { - setStore("autoScrolling", false) + setState("autoScrolling", false) }) }) } createEffect(() => { if (!working()) { - setStore("userScrolled", false) + setState("userScrolled", false) } }) createResizeObserver( - () => store.stickyTitleRef, + () => state.stickyTitleRef, ({ height }) => { - const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0 - setStore("stickyHeaderHeight", height + triggerHeight + 8) + const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", height + triggerHeight + 8) }, ) createResizeObserver( - () => store.stickyTriggerRef, + () => state.stickyTriggerRef, ({ height }) => { - const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0 - setStore("stickyHeaderHeight", titleHeight + height + 8) + const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", titleHeight + height + 8) }, ) - createEffect(() => { - lastPart() - scrollToBottom() - }) - - createEffect(() => { - const timer = setInterval(() => { - setStore("duration", duration()) - }, 1000) - onCleanup(() => clearInterval(timer)) - }) - - createEffect(() => { - const newStatus = rawStatus() - if (newStatus === store.status || !newStatus) return - - const timeSinceLastChange = Date.now() - store.lastStatusChange - - if (timeSinceLastChange >= 2500) { - setStore("status", newStatus) - setStore("lastStatusChange", Date.now()) - if (store.statusTimeout) { - clearTimeout(store.statusTimeout) - setStore("statusTimeout", undefined) - } - } else { - if (store.statusTimeout) clearTimeout(store.statusTimeout) - setStore( - "statusTimeout", - setTimeout(() => { - setStore("status", rawStatus()) - setStore("lastStatusChange", Date.now()) - setStore("statusTimeout", undefined) - }, 2500 - timeSinceLastChange) as unknown as number, - ) - } - }) - - createEffect((prev) => { - const isWorking = working() - if (prev && !isWorking && !store.userScrolled) { - setStore("stepsExpanded", false) - } - return isWorking - }, working()) - return ( -
+
-
- {/* Title (sticky) */} -
setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> -
-
- - - - - -

{message()!.summary?.title}

-
-
-
-
-
- {/* User Message */} -
- -
- {/* Trigger (sticky) */} -
setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - -
- {/* Response */} - -
- - {(assistantMessage) => { - const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) - const last = createMemo(() => - parts() - .filter((p) => p?.type === "text") - .at(-1), - ) - return ( + + {(message) => { + const assistantMessages = createMemo(() => { + return messages()?.filter( + (m) => m.role === "assistant" && m.parentID == message().id, + ) as AssistantMessage[] + }) + const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) + const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const parts = createMemo(() => data.store.part[message().id]) + const lastTextPart = createMemo(() => + assistantMessageParts() + .filter((p) => p?.type === "text") + ?.at(-1), + ) + const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text) + const lastTextPartShown = createMemo( + () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0, + ) + + const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) + const currentTask = createMemo( + () => + assistantParts().findLast( + (p) => + p && + p.type === "tool" && + p.tool === "task" && + p.state && + "metadata" in p.state && + p.state.metadata && + p.state.metadata.sessionId && + p.state.status === "running", + ) as ToolPart, + ) + const resolvedParts = createMemo(() => { + let resolved = assistantParts() + const task = currentTask() + if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { + const messages = data.store.message[task.state.metadata.sessionId as string]?.filter( + (m) => m.role === "assistant", + ) + resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() + } + return resolved + }) + const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) + const rawStatus = createMemo(() => { + const last = lastPart() + if (!last) return undefined + + if (last.type === "tool") { + switch (last.tool) { + case "task": + return "Delegating work" + case "todowrite": + case "todoread": + return "Planning next steps" + case "read": + return "Gathering context" + case "list": + case "grep": + case "glob": + return "Searching the codebase" + case "webfetch": + return "Searching the web" + case "edit": + case "write": + return "Making edits" + case "bash": + return "Running commands" + default: + break + } + } else if (last.type === "reasoning") { + const text = last.text ?? "" + const match = text.trimStart().match(/^\*\*(.+?)\*\*/) + if (match) return `Thinking · ${match[1].trim()}` + return "Thinking" + } else if (last.type === "text") { + return "Gathering thoughts" + } + return undefined + }) + + function duration() { + const completed = lastAssistantMessage()?.time.completed + const from = DateTime.fromMillis(message()!.time.created) + const to = completed ? DateTime.fromMillis(completed) : DateTime.now() + const interval = Interval.fromDateTimes(from, to) + const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] + + return interval.toDuration(unit).normalize().toHuman({ + notation: "compact", + unitDisplay: "narrow", + compactDisplay: "short", + showZeros: false, + }) + } + + createEffect(() => { + lastPart() + scrollToBottom() + }) + + const [store, setStore] = createStore({ + status: rawStatus(), + stepsExpanded: true, + duration: duration(), + }) + + createEffect(() => { + const timer = setInterval(() => { + setStore("duration", duration()) + }, 1000) + onCleanup(() => clearInterval(timer)) + }) + + let lastStatusChange = Date.now() + let statusTimeout: number | undefined + createEffect(() => { + const newStatus = rawStatus() + if (newStatus === store.status || !newStatus) return + + const timeSinceLastChange = Date.now() - lastStatusChange + + if (timeSinceLastChange >= 2500) { + setStore("status", newStatus) + lastStatusChange = Date.now() + if (statusTimeout) { + clearTimeout(statusTimeout) + statusTimeout = undefined + } + } else { + if (statusTimeout) clearTimeout(statusTimeout) + statusTimeout = setTimeout(() => { + setStore("status", rawStatus()) + lastStatusChange = Date.now() + statusTimeout = undefined + }, 2500 - timeSinceLastChange) as unknown as number + } + }) + + createEffect((prev) => { + const isWorking = working() + if (prev && !isWorking && !state.userScrolled) { + setStore("stepsExpanded", false) + } + return isWorking + }, working()) + + return ( +
+ {/* Title (sticky) */} +
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> +
+
+ + + + + +

{message().summary?.title}

+
+
+
+
+
+ {/* User Message */} +
+ +
+ {/* Trigger (sticky) */} +
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> +
- - {/* Summary */} - -
-
-

- - Summary - Response - -

- - {(summary) => ( - - )} + · + {store.duration} + + +
+ {/* Response */} + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( + + + p?.id !== last()?.id)} + /> + + + + + + ) + }} + + + + {error()?.data?.message as string} + + +
+
+ {/* Summary */} + +
+
+

+ + Summary + Response + +

+ + {(summary) => ( + + )} + +
+ + + {(diff) => ( + + + +
+
+ +
+ + {getDirectory(diff.file)}‎ + + {getFilename(diff.file)} +
+
+
+ + +
+
+
+
+ + + +
+ )} +
+
+
+
+ + + {error()?.data?.message as string} +
- - - {(diff) => ( - - - -
-
- -
- - {getDirectory(diff.file)}‎ - - {getFilename(diff.file)} -
-
-
- - -
-
-
-
- - - -
- )} -
-
-
-
- - - {error()?.data?.message as string} - - -
+ ) + }} +
{props.children}
From c36f3b9dbe5547576545a77679b8898c205a0c30 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:50:10 -0600 Subject: [PATCH 109/575] wip(desktop): progress --- packages/desktop/src/context/notification.tsx | 4 +++- packages/ui/src/assets/audio/nope-01.aac | Bin 0 -> 6316 bytes packages/ui/src/assets/audio/nope-02.aac | Bin 0 -> 7431 bytes packages/ui/src/assets/audio/nope-03.aac | Bin 0 -> 6688 bytes packages/ui/src/assets/audio/nope-04.aac | Bin 0 -> 5573 bytes packages/ui/src/assets/audio/nope-05.aac | Bin 0 -> 6316 bytes 6 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/assets/audio/nope-01.aac create mode 100644 packages/ui/src/assets/audio/nope-02.aac create mode 100644 packages/ui/src/assets/audio/nope-03.aac create mode 100644 packages/ui/src/assets/audio/nope-04.aac create mode 100644 packages/ui/src/assets/audio/nope-05.aac diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx index 744e4fdf3..c18b12796 100644 --- a/packages/desktop/src/context/notification.tsx +++ b/packages/desktop/src/context/notification.tsx @@ -5,6 +5,7 @@ import { useGlobalSDK } from "./global-sdk" import { EventSessionError } from "@opencode-ai/sdk/v2" import { makeAudioPlayer } from "@solid-primitives/audio" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" +import errorSound from "@opencode-ai/ui/audio/error-3.aac" type NotificationBase = { directory?: string @@ -29,6 +30,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi name: "Notification", init: () => { const idlePlayer = makeAudioPlayer(idleSound) + const errorPlayer = makeAudioPlayer(errorSound) const globalSDK = useGlobalSDK() const [store, setStore] = makePersisted( @@ -65,8 +67,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi break } case "session.error": { + errorPlayer.play() const session = event.properties.sessionID ?? "global" - // errorPlayer.play() setStore("list", store.list.length, { ...base, type: "error", diff --git a/packages/ui/src/assets/audio/nope-01.aac b/packages/ui/src/assets/audio/nope-01.aac new file mode 100644 index 0000000000000000000000000000000000000000..9fb614d0808026ac186a8641d4dd9e5f1bff6073 GIT binary patch literal 6316 zcmezWF`z*&$wg7ol|dwc(e3|7uBcG}3xU`di$u0i8|?WH404M{GKxOO8rc5>!7ldg zXARBY{;EFTwaD&X`>gX<_ig|3|Mhyo@4vpjan)J9Qrjy;dG9R$whu|w`$RnCZ~IvyVad{5=$|%~S{&DioX1(OmE( z&*+b!0{ed@4@af`_pUkd+kPDrp1XRJwaJR5p8T#i_51roch9Z(w>{*}>wxY4HkZ>$+XfZySuX4eJQDnf>0;{4C3~2+HZGbp zH+jY1q>ELNye1LWt7j`{_4w$t=+FD@r16?dW73zTcL8_ZIu_mR+F;X= zm19N|vVx<}l*ul+H7R&wq_627r5SB8Gqq(V-0;$_u3}y8bGcxW^Pv!dP?1Xp_d0`S za?4H~=pBE#R|pSISQuD-oq{mc0s&!euIO3o0zHKAD5B0=!Z zW>Gh-pjWA8F0XxT!WsM}EJYXTiHhkT_@{Sb&#IRzBi^RXQDnTS@=A`&(CmuWjSUz3 znG2&Ne@amo)PJ^EXT4O_w;#pFka)0TCHlZ=4|$sqe_dLIF8P; zF-!@|+oznL*>`28yYuw#54cz?&#mEKyJn4x5isz-aA{721ilKedXSpDSi#Onk~g(k z?mI&Tdl!o&bMy5FC+|M_l<@4#nahk{w@%}!zQg!HPD3DtFaNH<(h&n5fYPU4lZ&F@ zXblRhHxYE~i$m0}LAQXbP*AI7G|Uk}4QJ7=)iRpQ;em|G7)|D=;Xx1HXfmfqD5Kah zn#@sxgD#xWWKNf0Mz&-$nIne=eb}SPoIc@W>;BG&7WMqnfhrG0a)TvgT2mj?oIJEdxb60h_PIIc4F$Ru{Oy!C^_|Hy5AVejnBya!#MV?Tk literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-02.aac b/packages/ui/src/assets/audio/nope-02.aac new file mode 100644 index 0000000000000000000000000000000000000000..75603cc16f8596b1a787d9b58f6bb9d843f21dc4 GIT binary patch literal 7431 zcmeHMS5%YR)(t%rA)yJOhGGH&BB6?uU_d}hs3H)0lOi3ZgpRa;G$T!^p(9PCOD71@ zM4E^oMG)`^QWO#L>-opI_nx;q?l|W>IbXiLAHKcDm}{=R*W8=5iw>8tLqVbH09G`a z25Fbh{1^UU0JBlZ`sA-hJLw1I(gI>ooq=#B(hoG#G%e>Go)|%fS9&`~Ag!;an@^J- z<$X3_ZIB?pH0yBqI_*verx@%kbY&5qx@!3%&v0dR_u@v2gUB@)GqqBcmZYW`r-Hxm zZHtvtFpXpcu_+Ap3dN_kjn}SqSc=pcQyF8YZo{wyUFDbOBAQKHAn5~67G#%aez+=ftPf|Gzo;01%D%|ys} z>G7aXm7&E{JhJE2Kw1;{a{K&%XSJ%DH1WI1RlG#e9bbiB>;WP{U{!4b{Dq|&og4&< zWUW}$7xaD336}NX_U{&SfI16rk77v~*W)rfhf1Xl@$hxL6BCRv5>m?!V1$9+bpd#p z*pu!6%xMZXf_Mw@j@?~96NH4WLUo8&7n|nK@96Go&&GsyTwar_p1U;g)tQ`Eq%(Qu zar50w|3R9!j`@c^w|A~ZRt?5S*8x=@K1ANs#TOc@XWh4_-)^PVUc+W%Xe|bbN;nZ0 zj*rg~s-{j+k7LLh7bo@=c`fR~ao1u`l4IX~?E3aeLS^O53;Eg_DtJ7_j%DTiOVil2 zg3$7Xp)(EFYN~c19-N;JNjEnY9UGX0WOQ^yDXywuLrdh20-IvEcSl0wFQx#6|_!w(oEm;y3hb}djcH*paBdW-pc``)}+mKpD1lz)vJTk_-IEjo-oV1 z%oer|yvHXzaY-WG;{I^BzyCRZH^dyi(D+406`)oqX1Y(3GauJ-pP-+m9ZU}7E^Fa*y&U z@15G>a_Rcdj?#wVR^KF>!1CH)RJ!A#o@+&pGxuGMYKp|Fz&>=sPT;f=nOIRu5 zmEetVA5`@v(K)@T^($bzOzIz4?Ql8!Rwct>>c(z+SN9uVsFf9lgnp^-VSU1N=rwbF`UI|A zM?3>B77@D)s?6`>?-ej)xhI(m_UM56I1gbLQm9nvN8jYstTc@>GCkPTtJ)8R`PhO+ zj{5Q3Un=D58m}H$_OoL532*>P;EPA}H=<5FrS(ula@wjLbg8&d2zTz9U#aUWV{$Dm zK^mc_**ARaC{TXs=3J|2KJO@Bc2gNT&I;YLwJ%{VFrQH+MkO*(cm!e4nZDX`Ndsny zc08CFhIzr6a=Y52A#hVT54Yp?l>$~9F-|;HmPLZ;xt8fp1dN6o-KXYKuOjYk$a3ap z^ZqTQOg3a3BLgX^j9EZjc`u8z;_mFwPnzi>yHqv_0#ID!tOy72F)dr=ejMm6-uJPU zb=|glq^e_oJjdKrITqUn;&|qCC)d&bRM6?fEW`eVMrQDKo3eI?FxpJTqz###Fb{V_ zc~{_w;Y;_8o#xKrrOi-fjG9F|21|l*Sh(qj7y>AZ);x8$bH%>yJ`AvVUIemCKodc^C7Z zyeE>q?0se;fIj?(9?5lp$5;Q8t}lvGNrNesa@!<5WA zn|3aRIQLqq*w}v&C(kgvu7-0#1}T8qEtsrZX^VqPhDx-Uon?$pC`kU+!(%)SM=b^Q z2K*w7YZ6d(UM}1RCGGuScnv(@pj~>Irmun>ylcZ-8BLd#^!j{i#))KUi*yIXzE|MD zZIuRaD;r91uZ-MzLExuQ=+W%)A?M+BThn@69^*a7k*7_D^vW==w?kBo*;K9Fs8}8S zw8`D=6+*@=WnpSudLc?tV{=sGhBvafCFa4~Wy2+-;Ik`2*4h<@9COTp$>S)g{1`8R zZxvIq^o`-8LP>P|Py2`!ZEZCbM5OfE1AE!XGt|^WiJ)==`p6wxVE&W*=B+%#9C3nl zeoL{n27el8y@RsfRf7Ox%sU5Wxvb{r&O+egE2>xnfFwTy1zyv5KOf+qBpKuyi^@F?(ofn;N9&*}G@m;bHIcMTt0Y`V0wDtXBt>H?1qRt>myHFZ0`@S3)5+SR=5)K$5}jgWii=ven)p7cN~_td3f0A7Pe8%r+0amZ*IMb7o+g* zTQE91+R%)7r|46!`w};PvI4BdUg~4)a)@Bypb3p{p^z zdP8DVv+pyNSrBK8`*}7#xB5(@mnT+?>`uYCuFo7rQN)KM1*49S*$lc}*xm&mtr_>} z&QZJND>N^Ct!OtAVchK0(j%L)*h67#UZ2vh4Fo8nU6p7rf)ZWM$X%e>4W*I)aM-v^ zErukmA6-%rak1}o__jV3Pl;<6&$N(qs7x3X1bE2Rl%CSRPo+)o{(8XdBCFMNKkZZ0 zaxkY)vOzVkJUuLZe{3}61U6T&YaSd;brSGkiW#uCfEc)8#_^Q}d>9oR#*(=r)1W0} ziDA68#Tk8O$vRPs=-w?!gc#M8eSok4D+O z)Ie{;V3TgNkJrTw>G#XBS(e_SW;ZL5Vau2&gW6FD>9||qg7d}$*OS^q=$f~DyXDnJ zfYX<*S0d%#gv>igvONw$<#i+G-Z8%&|53W*2dwztGp%3?fa**lt%{uJnFT!6oX(we zvNrF2{moB%cS0Ba__S-fwHiYTxF_p{ymp(Ne^Khlo)^O9y-j#zs}Fah=Ts8IH}W z!Ho*7qiR|0V(H|*-=^f+rA(tUN;6TSc-$5u`Z)nWm#v|KDKLG@cs@4hT$3O>-atqT z;g>QO>ePl}Zwbp2)(2)kJCVFpjG0{AWO!+m*ddH+zJI&)qpACAi@j@5Y&D#H-pqYa zH~x&lEvd{I{%9ZLyD))3y6^B8#%v=Z*c>&PZ*TFXg=|@+dPne+A z>wlSrSv_J%0It?%O30O8`7Rz<_1v@<4G`ESDAK%`J!d!C&6`R~bJiKGyxyj%0gw*1 z=u{7ADx4*pzVa|OM2D3+LVOhkTe&q&na9>%i*6|?b!Byjhu9wh2Sp986tSjwd%tvW zPLntdecQRWtmxBa`Fn$wPiRa2S+pW#tk9qg!m~u%h*>mRih(LY4GB~&s{kfF!Ynec zw)osAk;#aTq*&sOK&mmTd@CC6h%K$dM!hjP`Owg$tB~6gyQe5|jLX|GW4?1JHMtLb z8lNdh+Su3dk02tq@<9DslQNr%FZStIeTOwa%+j~a5d7-(y{gkPLsQGlf+J-&w^yHM zGP22w>fmcrrdTh$8q#}OiA@U7!9AiK57T`1PDH;pY}?o*)8c}VwgZSk~aa0XlEJk0x!~qPHnAEqN(&0{M)d2L=N2QPiaZW_8BE<_) ze})556ZO96sy%0wR@!{d5#ZoM$;9G#2L)Q{9(+*a=Xz+IY6k6f4tdm`EhyCNh2i5K z1wIEEs5pT9(VpaPq9E?WUU)lGbycwth!3z&M+f@<%}+E!2nxv%Z%wi-l+1UB(=j9z zkc$xz$&8lPqs`T#+6m#)W`PB9fUxjzPN%{KocvKkZ*?qQ`;<^R{UGpjEiw2*z+ZR zN(I$5(2BWAu2b9G>I2zhKSZ+oJ$rb1ID!dRBxBaUndFHv%6dO#Ns=#Hcq93q?(w{G z#mB__uB{z@zaW=%5H+PmaS5fjI_06tvCSMVKoy}z^G;aE1_oKx;OYU!i@!^KTH2D5 zOVd9z#6#_<5g?C-`P?1j;>g30HTd%~2-`VNoy!z>%}4g@#^(mb@}EW>{D@3pH{ep=rS6AP#uQB4k@CO4DyQ%-()0)*Ey*VU3 zn?d=9{oL@9Ff If54sp19W#k{r~^~ literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-03.aac b/packages/ui/src/assets/audio/nope-03.aac new file mode 100644 index 0000000000000000000000000000000000000000..1fe459a16e8de42721ba6813a0699fb019b6a741 GIT binary patch literal 6688 zcmeI0S6Gv4+JzGcRjC3JKuD;eN^gR6DTXQ_Qk34C5D=sokQ$Yi(2Mk5LjXZ~7q9?Q zq&F#26a+yKGO+i|-q)PXT>t!Mu9NRQc#rP&JnMU}h2Paf3R$Z{p=tm+J>V_;F1hJ% z{fmIbdM+LAf30TxIdTyKIVijrNpgnA<69ydpn3Gc^Wtt9c3f0h7IMFgs7BE-*!?4# z+oB85F;4X7jIAUUP8Ew74kz1P|K~x%VJfFC6H3v>EP+uzDOdq{Uk+lWn-nu-*AT`Lm9(3jr1M!ftP2s{XqGPoAJ?bm=wt7j->pW-&k6X(Tn9ADn$GyD;W-yS|Qq) z`I)A%*@vgK_PwR@NTJI8%B6AlwG(U4Zr*NLqViZmC$M;Z9@yQ&utS5&H!Db>f15%+ zkYre=1AnN(=TM@WG=vLGnklUr2T>*OvB{!ic?q;B?^1p6mUB2RBf0u1X*A2&o?Ka2 zePjmGPEb!q%^31x;}@SIA<|3&;+Gf32)Iv@v;LShH_5J}{lbSE%B0Z8n8ZCo>l{;^ zFI>t24>EBRcV)_?UtxqA%-Hnbx7MOn4rbDrX+SZdhAEn$_K+X$rDtGX34VPiR*B;s zZ6K`@ls=4sKu;~?(ge;oxUX~BguB#Og1DFSq0e~oa1BeBBKLjU9d@9Dze?;B$AC%0 zOtZ1Pwm$hgg{x139TVJ78ACl@ z_MrTQYZl=4&w50uM4Z3DfXT(G8wby4E73ZL=j+zJtuanSmldwpHc=2iHJ=?bd1=~X zs%kx_iqshMe1m2;YXPPSgl`p*mzI{!0a1zkx6=(rbJ^iz^Vi;rI2DB_B{EXnSZU5K zka{fXBpapHS^VL>_RfqBx7zwBw<&*oUTJ5l@F9~hajK+L=>CGPRn%y++48HS*RqeF z=LvRauU@;enh0Vzl~)cW8hkK0`u zn>Er15w6}(1HEYtTX*^hXmVO#?totxG+T~}?%r{tHNLQ(&A0FnoRq@2ec$9Tel0qX82WhH#NXCMbGb2NoA}g?8EegU~ zM^DnxAuH~5@Px19K_smr&yxMx^)$MK{uVs@)&F>gPiKbp`(t+0`*or25NA@Lf<^rox0g>#LkNTj%FkjyDl3R$jxL zS~8(1o6HUNTu;!FlSw*oFw3hK3*$+0PR>}Xj$l;dD*Ps3R&+Zf51vm01VmDGgod+m zf-i?Mx9DXoO82Lh<`P)xlpsWuF0t4vtY<2d8sy9gnz0}r8?pOmhmvlzE9W*>pZj$Q zdE9s5m(*f66yB#gT^yxBBLz)t<&$aCZOf{R9ZU+6?UWBf{TQ#@{)#V}UyearfDAr4 zscV@w!p0J#Z?>0=h9xNa+eA&Tyk|=s-xt6Wk*=L=yc+%Y+M%gfU6nE(XB{|L`hzr&mrF2N$&;&Z563kRvz&k{ zav967E~_y$hh^_E1+^tCvE>LdGnIx@ZqJ#^!K9b{^c({eYdt#57C5gY#8|tEkk!cm z0p!s@mdoZDX3j0cbsYzZ~v ztu|E$;(ZO-_aCY+nf89V?K(oxP_iTX!q3&=!I#ZTbQ3qoZ+pu-(hxRB+Rr_%^86M8 zpgiqSkls~_-~>WtxfK_m^aHK}&oeZ#xBxp6gQ@c-xv)SS6IRJ<#LB303eH3QY4pd6tj&bs8<$sJC&W zIf91$MqEgq1;0?*qwXNDP9IiAA{lyFKEuj6`8q(Vs6A{jwW+Cwfp$YnZ$#X;_P8>! z&pMDGf*|5yn?XpnF<68(T7uLmH)ix5?N1pp1x95IYAA9$Eh;9*qgmYa;ZyM>ec=R0 z)006uBGxA2uP|E`gZD>h1KT&*4+IHem%-u(8ue;)cXaxl-U4`^akPSLO_-lgFf5WP zPdi47%sggxK4of9N# zkma#fNnXv$qV6NbpPz6NJ*R?+ZVqdE_V1^?Hd{t&+H3L8*an(-1|7|Wb|vb&VmuCl z)8h}_slYx86$kfgJ()*Jwx(8NXa^lW`;V94C3>#W`?2&1I$fgvjE%7puInoApLPCb z(jL5QNr1!&9DiJAAZ8stwMKx%87%s8ORxLUMt6kYu+SrG+X@K*+x9a2h+6HM3P-?w zmZEni+XZyiRUtf=6H>_O?=%iM$}0#vcttWazthj3g+MXOJPQey&o@8aW$8tTTQ`5E zFsSQ+*$B}(X3>Ru&pNR1=#LLZ)PlmQQg;mA-iI5R-TkO--z7B9AluOModU*5-8Fl6 zLs1Ffa_6dMY~k^c22pm9%^y1;VAXui`Px!cH>75PkCq@pcZF-pjt;0#U|g*UX$W7Y zovfgZkHv8aCfPn|bdjFVA!GU^V5bWSH?NvSsTyc3lEc+L(!7LZy&5QFXhme2XJwwc zO$(un;@zqGTLKCX6?VVbIfW__xeJLLWytO&_u;GFo!0-{mORw=xc;mkQ18!k(6+ZM z4txRJfQw)lhei_M)O1i;<$f z3oSOTMl@kD3B6)Vb^zc4S!j-`u%2>cNC7}>Sm_D`dF}9B@|5v-2?goUhi5pekj z+%A%r&({WGgtdQa&CJO4MaCyJm2#BukUE9Mz!Ryv@`%J?M!-99!WcPhFvgBcu2oWm zLj_m3Oham$DT*M5_>faheFY%wWvq^s`T;EEZQ|M zXLH5@C+{qf*?6Xq`;YRriQM}HxPXtpWtfb$Qm`I=wmF zWj`&;<`Z@$Pmw%2j!m-&s_~qpy2dte?`suH(R|wS$t@KI_l40q`J#!k2S>M&qK?IY zFat%j3y8}*EQzb{NIlqb{!(G}h!p}RidkNRn}i;KSoRob0rW*btwr;iFNeX!VzQMu zAi|Zs36&mN>|X|j4ER12KOS_QY*dQ>T<5_dK(!~SoI}acr`r$wly0Zw-A`n|FFV~u zWT0HhV02RE%Jc%74(!H+6|!<6xwhx<9*hiibw}l^AFVQ+3MC&lLh|5 z6zEHl!YtnNkOxwI6s=&_uUO#d%X=Fvzugk0WhHI*Y!2ghroagFKUK{3db8BFC;#L zC`z`#mknKl>e^a)>H@u!eH!3?ZCN`gE8k~Fx=y>sTXARm{6s2oD!M%}8zS$^=4O6K zw1ZM8ITfFJVshCoWpifmuSasK5tS9cnXL2{g(zkRa~Cw@yJo?Q@?oP- z;@}$tHx}YC@?`W5ovYv1vwz0n)v$cm-QBRa-YFyHT2h}_>iNaa@Zbqzt)zA9D%5e2ZRON}y=oul8qFVpTL$%GOiP|AcQ4kwx4v359 z4<(uYByAw272vU>dt$*U znvkG&I3)=O1t zW>b}WUTiG1Q}rBJU1wYvXSPO|CrHl-n|l6G---p}Vl-DzTV9Q5w-1gTGt+ZQSz7v- zWWUxe6t)T0`62-#n^DzL<{%07VTo;U`*0)#JubcmuC&6%PGu{EcBuKH8+?c(MBX*u zeDcLhXS2-FYrIMfr@5tnFl&&0G)sjI?vwcS(y0{NT0u9JdM{PF7pG%KUQnD(T-WuK zM|jC(w>B$@Srjwiraer40`*w{vy!qsGRg4Dfy4TvQX$ba!h-Wn;mHvKo)ctEX-zhPg%H~ zGK9X@15aJ>TYnLlbNs&#+8?#b#hBClZWRC3BmVvU|7#R4#{4((ze)dfvRLo@-xDyQvx||p#Ks;jUwVD)ExfRqMeaPLrtD{ku;jO0s+b}x_k=m-`)COo}3>n zR+RrsTmD3Tk>==d&YKXzdzNJ<%Uz30At1#=&9h3g9{+v$ByqwHAhyU2&T+y1?+ZVE P!~Zw<%k2Ld{N{fE4(1i- literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/audio/nope-04.aac b/packages/ui/src/assets/audio/nope-04.aac new file mode 100644 index 0000000000000000000000000000000000000000..b731a2a0790b6a90dffd9520d2d75510077b4ed2 GIT binary patch literal 5573 zcmeI0doJ3yYama)~VAnN~^5;}+4% zHJ3${DVbX$B$p!hTgvwPdCuwSJe}X~ujl8S-yh!pd_V7VzMu0s=kg4ROfy3+iXn|c!wlKBtgBCI!uO3oUE(742sYh*SuLxVXGq^}gSKR1R*4Ku6RG?=L z%yYgNqXMS{T%ZEIZe6!I$d?>x&Jf0NTwa^#6Z=5^w%!R2aj(Q~%K^F+A2fCpySJQf zP_e6Hje&JQfnm2cjoirC;0NvOmQ*m?F%^7-w{VDhTd9*#HygXqL-EP(JocS52@H#M zfR$>vg<*Z%_Z2m&8GmX(Ff#51T1;P9A8m&7Xi)c8n!&2D(vJHLJH_`lh-vPWc-B28 zGVzM5=ZOabS!xOm#gNN^*1m~*`EG@scW#d$CKIW}!pqs-TvfD%5*<%3t+oG+Lm4Jz zn)1xwjHP&e+*xAs{3CHyq37ySFkhQ=s`YG6l7$NMWN=}>V%g**$Cf2-@W@}3Fismm z>-F*YhH09_cdyBPn*u)9+knrrJ3+jw$f-mU!k+@()9~fR=ZfB!r&_Ds8cr)V31ly* z___|G%?EqzzWpY0W=AAfB=Jh7^4|~F%WugXpv;Vsvjx@a?2bmLm~ zxFu0csyG;GOo@;tR8a?GI(&Ulhz!Hg)SA`Wa#sKjtGzAOZ!y=9ux_}Ib+WF!ULNUT zZ@`%*X(flqXt>WOm5jX94BjPY{S?JNtm|~N%lX?qFP-hk>spUZ1{5_%(%S;ilO2w` zckjQs9vtmwlu zCG4XNY_zWPVoTa{5+8Upx4f#})G3?gKNaDiSyl7QIPbBuhHTNN3+j+|(`^f`GxOO8 zzZ5Fh%7LHe*$|2*$OEs{d`)$3lEx#^pfs7+t8J=4n>Iis2;m4iU2&q$AjXRuUL#2? z;7BDgew`+h?^QQSN1qob>k0$HjBs zb*BVmW|&uc$k&}fisrq%82w${mj+ao4iP6_1rAQRboTkYv%&fELAx~+a`UA9Dqnck zml_YcQ?3~f)L9N&j6lXNnZDn-x2Q;FGss0e>x22j_>4d{;Nh*aR8H%Vt``8dUei7- z^D9UyihsApdYJMFQcl6OB?f+3bG#fX2nmqlj4{Lq847-c_$DTGe5v~Sm>U>W6s~M? zaV_+uDIjumodWcd7#)J?K<6n6~t)H9UxkUV8>Hy_go1b=t|ZPpYnN8YBt~yAOg>+RK+>!T`iE@ zx;6zT82Fv-tg^;|EyX^=0|i784ZrkGTnfl~P+z&i2>J7a3fKT^vFlJP1$U-or|V-~ZIDBY5i9WC%W`v^j4C z_n)jJtN%)bQUO{m=2X;kZYh1_Y|cYyl{3-I5sOfyqBir|Sg~ z;3>zCUFo&8B1-z_F?{@0mN%jB@Y2G%gHw*&OfMOGqBK zh{)_pVl&$!Gj($ooribIXXGLeo(x(lh{OjR29=P8H1>=r$cKrZ(T%LpQNsDdy*76C zm7#v#p0c7VfS^%agy*4t$XbvHb@qo^2 zv~h&U?<9@!KiWQ)DY-;Av0!@sh_#oEZpvE%yQ?8-aAiDx#@6=?PO9?FMhN4!sW{op z$!lXBs7F6$Bg9jZj{lq_p3rF72rX7N+O3VA2cb^OR=(iD#r$_kn|06a28yKwK<^_9 zpF?b${!io2d+X9{et>M)l`Gyjy=z^K_{ONbJdg@SKm22g&Fq#1T3gaeenb~L>Zl^K_+VS-XdX?=xuaj z)G$PsM4NxSyV*Uv-*(UY|Mp&A?sGq1o^!6>{giXz_p}2At>F-eB7k0-KncG`Zt{=* zIAH!QkG|&bRtx^O<%*DR5RLvoQv8q4=Wx>cF*?I>=sgUSbN?78YBbarIi?p?a*?yV zbc;S;4(iwWkq%I!0Da9d)%Nn_?fQ8@KFdwJmgPzD>Z;qy1m&h7QS3AxFMJUMHdzTe z2K`i91xtM)&tpL?6VMZ2GjXuJq3v8~|*tK0~b3vBMz7ejgAG>hchl}C2cZB`_jWV==r zN$agCno0V3HC-m2e@Ibbk9YK`Uu9|8-$&`D5s6m2;S+ZQw!pWS+xsQ+cob)coR;}t zZ1(ApcxKK4+oS0Zk~L}FJV=xVw!dt)VS8OB(2oXOSw_bgeA(^IabV^3MZHroylG~5 zYFkQs(?R6|u8&E$XwBnj$Ejot4b7YxCL=The=CdiaiiOxD>E`>9{9uN(>4nS}HhN zbGrhHUR|-CGcjt{dLPUi|5n-HwPjauh*Mouu;4mYajC4}8OZ|!AGHeJ2bG6p&u8|$ zZXF+GD-}@px@au=BsRB_ymNRPX8%k2aD$hDm*+DTQ&-E>a4_9?kmb$M?&zs@SqcpA z$1S}ROsQN|O>;lRdXaVh%?oW$ZZt`OvrKE6H4~HVXU3Nr2j6B{(gv<#DX7H)0 zkBO)}I^ulACbi#@kDVPVHf1Jvhcz|#XaE*3(Mv(xAk5LZ7XcRx>|6@Q_B4`|pb$a^ z%3nmCimJsF=nCT()y8^5aDW=nXPYTs{G7d3C0qZaGNH)yoACJTr*%Nho2(;CUpo@2 zm9Z8x)7@eAp$=IalJiYLM+r`AO}SPy;k1P0tTQopUL;e;%A~8YMReb(nsQkC4wS`} z7w!IvGdwoNblBLT2^Gzg1dc1NU)P%d!0l^Q7Pqf!R8qXYyXBRd@$h=txTgiStl|NS zytC*0g}#r|(Tb7P;A~3$+^R;+T}7RHtMhV2DWh~Q7Y2vWr)?@JkCG$r(A1FF@_7lJ z+N&#KY9Wa4_=pBtyma!)1Exi_FeQDNfXt-3+i^zc)l|a(mD{&y@~=h$-`@sDmu+ho z2Xw-dInIVnf1gZ+s$%}xQ zo$043)$?rhlD*p1kWh5Z3(R)E<|j@2TRVeGcgSP_HDk9`!&A%Q8iXb&%&q(Va|VtL zbFm9TuV+{nlx!ScExq%U7f9QuV+v=G$9}{aJR4K8Bt~CPT7Q-`(sI-#@wAJ7f^if| zo>l=MD(5!w-Ah@DmyO8rXjWr&r+8dYTfvu^9(v-$aZ2n^2w=SgERKc$23&L|bHUCP zS*;7!7oh~{OHa{1x#m!)(M+b!Xv(X~4InV?)js~x?5Sw>4GMO) z>Nan#Nn_y|*E;UMd}>?g*)wUd9@X@|R+9wX_{)(7sdY8$4_}X~&+X==hkoDl}IVWzHXT9F{L$$@xVN)w2zrtA&o7sIsx;Ji05z9Ag zy|W9X$j#*wGzzzsTh?F3FsX#6d4kf7H2I?qg(y#IA@sFcEoDL zsfa5R09*}yT}U4x`FMDsgy~*X;jTh>{r==D>j}cS*Aw3Q4G>m+J92%&ZR5_^x>JE_ z3*48&s8}iJ#Dtcl#gLe z-)%l4%Oxj?XY@i-lFP)%Hc@$v;Komv81Kn(L70&dzt1TC5q(3#JC=4Wy z*RlPg$=Ey6-9zUs&vDgt78M1G2zTIE?IVA#yOS>Cyyi*0UjJ&quQD(s9jd+CkIhTO znznjMfBv|tm2tAisPEOksO;dKk(QWtZ_Y)@*Sx73C#)D^{k4m0KVcI({sp1db&aM# zK=^(Tz#e32mIn9Hza1@dbhDA?>z*x(Bd4DmdH+BieTPmAZ>v%LnEy2AJF|GCsKjhe zDY#J&CaR!&_~x8@8aY-Kf0m}u3i@PAFoK3#;xceejLF<7DqD7mRomV^{mq&*mnry?y0N67Bf{XittY4+RDMN?X76Omq$y9=R& zFvm!o$>7;?Y)i+kJbh%B6GM;*okr{1FJ=enH z8C^`ZBX#cw&RAip-ZU*|ITBpFTDEdu$ai?hl#%*z&NLWmx|d22Y7@jkZ(n6|ui4l2 zV+s|QIg$y(oX6=gTbV1u?-vmNB3t6lZU(Td7X^PZxE@76iGAJShiW-#NpIB5^jege zupSG`)=WU)FtlxP;D*40hvc3ut+Q@g{;BGd*%k&>rYYXi-q9bx9KY`f`Jnp>+2^NX zNyE`_ZtD+ej@wG>|tS=yoY)$7jMj~es7F#j?% z%@!&PE7;ycC`DjND;kDm_1qPVMBe3+B$~WQ)yJtOP_Cf$dj!W^b8Y8ioimIJ*liK9 z*ME}Yx|EG32J)K~uoA&l{ZYZNL_0XrNR}sg6HPx}l|>OrP$6D6TdMlfyyh_7@9D!bslyByUm)OW_+jG`V+*bToz=-Z zlTZQ@JCl(6VI8((uSLAh?rQ3$>&iG@?^b7Br;ybu^d0mHLbbKa^^^S|!#& z>lInLTPPez;c`GflZmryC~3uV@&N#~s@#~zbGT8%vC%RlrL0qSxYPX8(pED-1U<3$ zB`&_jpTBXP`8~Rjg=le9#6?4_f$|Iv1=Bn7EAl!*Y!i&(RXM{daDX%+C|@N=Bs5NS zPngl+YtqJj-ceuRa@t*xSaD@6qEGI6Pl;_DdiZn5z(NyN>R{_?n1!QT_FHx(iRZc& zSAOyY9kQ=z3R;p<<%~#FH1D~RQ~e4M*(R};w5gM)d7`X(@3=D<6gHCVe-Q9?;LPR= zD@~KMc22Fc1v|Ffk?M1Zz(|n z?Cfl0!z&)Y$hsIb5jQK+kyuE)N(Ug2n=kjnB_DM^zvG_)h>r#c zxnHphRt=DqY!{BG5i|5yZ!?#~GY3lV$ZT%{gf2FuvP3QZdT4m9K)>x>Bue{nfpxHf zyvd^ngXd_@gS>n;I%dbBy3!r_s6uOCe#C^KDD;R}_j;31vA37G_mIRnU(Q8y|BPd_ zd$hByybWVyWt{~zPWK$pW*AcqKJ{=86Xfz=H)5Dq{g% z^$Gp>#`YWl0jp|^C;UTTd>KyNsN_RXLsuLC-1D z1ZbfP)v=V566(5kNd`gXPzK`?hlC5M^vsdhI+}Y=u9@PbVh>E>xJ*h*_;Y6a0?Al+ z=zg%Bd`a&#h`pRx0&EL}_OIwe`LdnIdfuQgE)Y2T8GOT%*J-{>Zfe( z#g(^*vfW*lGPPBCDrlILblaJH%0K$!K&p>Qn85!>LVs>^=EO8E88DGCtUKs}El5dg iUvekM&yR9dnBDZh*Ie;5@Bh(%Ik2DsQUAZ|)4u=@8RTLB literal 0 HcmV?d00001 From c0d009d5f33c368f61ebe9a87460b1fbf5801d33 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:54:44 -0600 Subject: [PATCH 110/575] wip(desktop): progress --- packages/desktop/src/context/notification.tsx | 2 +- packages/desktop/src/pages/layout.tsx | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx index c18b12796..705551944 100644 --- a/packages/desktop/src/context/notification.tsx +++ b/packages/desktop/src/context/notification.tsx @@ -5,7 +5,7 @@ import { useGlobalSDK } from "./global-sdk" import { EventSessionError } from "@opencode-ai/sdk/v2" import { makeAudioPlayer } from "@solid-primitives/audio" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" -import errorSound from "@opencode-ai/ui/audio/error-3.aac" +import errorSound from "@opencode-ai/ui/audio/nope-03.aac" type NotificationBase = { directory?: string diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 08d24dc6f..df162c187 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -12,6 +12,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { Spinner } from "@opencode-ai/ui/spinner" import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session, Project } from "@opencode-ai/sdk/v2/client" @@ -287,6 +288,11 @@ export default function Layout(props: ParentProps) { const updated = createMemo(() => DateTime.fromMillis(session.time.updated)) const notifications = createMemo(() => notification.session.unseen(session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo( + () => + session.id !== params.id && + globalSync.child(props.project.worktree)[0].session_status[session.id]?.type === "busy", + ) async function archive(session: Session) { await globalSDK.client.session.update({ directory: session.directory, @@ -319,6 +325,9 @@ export default function Layout(props: ParentProps) {
+ + +
From 315836c0b7f7f852c99d8c9945fa15e63a3ea2bc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:06:05 -0600 Subject: [PATCH 111/575] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 6 +- packages/desktop/src/pages/session.tsx | 64 ++++++++----------- 2 files changed, 29 insertions(+), 41 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index fd5574990..4dd8d878c 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -591,8 +591,8 @@ export const PromptInput: Component = (props) => { {/* Popover for file mentions and slash commands */}
@@ -602,7 +602,7 @@ export const PromptInput: Component = (props) => { {(i) => (
From df2ebfac7d3dca6c2262258e6ee85a3c22cc53c3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:52:54 -0600 Subject: [PATCH 115/575] wip(desktop): progress --- packages/desktop/src/context/layout.tsx | 15 +++++ packages/desktop/src/pages/layout.tsx | 6 ++ packages/desktop/src/pages/session.tsx | 69 ++++++++++++++++++++- packages/ui/src/components/session-turn.tsx | 20 +++++- 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index af71c6a00..604f7c5d1 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -46,6 +46,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, + steps: { + expanded: false, + }, sessionTabs: {} as Record, }), { @@ -161,6 +164,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, + steps: { + expanded: createMemo(() => store.steps?.expanded ?? false), + toggle() { + setStore("steps", "expanded", (x) => !x) + }, + expand() { + setStore("steps", "expanded", true) + }, + collapse() { + setStore("steps", "expanded", false) + }, + }, tabs(sessionKey: string) { const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) return { diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index bb2302503..53078e01b 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -156,6 +156,12 @@ export default function Layout(props: ParentProps) { }, ] : []), + { + id: "provider.connect", + title: "Connect provider", + category: "Provider", + onSelect: () => connectProvider(), + }, { id: "session.previous", title: "Previous session", diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 5c74f2d2e..d0c3bf7de 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -34,6 +34,7 @@ import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectModel } from "@/components/dialog-select-model" import { useCommand } from "@/context/command" import { useNavigate, useParams } from "@solidjs/router" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" @@ -70,6 +71,25 @@ export default function Page() { setMessageStore("messageId", message?.id) } + function navigateMessageByOffset(offset: number) { + const messages = userMessages() + if (messages.length === 0) return + + const current = activeMessage() + const currentIndex = current ? messages.findIndex((m) => m.id === current.id) : -1 + + let targetIndex: number + if (currentIndex === -1) { + targetIndex = offset > 0 ? 0 : messages.length - 1 + } else { + targetIndex = currentIndex + offset + } + + if (targetIndex < 0 || targetIndex >= messages.length) return + + setActiveMessage(messages[targetIndex]) + } + const last = createMemo( () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, ) @@ -118,7 +138,7 @@ export default function Page() { title: "New session", description: "Create a new session", category: "Session", - keybind: "mod+n", + keybind: "mod+shift+s", slash: "new", onSelect: () => navigate(`/${params.dir}/session`), }, @@ -163,6 +183,49 @@ export default function Page() { keybind: "ctrl+shift+`", onSelect: () => terminal.new(), }, + { + id: "steps.toggle", + title: "Toggle steps", + description: "Show or hide the steps", + category: "View", + keybind: "mod+e", + slash: "steps", + onSelect: () => layout.steps.toggle(), + }, + { + id: "message.previous", + title: "Previous message", + description: "Go to the previous user message", + category: "Session", + keybind: "mod+arrowup", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(-1), + }, + { + id: "message.next", + title: "Next message", + description: "Go to the next user message", + category: "Session", + keybind: "mod+arrowdown", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(1), + }, + { + id: "model.choose", + title: "Choose model", + description: "Select a different model", + category: "Model", + slash: "model", + onSelect: () => dialog.replace(() => ), + }, + { + id: "agent.cycle", + title: "Cycle agent", + description: "Switch to the next agent", + category: "Agent", + slash: "agent", + onSelect: () => local.agent.move(1), + }, ]) // Handle keyboard events that aren't commands @@ -492,6 +555,10 @@ export default function Page() { + expanded ? layout.steps.expand() : layout.steps.collapse() + } classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20", diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 54dd01091..807092d03 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -24,6 +24,8 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string + stepsExpanded?: boolean + onStepsExpandedChange?: (expanded: boolean) => void classes?: { root?: string content?: string @@ -222,10 +224,17 @@ export function SessionTurn( const [store, setStore] = createStore({ status: rawStatus(), - stepsExpanded: working(), + stepsExpanded: props.stepsExpanded ?? working(), duration: duration(), }) + // Sync with controlled prop + createEffect(() => { + if (props.stepsExpanded !== undefined) { + setStore("stepsExpanded", props.stepsExpanded) + } + }) + createEffect(() => { const timer = setInterval(() => { setStore("duration", duration()) @@ -262,6 +271,7 @@ export function SessionTurn( const isWorking = working() if (prev && !isWorking && !state.userScrolled) { setStore("stepsExpanded", false) + props.onStepsExpandedChange?.(false) } return isWorking }, working()) @@ -278,7 +288,7 @@ export function SessionTurn(
- + @@ -298,7 +308,11 @@ export function SessionTurn( data-slot="session-turn-collapsible-trigger-content" variant="ghost" size="small" - onClick={() => setStore("stepsExpanded", !store.stepsExpanded)} + onClick={() => { + const next = !store.stepsExpanded + setStore("stepsExpanded", next) + props.onStepsExpandedChange?.(next) + }} > From 5e37a902ce0ad209cedb0a85e997f1964064424a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:59:01 -0600 Subject: [PATCH 116/575] wip(desktop): progress --- packages/desktop/src/context/layout.tsx | 15 --------------- packages/desktop/src/pages/session.tsx | 10 +++++----- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 604f7c5d1..af71c6a00 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -46,9 +46,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, - steps: { - expanded: false, - }, sessionTabs: {} as Record, }), { @@ -164,18 +161,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, - steps: { - expanded: createMemo(() => store.steps?.expanded ?? false), - toggle() { - setStore("steps", "expanded", (x) => !x) - }, - expand() { - setStore("steps", "expanded", true) - }, - collapse() { - setStore("steps", "expanded", false) - }, - }, tabs(sessionKey: string) { const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) return { diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index d0c3bf7de..d49779587 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -115,6 +115,7 @@ export default function Page() { clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, + stepsExpanded: false, }) let inputRef!: HTMLDivElement @@ -190,7 +191,8 @@ export default function Page() { category: "View", keybind: "mod+e", slash: "steps", - onSelect: () => layout.steps.toggle(), + disabled: !params.id, + onSelect: () => setStore("stepsExpanded", (x) => !x), }, { id: "message.previous", @@ -555,10 +557,8 @@ export default function Page() { - expanded ? layout.steps.expand() : layout.steps.collapse() - } + stepsExpanded={store.stepsExpanded} + onStepsExpandedChange={(expanded) => setStore("stepsExpanded", expanded)} classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20", From ff6864a7ca3772e6f2702d585c6bb64a40bd6cce Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:05:50 -0600 Subject: [PATCH 117/575] feat(desktop): custom commands --- .../desktop/src/components/prompt-input.tsx | 74 ++++++++++++++++--- packages/desktop/src/context/global-sync.tsx | 4 + 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 51c4e24d2..87f91104c 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -61,6 +61,7 @@ interface SlashCommand { title: string description?: string keybind?: string + type: "builtin" | "custom" } export const PromptInput: Component = (props) => { @@ -208,8 +209,8 @@ export const PromptInput: Component = (props) => { }) // Get slash commands from registered commands (only those with explicit slash trigger) - const slashCommands = createMemo(() => - command.options + const slashCommands = createMemo(() => { + const builtin = command.options .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) .map((opt) => ({ id: opt.id, @@ -217,15 +218,46 @@ export const PromptInput: Component = (props) => { title: opt.title, description: opt.description, keybind: opt.keybind, - })), - ) + type: "builtin" as const, + })) + + const custom = sync.data.command.map((cmd) => ({ + id: `custom.${cmd.name}`, + trigger: cmd.name, + title: cmd.name, + description: cmd.description, + type: "custom" as const, + })) + + return [...custom, ...builtin] + }) const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return - // Since slash commands only trigger from start, just clear the input + setStore("popover", null) + + if (cmd.type === "custom") { + // For custom commands, insert the command text so user can add arguments + const text = `/${cmd.trigger} ` + editorRef.innerHTML = "" + editorRef.textContent = text + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + // Set cursor at end + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const sel = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + sel?.removeAllRanges() + sel?.addRange(range) + }) + return + } + + // For built-in commands, clear input and execute immediately editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) - setStore("popover", null) command.trigger(cmd.id, "slash") } @@ -571,6 +603,23 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + // Check if this is a custom command + if (text.startsWith("/")) { + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) // Remove leading "/" + const customCommand = sync.data.command.find((c) => c.name === commandName) + if (customCommand) { + sdk.client.session.command({ + sessionID: existing.id, + command: commandName, + arguments: args.join(" "), + agent: local.agent.current()!.name, + model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`, + }) + return + } + } + sdk.client.session.prompt({ sessionID: existing.id, agent: local.agent.current()!.name, @@ -641,9 +690,16 @@ export const PromptInput: Component = (props) => { {cmd.description}
- - {formatKeybind(cmd.keybind!)} - +
+ + + custom + + + + {formatKeybind(cmd.keybind!)} + +
)} diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 8151a2c6f..b90dde34f 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -13,6 +13,7 @@ import { type SessionStatus, type ProviderListResponse, type ProviderAuthResponse, + type Command, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -24,6 +25,7 @@ import { onMount } from "solid-js" type State = { ready: boolean agent: Agent[] + command: Command[] project: string provider: ProviderListResponse config: Config @@ -79,6 +81,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple path: { state: "", config: "", worktree: "", directory: "", home: "" }, ready: false, agent: [], + command: [], session: [], session_status: {}, session_diff: {}, @@ -118,6 +121,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)), path: () => sdk.path.get().then((x) => setStore("path", x.data!)), agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])), session: () => loadSessions(directory), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), From df2713a6c263a006539efad84e64103caee2d3f5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:14:03 -0600 Subject: [PATCH 118/575] chore: cleanup --- .../desktop/src/components/prompt-input.tsx | 21 ++++++------------- packages/desktop/src/context/command.tsx | 17 --------------- packages/desktop/src/pages/session.tsx | 19 +++-------------- packages/ui/src/components/session-turn.tsx | 1 - 4 files changed, 9 insertions(+), 49 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 87f91104c..9be09507a 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -77,7 +77,6 @@ export const PromptInput: Component = (props) => { const command = useCommand() let editorRef!: HTMLDivElement - // Session-derived state const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) @@ -183,10 +182,10 @@ export const PromptInput: Component = (props) => { } onMount(() => { - editorRef?.addEventListener("paste", handlePaste) + editorRef.addEventListener("paste", handlePaste) }) onCleanup(() => { - editorRef?.removeEventListener("paste", handlePaste) + editorRef.removeEventListener("paste", handlePaste) }) createEffect(() => { @@ -208,7 +207,6 @@ export const PromptInput: Component = (props) => { onSelect: handleFileSelect, }) - // Get slash commands from registered commands (only those with explicit slash trigger) const slashCommands = createMemo(() => { const builtin = command.options .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) @@ -237,12 +235,10 @@ export const PromptInput: Component = (props) => { setStore("popover", null) if (cmd.type === "custom") { - // For custom commands, insert the command text so user can add arguments const text = `/${cmd.trigger} ` editorRef.innerHTML = "" editorRef.textContent = text prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - // Set cursor at end requestAnimationFrame(() => { editorRef.focus() const range = document.createRange() @@ -255,7 +251,6 @@ export const PromptInput: Component = (props) => { return } - // For built-in commands, clear input and execute immediately editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) command.trigger(cmd.id, "slash") @@ -287,7 +282,7 @@ export const PromptInput: Component = (props) => { } editorRef.innerHTML = "" - currentParts.forEach((part: ContentPart) => { + currentParts.forEach((part) => { if (part.type === "text") { editorRef.appendChild(document.createTextNode(part.content)) } else if (part.type === "file") { @@ -374,7 +369,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() - const rawText = currentPrompt.map((p: ContentPart) => p.content).join("") + const rawText = currentPrompt.map((p) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -498,7 +493,6 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - // Handle popover navigation if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { if (store.popover === "file") { onKeyDown(event) @@ -510,7 +504,6 @@ export const PromptInput: Component = (props) => { } if (event.key === "ArrowUp" || event.key === "ArrowDown") { - // Skip history navigation when modifier keys are pressed (used for other commands) if (event.altKey || event.ctrlKey || event.metaKey) return const { collapsed, onFirstLine, onLastLine } = getCaretLineState() if (!collapsed) return @@ -554,7 +547,7 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() - const text = currentPrompt.map((part: ContentPart) => part.content).join("") + const text = currentPrompt.map((part) => part.content).join("") if (text.trim().length === 0) { if (working()) abort() return @@ -574,7 +567,7 @@ export const PromptInput: Component = (props) => { const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) const attachments = currentPrompt.filter( - (part: ContentPart) => part.type === "file", + (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] const attachmentParts = attachments.map((attachment) => { @@ -603,7 +596,6 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) - // Check if this is a custom command if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") const commandName = cmdName.slice(1) // Remove leading "/" @@ -639,7 +631,6 @@ export const PromptInput: Component = (props) => { return (
- {/* Popover for file mentions and slash commands */}
void } @@ -197,7 +182,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const handleKeyDown = (event: KeyboardEvent) => { if (suspended()) return - // Check for command palette keybind (mod+shift+p) const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { event.preventDefault() @@ -205,7 +189,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return } - // Check registered command keybinds for (const option of options()) { if (option.disabled) continue if (!option.keybind) continue diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index d49779587..9e743e48f 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -49,7 +49,6 @@ export default function Page() { const params = useParams() const navigate = useNavigate() - // Session-specific derived state const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) @@ -132,7 +131,6 @@ export default function Page() { } }) - // Register commands for this page command.register(() => [ { id: "session.new", @@ -230,28 +228,17 @@ export default function Page() { }, ]) - // Handle keyboard events that aren't commands const handleKeyDown = (event: KeyboardEvent) => { - // Don't interfere with terminal // @ts-expect-error - if (document.activeElement?.dataset?.component === "terminal") { - return - } - - // Don't interfere with dialogs - if (dialog.stack.length > 0) { - return - } + if (document.activeElement?.dataset?.component === "terminal") return + if (dialog.stack.length > 0) return const focused = document.activeElement === inputRef if (focused) { - if (event.key === "Escape") { - inputRef?.blur() - } + if (event.key === "Escape") inputRef?.blur() return } - // Focus input when typing characters if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 807092d03..f905abbd1 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -228,7 +228,6 @@ export function SessionTurn( duration: duration(), }) - // Sync with controlled prop createEffect(() => { if (props.stepsExpanded !== undefined) { setStore("stepsExpanded", props.stepsExpanded) From 5eaa8e1bf4862bfc64f114f7e9b31fc22e79be44 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:18:15 -0600 Subject: [PATCH 119/575] chore: cleanup --- packages/desktop/src/pages/session.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 9e743e48f..05a9e8a1d 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -233,6 +233,19 @@ export default function Page() { if (document.activeElement?.dataset?.component === "terminal") return if (dialog.stack.length > 0) return + if (event.key === "PageUp" || event.key === "PageDown") { + const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement + if (scrollContainer) { + event.preventDefault() + const scrollAmount = scrollContainer.clientHeight * 0.8 + scrollContainer.scrollBy({ + top: event.key === "PageUp" ? -scrollAmount : scrollAmount, + behavior: "instant", + }) + } + return + } + const focused = document.activeElement === inputRef if (focused) { if (event.key === "Escape") inputRef?.blur() From 44d6c5780d41616bf29a749020c9d7f98895407f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:25:24 -0600 Subject: [PATCH 120/575] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 47 +++++++------------ 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 9be09507a..37d05c311 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -133,31 +133,20 @@ export const PromptInput: Component = (props) => { }) } - const getCaretLineState = () => { + const getCaretState = () => { const selection = window.getSelection() - if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false } - const range = selection.getRangeAt(0) - const rect = range.getBoundingClientRect() - const editorRect = editorRef.getBoundingClientRect() - const style = window.getComputedStyle(editorRef) - const paddingTop = parseFloat(style.paddingTop) || 0 - const paddingBottom = parseFloat(style.paddingBottom) || 0 - let lineHeight = parseFloat(style.lineHeight) - if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16 - const scrollTop = editorRef.scrollTop - let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop - if (!Number.isFinite(relativeTop)) relativeTop = scrollTop - relativeTop = Math.max(0, relativeTop) - let caretHeight = rect.height - if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight - const relativeBottom = relativeTop + caretHeight - const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom) - const threshold = Math.max(2, lineHeight / 2) - + const textLength = promptLength(prompt.current()) + if (!selection || selection.rangeCount === 0) { + return { collapsed: false, cursorPosition: 0, textLength } + } + const anchorNode = selection.anchorNode + if (!anchorNode || !editorRef.contains(anchorNode)) { + return { collapsed: false, cursorPosition: 0, textLength } + } return { collapsed: selection.isCollapsed, - onFirstLine: relativeTop <= threshold, - onLastLine: contentHeight - relativeBottom <= threshold, + cursorPosition: getCursorPosition(editorRef), + textLength, } } @@ -505,17 +494,13 @@ export const PromptInput: Component = (props) => { if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (event.altKey || event.ctrlKey || event.metaKey) return - const { collapsed, onFirstLine, onLastLine } = getCaretLineState() + const { collapsed, cursorPosition, textLength } = getCaretState() if (!collapsed) return - const cursorPos = getCursorPosition(editorRef) - const textLength = promptLength(prompt.current()) const inHistory = store.historyIndex >= 0 - const isStart = cursorPos === 0 - const isEnd = cursorPos === textLength - const atAbsoluteStart = onFirstLine && isStart - const atAbsoluteEnd = onLastLine && isEnd - const allowUp = (inHistory && isEnd) || atAbsoluteStart - const allowDown = (inHistory && isStart) || atAbsoluteEnd + const atAbsoluteStart = cursorPosition === 0 + const atAbsoluteEnd = cursorPosition === textLength + const allowUp = (inHistory && atAbsoluteEnd) || atAbsoluteStart + const allowDown = (inHistory && atAbsoluteStart) || atAbsoluteEnd if (event.key === "ArrowUp") { if (!allowUp) return From 5cf6a1343c6ca088bd2b586197faf7fe58961290 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:34:00 -0600 Subject: [PATCH 121/575] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 191 +++++++++++++--- packages/desktop/src/context/global-sync.tsx | 40 +++- packages/desktop/src/context/local.tsx | 2 +- packages/desktop/src/context/prompt.tsx | 14 +- packages/desktop/src/pages/layout.tsx | 211 ++++++++++++------ packages/desktop/src/pages/session.tsx | 148 ++++++++---- packages/desktop/src/utils/prompt.ts | 47 ++++ packages/ui/src/components/message-part.css | 76 ++++++- packages/ui/src/components/message-part.tsx | 96 +++++++- packages/ui/src/components/session-turn.tsx | 10 +- 10 files changed, 676 insertions(+), 159 deletions(-) create mode 100644 packages/desktop/src/utils/prompt.ts diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 37d05c311..f3f758102 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,10 +1,10 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useNavigate, useParams } from "@solidjs/router" @@ -22,6 +22,9 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand, formatKeybind } from "@/context/command" +const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] + interface PromptInputProps { class?: string ref?: (el: HTMLDivElement) => void @@ -93,11 +96,15 @@ export const PromptInput: Component = (props) => { historyIndex: number savedPrompt: Prompt | null placeholder: number + dragging: boolean + imageAttachments: ImageAttachmentPart[] }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + dragging: false, + imageAttachments: [], }) const MAX_HISTORY = 100 @@ -113,16 +120,17 @@ export const PromptInput: Component = (props) => { ) const clonePromptParts = (prompt: Prompt): Prompt => - prompt.map((part) => - part.type === "text" - ? { ...part } - : { - ...part, - selection: part.selection ? { ...part.selection } : undefined, - }, - ) + prompt.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + return { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + } + }) - const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + const promptLength = (prompt: Prompt) => + prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) @@ -162,14 +170,89 @@ export const PromptInput: Component = (props) => { const isFocused = createFocusSignal(() => editorRef) - const handlePaste = (event: ClipboardEvent) => { + const addImageAttachment = async (file: File) => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string + const attachment: ImageAttachmentPart = { + type: "image", + id: crypto.randomUUID(), + filename: file.name, + mime: file.type, + dataUrl, + } + setStore( + produce((draft) => { + draft.imageAttachments.push(attachment) + }), + ) + } + reader.readAsDataURL(file) + } + + const removeImageAttachment = (id: string) => { + setStore( + produce((draft) => { + draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) + }), + ) + } + + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + + const items = Array.from(clipboardData.items) + const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (imageItems.length > 0) { + event.preventDefault() + event.stopPropagation() + for (const item of imageItems) { + const file = item.getAsFile() + if (file) await addImageAttachment(file) + } + return + } + event.preventDefault() event.stopPropagation() - // @ts-expect-error - const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? "" + const plainText = clipboardData.getData("text/plain") ?? "" addPart({ type: "text", content: plainText, start: 0, end: 0 }) } + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + setStore("dragging", true) + } + } + + const handleDragLeave = (event: DragEvent) => { + const related = event.relatedTarget as Node | null + const form = event.currentTarget as HTMLElement + if (!related || !form.contains(related)) { + setStore("dragging", false) + } + } + + const handleDrop = async (event: DragEvent) => { + event.preventDefault() + setStore("dragging", false) + + const files = event.dataTransfer?.files + if (!files) return + + for (const file of Array.from(files)) { + if (ACCEPTED_FILE_TYPES.includes(file.type)) { + await addImageAttachment(file) + } + } + } + onMount(() => { editorRef.addEventListener("paste", handlePaste) }) @@ -328,7 +411,7 @@ export const PromptInput: Component = (props) => { const handleInput = () => { const rawParts = parseFromDOM() const cursorPosition = getCursorPosition(editorRef) - const rawText = rawParts.map((p) => p.content).join("") + const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) // Slash commands only trigger when / is at the start of input @@ -358,7 +441,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() - const rawText = currentPrompt.map((p) => p.content).join("") + const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -424,7 +507,7 @@ export const PromptInput: Component = (props) => { const addToHistory = (prompt: Prompt) => { const text = prompt - .map((p) => p.content) + .map((p) => ("content" in p ? p.content : "")) .join("") .trim() if (!text) return @@ -432,7 +515,7 @@ export const PromptInput: Component = (props) => { const entry = clonePromptParts(prompt) const lastEntry = history.entries[0] if (lastEntry) { - const lastText = lastEntry.map((p) => p.content).join("") + const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("") if (lastText === text) return } @@ -532,8 +615,9 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() - const text = currentPrompt.map((part) => part.content).join("") - if (text.trim().length === 0) { + const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") + const hasImageAttachments = store.imageAttachments.length > 0 + if (text.trim().length === 0 && !hasImageAttachments) { if (working()) abort() return } @@ -555,7 +639,7 @@ export const PromptInput: Component = (props) => { (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] - const attachmentParts = attachments.map((attachment) => { + const fileAttachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` @@ -577,9 +661,17 @@ export const PromptInput: Component = (props) => { } }) + const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })) + tabs().setActive(undefined) editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("imageAttachments", []) if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") @@ -609,7 +701,8 @@ export const PromptInput: Component = (props) => { type: "text", text, }, - ...attachmentParts, + ...fileAttachmentParts, + ...imageAttachmentParts, ], }) } @@ -686,12 +779,58 @@ export const PromptInput: Component = (props) => { + +
+
+ + Drop images or PDFs here +
+
+
+ 0}> +
+ + {(attachment) => ( +
+ + +
+ } + > + {attachment.filename} + + +
+ {attachment.filename} +
+
+ )} + +
+
{ @@ -706,7 +845,7 @@ export const PromptInput: Component = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - +
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
@@ -735,7 +874,7 @@ export const PromptInput: Component = (props) => {
@@ -755,7 +894,7 @@ export const PromptInput: Component = (props) => { > { - const sessions = (x.data ?? []) + const oneHourAgo = Date.now() - 60 * 60 * 1000 + const nonArchived = (x.data ?? []) .slice() .filter((s) => !s.time.archived) .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, 5) + // Include at least 5 sessions, plus any updated in the last hour + const sessions = nonArchived.filter((s, i) => { + if (i < 5) return true + const updated = new Date(s.time.updated).getTime() + return updated > oneHourAgo + }) const [, setStore] = child(directory) setStore("session", sessions) }) @@ -220,6 +226,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.removed": { + const messages = store.message[event.properties.sessionID] + if (!messages) break + const result = Binary.search(messages, event.properties.messageID, (m) => m.id) + if (result.found) { + setStore( + "message", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } case "message.part.updated": { const part = event.properties.part const parts = store.part[part.messageID] @@ -241,6 +262,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.part.removed": { + const parts = store.part[event.properties.messageID] + if (!parts) break + const result = Binary.search(parts, event.properties.partID, (p) => p.id) + if (result.found) { + setStore( + "part", + event.properties.messageID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } } }) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 6ec9778cc..b12679210 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -406,7 +406,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ case "file.watcher.updated": const relativePath = relative(event.properties.file) if (relativePath.startsWith(".git/")) return - load(relativePath) + if (store.node[relativePath]) load(relativePath) break } }) diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx index c3b3bbace..2da0a08d5 100644 --- a/packages/desktop/src/context/prompt.tsx +++ b/packages/desktop/src/context/prompt.tsx @@ -21,7 +21,15 @@ export interface FileAttachmentPart extends PartBase { selection?: TextSelection } -export type ContentPart = TextPart | FileAttachmentPart +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart export type Prompt = ContentPart[] export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] @@ -38,6 +46,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { return false } + if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { + return false + } } return true } @@ -49,6 +60,7 @@ function cloneSelection(selection?: TextSelection) { function clonePart(part: ContentPart): ContentPart { if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } return { ...part, selection: cloneSelection(part.selection), diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 53078e01b..6632abe3a 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -55,10 +55,32 @@ export default function Layout(props: ParentProps) { const dialog = useDialog() const command = useCommand() + function flattenSessions(sessions: Session[]): Session[] { + const childrenMap = new Map() + for (const session of sessions) { + if (session.parentID) { + const children = childrenMap.get(session.parentID) ?? [] + children.push(session) + childrenMap.set(session.parentID, children) + } + } + const result: Session[] = [] + function visit(session: Session) { + result.push(session) + for (const child of childrenMap.get(session.id) ?? []) { + visit(child) + } + } + for (const session of sessions) { + if (!session.parentID) visit(session) + } + return result + } + const currentSessions = createMemo(() => { if (!params.dir) return [] const directory = base64Decode(params.dir) - return globalSync.child(directory)[0].session ?? [] + return flattenSessions(globalSync.child(directory)[0].session ?? []) }) function navigateSessionByOffset(offset: number) { @@ -98,7 +120,7 @@ export default function Layout(props: ParentProps) { const nextProject = projects[nextProjectIndex] if (!nextProject) return - const nextProjectSessions = globalSync.child(nextProject.worktree)[0].session ?? [] + const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? []) if (nextProjectSessions.length === 0) { // Navigate to the project's new session page if no sessions navigateToProject(nextProject.worktree) @@ -375,6 +397,98 @@ export default function Layout(props: ParentProps) { ) } + const SessionItem = (props: { + session: Session + slug: string + project: Project + depth?: number + childrenMap: Map + }): JSX.Element => { + const notification = useNotification() + const depth = props.depth ?? 0 + const children = createMemo(() => props.childrenMap.get(props.session.id) ?? []) + const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) + const notifications = createMemo(() => notification.session.unseen(props.session.id)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo( + () => + props.session.id !== params.id && + globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy", + ) + return ( + <> +
+ + +
+ + {props.session.title} + + + + {(child) => ( + + )} + + + ) + } + const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => { const notification = useNotification() const sortable = createSortable(props.project.worktree) @@ -382,6 +496,18 @@ export default function Layout(props: ParentProps) { const name = createMemo(() => getFilename(props.project.worktree)) const [store, setStore] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session ?? []) + const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) + const childSessionsByParent = createMemo(() => { + const map = new Map() + for (const session of sessions()) { + if (session.parentID) { + const children = map.get(session.parentID) ?? [] + children.push(session) + map.set(session.parentID, children) + } + } + return map + }) const [expanded, setExpanded] = createSignal(true) return ( // @ts-ignore @@ -421,78 +547,17 @@ export default function Layout(props: ParentProps) {