From 2d5df3ad7671f3fe6d7f46b65218f6901c77e9d6 Mon Sep 17 00:00:00 2001 From: oribi Date: Thu, 30 Oct 2025 16:32:39 +0200 Subject: [PATCH 01/27] fix: agent model selection priority issue (#3572) --- packages/tui/internal/app/app.go | 26 ++++----- packages/tui/internal/app/app_test.go | 76 +++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 708b9257..e0f1d992 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -490,19 +490,7 @@ func (a *App) InitializeProvider() tea.Cmd { } } - // Priority 2: Config file model setting - if selectedProvider == nil && a.Config.Model != "" { - if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil && - model != nil { - selectedProvider = provider - selectedModel = model - slog.Debug("Selected model from config", "provider", provider.ID, "model", model.ID) - } else { - slog.Debug("Config model not found", "model", a.Config.Model) - } - } - - // Priority 3: Current agent's preferred model + // Priority 2: Current agent's preferred model if selectedProvider == nil && a.Agent().Model.ModelID != "" { if provider, model := findModelByProviderAndModelID(providers, a.Agent().Model.ProviderID, a.Agent().Model.ModelID); provider != nil && model != nil { @@ -522,6 +510,18 @@ func (a *App) InitializeProvider() tea.Cmd { } } + // Priority 3: Config file model setting + if selectedProvider == nil && a.Config.Model != "" { + if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil && + model != nil { + selectedProvider = provider + selectedModel = model + slog.Debug("Selected model from config", "provider", provider.ID, "model", model.ID) + } else { + slog.Debug("Config model not found", "model", a.Config.Model) + } + } + // Priority 4: Recent model usage (most recently used model) if selectedProvider == nil && len(a.State.RecentlyUsedModels) > 0 { recentUsage := a.State.RecentlyUsedModels[0] // Most recent is first diff --git a/packages/tui/internal/app/app_test.go b/packages/tui/internal/app/app_test.go index 9260a991..e716d437 100644 --- a/packages/tui/internal/app/app_test.go +++ b/packages/tui/internal/app/app_test.go @@ -226,3 +226,79 @@ func TestFindProviderByID(t *testing.T) { }) } } + +// TestModelSelectionPriority tests the priority order for model selection +func TestModelSelectionPriority(t *testing.T) { + providers := []opencode.Provider{ + { + ID: "anthropic", + Models: map[string]opencode.Model{ + "claude-opus": {ID: "claude-opus"}, + }, + }, + { + ID: "openai", + Models: map[string]opencode.Model{ + "gpt-4": {ID: "gpt-4"}, + }, + }, + } + + tests := []struct { + name string + agentProviderID string + agentModelID string + configModel string + expectedProviderID string + expectedModelID string + description string + }{ + { + name: "agent model takes priority over config", + agentProviderID: "openai", + agentModelID: "gpt-4", + configModel: "anthropic/claude-opus", + expectedProviderID: "openai", + expectedModelID: "gpt-4", + description: "When agent specifies a model, it should be used even if config has a different model", + }, + { + name: "config model used when agent has no model", + agentProviderID: "", + agentModelID: "", + configModel: "anthropic/claude-opus", + expectedProviderID: "anthropic", + expectedModelID: "claude-opus", + description: "When agent has no model specified, config model should be used as fallback", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var selectedProvider *opencode.Provider + var selectedModel *opencode.Model + + // Simulate priority 2: Agent model check + if tt.agentModelID != "" { + selectedProvider, selectedModel = findModelByProviderAndModelID(providers, tt.agentProviderID, tt.agentModelID) + } + + // Simulate priority 3: Config model fallback + if selectedProvider == nil && tt.configModel != "" { + selectedProvider, selectedModel = findModelByFullID(providers, tt.configModel) + } + + if selectedProvider == nil || selectedModel == nil { + t.Fatalf("Expected to find model, but got nil - %s", tt.description) + } + + if selectedProvider.ID != tt.expectedProviderID { + t.Errorf("Expected provider %s, got %s - %s", tt.expectedProviderID, selectedProvider.ID, tt.description) + } + + if selectedModel.ID != tt.expectedModelID { + t.Errorf("Expected model %s, got %s - %s", tt.expectedModelID, selectedModel.ID, tt.description) + } + }) + } +} From 42e0b47a7db1ce06f422211ed3818c49474d4614 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Oct 2025 10:56:30 -0500 Subject: [PATCH 02/27] fix: better frontmatter errors --- packages/opencode/src/cli/error.ts | 13 ++++++++--- packages/opencode/src/config/config.ts | 11 ++++----- packages/opencode/src/config/markdown.ts | 29 ++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 1bc20de3..7c873ae5 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,3 +1,4 @@ +import { ConfigMarkdown } from "@/config/markdown" import { Config } from "../config/config" import { MCP } from "../mcp" import { UI } from "./ui" @@ -7,16 +8,22 @@ export function FormatError(input: unknown) { return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` if (Config.JsonError.isInstance(input)) { return ( - `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "") + `Config file at ${input.data.path} is not valid JSON(C)` + + (input.data.message ? `: ${input.data.message}` : "") ) } if (Config.ConfigDirectoryTypoError.isInstance(input)) { return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Use "${input.data.suggestion}" instead. This is a common typo.` } + if (ConfigMarkdown.FrontmatterError.isInstance(input)) { + return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}` + } if (Config.InvalidError.isInstance(input)) return [ - `Config file at ${input.data.path} is invalid` + (input.data.message ? `: ${input.data.message}` : ""), - ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []), + `Config file at ${input.data.path} is invalid` + + (input.data.message ? `: ${input.data.message}` : ""), + ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? + []), ].join("\n") if (UI.CancelledError.isInstance(input)) return "" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 12a5c162..031c4d82 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -9,7 +9,6 @@ import { Global } from "../global" import fs from "fs/promises" import { lazy } from "../util/lazy" import { NamedError } from "../util/error" -import matter from "gray-matter" import { Flag } from "../flag/flag" import { Auth } from "../auth" import { @@ -21,6 +20,7 @@ import { Instance } from "../project/instance" import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" +import { ConfigMarkdown } from "./markdown" export namespace Config { const log = Log.create({ service: "config" }) @@ -191,8 +191,7 @@ export namespace Config { dot: true, cwd: dir, })) { - const content = await Bun.file(item).text() - const md = matter(content) + const md = await ConfigMarkdown.parse(item) if (!md.data) continue const name = (() => { @@ -231,8 +230,7 @@ export namespace Config { dot: true, cwd: dir, })) { - const content = await Bun.file(item).text() - const md = matter(content) + const md = await ConfigMarkdown.parse(item) if (!md.data) continue // Extract relative path from agent folder for nested agents @@ -274,8 +272,7 @@ export namespace Config { dot: true, cwd: dir, })) { - const content = await Bun.file(item).text() - const md = matter(content) + const md = await ConfigMarkdown.parse(item) if (!md.data) continue const config = { diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index a4dcbf5d..3e84bbf4 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -1,3 +1,7 @@ +import { NamedError } from "@/util/error" +import matter from "gray-matter" +import { z } from "zod" + export namespace ConfigMarkdown { export const FILE_REGEX = /(? Date: Thu, 30 Oct 2025 18:10:42 +0200 Subject: [PATCH 03/27] fix: preserve metadata from MCP tool results in `tool.execute.after` hook (#3573) --- packages/opencode/src/session/prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 96dfbad0..9042ed16 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -618,7 +618,7 @@ export namespace SessionPrompt { return { title: "", - metadata: {}, + metadata: result.metadata ?? {}, output, } } From dc87659791fb2119824b97934b0fcc7aebf35ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Haris=20Gu=C5=A1i=C4=87?= Date: Thu, 30 Oct 2025 17:31:44 +0100 Subject: [PATCH 04/27] tweak: move zod validation for tools to ensure it always runs (#3565) --- packages/opencode/src/session/prompt.ts | 1 - packages/opencode/src/tool/tool.ts | 9 +++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9042ed16..080f9d99 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -534,7 +534,6 @@ export namespace SessionPrompt { args, }, ) - item.parameters.parse(args) const result = await item.execute(args, { sessionID: input.sessionID, abort: options.abortSignal!, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index c7a28c51..f826d0c9 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -42,8 +42,13 @@ export namespace Tool { return { id, init: async () => { - if (init instanceof Function) return init() - return init + const toolInfo = init instanceof Function ? await init() : init + const execute = toolInfo.execute + toolInfo.execute = (args, ctx) => { + toolInfo.parameters.parse(args) + return execute(args, ctx) + } + return toolInfo }, } } From ee3fd3f7be028471d1f4322c7d2a0072819a8cd0 Mon Sep 17 00:00:00 2001 From: Jay V Date: Thu, 30 Oct 2025 12:40:03 -0400 Subject: [PATCH 05/27] ignore:lander --- packages/console/app/src/routes/api/enterprise.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/api/enterprise.ts b/packages/console/app/src/routes/api/enterprise.ts index 3dc00adb..e33737d5 100644 --- a/packages/console/app/src/routes/api/enterprise.ts +++ b/packages/console/app/src/routes/api/enterprise.ts @@ -26,7 +26,7 @@ export async function POST(event: APIEvent) { // Create email content const emailContent = ` ${body.message}

--- +--
${body.name}
${body.role}
${body.email}`.trim() From 3541fdcb2019676fb82351e909a8e9b740cb8237 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Oct 2025 11:59:54 -0500 Subject: [PATCH 06/27] tweak: adjust deno lsp (#3581) --- packages/opencode/src/lsp/index.ts | 5 +- packages/opencode/src/lsp/server.ts | 153 +++++++++++++++++++++++----- 2 files changed, 131 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index d533815f..71e3b62f 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -163,7 +163,10 @@ export namespace LSP { const clients = await getClients(input) await run(async (client) => { if (!clients.includes(client)) return - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() + + const wait = waitForDiagnostics + ? client.waitForDiagnostics({ path: input }) + : Promise.resolve() await client.notify.open({ path: input }) return wait }).catch((err) => { diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index ee0f73fc..da450879 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -54,7 +54,17 @@ export namespace LSPServer { export const Deno: Info = { id: "deno", - root: NearestRoot(["deno.json", "deno.jsonc"]), + root: async (file) => { + const files = Filesystem.up({ + targets: ["deno.json", "deno.jsonc"], + start: path.dirname(file), + stop: Instance.directory, + }) + const first = await files.next() + await files.return() + if (!first.value) return undefined + return path.dirname(first.value) + }, extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], async spawn(root) { const deno = Bun.which("deno") @@ -78,7 +88,9 @@ export namespace LSPServer { ), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], async spawn(root) { - const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {}) + const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch( + () => {}, + ) if (!tsserver) return const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], { cwd: root, @@ -101,7 +113,13 @@ export namespace LSPServer { export const Vue: Info = { id: "vue", extensions: [".vue"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + root: NearestRoot([ + "package-lock.json", + "bun.lockb", + "bun.lock", + "pnpm-lock.yaml", + "yarn.lock", + ]), async spawn(root) { let binary = Bun.which("vue-language-server") const args: string[] = [] @@ -149,17 +167,31 @@ export namespace LSPServer { export const ESLint: Info = { id: "eslint", - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + root: NearestRoot([ + "package-lock.json", + "bun.lockb", + "bun.lock", + "pnpm-lock.yaml", + "yarn.lock", + ]), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], async spawn(root) { const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {}) if (!eslint) return log.info("spawning eslint server") - const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") + const serverPath = path.join( + Global.Path.bin, + "vscode-eslint", + "server", + "out", + "eslintServer.js", + ) if (!(await Bun.file(serverPath).exists())) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading and building VS Code ESLint server") - const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") + const response = await fetch( + "https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip", + ) if (!response.ok) return const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip") @@ -284,12 +316,25 @@ export namespace LSPServer { export const Pyright: Info = { id: "pyright", extensions: [".py", ".pyi"], - root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]), + root: NearestRoot([ + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + "Pipfile", + "pyrightconfig.json", + ]), async spawn(root) { let binary = Bun.which("pyright-langserver") const args = [] if (!binary) { - const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js") + const js = path.join( + Global.Path.bin, + "node_modules", + "pyright", + "dist", + "pyright-langserver.js", + ) if (!(await Bun.file(js).exists())) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "pyright"], { @@ -307,9 +352,11 @@ export namespace LSPServer { const initialization: Record = {} - const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( - (p): p is string => p !== undefined, - ) + const potentialVenvPaths = [ + process.env["VIRTUAL_ENV"], + path.join(root, ".venv"), + path.join(root, "venv"), + ].filter((p): p is string => p !== undefined) for (const venvPath of potentialVenvPaths) { const isWindows = process.platform === "win32" const potentialPythonPath = isWindows @@ -360,7 +407,9 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading elixir-ls from GitHub releases") - const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") + const response = await fetch( + "https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip", + ) if (!response.ok) return const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") await Bun.file(zipPath).write(response) @@ -410,7 +459,9 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading zls from GitHub releases") - const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest") + const releaseResponse = await fetch( + "https://api.github.com/repos/zigtools/zls/releases/latest", + ) if (!releaseResponse.ok) { log.error("Failed to fetch zls release info") return @@ -585,7 +636,13 @@ export namespace LSPServer { export const Clangd: Info = { id: "clangd", - root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]), + root: NearestRoot([ + "compile_commands.json", + "compile_flags.txt", + ".clangd", + "CMakeLists.txt", + "Makefile", + ]), extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], async spawn(root) { let bin = Bun.which("clangd", { @@ -595,7 +652,9 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading clangd from GitHub releases") - const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") + const releaseResponse = await fetch( + "https://api.github.com/repos/clangd/clangd/releases/latest", + ) if (!releaseResponse.ok) { log.error("Failed to fetch clangd release info") return @@ -664,12 +723,24 @@ export namespace LSPServer { export const Svelte: Info = { id: "svelte", extensions: [".svelte"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + root: NearestRoot([ + "package-lock.json", + "bun.lockb", + "bun.lock", + "pnpm-lock.yaml", + "yarn.lock", + ]), async spawn(root) { let binary = Bun.which("svelteserver") const args: string[] = [] if (!binary) { - const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js") + const js = path.join( + Global.Path.bin, + "node_modules", + "svelte-language-server", + "bin", + "server.js", + ) if (!(await Bun.file(js).exists())) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], { @@ -704,9 +775,17 @@ export namespace LSPServer { export const Astro: Info = { id: "astro", extensions: [".astro"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + root: NearestRoot([ + "package-lock.json", + "bun.lockb", + "bun.lock", + "pnpm-lock.yaml", + "yarn.lock", + ]), async spawn(root) { - const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {}) + const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch( + () => {}, + ) if (!tsserver) { log.info("typescript not found, required for Astro language server") return @@ -716,7 +795,14 @@ export namespace LSPServer { let binary = Bun.which("astro-ls") const args: string[] = [] if (!binary) { - const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js") + const js = path.join( + Global.Path.bin, + "node_modules", + "@astrojs", + "language-server", + "bin", + "nodeServer.js", + ) if (!(await Bun.file(js).exists())) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], { @@ -794,7 +880,9 @@ export namespace LSPServer { .then(({ stdout }) => stdout.toString().trim()) const launcherJar = path.join(launcherDir, jarFileName) if (!(await fs.exists(launcherJar))) { - log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) + log.error( + `Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`, + ) return } const configFile = path.join( @@ -860,7 +948,9 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("downloading lua-language-server from GitHub releases") - const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") + const releaseResponse = await fetch( + "https://api.github.com/repos/LuaLS/lua-language-server/releases/latest", + ) if (!releaseResponse.ok) { log.error("Failed to fetch lua-language-server release info") return @@ -897,7 +987,9 @@ export namespace LSPServer { const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}` if (!supportedCombos.includes(assetSuffix)) { - log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`) + log.error( + `Platform ${platform} and architecture ${arch} is not supported by lua-language-server`, + ) return } @@ -920,7 +1012,10 @@ export namespace LSPServer { // Unlike zls which is a single self-contained binary, // lua-language-server needs supporting files (meta/, locale/, etc.) // Extract entire archive to dedicated directory to preserve all files - const installDir = path.join(Global.Path.bin, `lua-language-server-${lualsArch}-${lualsPlatform}`) + const installDir = path.join( + Global.Path.bin, + `lua-language-server-${lualsArch}-${lualsPlatform}`, + ) // Remove old installation if exists const stats = await fs.stat(installDir).catch(() => undefined) @@ -945,7 +1040,11 @@ export namespace LSPServer { await fs.rm(tempPath, { force: true }) // Binary is located in bin/ subdirectory within the extracted archive - bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : "")) + bin = path.join( + installDir, + "bin", + "lua-language-server" + (platform === "win32" ? ".exe" : ""), + ) if (!(await Bun.file(bin).exists())) { log.error("Failed to extract lua-language-server binary") @@ -954,7 +1053,9 @@ export namespace LSPServer { if (platform !== "win32") { const ok = await $`chmod +x ${bin}`.quiet().catch((error) => { - log.error("Failed to set executable permission for lua-language-server binary", { error }) + log.error("Failed to set executable permission for lua-language-server binary", { + error, + }) }) if (!ok) return } From 30f4c2cf4c6c01339434c617fb9d930f6e960883 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:26:06 -0500 Subject: [PATCH 07/27] wip: desktop work --- bun.lock | 19 +- packages/desktop/package.json | 3 +- .../desktop/src/components/diff-changes.tsx | 20 - packages/desktop/src/components/message.tsx | 253 ++------- .../src/components/session-timeline.tsx | 536 ------------------ packages/desktop/src/pages/index.tsx | 9 +- packages/ui/package.json | 2 + packages/ui/src/components/collapsible.css | 2 +- packages/ui/src/components/diff-changes.css | 28 + packages/ui/src/components/diff-changes.tsx | 24 + packages/ui/src/components/index.ts | 4 + packages/ui/src/components/message-part.css | 22 + packages/ui/src/components/message-part.tsx | 87 +++ packages/ui/src/components/tool-display.css | 76 +++ packages/ui/src/components/tool-display.tsx | 95 ++++ packages/ui/src/components/tool-registry.tsx | 33 ++ packages/ui/src/styles/index.css | 3 + 17 files changed, 427 insertions(+), 789 deletions(-) delete mode 100644 packages/desktop/src/components/diff-changes.tsx delete mode 100644 packages/desktop/src/components/session-timeline.tsx create mode 100644 packages/ui/src/components/diff-changes.css create mode 100644 packages/ui/src/components/diff-changes.tsx create mode 100644 packages/ui/src/components/message-part.css create mode 100644 packages/ui/src/components/message-part.tsx create mode 100644 packages/ui/src/components/tool-display.css create mode 100644 packages/ui/src/components/tool-display.tsx create mode 100644 packages/ui/src/components/tool-registry.tsx diff --git a/bun.lock b/bun.lock index dc3f6b62..bb1d6567 100644 --- a/bun.lock +++ b/bun.lock @@ -114,7 +114,6 @@ "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@pierre/precision-diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/event-bus": "1.1.2", @@ -141,7 +140,6 @@ "@types/luxon": "3.7.1", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", - "opencode": "workspace:*", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", @@ -281,6 +279,7 @@ "version": "0.15.29", "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/sdk": "workspace:*", "@pierre/precision-diffs": "catalog:", "@solidjs/meta": "catalog:", "fuzzysort": "catalog:", @@ -1080,7 +1079,7 @@ "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], - "@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="], + "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-kUTRVKPsB/28H5Ko6qEsyudBiWEDLst+Sfi+hwr59E0GLHV0h8RfgbQU7fdN5Lt9A8R1ulRiZyTvAizkROjwDA=="], @@ -3518,6 +3517,8 @@ "@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="], + "@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="], + "@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="], "@pierre/precision-diffs/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="], @@ -3530,10 +3531,6 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], - - "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], - "@slack/bolt/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], @@ -3798,8 +3795,6 @@ "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "shiki/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], - "sitemap/sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -3954,6 +3949,8 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="], + "@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="], "@expressive-code/plugin-shiki/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="], @@ -4088,6 +4085,8 @@ "@opencode-ai/web/shiki/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], + "@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], + "@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], "@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="], @@ -4350,6 +4349,8 @@ "@actions/github/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="], + "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="], "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="], diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 40081521..32fe27b8 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -4,6 +4,7 @@ "description": "", "type": "module", "scripts": { + "typecheck": "tsgo --noEmit", "start": "vite", "dev": "vite", "build": "vite build", @@ -11,7 +12,6 @@ }, "license": "MIT", "devDependencies": { - "opencode": "workspace:*", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/luxon": "3.7.1", @@ -26,7 +26,6 @@ "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@pierre/precision-diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/desktop/src/components/diff-changes.tsx b/packages/desktop/src/components/diff-changes.tsx deleted file mode 100644 index 3b633f70..00000000 --- a/packages/desktop/src/components/diff-changes.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { FileDiff } from "@opencode-ai/sdk" -import { createMemo, Show } from "solid-js" - -export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) { - const additions = createMemo(() => - Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) : props.diff.additions, - ) - const deletions = createMemo(() => - Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) : props.diff.deletions, - ) - const total = createMemo(() => additions() + deletions()) - return ( - 0}> -
- {`+${additions()}`} - {`-${deletions()}`} -
-
- ) -} diff --git a/packages/desktop/src/components/message.tsx b/packages/desktop/src/components/message.tsx index 589ca311..9e9e06d3 100644 --- a/packages/desktop/src/components/message.tsx +++ b/packages/desktop/src/components/message.tsx @@ -1,238 +1,57 @@ -import type { Part, ReasoningPart, TextPart, ToolPart, Message, AssistantMessage, UserMessage } from "@opencode-ai/sdk" -import { children, Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js" +import type { Part, TextPart, ToolPart, Message } from "@opencode-ai/sdk" +import { createMemo, For, Show } from "solid-js" import { Dynamic } from "solid-js/web" import { Markdown } from "./markdown" -import { Checkbox, Collapsible, Diff, Icon, IconProps } from "@opencode-ai/ui" +import { Checkbox, Diff, Icon } from "@opencode-ai/ui" +import { Message as MessageDisplay, registerPartComponent } from "@opencode-ai/ui" +import { BasicTool, GenericTool, ToolRegistry, DiffChanges } from "@opencode-ai/ui" import { getDirectory, getFilename } from "@/utils" -import type { Tool } from "opencode/tool/tool" -import type { ReadTool } from "opencode/tool/read" -import type { ListTool } from "opencode/tool/ls" -import type { GlobTool } from "opencode/tool/glob" -import type { GrepTool } from "opencode/tool/grep" -import type { WebFetchTool } from "opencode/tool/webfetch" -import type { TaskTool } from "opencode/tool/task" -import type { BashTool } from "opencode/tool/bash" -import type { EditTool } from "opencode/tool/edit" -import type { WriteTool } from "opencode/tool/write" -import type { TodoWriteTool } from "opencode/tool/todo" -import { DiffChanges } from "./diff-changes" export function Message(props: { message: Message; parts: Part[] }) { - return ( - - - {(userMessage) => } - - - {(assistantMessage) => } - - - ) + return } -function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) { - const filteredParts = createMemo(() => { - return props.parts?.filter((x) => { - if (x.type === "reasoning") return false - return x.type !== "tool" || x.tool !== "todoread" - }) - }) +registerPartComponent("text", function TextPartDisplay(props) { + const part = props.part as TextPart return ( -
- {(part) => } -
- ) -} - -function UserMessage(props: { message: UserMessage; parts: Part[] }) { - const text = createMemo(() => - props.parts - ?.filter((p) => p.type === "text" && !p.synthetic) - ?.map((p) => (p as TextPart).text) - ?.join(""), - ) - return
{text()}
-} - -export function Part(props: { part: Part; message: Message; hideDetails?: boolean }) { - const component = createMemo(() => PART_MAPPING[props.part.type as keyof typeof PART_MAPPING]) - return ( - - + + ) -} +}) -const PART_MAPPING = { - text: TextPart, - tool: ToolPart, - reasoning: ReasoningPart, -} - -function ReasoningPart(props: { part: ReasoningPart; message: Message }) { +registerPartComponent("reasoning", function ReasoningPartDisplay(props) { + const part = props.part as any return ( - - + + ) -} +}) -function TextPart(props: { part: TextPart; message: Message }) { - return ( - - - - ) -} - -function ToolPart(props: { part: ToolPart; message: Message; hideDetails?: boolean }) { +registerPartComponent("tool", function ToolPartDisplay(props) { + const part = props.part as ToolPart const component = createMemo(() => { - const render = ToolRegistry.render(props.part.tool) ?? GenericTool - const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) - const input = props.part.state.status === "completed" ? props.part.state.input : {} + const render = ToolRegistry.render(part.tool) ?? GenericTool + const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {}) + const input = part.state.status === "completed" ? part.state.input : {} return ( ) }) return {component()} -} +}) -type TriggerTitle = { - title: string - titleClass?: string - subtitle?: string - subtitleClass?: string - args?: string[] - argsClass?: string - action?: JSX.Element -} - -const isTriggerTitle = (val: any): val is TriggerTitle => { - return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node) -} - -function BasicTool(props: { - icon: IconProps["name"] - trigger: TriggerTitle | JSX.Element - children?: JSX.Element - hideDetails?: boolean -}) { - const resolved = children(() => props.children) - return ( - - -
-
- -
- - - {(trigger) => ( -
-
- - {trigger().title} - - - - {trigger().subtitle} - - - - - {(arg) => ( - - {arg} - - )} - - -
- {trigger().action} -
- )} -
- {props.trigger as JSX.Element} -
-
-
- - - -
-
- - {resolved()} - -
- // <> - // {props.part.state.error.replace("Error: ", "")} - // - ) -} - -function GenericTool(props: ToolProps) { - return -} - -type ToolProps = { - input: Partial> - metadata: Partial> - tool: string - output?: string - hideDetails?: boolean -} - -const ToolRegistry = (() => { - const state: Record< - string, - { - name: string - render?: Component> - } - > = {} - function register(input: { name: string; render?: Component> }) { - state[input.name] = input - return input - } - return { - register, - render(name: string) { - return state[name]?.render - }, - } -})() - -ToolRegistry.register({ +ToolRegistry.register({ name: "read", render(props) { return ( @@ -244,7 +63,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "list", render(props) { return ( @@ -257,7 +76,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "glob", render(props) { return ( @@ -277,7 +96,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "grep", render(props) { const args = [] @@ -300,7 +119,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "webfetch", render(props) { return ( @@ -325,7 +144,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "task", render(props) { return ( @@ -345,7 +164,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "bash", render(props) { return ( @@ -364,7 +183,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "edit", render(props) { return ( @@ -402,7 +221,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "write", render(props) { return ( @@ -431,7 +250,7 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ +ToolRegistry.register({ name: "todowrite", render(props) { return ( @@ -439,13 +258,13 @@ ToolRegistry.register({ icon="checklist" trigger={{ title: "To-dos", - subtitle: `${props.input.todos?.filter((t) => t.status === "completed").length}/${props.input.todos?.length}`, + subtitle: `${props.input.todos?.filter((t: any) => t.status === "completed").length}/${props.input.todos?.length}`, }} >
- {(todo) => ( + {(todo: any) => (
{todo.content}
diff --git a/packages/desktop/src/components/session-timeline.tsx b/packages/desktop/src/components/session-timeline.tsx deleted file mode 100644 index e1f3beae..00000000 --- a/packages/desktop/src/components/session-timeline.tsx +++ /dev/null @@ -1,536 +0,0 @@ -import { Icon, Tooltip } from "@opencode-ai/ui" -import { Collapsible } from "@/ui" -import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk" -import { DateTime } from "luxon" -import { - createSignal, - For, - Match, - splitProps, - Switch, - type ComponentProps, - type ParentProps, - createEffect, - createMemo, - Show, -} from "solid-js" -import { getFilename } from "@/utils" -import { Markdown } from "./markdown" -import { Code } from "./code" -import { createElementSize } from "@solid-primitives/resize-observer" -import { createScrollPosition } from "@solid-primitives/scroll" -import { ProgressCircle } from "./progress-circle" -import { pipe, sumBy } from "remeda" -import { useSync } from "@/context/sync" -import { useLocal } from "@/context/local" - -function Part(props: ParentProps & ComponentProps<"div">) { - const [local, others] = splitProps(props, ["class", "classList", "children"]) - return ( -
-

{local.children}

-
- ) -} - -function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps) { - return ( - - - {props.title} - - -

- {props.children} -

-
-
- ) -} - -function ReadToolPart(props: { part: ToolPart }) { - const sync = useSync() - const local = useLocal() - return ( - - - Reading file... - - - {(state) => { - const path = state().input["filePath"] as string - return ( - local.file.open(path)}> - Read {getFilename(path)} - - ) - }} - - - {(state) => ( -
- - Read {getFilename(state().input["filePath"] as string)} - -
{sync.sanitize(state().error)}
-
- )} -
-
- ) -} - -function EditToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - - - Preparing edit... - - - {(state) => ( - - Edit {getFilename(state().input["filePath"] as string)} - - } - > - - - )} - - - {(state) => ( - - Edit {getFilename(state().input["filePath"] as string)} - - } - > -
{sync.sanitize(state().error)}
-
- )} -
-
- ) -} - -function WriteToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - - - Preparing write... - - - {(state) => ( - - Write {getFilename(state().input["filePath"] as string)} - - } - > -
-
- )} -
- - {(state) => ( -
- - Write {getFilename(state().input["filePath"] as string)} - -
{sync.sanitize(state().error)}
-
- )} -
-
- ) -} - -function BashToolPart(props: { part: ToolPart }) { - const sync = useSync() - return ( - - - Writing shell command... - - - {(state) => ( - - Run command: {state().input["command"]} - - } - > - - - )} - - - {(state) => ( - - Shell {state().input["command"]} - - } - > -
{sync.sanitize(state().error)}
-
- )} -
-
- ) -} - -function ToolPart(props: { part: ToolPart }) { - // read - // edit - // write - // bash - // ls - // glob - // grep - // todowrite - // todoread - // webfetch - // websearch - // patch - // task - return ( -
- - {props.part.type}:{props.part.tool} - - } - > - - - - - - - - - - - - - -
- ) -} - -export default function SessionTimeline(props: { session: string; class?: string }) { - const sync = useSync() - const [scrollElement, setScrollElement] = createSignal(undefined) - const [root, setRoot] = createSignal(undefined) - const [tail, setTail] = createSignal(true) - const size = createElementSize(root) - const scroll = createScrollPosition(scrollElement) - - const valid = (part: Part) => { - if (!part) return false - switch (part.type) { - case "step-start": - case "step-finish": - case "file": - case "patch": - return false - case "text": - return !part.synthetic && part.text.trim() - case "reasoning": - return part.text.trim() - case "tool": - switch (part.tool) { - case "todoread": - case "todowrite": - case "list": - case "grep": - return false - } - return true - default: - return true - } - } - - const hasValidParts = (message: Message) => { - return sync.data.part[message.id]?.filter(valid).length > 0 - } - - const hasTextPart = (message: Message) => { - return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text") - } - - const session = createMemo(() => sync.session.get(props.session)) - const messages = createMemo(() => sync.data.message[props.session] ?? []) - const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? []) - const working = createMemo(() => { - const last = messages()[messages().length - 1] - if (!last) return false - if (last.role === "user") return true - return !last.time.completed - }) - - 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(() => { - return messages().findLast((x) => x.role === "assistant") as AssistantMessage - }) - - const model = createMemo(() => { - if (!last()) return - const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] - return model - }) - - const tokens = createMemo(() => { - if (!last()) return - const tokens = last().tokens - const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write - return new Intl.NumberFormat("en-US", { - notation: "compact", - compactDisplay: "short", - }).format(total) - }) - - const context = createMemo(() => { - if (!last()) return - if (!model()?.limit.context) return 0 - const tokens = last().tokens - const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write - return Math.round((total / model()!.limit.context) * 100) - }) - - const getScrollParent = (el: HTMLElement | null): HTMLElement | undefined => { - let p = el?.parentElement - while (p && p !== document.body) { - const s = getComputedStyle(p) - if (s.overflowY === "auto" || s.overflowY === "scroll") return p - p = p.parentElement - } - return undefined - } - - createEffect(() => { - if (!root()) return - setScrollElement(getScrollParent(root()!)) - }) - - const scrollToBottom = () => { - const element = scrollElement() - if (!element) return - element.scrollTop = element.scrollHeight - } - - createEffect(() => { - size.height - if (tail()) scrollToBottom() - }) - - createEffect(() => { - if (working()) { - setTail(true) - scrollToBottom() - } - }) - - let lastScrollY = 0 - createEffect(() => { - if (scroll.y < lastScrollY) { - setTail(false) - } - lastScrollY = scroll.y - }) - - const duration = (part: Part) => { - switch (part.type) { - default: - if ( - "time" in part && - part.time && - "start" in part.time && - part.time.start && - "end" in part.time && - part.time.end - ) { - const start = DateTime.fromMillis(part.time.start) - const end = DateTime.fromMillis(part.time.end) - return end.diff(start).toFormat("s") - } - return "" - } - } - - createEffect(() => { - console.log("WHAT") - console.log(JSON.stringify(messagesWithValidParts())) - }) - - return ( -
-
-
- - - - -
{context()}%
-
-
{cost()}
-
-
-
    - - {(message) => ( -
    - - {(part) => ( -
  • - {part.type}
  • }> - - {(part) => ( - - -
    - - {part().text} - -
    -
    - - - -
    - )} -
    - - {(part) => ( - Thinking}> - - Thought for {duration(part())}s - - - } - > - - - )} - - {(part) => } - - - )} -
    -
- )} -
- - - - -
- - Raw Session Data - -
-
- -
    -
  • - - -
    - - session - -
    -
    - - - -
    -
  • - - {(message) => ( - <> -
  • - - -
    - - {message.role === "user" ? "user" : "assistant"} - -
    -
    - - - -
    -
  • - - {(part) => ( -
  • - - -
    - - {part.type} - -
    -
    - - - -
    -
  • - )} -
    - - )} -
    -
-
-
-
-
- ) -} diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 5216c427..2a676162 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -9,6 +9,7 @@ import { Accordion, Diff, Collapsible, + Part, } from "@opencode-ai/ui" import { FileIcon } from "@/ui" import FileTree from "@/components/file-tree" @@ -33,9 +34,9 @@ import { Code } from "@/components/code" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { ProgressCircle } from "@/components/progress-circle" -import { Message, Part } from "@/components/message" +import { Message } from "@/components/message" import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" -import { DiffChanges } from "@/components/diff-changes" +import { DiffChanges } from "@opencode-ai/ui" import { Markdown } from "@/components/markdown" export default function Page() { @@ -497,7 +498,7 @@ export default function Page() { +
New session
@@ -660,7 +661,7 @@ export default function Page() { class="flex flex-col items-start self-stretch gap-8 min-h-screen" > {/* Title */} -
+

{title() ?? prompt()}

diff --git a/packages/ui/package.json b/packages/ui/package.json index 8fd6bff6..520baf6e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,6 +11,7 @@ "./fonts/*": "./src/assets/fonts/*" }, "scripts": { + "typecheck": "tsgo --noEmit", "dev": "vite", "generate:tailwind": "bun run script/tailwind.ts" }, @@ -24,6 +25,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/sdk": "workspace:*", "@pierre/precision-diffs": "catalog:", "@solidjs/meta": "catalog:", "fuzzysort": "catalog:", diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index 3d8c8ebe..4b2c14d4 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -11,7 +11,7 @@ [data-slot="collapsible-trigger"] { width: 100%; display: flex; - height: 40px; + height: 32px; padding: 6px 8px 6px 12px; align-items: center; align-self: stretch; diff --git a/packages/ui/src/components/diff-changes.css b/packages/ui/src/components/diff-changes.css new file mode 100644 index 00000000..afca5147 --- /dev/null +++ b/packages/ui/src/components/diff-changes.css @@ -0,0 +1,28 @@ +[data-component="diff-changes"] { + display: flex; + gap: 8px; + justify-content: flex-end; + align-items: center; + + [data-slot="additions"] { + font-family: var(--font-family-mono); + 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); + text-align: right; + color: var(--text-diff-add-base); + } + + [data-slot="deletions"] { + font-family: var(--font-family-mono); + 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); + text-align: right; + color: var(--text-diff-delete-base); + } +} diff --git a/packages/ui/src/components/diff-changes.tsx b/packages/ui/src/components/diff-changes.tsx new file mode 100644 index 00000000..7661a974 --- /dev/null +++ b/packages/ui/src/components/diff-changes.tsx @@ -0,0 +1,24 @@ +import type { FileDiff } from "@opencode-ai/sdk" +import { createMemo, Show } from "solid-js" + +export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) { + const additions = createMemo(() => + Array.isArray(props.diff) + ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) + : props.diff.additions, + ) + const deletions = createMemo(() => + Array.isArray(props.diff) + ? props.diff.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) + : props.diff.deletions, + ) + const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0)) + return ( + 0}> +
+ {`+${additions()}`} + {`-${deletions()}`} +
+
+ ) +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 16cbb7d9..4b60ddab 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -4,12 +4,16 @@ export * from "./checkbox" export * from "./collapsible" export * from "./dialog" export * from "./diff" +export * from "./diff-changes" export * from "./icon" export * from "./icon-button" export * from "./input" export * from "./fonts" export * from "./list" +export * from "./message-part" export * from "./select" export * from "./select-dialog" export * from "./tabs" +export * from "./tool-display" +export * from "./tool-registry" export * from "./tooltip" diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css new file mode 100644 index 00000000..8931d3bc --- /dev/null +++ b/packages/ui/src/components/message-part.css @@ -0,0 +1,22 @@ +[data-component="assistant-message"] { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; +} + +[data-component="user-message"] { + 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); + color: var(--text-base); + display: -webkit-box; + line-clamp: 3; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx new file mode 100644 index 00000000..eddd796e --- /dev/null +++ b/packages/ui/src/components/message-part.tsx @@ -0,0 +1,87 @@ +import { Component, createMemo, For, Match, Show, Switch } from "solid-js" +import { Dynamic } from "solid-js/web" +import { + AssistantMessage, + Message as MessageType, + Part as PartType, + TextPart, + ToolPart, + UserMessage, +} from "@opencode-ai/sdk" + +export interface MessageProps { + message: MessageType + parts: PartType[] +} + +export interface MessagePartProps { + part: PartType + message: MessageType + hideDetails?: boolean +} + +export type PartComponent = Component + +const PART_MAPPING: Record = {} + +export function registerPartComponent(type: string, component: PartComponent) { + PART_MAPPING[type] = component +} + +export function Message(props: MessageProps) { + return ( + + + {(userMessage) => ( + + )} + + + {(assistantMessage) => ( + + )} + + + ) +} + +export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) { + const filteredParts = createMemo(() => { + return props.parts?.filter((x) => { + if (x.type === "reasoning") return false + return x.type !== "tool" || (x as ToolPart).tool !== "todoread" + }) + }) + return ( +
+ {(part) => } +
+ ) +} + +export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { + const text = createMemo(() => + props.parts + ?.filter((p) => p.type === "text" && !(p as TextPart).synthetic) + ?.map((p) => (p as TextPart).text) + ?.join(""), + ) + return
{text()}
+} + +export function Part(props: MessagePartProps) { + const component = createMemo(() => PART_MAPPING[props.part.type]) + return ( + + + + ) +} diff --git a/packages/ui/src/components/tool-display.css b/packages/ui/src/components/tool-display.css new file mode 100644 index 00000000..f3d9f865 --- /dev/null +++ b/packages/ui/src/components/tool-display.css @@ -0,0 +1,76 @@ +[data-component="tool-trigger"] { + width: 100%; + display: flex; + align-items: center; + align-self: stretch; + gap: 20px; + justify-content: space-between; + + [data-slot="tool-trigger-content"] { + width: 100%; + display: flex; + align-items: center; + align-self: stretch; + gap: 20px; + } + + [data-slot="tool-icon"] { + flex-shrink: 0; + } + + [data-slot="tool-info"] { + flex-grow: 1; + min-width: 0; + } + + [data-slot="tool-info-structured"] { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; + } + + [data-slot="tool-info-main"] { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + [data-slot="tool-title"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-base); + + &.capitalize { + text-transform: capitalize; + } + } + + [data-slot="tool-subtitle"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-weak); + } + + [data-slot="tool-arg"] { + 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); + color: var(--text-weak); + } +} diff --git a/packages/ui/src/components/tool-display.tsx b/packages/ui/src/components/tool-display.tsx new file mode 100644 index 00000000..43574fbb --- /dev/null +++ b/packages/ui/src/components/tool-display.tsx @@ -0,0 +1,95 @@ +import { children, For, Match, Show, Switch, type JSX } from "solid-js" +import { Collapsible } from "./collapsible" +import { Icon, IconProps } from "./icon" + +export type TriggerTitle = { + title: string + titleClass?: string + subtitle?: string + subtitleClass?: string + args?: string[] + argsClass?: string + action?: JSX.Element +} + +const isTriggerTitle = (val: any): val is TriggerTitle => { + return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node) +} + +export interface BasicToolProps { + icon: IconProps["name"] + trigger: TriggerTitle | JSX.Element + children?: JSX.Element + hideDetails?: boolean +} + +export function BasicTool(props: BasicToolProps) { + const resolved = children(() => props.children) + return ( + + +
+
+ +
+ + + {(trigger) => ( +
+
+ + {trigger().title} + + + + {trigger().subtitle} + + + + + {(arg) => ( + + {arg} + + )} + + +
+ {trigger().action} +
+ )} +
+ {props.trigger as JSX.Element} +
+
+
+ + + +
+
+ + {resolved()} + +
+ ) +} + +export function GenericTool(props: { tool: string; hideDetails?: boolean }) { + return +} diff --git a/packages/ui/src/components/tool-registry.tsx b/packages/ui/src/components/tool-registry.tsx new file mode 100644 index 00000000..8ee7d829 --- /dev/null +++ b/packages/ui/src/components/tool-registry.tsx @@ -0,0 +1,33 @@ +import { Component } from "solid-js" + +export interface ToolProps { + input: Record + metadata: Record + tool: string + output?: string + hideDetails?: boolean +} + +export type ToolComponent = Component + +const state: Record< + string, + { + name: string + render?: ToolComponent + } +> = {} + +export function registerTool(input: { name: string; render?: ToolComponent }) { + state[input.name] = input + return input +} + +export function getTool(name: string) { + return state[name]?.render +} + +export const ToolRegistry = { + register: registerTool, + render: getTool, +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 94fa894d..3ebe6e9e 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -9,15 +9,18 @@ @import "../components/button.css" layer(components); @import "../components/checkbox.css" layer(components); @import "../components/diff.css" layer(components); +@import "../components/diff-changes.css" layer(components); @import "../components/collapsible.css" layer(components); @import "../components/dialog.css" layer(components); @import "../components/icon.css" layer(components); @import "../components/icon-button.css" layer(components); @import "../components/input.css" layer(components); @import "../components/list.css" layer(components); +@import "../components/message-part.css" layer(components); @import "../components/select.css" layer(components); @import "../components/select-dialog.css" layer(components); @import "../components/tabs.css" layer(components); +@import "../components/tool-display.css" layer(components); @import "../components/tooltip.css" layer(components); @import "./utilities.css" layer(utilities); From 3b2093595957d988ef39a3f2b66d9e37feac8c26 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 08:37:47 -0500 Subject: [PATCH 08/27] wip: desktop work --- .../src/components/progress-circle.tsx | 48 -------------- packages/desktop/src/pages/index.tsx | 4 +- packages/ui/src/components/index.ts | 1 + .../ui/src/components/progress-circle.css | 12 ++++ .../ui/src/components/progress-circle.tsx | 63 +++++++++++++++++++ packages/ui/src/styles/index.css | 1 + 6 files changed, 79 insertions(+), 50 deletions(-) delete mode 100644 packages/desktop/src/components/progress-circle.tsx create mode 100644 packages/ui/src/components/progress-circle.css create mode 100644 packages/ui/src/components/progress-circle.tsx diff --git a/packages/desktop/src/components/progress-circle.tsx b/packages/desktop/src/components/progress-circle.tsx deleted file mode 100644 index d56197ed..00000000 --- a/packages/desktop/src/components/progress-circle.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Component, createMemo } from "solid-js" - -interface ProgressCircleProps { - percentage: number - size?: number - strokeWidth?: number -} - -export const ProgressCircle: Component = (props) => { - // --- Set default values for props --- - const size = () => props.size || 16 - const strokeWidth = () => props.strokeWidth || 3 - - // --- Constants for SVG calculation --- - const viewBoxSize = 16 - const center = viewBoxSize / 2 - const radius = () => center - strokeWidth() / 2 - const circumference = createMemo(() => 2 * Math.PI * radius()) - - // --- Reactive Calculation for the progress offset --- - const offset = createMemo(() => { - const clampedPercentage = Math.max(0, Math.min(100, props.percentage || 0)) - const progress = clampedPercentage / 100 - return circumference() * (1 - progress) - }) - - return ( - - - - - ) -} diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 2a676162..30635a5e 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -10,6 +10,8 @@ import { Diff, Collapsible, Part, + DiffChanges, + ProgressCircle, } from "@opencode-ai/ui" import { FileIcon } from "@/ui" import FileTree from "@/components/file-tree" @@ -33,10 +35,8 @@ import type { JSX } from "solid-js" import { Code } from "@/components/code" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" -import { ProgressCircle } from "@/components/progress-circle" import { Message } from "@/components/message" import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" -import { DiffChanges } from "@opencode-ai/ui" import { Markdown } from "@/components/markdown" export default function Page() { diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 4b60ddab..63be24d5 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -11,6 +11,7 @@ export * from "./input" export * from "./fonts" export * from "./list" export * from "./message-part" +export * from "./progress-circle" export * from "./select" export * from "./select-dialog" export * from "./tabs" diff --git a/packages/ui/src/components/progress-circle.css b/packages/ui/src/components/progress-circle.css new file mode 100644 index 00000000..59182518 --- /dev/null +++ b/packages/ui/src/components/progress-circle.css @@ -0,0 +1,12 @@ +[data-component="progress-circle"] { + transform: rotate(-90deg); + + [data-slot="background"] { + stroke: var(--border-weak-base); + } + + [data-slot="progress"] { + stroke: var(--border-active); + transition: stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1); + } +} diff --git a/packages/ui/src/components/progress-circle.tsx b/packages/ui/src/components/progress-circle.tsx new file mode 100644 index 00000000..a659c0f2 --- /dev/null +++ b/packages/ui/src/components/progress-circle.tsx @@ -0,0 +1,63 @@ +import { type ComponentProps, createMemo, splitProps } from "solid-js" + +export interface ProgressCircleProps extends Pick, "class" | "classList"> { + percentage: number + size?: number + strokeWidth?: number +} + +export function ProgressCircle(props: ProgressCircleProps) { + const [split, rest] = splitProps(props, [ + "percentage", + "size", + "strokeWidth", + "class", + "classList", + ]) + + const size = () => split.size || 16 + const strokeWidth = () => split.strokeWidth || 3 + + const viewBoxSize = 16 + const center = viewBoxSize / 2 + const radius = () => center - strokeWidth() / 2 + const circumference = createMemo(() => 2 * Math.PI * radius()) + + const offset = createMemo(() => { + const clampedPercentage = Math.max(0, Math.min(100, split.percentage || 0)) + const progress = clampedPercentage / 100 + return circumference() * (1 - progress) + }) + + return ( + + + + + ) +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 3ebe6e9e..9742caa0 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -17,6 +17,7 @@ @import "../components/input.css" layer(components); @import "../components/list.css" layer(components); @import "../components/message-part.css" layer(components); +@import "../components/progress-circle.css" layer(components); @import "../components/select.css" layer(components); @import "../components/select-dialog.css" layer(components); @import "../components/tabs.css" layer(components); From dcf865a889a97d00e36aa4d45464d1612b3281bd Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 09:09:21 -0500 Subject: [PATCH 09/27] wip: desktop work --- packages/desktop/src/pages/index.tsx | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 30635a5e..1ae39a6b 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -272,7 +272,7 @@ export default function Page() { const TabVisual = (props: { file: LocalFile }): JSX.Element => { return (
- +
- props.onTabClick(props.file)}> + props.onTabClick(props.file)} + > + props.onTabClose(props.file)} + /> - props.onTabClose(props.file)} - />
@@ -874,7 +880,7 @@ export default function Page() { const draggedFile = local.file.node(id) if (!draggedFile) return null return ( -
+
) From 19974daa67b034062e991cff6611477741c0a09d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:16:45 -0500 Subject: [PATCH 10/27] wip: desktop work --- packages/desktop/src/components/message.tsx | 25 ++++++++++++++++-- packages/ui/src/components/card.css | 29 +++++++++++++++++++++ packages/ui/src/components/card.tsx | 22 ++++++++++++++++ packages/ui/src/components/icon.tsx | 1 + packages/ui/src/components/index.ts | 1 + packages/ui/src/styles/index.css | 1 + 6 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/card.css create mode 100644 packages/ui/src/components/card.tsx diff --git a/packages/desktop/src/components/message.tsx b/packages/desktop/src/components/message.tsx index 9e9e06d3..78be29d7 100644 --- a/packages/desktop/src/components/message.tsx +++ b/packages/desktop/src/components/message.tsx @@ -1,8 +1,8 @@ import type { Part, TextPart, ToolPart, Message } from "@opencode-ai/sdk" -import { createMemo, For, Show } from "solid-js" +import { createMemo, For, Match, Show, Switch } from "solid-js" import { Dynamic } from "solid-js/web" import { Markdown } from "./markdown" -import { Checkbox, Diff, Icon } from "@opencode-ai/ui" +import { Card, Checkbox, Diff, Icon } from "@opencode-ai/ui" import { Message as MessageDisplay, registerPartComponent } from "@opencode-ai/ui" import { BasicTool, GenericTool, ToolRegistry, DiffChanges } from "@opencode-ai/ui" import { getDirectory, getFilename } from "@/utils" @@ -36,6 +36,27 @@ registerPartComponent("tool", function ToolPartDisplay(props) { const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {}) const input = part.state.status === "completed" ? part.state.input : {} + if (part.state.status === "error") { + const error = part.state.error.replace("Error: ", "") + const [title, ...rest] = error.split(": ") + return ( + +
+ + + +
+
{title}
+ {rest.join(": ")} +
+
+ {error} +
+
+
+ ) + } + return ( { + variant?: "normal" | "error" | "warning" | "success" | "info" +} + +export function Card(props: CardProps) { + const [split, rest] = splitProps(props, ["variant", "class", "classList"]) + return ( +
+ {props.children} +
+ ) +} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 5736146e..a2e12729 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -149,6 +149,7 @@ const newIcons = { console: ``, "code-lines": ``, "square-arrow-top-right": ``, + "circle-ban-sign": ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 63be24d5..29e8cfe3 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -1,5 +1,6 @@ export * from "./accordion" export * from "./button" +export * from "./card" export * from "./checkbox" export * from "./collapsible" export * from "./dialog" diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 9742caa0..4fe13055 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -7,6 +7,7 @@ @import "../components/accordion.css" layer(components); @import "../components/button.css" layer(components); +@import "../components/card.css" layer(components); @import "../components/checkbox.css" layer(components); @import "../components/diff.css" layer(components); @import "../components/diff-changes.css" layer(components); From dce287a42d5b4bb965ee74707bf7b0b0709d61f1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:33:45 -0500 Subject: [PATCH 11/27] wip: desktop work --- bun.lock | 4 +- package.json | 2 +- packages/desktop/src/components/message.tsx | 65 +++++++++++---------- packages/desktop/src/pages/index.tsx | 2 +- packages/ui/src/components/diff.tsx | 10 +--- packages/ui/src/components/message-part.tsx | 6 +- 6 files changed, 43 insertions(+), 46 deletions(-) diff --git a/bun.lock b/bun.lock index bb1d6567..c651e3a6 100644 --- a/bun.lock +++ b/bun.lock @@ -347,7 +347,7 @@ "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/precision-diffs": "0.3.6", + "@pierre/precision-diffs": "0.4.1", "@solidjs/meta": "0.29.4", "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", @@ -941,7 +941,7 @@ "@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="], - "@pierre/precision-diffs": ["@pierre/precision-diffs@0.3.6", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-cKM3HcMmyr5wPFll0bHYcgHplcHgMlL6Dw4Pi4giL0jVt7ySlGwwVyXTRFW5Fva43stOL+EWB+9U5VBDSktBJA=="], + "@pierre/precision-diffs": ["@pierre/precision-diffs@0.4.1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-AoozHakINGyNJFgbYc/1PlDK0yunrAxbtXEMBe9fdu8RLkNjVtYRTLw7EF2mM/YuVoVRjj2HT/2VJ4a2rMyDOA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], diff --git a/package.json b/package.json index 32e974f5..2a0c07d8 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/precision-diffs": "0.3.6", + "@pierre/precision-diffs": "0.4.1", "@solidjs/meta": "0.29.4", "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", diff --git a/packages/desktop/src/components/message.tsx b/packages/desktop/src/components/message.tsx index 78be29d7..70d03591 100644 --- a/packages/desktop/src/components/message.tsx +++ b/packages/desktop/src/components/message.tsx @@ -15,7 +15,7 @@ registerPartComponent("text", function TextPartDisplay(props) { const part = props.part as TextPart return ( - + ) }) @@ -36,36 +36,41 @@ registerPartComponent("tool", function ToolPartDisplay(props) { const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {}) const input = part.state.status === "completed" ? part.state.input : {} - if (part.state.status === "error") { - const error = part.state.error.replace("Error: ", "") - const [title, ...rest] = error.split(": ") - return ( - -
- - - -
-
{title}
- {rest.join(": ")} -
-
- {error} -
-
-
- ) - } - return ( - + + + {(error) => { + const cleaned = error().replace("Error: ", "") + const [title, ...rest] = cleaned.split(": ") + return ( + +
+ + + +
+
{title}
+ {rest.join(": ")} +
+
+ {cleaned} +
+
+
+ ) + }} +
+ + + +
) }) diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 1ae39a6b..0ff4423a 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -832,7 +832,7 @@ export default function Page() {
-
+
{(assistantMessage) => { const parts = createMemo( diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 731b1bfe..36b57c56 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -3,12 +3,12 @@ import { FileDiff, type DiffLineAnnotation, type HunkData, - DiffFileRendererOptions, + FileDiffOptions, // registerCustomTheme, } from "@pierre/precision-diffs" import { ComponentProps, createEffect, splitProps } from "solid-js" -export type DiffProps = Omit, "themes"> & { +export type DiffProps = FileDiffOptions & { before: FileContents after: FileContents annotations?: DiffLineAnnotation[] @@ -54,13 +54,9 @@ export function Diff(props: DiffProps) { // When ready to render, simply call .render with old/new file, optional // annotations and a container element to hold the diff createEffect(() => { - // @ts-expect-error const instance = new FileDiff({ // theme: "pierre-light", - // theme: "pierre-light", - // Or can also provide a 'themes' prop, which allows the code to adapt - // to your OS light or dark theme - themes: { dark: "pierre-dark", light: "pierre-light" }, + theme: { dark: "pierre-dark", light: "pierre-light" }, // When using the 'themes' prop, 'themeType' allows you to force 'dark' // or 'light' theme, or inherit from the OS ('system') theme. themeType: "system", diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index eddd796e..06f5046d 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -55,11 +55,7 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part return x.type !== "tool" || (x as ToolPart).tool !== "todoread" }) }) - return ( -
- {(part) => } -
- ) + return {(part) => } } export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { From 582ed7c363fec4faa8cf393e023024ac90e65b82 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:43:04 -0500 Subject: [PATCH 12/27] wip: desktop work --- packages/ui/src/components/diff.css | 4 ++++ packages/ui/src/components/diff.tsx | 7 +++++-- packages/ui/src/styles/theme.css | 6 ++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/diff.css b/packages/ui/src/components/diff.css index c4e83187..860e3b1d 100644 --- a/packages/ui/src/components/diff.css +++ b/packages/ui/src/components/diff.css @@ -22,5 +22,9 @@ width: var(--pjs-column-content-width); left: var(--pjs-column-number-width); padding-left: 8px; + + [data-slot="diff-hunk-separator-content-span"] { + mix-blend-mode: var(--text-mix-blend-mode); + } } } diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 36b57c56..e9e46d6b 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -109,8 +109,11 @@ export function Diff(props: DiffProps) { numCol.dataset["slot"] = "diff-hunk-separator-line-number" fragment.appendChild(numCol) const contentCol = document.createElement("div") - contentCol.textContent = `${hunkData.lines} unmodified lines` contentCol.dataset["slot"] = "diff-hunk-separator-content" + const span = document.createElement("span") + span.dataset["slot"] = "diff-hunk-separator-content-span" + span.textContent = `${hunkData.lines} unmodified lines` + contentCol.appendChild(span) fragment.appendChild(contentCol) return fragment }, @@ -166,7 +169,7 @@ export function Diff(props: DiffProps) { "--pjs-font-family": "var(--font-family-mono)", "--pjs-font-size": "var(--font-size-small)", "--pjs-line-height": "24px", - "--pjs-tab-size": 4, + "--pjs-tab-size": 2, "--pjs-font-features": "var(--font-family-mono--font-feature-settings)", "--pjs-header-font-family": "var(--font-family-sans)", "--pjs-gap-block": 0, diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 600de584..c401dcae 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -59,10 +59,14 @@ 0 0 0 3px var(--border-weak-selected, rgba(1, 103, 255, 0.29)), 0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)), 0 1px 2px -1px rgba(19, 16, 16, 0.25), 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12); + + --text-mix-blend-mode: multiply; } :root { /* OC-1-Light */ + --text-mix-blend-mode: multiply; + color-scheme: light; --background-base: #f8f7f7; --background-weak: var(--smoke-light-3); @@ -292,6 +296,8 @@ --button-ghost-hover2: var(--smoke-light-alpha-3); @media (prefers-color-scheme: dark) { + --text-mix-blend-mode: plus-lighter; + /* OC-1-Dark */ color-scheme: dark; --background-base: var(--smoke-dark-1); From ee7612a31c02ea442d8bf3e5d3b75ff572fac26a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:02:44 -0500 Subject: [PATCH 13/27] wip: desktop work --- packages/desktop/src/pages/index.tsx | 91 +--------------- packages/ui/src/components/diff-changes.css | 11 ++ packages/ui/src/components/diff-changes.tsx | 110 ++++++++++++++++++-- 3 files changed, 117 insertions(+), 95 deletions(-) diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 0ff4423a..9c27ab51 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -535,101 +535,14 @@ export default function Page() { > {(message) => { - const countLines = (text: string) => { - if (!text) return 0 - return text.split("\n").length - } - - const additions = createMemo( - () => - message.summary?.diffs.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) ?? 0, - ) - - const deletions = createMemo( - () => - message.summary?.diffs.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) ?? 0, - ) - - const totalBeforeLines = createMemo( - () => - message.summary?.diffs.reduce((acc, diff) => acc + countLines(diff.before), 0) ?? - 0, - ) - - const blockCounts = createMemo(() => { - const TOTAL_BLOCKS = 5 - - const adds = additions() - const dels = deletions() - const unchanged = Math.max(0, totalBeforeLines() - dels) - - const totalActivity = unchanged + adds + dels - - if (totalActivity === 0) { - return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS } - } - - const percentAdded = adds / totalActivity - const percentDeleted = dels / totalActivity - const added_raw = percentAdded * TOTAL_BLOCKS - const deleted_raw = percentDeleted * TOTAL_BLOCKS - - let added = adds > 0 ? Math.ceil(added_raw) : 0 - let deleted = dels > 0 ? Math.ceil(deleted_raw) : 0 - - let total_allocated = added + deleted - if (total_allocated > TOTAL_BLOCKS) { - if (added_raw < deleted_raw) { - added = Math.floor(added_raw) - } else { - deleted = Math.floor(deleted_raw) - } - - total_allocated = added + deleted - if (total_allocated > TOTAL_BLOCKS) { - if (added_raw < deleted_raw) { - deleted = Math.floor(deleted_raw) - } else { - added = Math.floor(added_raw) - } - } - } - - const neutral = Math.max(0, TOTAL_BLOCKS - added - deleted) - - return { added, deleted, neutral } - }) - - const ADD_COLOR = "var(--icon-diff-add-base)" - const DELETE_COLOR = "var(--icon-diff-delete-base)" - const NEUTRAL_COLOR = "var(--icon-weak-base)" - - const visibleBlocks = createMemo(() => { - const counts = blockCounts() - const blocks = [ - ...Array(counts.added).fill(ADD_COLOR), - ...Array(counts.deleted).fill(DELETE_COLOR), - ...Array(counts.neutral).fill(NEUTRAL_COLOR), - ] - return blocks.slice(0, 5) - }) + const diffs = createMemo(() => message.summary?.diffs ?? []) return (
  • local.session.setActiveMessage(message.id)} > -
    - - - - {(color, i) => ( - - )} - - - -
    +
    props.variant ?? "default" -export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) { const additions = createMemo(() => Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) @@ -13,11 +15,107 @@ export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) { : props.diff.deletions, ) const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0)) + + const countLines = (text: string) => { + if (!text) return 0 + return text.split("\n").length + } + + const totalBeforeLines = createMemo(() => { + if (!Array.isArray(props.diff)) return countLines(props.diff.before || "") + return props.diff.reduce((acc, diff) => acc + countLines(diff.before || ""), 0) + }) + + const blockCounts = createMemo(() => { + const TOTAL_BLOCKS = 5 + + const adds = additions() ?? 0 + const dels = deletions() ?? 0 + + if (adds === 0 && dels === 0) { + return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS } + } + + const total = adds + dels + + if (total < 5) { + const added = adds > 0 ? 1 : 0 + const deleted = dels > 0 ? 1 : 0 + const neutral = TOTAL_BLOCKS - added - deleted + return { added, deleted, neutral } + } + + const ratio = adds > dels ? adds / dels : dels / adds + let BLOCKS_FOR_COLORS = TOTAL_BLOCKS + + if (total < 20) { + BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1 + } else if (ratio < 4) { + BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1 + } + + const percentAdded = adds / total + const percentDeleted = dels / total + + const added_raw = percentAdded * BLOCKS_FOR_COLORS + const deleted_raw = percentDeleted * BLOCKS_FOR_COLORS + + let added = adds > 0 ? Math.max(1, Math.round(added_raw)) : 0 + let deleted = dels > 0 ? Math.max(1, Math.round(deleted_raw)) : 0 + + // Cap bars based on actual change magnitude + if (adds > 0 && adds <= 5) added = Math.min(added, 1) + if (adds > 5 && adds <= 10) added = Math.min(added, 2) + if (dels > 0 && dels <= 5) deleted = Math.min(deleted, 1) + if (dels > 5 && dels <= 10) deleted = Math.min(deleted, 2) + + let total_allocated = added + deleted + if (total_allocated > BLOCKS_FOR_COLORS) { + if (added_raw > deleted_raw) { + added = BLOCKS_FOR_COLORS - deleted + } else { + deleted = BLOCKS_FOR_COLORS - added + } + total_allocated = added + deleted + } + + const neutral = Math.max(0, TOTAL_BLOCKS - total_allocated) + + return { added, deleted, neutral } + }) + + const ADD_COLOR = "var(--icon-diff-add-base)" + const DELETE_COLOR = "var(--icon-diff-delete-base)" + const NEUTRAL_COLOR = "var(--icon-weak-base)" + + const visibleBlocks = createMemo(() => { + const counts = blockCounts() + const blocks = [ + ...Array(counts.added).fill(ADD_COLOR), + ...Array(counts.deleted).fill(DELETE_COLOR), + ...Array(counts.neutral).fill(NEUTRAL_COLOR), + ] + return blocks.slice(0, 5) + }) + return ( - 0}> -
    - {`+${additions()}`} - {`-${deletions()}`} + 0 : true}> +
    + + + + + + {(color, i) => } + + + + + + {`+${additions()}`} + {`-${deletions()}`} + +
    ) From e944ff028639de0e3263aa57cc0c6bafea64e292 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:13:02 -0500 Subject: [PATCH 14/27] wip: desktop work --- bun.lock | 1 + packages/ui/package.json | 1 + packages/ui/tsconfig.json | 14 +++++++++++--- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index c651e3a6..3dbe4eac 100644 --- a/bun.lock +++ b/bun.lock @@ -291,6 +291,7 @@ }, "devDependencies": { "@tailwindcss/vite": "catalog:", + "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", diff --git a/packages/ui/package.json b/packages/ui/package.json index 520baf6e..0b3064e3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@types/bun": "catalog:", + "@tsconfig/node22": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-solid": "catalog:", diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 8b4ebee2..440aa8f9 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,10 +1,11 @@ { + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/node22/tsconfig.json", "compilerOptions": { // General "jsx": "preserve", "jsxImportSource": "solid-js", "target": "ESNext", - // Modules "allowSyntheticDefaultImports": true, "esModuleInterop": true, @@ -12,9 +13,16 @@ "module": "ESNext", "moduleResolution": "bundler", "noEmit": true, - + "lib": [ + "es2022", + "dom", + "dom.iterable" + ], // Type Checking & Safety "strict": true, - "types": ["vite/client"] + "types": [ + "vite/client", + "bun" + ] } } From c68607fb2bfdcfb09ba5cd9f339e2c86f2e27c0c Mon Sep 17 00:00:00 2001 From: Brandon <37277901+brandonwisnicki@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:15:21 -0400 Subject: [PATCH 15/27] feat: Adds session id to Agent context metadata (#3559) Co-authored-by: Brandon Wisnicki --- packages/opencode/src/tool/task.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 830c298a..342645c3 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -33,6 +33,14 @@ export const TaskTool = Tool.define("task", async () => { }) const msg = await Session.getMessage({ sessionID: ctx.sessionID, messageID: ctx.messageID }) if (msg.info.role !== "assistant") throw new Error("Not an assistant message") + + ctx.metadata({ + title: params.description, + metadata: { + sessionId: session.id, + }, + }) + const messageID = Identifier.ascending("message") const parts: Record = {} const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { @@ -44,6 +52,7 @@ export const TaskTool = Tool.define("task", async () => { title: params.description, metadata: { summary: Object.values(parts).sort((a, b) => a.id?.localeCompare(b.id)), + sessionId: session.id, }, }) }) @@ -87,6 +96,7 @@ export const TaskTool = Tool.define("task", async () => { title: params.description, metadata: { summary: all, + sessionId: session.id, }, output: (result.parts.findLast((x: any) => x.type === "text") as any)?.text ?? "", } From 89eac737a5887c4bef41fa2abcbcc0ca252fa304 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:30:38 -0500 Subject: [PATCH 16/27] wip: desktop work --- packages/desktop/src/pages/index.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 9c27ab51..552269eb 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -384,6 +384,7 @@ export default function Page() { {(session) => { const diffs = createMemo(() => session.summary?.diffs ?? []) const filesChanged = createMemo(() => diffs().length) + const updated = DateTime.fromMillis(session.time.updated) return (
    @@ -392,7 +393,14 @@ export default function Page() { {session.title} - {DateTime.fromMillis(session.time.updated).toRelative()} + {Math.abs(updated.diffNow().as("seconds")) < 60 + ? "Now" + : updated + .toRelative({ style: "short", unit: ["days", "hours", "minutes"] }) + ?.replace(" ago", "") + ?.replace(/ days?/, "d") + ?.replace(" min.", "m") + ?.replace(" hr.", "h")}
    From 2a0b67d84f048207d20d952cafa10c430451dc70 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:32:25 -0500 Subject: [PATCH 17/27] fix: lander space --- .../app/src/routes/enterprise/index.tsx | 167 +++++++++--------- 1 file changed, 84 insertions(+), 83 deletions(-) diff --git a/packages/console/app/src/routes/enterprise/index.tsx b/packages/console/app/src/routes/enterprise/index.tsx index 5bca6f38..4af0ccce 100644 --- a/packages/console/app/src/routes/enterprise/index.tsx +++ b/packages/console/app/src/routes/enterprise/index.tsx @@ -65,94 +65,95 @@ export default function Enterprise() {

    Your code is yours

    OpenCode operates securely inside your organization with no data or context stored - and no licensing restrictions or ownership claims. Start a trial with your team - , then deploy it across your organization by integrating it with your SSO and internal AI gateway. + and no licensing restrictions or ownership claims. Start a trial with your team, + then deploy it across your organization by integrating it with your SSO and + internal AI gateway.

    Let us know and how we can help.

    -
    -
    - - - +
    +
    + + + +
    + Thanks to OpenCode, we found a way to create software to track all our assets — + even the imaginary ones. +
    + + + + + + + + + + + +
    - Thanks to OpenCode, we found a way to create software to track all our assets — - even the imaginary ones. -
    - - - - - - - - - - - -
    -
    From dc6e54503cb400ea2533740c9a92d09c8a50d077 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:49:29 -0500 Subject: [PATCH 18/27] wip: desktop work --- bun.lock | 4 + packages/desktop/src/components/code.tsx | 2 +- packages/desktop/src/components/markdown.tsx | 23 -- packages/desktop/src/components/message.tsx | 304 --------------- packages/desktop/src/context/local.tsx | 2 - packages/desktop/src/index.tsx | 4 +- packages/desktop/src/pages/index.tsx | 4 +- packages/ui/package.json | 4 + .../{tool-display.css => basic-tool.css} | 0 .../{tool-display.tsx => basic-tool.tsx} | 0 packages/ui/src/components/index.ts | 8 +- packages/ui/src/components/markdown.css | 24 ++ packages/ui/src/components/markdown.tsx | 36 ++ packages/ui/src/components/message-part.css | 107 +++++ packages/ui/src/components/message-part.tsx | 365 +++++++++++++++++- packages/ui/src/components/tool-registry.tsx | 33 -- packages/ui/src/context/helper.tsx | 25 ++ .../{desktop => ui}/src/context/marked.tsx | 0 .../{desktop => ui}/src/context/shiki.tsx | 6 +- packages/ui/src/styles/index.css | 3 +- 20 files changed, 581 insertions(+), 373 deletions(-) delete mode 100644 packages/desktop/src/components/markdown.tsx delete mode 100644 packages/desktop/src/components/message.tsx rename packages/ui/src/components/{tool-display.css => basic-tool.css} (100%) rename packages/ui/src/components/{tool-display.tsx => basic-tool.tsx} (100%) create mode 100644 packages/ui/src/components/markdown.css create mode 100644 packages/ui/src/components/markdown.tsx delete mode 100644 packages/ui/src/components/tool-registry.tsx create mode 100644 packages/ui/src/context/helper.tsx rename packages/{desktop => ui}/src/context/marked.tsx (100%) rename packages/{desktop => ui}/src/context/shiki.tsx (98%) diff --git a/bun.lock b/bun.lock index 3dbe4eac..977ce9de 100644 --- a/bun.lock +++ b/bun.lock @@ -281,10 +281,14 @@ "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@pierre/precision-diffs": "catalog:", + "@shikijs/transformers": "3.9.2", "@solidjs/meta": "catalog:", "fuzzysort": "catalog:", "luxon": "catalog:", + "marked": "16.2.0", + "marked-shiki": "1.2.1", "remeda": "catalog:", + "shiki": "3.9.2", "solid-js": "catalog:", "solid-list": "catalog:", "virtua": "catalog:", diff --git a/packages/desktop/src/components/code.tsx b/packages/desktop/src/components/code.tsx index 11518e73..c214fd5e 100644 --- a/packages/desktop/src/components/code.tsx +++ b/packages/desktop/src/components/code.tsx @@ -2,7 +2,7 @@ import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "s import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js" import { useLocal, type TextSelection } from "@/context/local" import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils" -import { useShiki } from "@/context/shiki" +import { useShiki } from "@opencode-ai/ui" type DefinedSelection = Exclude diff --git a/packages/desktop/src/components/markdown.tsx b/packages/desktop/src/components/markdown.tsx deleted file mode 100644 index e0f185f5..00000000 --- a/packages/desktop/src/components/markdown.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useMarked } from "@/context/marked" -import { createResource } from "solid-js" - -function strip(text: string): string { - const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/ - const match = text.match(wrappedRe) - return match ? match[2] : text -} -export function Markdown(props: { text: string; class?: string }) { - const marked = useMarked() - const [html] = createResource( - () => strip(props.text), - async (markdown) => { - return marked.parse(markdown) - }, - ) - return ( -
    - ) -} diff --git a/packages/desktop/src/components/message.tsx b/packages/desktop/src/components/message.tsx deleted file mode 100644 index 70d03591..00000000 --- a/packages/desktop/src/components/message.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import type { Part, TextPart, ToolPart, Message } from "@opencode-ai/sdk" -import { createMemo, For, Match, Show, Switch } from "solid-js" -import { Dynamic } from "solid-js/web" -import { Markdown } from "./markdown" -import { Card, Checkbox, Diff, Icon } from "@opencode-ai/ui" -import { Message as MessageDisplay, registerPartComponent } from "@opencode-ai/ui" -import { BasicTool, GenericTool, ToolRegistry, DiffChanges } from "@opencode-ai/ui" -import { getDirectory, getFilename } from "@/utils" - -export function Message(props: { message: Message; parts: Part[] }) { - return -} - -registerPartComponent("text", function TextPartDisplay(props) { - const part = props.part as TextPart - return ( - - - - ) -}) - -registerPartComponent("reasoning", function ReasoningPartDisplay(props) { - const part = props.part as any - return ( - - - - ) -}) - -registerPartComponent("tool", function ToolPartDisplay(props) { - const part = props.part as ToolPart - const component = createMemo(() => { - const render = ToolRegistry.render(part.tool) ?? GenericTool - const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {}) - const input = part.state.status === "completed" ? part.state.input : {} - - return ( - - - {(error) => { - const cleaned = error().replace("Error: ", "") - const [title, ...rest] = cleaned.split(": ") - return ( - -
    - - - -
    -
    {title}
    - {rest.join(": ")} -
    -
    - {cleaned} -
    -
    -
    - ) - }} -
    - - - -
    - ) - }) - - return {component()} -}) - -ToolRegistry.register({ - name: "read", - render(props) { - return ( - - ) - }, -}) - -ToolRegistry.register({ - name: "list", - render(props) { - return ( - - -
    {props.output}
    -
    -
    - ) - }, -}) - -ToolRegistry.register({ - name: "glob", - render(props) { - return ( - {props.output}
    -
    - - ) - }, -}) - -ToolRegistry.register({ - name: "grep", - render(props) { - const args = [] - if (props.input.pattern) args.push("pattern=" + props.input.pattern) - if (props.input.include) args.push("include=" + props.input.include) - return ( - - -
    {props.output}
    -
    -
    - ) - }, -}) - -ToolRegistry.register({ - name: "webfetch", - render(props) { - return ( - - -
    - ), - }} - > - -
    {props.output}
    -
    - - ) - }, -}) - -ToolRegistry.register({ - name: "task", - render(props) { - return ( - - -
    {props.output}
    -
    -
    - ) - }, -}) - -ToolRegistry.register({ - name: "bash", - render(props) { - return ( - - -
    {props.output}
    -
    -
    - ) - }, -}) - -ToolRegistry.register({ - name: "edit", - render(props) { - return ( - -
    -
    Edit
    -
    - - {getDirectory(props.input.filePath!)} - - {getFilename(props.input.filePath ?? "")} -
    -
    -
    - - - -
    -
    - } - > - -
    - -
    -
    - - ) - }, -}) - -ToolRegistry.register({ - name: "write", - render(props) { - return ( - -
    -
    Write
    -
    - - {getDirectory(props.input.filePath!)} - - {getFilename(props.input.filePath ?? "")} -
    -
    -
    {/* */}
    -
    - } - > - -
    {props.output}
    -
    - - ) - }, -}) - -ToolRegistry.register({ - name: "todowrite", - render(props) { - return ( - t.status === "completed").length}/${props.input.todos?.length}`, - }} - > - -
    - - {(todo: any) => ( - -
    {todo.content}
    -
    - )} -
    -
    -
    -
    - ) - }, -}) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 9c4d70fc..09fce635 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -480,8 +480,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const getMessageText = (message: Message | Message[] | undefined): string => { if (!message) return "" if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ") - const fileParts = sync.data.part[message.id]?.filter((p) => p.type === "file") - return sync.data.part[message.id] ?.filter((p) => p.type === "text") ?.filter((p) => !p.synthetic) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 9c7a07fe..0d631a5a 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -3,9 +3,7 @@ import "@/index.css" import { render } from "solid-js/web" import { Router, Route } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" -import { Fonts } from "@opencode-ai/ui" -import { ShikiProvider } from "./context/shiki" -import { MarkedProvider } from "./context/marked" +import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui" import { SDKProvider } from "./context/sdk" import { SyncProvider } from "./context/sync" import { LocalProvider } from "./context/local" diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 552269eb..5237d78b 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -12,6 +12,7 @@ import { Part, DiffChanges, ProgressCircle, + Message, } from "@opencode-ai/ui" import { FileIcon } from "@/ui" import FileTree from "@/components/file-tree" @@ -35,9 +36,8 @@ import type { JSX } from "solid-js" import { Code } from "@/components/code" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" -import { Message } from "@/components/message" import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" -import { Markdown } from "@/components/markdown" +import { Markdown } from "@opencode-ai/ui" export default function Page() { const local = useLocal() diff --git a/packages/ui/package.json b/packages/ui/package.json index 0b3064e3..609c9fba 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -28,10 +28,14 @@ "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@pierre/precision-diffs": "catalog:", + "@shikijs/transformers": "3.9.2", "@solidjs/meta": "catalog:", "fuzzysort": "catalog:", "luxon": "catalog:", + "marked": "16.2.0", + "marked-shiki": "1.2.1", "remeda": "catalog:", + "shiki": "3.9.2", "solid-js": "catalog:", "solid-list": "catalog:", "virtua": "catalog:" diff --git a/packages/ui/src/components/tool-display.css b/packages/ui/src/components/basic-tool.css similarity index 100% rename from packages/ui/src/components/tool-display.css rename to packages/ui/src/components/basic-tool.css diff --git a/packages/ui/src/components/tool-display.tsx b/packages/ui/src/components/basic-tool.tsx similarity index 100% rename from packages/ui/src/components/tool-display.tsx rename to packages/ui/src/components/basic-tool.tsx diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 29e8cfe3..8d6ddc89 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -11,11 +11,15 @@ export * from "./icon-button" export * from "./input" export * from "./fonts" export * from "./list" +export * from "./markdown" export * from "./message-part" export * from "./progress-circle" export * from "./select" export * from "./select-dialog" export * from "./tabs" -export * from "./tool-display" -export * from "./tool-registry" +export * from "./basic-tool" export * from "./tooltip" + +export * from "../context/helper" +export * from "../context/shiki" +export * from "../context/marked" diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css new file mode 100644 index 00000000..ddf8b787 --- /dev/null +++ b/packages/ui/src/components/markdown.css @@ -0,0 +1,24 @@ +[data-component="markdown"] { + min-width: 0; + max-width: 100%; + overflow: auto; + scrollbar-width: none; + color: var(--text-base); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + &::-webkit-scrollbar { + display: none; + } + + /* p { */ + /* margin-top: 8px; */ + /* margin-bottom: 8px; */ + /* } */ +} diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx new file mode 100644 index 00000000..071132e8 --- /dev/null +++ b/packages/ui/src/components/markdown.tsx @@ -0,0 +1,36 @@ +import { useMarked } from "../context/marked" +import { ComponentProps, createResource, splitProps } from "solid-js" + +function strip(text: string): string { + const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/ + const match = text.match(wrappedRe) + return match ? match[2] : text +} + +export function Markdown( + props: ComponentProps<"div"> & { + text: string + class?: string + classList?: Record + }, +) { + const [local, others] = splitProps(props, ["text", "class", "classList"]) + const marked = useMarked() + const [html] = createResource( + () => strip(local.text), + async (markdown) => { + return marked.parse(markdown) + }, + ) + return ( +
    + ) +} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 8931d3bc..fa251a2b 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -20,3 +20,110 @@ -webkit-box-orient: vertical; overflow: hidden; } + +[data-component="text-part"] { + [data-component="markdown"] { + margin-top: 32px; + } +} + +[data-component="tool-error"] { + display: flex; + align-items: center; + gap: 8px; + + [data-slot="icon"] { + color: var(--icon-critical-active); + } + + [data-slot="content"] { + display: flex; + align-items: center; + gap: 8px; + } + + [data-slot="title"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--ember-light-11); + text-transform: capitalize; + } +} + +[data-component="tool-output"] { + white-space: pre; +} + +[data-component="edit-trigger"], +[data-component="write-trigger"] { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + [data-slot="title-area"] { + display: flex; + align-items: center; + gap: 8px; + } + + [data-slot="title"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-base); + text-transform: capitalize; + } + + [data-slot="path"] { + display: flex; + } + + [data-slot="directory"] { + color: var(--text-weak); + } + + [data-slot="filename"] { + color: var(--text-strong); + } + + [data-slot="actions"] { + display: flex; + gap: 16px; + align-items: center; + justify-content: flex-end; + } +} + +[data-component="edit-content"] { + border-top: 1px solid var(--border-weaker-base); +} + +[data-component="tool-action"] { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +[data-component="todos"] { + padding: 10px 12px 24px 48px; + display: flex; + flex-direction: column; + gap: 8px; + + [data-slot="todo-content"] { + &[data-completed="completed"] { + text-decoration: line-through; + color: var(--text-weaker); + } + } +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 06f5046d..1aaab751 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -8,6 +8,14 @@ import { ToolPart, UserMessage, } from "@opencode-ai/sdk" +import { BasicTool } from "./basic-tool" +import { GenericTool } from "./basic-tool" +import { Card } from "./card" +import { Icon } from "./icon" +import { Checkbox } from "./checkbox" +import { Diff } from "./diff" +import { DiffChanges } from "./diff-changes" +import { Markdown } from "./markdown" export interface MessageProps { message: MessageType @@ -22,7 +30,20 @@ export interface MessagePartProps { export type PartComponent = Component -const PART_MAPPING: Record = {} +export const PART_MAPPING: Record = {} + +function getFilename(path: string) { + if (!path) return "" + const trimmed = path.replace(/[\/]+$/, "") + const parts = trimmed.split("/") + return parts[parts.length - 1] ?? "" +} + +function getDirectory(path: string) { + const parts = path.split("/") + const dir = parts.slice(0, parts.length - 1).join("/") + return dir ? dir + "/" : "" +} export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component @@ -81,3 +102,345 @@ export function Part(props: MessagePartProps) { ) } + +export interface ToolProps { + input: Record + metadata: Record + tool: string + output?: string + hideDetails?: boolean +} + +export type ToolComponent = Component + +const state: Record< + string, + { + name: string + render?: ToolComponent + } +> = {} + +export function registerTool(input: { name: string; render?: ToolComponent }) { + state[input.name] = input + return input +} + +export function getTool(name: string) { + return state[name]?.render +} + +export const ToolRegistry = { + register: registerTool, + render: getTool, +} + +PART_MAPPING["tool"] = function ToolPartDisplay(props) { + const part = props.part as ToolPart + const component = createMemo(() => { + const render = ToolRegistry.render(part.tool) ?? GenericTool + const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {}) + const input = part.state.status === "completed" ? part.state.input : {} + + return ( + + + {(error) => { + const cleaned = error().replace("Error: ", "") + const [title, ...rest] = cleaned.split(": ") + return ( + +
    + + + +
    +
    {title}
    + {rest.join(": ")} +
    +
    + {cleaned} +
    +
    +
    + ) + }} +
    + + + +
    + ) + }) + + return {component()} +} + +PART_MAPPING["text"] = function TextPartDisplay(props) { + const part = props.part as TextPart + return ( + +
    + +
    +
    + ) +} + +PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { + const part = props.part as any + return ( + +
    + +
    +
    + ) +} + +ToolRegistry.register({ + name: "read", + render(props) { + return ( + + ) + }, +}) + +ToolRegistry.register({ + name: "list", + render(props) { + return ( + + +
    {props.output}
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "glob", + render(props) { + return ( + {props.output}
    + + + ) + }, +}) + +ToolRegistry.register({ + name: "grep", + render(props) { + const args = [] + if (props.input.pattern) args.push("pattern=" + props.input.pattern) + if (props.input.include) args.push("include=" + props.input.include) + return ( + + +
    {props.output}
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "webfetch", + render(props) { + return ( + + +
  • + ), + }} + > + +
    {props.output}
    +
    + + ) + }, +}) + +ToolRegistry.register({ + name: "task", + render(props) { + return ( + + +
    {props.output}
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "bash", + render(props) { + return ( + + +
    {props.output}
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "edit", + render(props) { + return ( + +
    +
    Edit
    +
    + + {getDirectory(props.input.filePath!)} + + {getFilename(props.input.filePath ?? "")} +
    +
    +
    + + + +
    +
    + } + > + +
    + +
    +
    +
    + ) + }, +}) + +ToolRegistry.register({ + name: "write", + render(props) { + return ( + +
    +
    Write
    +
    + + {getDirectory(props.input.filePath!)} + + {getFilename(props.input.filePath ?? "")} +
    +
    +
    {/* */}
    +
    + } + > + +
    {props.output}
    +
    + + ) + }, +}) + +ToolRegistry.register({ + name: "todowrite", + render(props) { + return ( + t.status === "completed").length}/${props.input.todos?.length}`, + }} + > + +
    + + {(todo: any) => ( + +
    + {todo.content} +
    +
    + )} +
    +
    +
    +
    + ) + }, +}) diff --git a/packages/ui/src/components/tool-registry.tsx b/packages/ui/src/components/tool-registry.tsx deleted file mode 100644 index 8ee7d829..00000000 --- a/packages/ui/src/components/tool-registry.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Component } from "solid-js" - -export interface ToolProps { - input: Record - metadata: Record - tool: string - output?: string - hideDetails?: boolean -} - -export type ToolComponent = Component - -const state: Record< - string, - { - name: string - render?: ToolComponent - } -> = {} - -export function registerTool(input: { name: string; render?: ToolComponent }) { - state[input.name] = input - return input -} - -export function getTool(name: string) { - return state[name]?.render -} - -export const ToolRegistry = { - register: registerTool, - render: getTool, -} diff --git a/packages/ui/src/context/helper.tsx b/packages/ui/src/context/helper.tsx new file mode 100644 index 00000000..6be88e77 --- /dev/null +++ b/packages/ui/src/context/helper.tsx @@ -0,0 +1,25 @@ +import { createContext, Show, useContext, type ParentProps } from "solid-js" + +export function createSimpleContext>(input: { + name: string + init: ((input: Props) => T) | (() => T) +}) { + const ctx = createContext() + + return { + provider: (props: ParentProps) => { + const init = input.init(props) + return ( + // @ts-expect-error + + {props.children} + + ) + }, + use() { + const value = useContext(ctx) + if (!value) throw new Error(`${input.name} context must be used within a context provider`) + return value + }, + } +} diff --git a/packages/desktop/src/context/marked.tsx b/packages/ui/src/context/marked.tsx similarity index 100% rename from packages/desktop/src/context/marked.tsx rename to packages/ui/src/context/marked.tsx diff --git a/packages/desktop/src/context/shiki.tsx b/packages/ui/src/context/shiki.tsx similarity index 98% rename from packages/desktop/src/context/shiki.tsx rename to packages/ui/src/context/shiki.tsx index b6c278bf..d33b98ab 100644 --- a/packages/desktop/src/context/shiki.tsx +++ b/packages/ui/src/context/shiki.tsx @@ -373,7 +373,11 @@ const theme: ThemeInput = { }, }, { - scope: ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"], + scope: [ + "storage.modifier.import.java", + "variable.language.wildcard.java", + "storage.modifier.package.java", + ], settings: { foreground: "var(--text-base)", }, diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 4fe13055..cea5a082 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -6,6 +6,7 @@ @import "./base.css" layer(base); @import "../components/accordion.css" layer(components); +@import "../components/basic-tool.css" layer(components); @import "../components/button.css" layer(components); @import "../components/card.css" layer(components); @import "../components/checkbox.css" layer(components); @@ -17,12 +18,12 @@ @import "../components/icon-button.css" layer(components); @import "../components/input.css" layer(components); @import "../components/list.css" layer(components); +@import "../components/markdown.css" layer(components); @import "../components/message-part.css" layer(components); @import "../components/progress-circle.css" layer(components); @import "../components/select.css" layer(components); @import "../components/select-dialog.css" layer(components); @import "../components/tabs.css" layer(components); -@import "../components/tool-display.css" layer(components); @import "../components/tooltip.css" layer(components); @import "./utilities.css" layer(utilities); From 4cebd69bf03fb9f00e2f793eb483251ecbd9a45a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:54:52 -0500 Subject: [PATCH 19/27] wip: desktop work --- packages/ui/src/components/markdown.css | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index ddf8b787..abc505a9 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -17,8 +17,21 @@ display: none; } - /* p { */ - /* margin-top: 8px; */ - /* margin-bottom: 8px; */ - /* } */ + h1, + h2, + h3 { + margin-top: 16px; + margin-bottom: 8px; + font-weight: var(--font-weight-medium); + } + + p { + margin-bottom: 8px; + } + + ul, + ol { + margin-top: 16px; + margin-bottom: 16px; + } } From 4f02d7d424bef3d5be7a9bb7a592d4f6d326a8a3 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 30 Oct 2025 14:12:27 -0400 Subject: [PATCH 20/27] zen: allow byok requests w/o a balance --- .../app/src/routes/zen/util/handler.ts | 84 +++++++++++++++---- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index f7a1f0e1..3163de34 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -13,7 +13,11 @@ import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js" import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js" import { logger } from "./logger" import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError } from "./error" -import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider" +import { + createBodyConverter, + createStreamPartConverter, + createResponseConverter, +} from "./provider/provider" import { Format } from "./format" import { anthropicHelper } from "./provider/anthropic" import { openaiHelper } from "./provider/openai" @@ -43,7 +47,11 @@ export async function handler( }) const zenData = ZenData.list() const modelInfo = validateModel(zenData, body.model) - const providerInfo = selectProvider(zenData, modelInfo, input.request.headers.get("x-real-ip") ?? "") + const providerInfo = selectProvider( + zenData, + modelInfo, + input.request.headers.get("x-real-ip") ?? "", + ) const authInfo = await authenticate(modelInfo, providerInfo) validateBilling(modelInfo, authInfo) validateModelSettings(authInfo) @@ -222,7 +230,11 @@ export async function handler( return { id: modelId, ...modelData } } - function selectProvider(zenData: ZenData, model: Awaited>, ip: string) { + function selectProvider( + zenData: ZenData, + model: Awaited>, + ip: string, + ) { const providers = model.providers .filter((provider) => !provider.disabled) .flatMap((provider) => Array(provider.weight ?? 1).fill(provider)) @@ -239,7 +251,11 @@ export async function handler( return { ...provider, ...zenData.providers[provider.id], - ...(provider.id === "anthropic" ? anthropicHelper : provider.id === "openai" ? openaiHelper : oaCompatHelper), + ...(provider.id === "anthropic" + ? anthropicHelper + : provider.id === "openai" + ? openaiHelper + : oaCompatHelper), } } @@ -279,11 +295,20 @@ export async function handler( .from(KeyTable) .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID)) .innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID)) - .innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID))) - .leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id))) + .innerJoin( + UserTable, + and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)), + ) + .leftJoin( + ModelTable, + and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)), + ) .leftJoin( ProviderTable, - and(eq(ProviderTable.workspaceID, KeyTable.workspaceID), eq(ProviderTable.provider, providerInfo.id)), + and( + eq(ProviderTable.workspaceID, KeyTable.workspaceID), + eq(ProviderTable.provider, providerInfo.id), + ), ) .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted))) .then((rows) => rows[0]), @@ -307,12 +332,20 @@ export async function handler( } function validateBilling(model: Model, authInfo: Awaited>) { - if (!authInfo || authInfo.isFree) return + if (!authInfo) return + if (authInfo.provider?.credentials) return + if (authInfo.isFree) return if (model.allowAnonymous) return const billing = authInfo.billing - if (!billing.paymentMethodID) throw new CreditsError("No payment method") - if (billing.balance <= 0) throw new CreditsError("Insufficient balance") + if (!billing.paymentMethodID) + throw new CreditsError( + `No payment method. Add a payment method here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`, + ) + if (billing.balance <= 0) + throw new CreditsError( + `Insufficient balance. Manage your billing here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`, + ) const now = new Date() const currentYear = now.getUTCFullYear() @@ -327,7 +360,7 @@ export async function handler( const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth() if (currentYear === dateYear && currentMonth === dateMonth) throw new MonthlyLimitError( - `Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}.`, + `Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`, ) } @@ -340,7 +373,9 @@ export async function handler( const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth() if (currentYear === dateYear && currentMonth === dateMonth) - throw new UserLimitError(`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}.`) + throw new UserLimitError( + `You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`, + ) } } @@ -364,12 +399,19 @@ export async function handler( providerInfo: Awaited>, usage: any, ) { - const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } = - providerInfo.normalizeUsage(usage) + const { + inputTokens, + outputTokens, + reasoningTokens, + cacheReadTokens, + cacheWrite5mTokens, + cacheWrite1hTokens, + } = providerInfo.normalizeUsage(usage) const modelCost = modelInfo.cost200K && - inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > 200_000 + inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > + 200_000 ? modelInfo.cost200K : modelInfo.cost @@ -420,7 +462,8 @@ export async function handler( if (!authInfo) return - const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent) + const cost = + authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent) await Database.transaction(async (tx) => { await tx.insert(UsageTable).values({ workspaceID: authInfo.workspaceID, @@ -460,7 +503,9 @@ export async function handler( `, timeMonthlyUsageUpdated: sql`now()`, }) - .where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))) + .where( + and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)), + ) }) await Database.use((tx) => @@ -487,7 +532,10 @@ export async function handler( eq(BillingTable.workspaceID, authInfo.workspaceID), eq(BillingTable.reload, true), lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)), - or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)), + or( + isNull(BillingTable.timeReloadLockedTill), + lt(BillingTable.timeReloadLockedTill, sql`now()`), + ), ), ), ) From 1a8b494055f17d2fced59c0dc00f8364e4d042f3 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 30 Oct 2025 14:12:49 -0400 Subject: [PATCH 21/27] wip: zen --- packages/console/core/script/lookup-user.ts | 33 +++++++++++++++++++++ packages/console/core/script/reset-db.ts | 22 +++++++++----- 2 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 packages/console/core/script/lookup-user.ts diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts new file mode 100644 index 00000000..af9bcc3a --- /dev/null +++ b/packages/console/core/script/lookup-user.ts @@ -0,0 +1,33 @@ +import { Database, eq } from "../src/drizzle/index.js" +import { AuthTable } from "../src/schema/auth.sql" + +// get input from command line +const email = process.argv[2] +if (!email) { + console.error("Usage: bun lookup-user.ts ") + process.exit(1) +} + +const authData = await printTable("Auth", (tx) => + tx.select().from(AuthTable).where(eq(AuthTable.subject, email)), +) +if (authData.length === 0) { + console.error("User not found") + process.exit(1) +} + +await printTable("Auth", (tx) => + tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID)), +) + +function printTable( + title: string, + callback: (tx: Database.TxOrDb) => Promise, +): Promise { + return Database.use(async (tx) => { + const data = await callback(tx) + console.log(`== ${title} ==`) + console.table(data) + return data + }) +} diff --git a/packages/console/core/script/reset-db.ts b/packages/console/core/script/reset-db.ts index 96ecf14e..bd00e196 100644 --- a/packages/console/core/script/reset-db.ts +++ b/packages/console/core/script/reset-db.ts @@ -1,13 +1,21 @@ import { Resource } from "@opencode-ai/console-resource" -import { Database } from "@opencode-ai/console-core/drizzle/index.js" -import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" -import { AccountTable } from "@opencode-ai/console-core/schema/account.sql.js" -import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" -import { BillingTable, PaymentTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js" -import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js" +import { Database } from "../src/drizzle/index.js" +import { UserTable } from "../src/schema/user.sql.js" +import { AccountTable } from "../src/schema/account.sql.js" +import { WorkspaceTable } from "../src/schema/workspace.sql.js" +import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js" +import { KeyTable } from "../src/schema/key.sql.js" if (Resource.App.stage !== "frank") throw new Error("This script is only for frank") -for (const table of [AccountTable, BillingTable, KeyTable, PaymentTable, UsageTable, UserTable, WorkspaceTable]) { +for (const table of [ + AccountTable, + BillingTable, + KeyTable, + PaymentTable, + UsageTable, + UserTable, + WorkspaceTable, +]) { await Database.use((tx) => tx.delete(table)) } From ba13f8da08f54a5362ac5432885be30ac1508203 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 30 Oct 2025 15:15:46 -0400 Subject: [PATCH 22/27] wip: fix --- bun.lock | 1 + packages/ui/package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index 977ce9de..d1f6cfd2 100644 --- a/bun.lock +++ b/bun.lock @@ -283,6 +283,7 @@ "@pierre/precision-diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solidjs/meta": "catalog:", + "@typescript/native-preview": "catalog:", "fuzzysort": "catalog:", "luxon": "catalog:", "marked": "16.2.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 609c9fba..91ab3867 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,6 +30,7 @@ "@pierre/precision-diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solidjs/meta": "catalog:", + "@typescript/native-preview": "catalog:", "fuzzysort": "catalog:", "luxon": "catalog:", "marked": "16.2.0", From 48f50cf55ea1cff07b69bede05e6010ea50778f0 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:41:13 -0500 Subject: [PATCH 23/27] wip: desktop work --- .../desktop/src/components/prompt-input.tsx | 15 +++- packages/desktop/src/context/local.tsx | 79 ++++++++++++++++--- packages/ui/src/components/tabs.css | 3 - packages/ui/src/hooks/use-filtered-list.tsx | 13 ++- 4 files changed, 87 insertions(+), 23 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index d6276c15..f1c19388 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -71,7 +71,7 @@ export const PromptInput: Component = (props) => { } }) - const { flat, active, onInput, onKeyDown } = useFilteredList({ + const { flat, active, onInput, onKeyDown, refetch } = useFilteredList({ items: local.file.search, key: (x) => x, onSelect: (path) => { @@ -81,6 +81,11 @@ export const PromptInput: Component = (props) => { }, }) + createEffect(() => { + local.model.recent() + refetch() + }) + createEffect( on( () => store.contentParts, @@ -369,16 +374,20 @@ export const PromptInput: Component = (props) => { items={local.model.list()} current={local.model.current()} filterKeys={["provider.name", "name", "id"]} - groupBy={(x) => x.provider.name} + groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} sortGroupsBy={(a, b) => { const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] + 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 (order.includes(aProvider) && !order.includes(bProvider)) return -1 if (!order.includes(aProvider) && order.includes(bProvider)) return 1 return order.indexOf(aProvider) - order.indexOf(bProvider) }} - onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)} + onSelect={(x) => + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true }) + } trigger={
    -
    Fig 1.
    26K GitHub Stars +
    Fig 1.
    29K GitHub Stars
    @@ -577,7 +577,7 @@ export default function Home() {
    -
    Fig 2.
    188 Contributors +
    Fig 2.
    230 Contributors
    @@ -619,7 +619,7 @@ export default function Home() { -
    Fig 3.
    200K Monthly Devs +
    Fig 3.
    250K Monthly Devs
    From dc96664578a3b51a72c928b906c81deaec5f9d14 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:57:32 -0500 Subject: [PATCH 26/27] chore: sanitize tool parts locally --- packages/desktop/src/context/sync.tsx | 35 +++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 12aa9587..ce2fa1ea 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -77,21 +77,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "message.part.updated": { - const parts = store.part[event.properties.part.messageID] + const part = sanitizePart(event.properties.part) + const parts = store.part[part.messageID] if (!parts) { - setStore("part", event.properties.part.messageID, [event.properties.part]) + setStore("part", part.messageID, [part]) break } - const result = Binary.search(parts, event.properties.part.id, (p) => p.id) + const result = Binary.search(parts, part.id, (p) => p.id) if (result.found) { - setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) + setStore("part", part.messageID, result.index, reconcile(part)) break } setStore( "part", - event.properties.part.messageID, + part.messageID, produce((draft) => { - draft.splice(result.index, 0, event.properties.part) + draft.splice(result.index, 0, part) }), ) break @@ -121,6 +122,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g")) const sanitize = (text: string) => text.replace(sanitizer(), "") const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") + const sanitizePart = (part: Part) => { + if (part.type === "tool") { + if (part.state.status === "completed") { + for (const key in part.state.metadata) { + if (typeof part.state.metadata[key] === "string") { + part.state.metadata[key] = sanitize(part.state.metadata[key] as string) + } + } + for (const key in part.state.input) { + if (typeof part.state.input[key] === "string") { + part.state.input[key] = sanitize(part.state.input[key] as string) + } + } + } + } + return part + } return { data: store, @@ -155,7 +173,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .slice() .sort((a, b) => a.id.localeCompare(b.id)) for (const message of messages.data!) { - draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) + draft.part[message.info.id] = message.parts + .slice() + .map(sanitizePart) + .sort((a, b) => a.id.localeCompare(b.id)) } }), ) From a3ba740de41eb1e4825a99dc8f519e1225357e55 Mon Sep 17 00:00:00 2001 From: Ritoban Dutta <124308320+ritoban23@users.noreply.github.com> Date: Fri, 31 Oct 2025 02:07:41 +0530 Subject: [PATCH 27/27] fix: resolve hanging permission prompts in headless mode (#3522) Co-authored-by: Aiden Cline --- packages/opencode/src/cli/cmd/run.ts | 31 +++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 7bde4a31..a979f8ba 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -14,6 +14,8 @@ import { Agent } from "../../agent/agent" import { Command } from "../../command" import { SessionPrompt } from "../../session/prompt" import { EOL } from "os" +import { Permission } from "@/permission" +import { select } from "@clack/prompts" const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -229,7 +231,9 @@ export const RunCommand = cmd({ const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] const title = part.state.title || - (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown") + (Object.keys(part.state.input).length > 0 + ? JSON.stringify(part.state.input) + : "Unknown") printEvent(color, tool, title) @@ -275,6 +279,31 @@ export const RunCommand = cmd({ UI.error(err) }) + Bus.subscribe(Permission.Event.Updated, async (evt) => { + const permission = evt.properties + const message = `Permission required to run: ${permission.title}` + + const result = await select({ + message, + options: [ + { value: "once", label: "Allow once" }, + { value: "always", label: "Always allow" }, + { value: "reject", label: "Reject" }, + ], + initialValue: "once", + }).catch(() => "reject") + const response = (result.toString().includes("cancel") ? "reject" : result) as + | "once" + | "always" + | "reject" + + Permission.respond({ + sessionID: session.id, + permissionID: permission.id, + response, + }) + }) + await (async () => { if (args.command) { return await SessionPrompt.command({