From ae67f43ff0f241dfb6a48ad949b8d6a56d62a675 Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Mon, 29 Dec 2025 04:39:10 +0000 Subject: [PATCH 01/78] feat: add support for `.claude/skills` directory (#6252) --- packages/opencode/src/skill/skill.ts | 63 +++++++++++++--------- packages/opencode/test/skill/skill.test.ts | 52 +++++++++--------- 2 files changed, 65 insertions(+), 50 deletions(-) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 41df88f8b6a9..16fa1d08f6a8 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -32,44 +32,59 @@ export namespace Skill { }), ) - const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md") + const OPENCODE_SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md") + const CLAUDE_SKILL_GLOB = new Bun.Glob(".claude/skills/**/SKILL.md") export const state = Instance.state(async () => { const directories = await Config.directories() const skills: Record = {} + const addSkill = async (match: string) => { + const md = await ConfigMarkdown.parse(match) + if (!md) { + return + } + + const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) + if (!parsed.success) return + + // Warn on duplicate skill names + if (skills[parsed.data.name]) { + log.warn("duplicate skill name", { + name: parsed.data.name, + existing: skills[parsed.data.name].location, + duplicate: match, + }) + } + + skills[parsed.data.name] = { + name: parsed.data.name, + description: parsed.data.description, + location: match, + } + } + for (const dir of directories) { - for await (const match of SKILL_GLOB.scan({ + for await (const match of OPENCODE_SKILL_GLOB.scan({ cwd: dir, absolute: true, onlyFiles: true, followSymlinks: true, })) { - const md = await ConfigMarkdown.parse(match) - if (!md) { - continue - } - - const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) - if (!parsed.success) continue - - // Warn on duplicate skill names - if (skills[parsed.data.name]) { - log.warn("duplicate skill name", { - name: parsed.data.name, - existing: skills[parsed.data.name].location, - duplicate: match, - }) - } - - skills[parsed.data.name] = { - name: parsed.data.name, - description: parsed.data.description, - location: match, - } + await addSkill(match) } } + for await (const match of CLAUDE_SKILL_GLOB.scan({ + cwd: Instance.worktree, + absolute: true, + onlyFiles: true, + followSymlinks: true, + dot: true, + })) { + await addSkill(match) + } + return skills }) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 4a1d75f9fca2..1da8105bd831 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -101,31 +101,31 @@ test("returns empty array when no skills exist", async () => { }) }) -// test("discovers skills from .claude/skills/ directory", async () => { -// await using tmp = await tmpdir({ -// git: true, -// init: async (dir) => { -// const skillDir = path.join(dir, ".claude", "skills", "claude-skill") -// await Bun.write( -// path.join(skillDir, "SKILL.md"), -// `--- -// name: claude-skill -// description: A skill in the .claude/skills directory. -// --- +test("discovers skills from .claude/skills/ directory", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const skillDir = path.join(dir, ".claude", "skills", "claude-skill") + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: claude-skill +description: A skill in the .claude/skills directory. +--- -// # Claude Skill -// `, -// ) -// }, -// }) +# Claude Skill +`, + ) + }, + }) -// await Instance.provide({ -// directory: tmp.path, -// fn: async () => { -// const skills = await Skill.all() -// expect(skills.length).toBe(1) -// expect(skills[0].name).toBe("claude-skill") -// expect(skills[0].location).toContain(".claude/skills/claude-skill/SKILL.md") -// }, -// }) -// }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const skills = await Skill.all() + expect(skills.length).toBe(1) + expect(skills[0].name).toBe("claude-skill") + expect(skills[0].location).toContain(".claude/skills/claude-skill/SKILL.md") + }, + }) +}) From c6221fc8b3cc3d63db0270e8f207bd3a33d39034 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 29 Dec 2025 04:39:43 +0000 Subject: [PATCH 02/78] chore: generate --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index cee002366a85..50115e769d45 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 6982f316be8f..62187f754cce 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -29,4 +29,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From 893888536a75a8e84831c1fa6d426377e4e2d326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Morpain?= Date: Mon, 29 Dec 2025 05:44:15 +0100 Subject: [PATCH 03/78] fix(bedrock): support region and bearer token configuration (#6332) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- packages/opencode/src/provider/provider.ts | 33 ++- .../test/provider/amazon-bedrock.test.ts | 236 ++++++++++++++++++ 2 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/test/provider/amazon-bedrock.test.ts diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0fdf26392f62..0f00c3a3036a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -165,29 +165,44 @@ export namespace Provider { } }, "amazon-bedrock": async () => { - const [awsProfile, awsAccessKeyId, awsBearerToken, awsRegion] = await Promise.all([ - Env.get("AWS_PROFILE"), - Env.get("AWS_ACCESS_KEY_ID"), - Env.get("AWS_BEARER_TOKEN_BEDROCK"), - Env.get("AWS_REGION"), - ]) + const auth = await Auth.get("amazon-bedrock") + const awsProfile = Env.get("AWS_PROFILE") + const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID") + const awsRegion = Env.get("AWS_REGION") + + const awsBearerToken = iife(() => { + const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK") + if (envToken) return envToken + if (auth?.type === "api") { + Env.set("AWS_BEARER_TOKEN_BEDROCK", auth.key) + return auth.key + } + return undefined + }) + if (!awsProfile && !awsAccessKeyId && !awsBearerToken) return { autoload: false } - const region = awsRegion ?? "us-east-1" + const defaultRegion = awsRegion ?? "us-east-1" const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers")) return { autoload: true, options: { - region, + region: defaultRegion, credentialProvider: fromNodeProviderChain(), }, - async getModel(sdk: any, modelID: string, _options?: Record) { + async getModel(sdk: any, modelID: string, options?: Record) { // Skip region prefixing if model already has global prefix if (modelID.startsWith("global.")) { return sdk.languageModel(modelID) } + // Region resolution precedence (highest to lowest): + // 1. options.region from opencode.json provider config + // 2. defaultRegion from AWS_REGION environment variable + // 3. Default "us-east-1" (baked into defaultRegion) + const region = options?.region ?? defaultRegion + let regionPrefix = region.split("-")[0] switch (regionPrefix) { diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts new file mode 100644 index 000000000000..30cd2d0b642a --- /dev/null +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -0,0 +1,236 @@ +import { test, expect } from "bun:test" +import path from "path" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" +import { Env } from "../../src/env" +import { Auth } from "../../src/auth" +import { Global } from "../../src/global" + +test("Bedrock: config region takes precedence over AWS_REGION env var", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "amazon-bedrock": { + options: { + region: "eu-west-1", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("AWS_REGION", "us-east-1") + Env.set("AWS_PROFILE", "default") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["amazon-bedrock"]).toBeDefined() + // Region from config should be used (not env var) + expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1") + }, + }) +}) + +test("Bedrock: falls back to AWS_REGION env var when no config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("AWS_REGION", "eu-west-1") + Env.set("AWS_PROFILE", "default") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["amazon-bedrock"]).toBeDefined() + expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1") + }, + }) +}) + +test("Bedrock: without explicit region config, uses AWS_REGION env or defaults", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("AWS_PROFILE", "default") + // AWS_REGION might be set in the environment, use that or default + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["amazon-bedrock"]).toBeDefined() + // Should have some region set (either from env or default) + expect(providers["amazon-bedrock"].options?.region).toBeDefined() + expect(typeof providers["amazon-bedrock"].options?.region).toBe("string") + }, + }) +}) + +test("Bedrock: uses config region in provider options", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "amazon-bedrock": { + options: { + region: "eu-north-1", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("AWS_PROFILE", "default") + }, + fn: async () => { + const providers = await Provider.list() + const bedrockProvider = providers["amazon-bedrock"] + expect(bedrockProvider).toBeDefined() + expect(bedrockProvider.options?.region).toBe("eu-north-1") + }, + }) +}) + +test("Bedrock: respects config region for different instances", async () => { + // First instance with EU config + await using tmp1 = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "amazon-bedrock": { + options: { + region: "eu-west-1", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp1.path, + init: async () => { + Env.set("AWS_PROFILE", "default") + Env.set("AWS_REGION", "us-east-1") + }, + fn: async () => { + const providers1 = await Provider.list() + expect(providers1["amazon-bedrock"].options?.region).toBe("eu-west-1") + }, + }) + + // Second instance with US config + await using tmp2 = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "amazon-bedrock": { + options: { + region: "us-west-2", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp2.path, + init: async () => { + Env.set("AWS_PROFILE", "default") + Env.set("AWS_REGION", "eu-west-1") + }, + fn: async () => { + const providers2 = await Provider.list() + expect(providers2["amazon-bedrock"].options?.region).toBe("us-west-2") + }, + }) +}) + +test("Bedrock: loads when bearer token from auth.json is present", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "amazon-bedrock": { + options: { + region: "eu-west-1", + }, + }, + }, + }), + ) + }, + }) + + // Setup auth.json with bearer token for amazon-bedrock + const authPath = path.join(Global.Path.data, "auth.json") + await Bun.write( + authPath, + JSON.stringify({ + "amazon-bedrock": { + type: "api", + key: "test-bearer-token", + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + init: async () => { + // Clear env vars so only auth.json should trigger autoload + Env.set("AWS_PROFILE", "") + Env.set("AWS_ACCESS_KEY_ID", "") + Env.set("AWS_BEARER_TOKEN_BEDROCK", "") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["amazon-bedrock"]).toBeDefined() + expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1") + }, + }) +}) From 896d18ab3fd588bf835dbe14553da1c83f7b05bd Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 29 Dec 2025 04:44:48 +0000 Subject: [PATCH 04/78] chore: generate --- packages/opencode/src/provider/provider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0f00c3a3036a..62bc5beaa00d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -169,7 +169,7 @@ export namespace Provider { const awsProfile = Env.get("AWS_PROFILE") const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID") const awsRegion = Env.get("AWS_REGION") - + const awsBearerToken = iife(() => { const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK") if (envToken) return envToken @@ -179,7 +179,7 @@ export namespace Provider { } return undefined }) - + if (!awsProfile && !awsAccessKeyId && !awsBearerToken) return { autoload: false } const defaultRegion = awsRegion ?? "us-east-1" From 05a9e7ce7a5acefa11fa9379757d0f05a2b30b5a Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:56:22 -0500 Subject: [PATCH 05/78] docs: clarify that MCP tools require glob patterns to disable (#6306) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/web/src/content/docs/mcp-servers.mdx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 03cbbe112994..eba7dfbd0f9a 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -354,12 +354,20 @@ If you have a large number of MCP servers you may want to only enable them per a #### Glob patterns -The glob pattern uses simple regex globbing patterns. +The glob pattern uses simple regex globbing patterns: -- `*` matches zero or more of any character +- `*` matches zero or more of any character (e.g., `"my-mcp*"` matches `my-mcp_search`, `my-mcp_list`, etc.) - `?` matches exactly one character - All other characters match literally +:::note +MCP server tools are registered with server name as prefix, so to diable all tools for a server simply use: +``` +"mymcpservername_*": false +``` +::: + + --- ## Examples From 6963f96d4b492b40f5e95ad80f98c40841c32bfd Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 29 Dec 2025 04:56:54 +0000 Subject: [PATCH 06/78] chore: generate --- packages/web/src/content/docs/mcp-servers.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index eba7dfbd0f9a..2101129b4d9f 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -362,11 +362,12 @@ The glob pattern uses simple regex globbing patterns: :::note MCP server tools are registered with server name as prefix, so to diable all tools for a server simply use: + ``` "mymcpservername_*": false ``` -::: +::: --- From 5d2cab39daf3adf290e09a1fa632c3575a42c7f9 Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Mon, 29 Dec 2025 00:01:43 -0500 Subject: [PATCH 07/78] docs: add compaction, watcher, experimental and provider options (#6304) Co-authored-by: OpenCode Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/web/src/content/docs/config.mdx | 76 +++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 751bb2b2146a..aa0a85320cf7 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -106,7 +106,8 @@ You can configure TUI-specific settings through the `tui` option. "scroll_speed": 3, "scroll_acceleration": { "enabled": true - } + }, + "diff_style": "auto" } } ``` @@ -115,6 +116,7 @@ Available options: - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.** - `scroll_speed` - Custom scroll speed multiplier (default: `1`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`. +- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column. [Learn more about using the TUI here](/docs/tui). @@ -178,6 +180,25 @@ You can configure the providers and models you want to use in your OpenCode conf The `small_model` option configures a separate model for lightweight tasks like title generation. By default, OpenCode tries to use a cheaper model if one is available from your provider, otherwise it falls back to your main model. +Provider options can include `timeout` and `setCacheKey`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic": { + "options": { + "timeout": 600000, + "setCacheKey": true + } + } + } +} +``` + +- `timeout` - Request timeout in milliseconds (default: 300000). Set to `false` to disable. +- `setCacheKey` - Ensure a cache key is always set for designated provider. + You can also configure [local models](/docs/models#local). [Learn more](/docs/models). --- @@ -363,6 +384,42 @@ For example, to ensure that the `edit` and `bash` tools require user approval: --- +### Compaction + +You can control context compaction behavior through the `compaction` option. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "compaction": { + "auto": true, + "prune": true + } +} +``` + +- `auto` - Automatically compact the session when context is full (default: `true`). +- `prune` - Remove old tool outputs to save tokens (default: `true`). + +--- + +### Watcher + +You can configure file watcher ignore patterns through the `watcher` option. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "watcher": { + "ignore": ["node_modules/**", "dist/**", ".git/**"] + } +} +``` + +Patterns follow glob syntax. Use this to exclude noisy directories from file watching. + +--- + ### MCP servers You can configure MCP servers you want to use through the `mcp` option. @@ -455,6 +512,23 @@ If a provider appears in both `enabled_providers` and `disabled_providers`, the --- +### Experimental + +The `experimental` key contains options that are under active development. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "experimental": {} +} +``` + +:::caution +Experimental options are not stable. They may change or be removed without notice. +::: + +--- + ## Variables You can use variable substitution in your config files to reference environment variables and file contents. From 4dae6d1fcfdfb4bfa3402046b296a7491e1c0520 Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Mon, 29 Dec 2025 00:05:17 -0500 Subject: [PATCH 08/78] meta: use colors for agents (#5845) --- .opencode/agent/docs.md | 1 + .opencode/agent/triage.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.opencode/agent/docs.md b/.opencode/agent/docs.md index 7e5307ea0b22..21cfc6a16e04 100644 --- a/.opencode/agent/docs.md +++ b/.opencode/agent/docs.md @@ -1,5 +1,6 @@ --- description: ALWAYS use this when writing docs +color: "#38A3EE" --- You are an expert technical documentation writer diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index b2db100e9cfb..539be1549176 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -2,6 +2,7 @@ mode: primary hidden: true model: opencode/claude-haiku-4-5 +color: "#44BA81" tools: "*": false "github-triage": true From 80f704ebbf78f517fba42a36e56852cca0a2f769 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 29 Dec 2025 02:47:51 -0600 Subject: [PATCH 09/78] fix(desktop): context usage alignment --- .../src/components/session-context-usage.tsx | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c8e6918ca5f4..0fa6bc99c989 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { useSync } from "@/context/sync" import { useParams } from "@solidjs/router" -import { AssistantMessage } from "@opencode-ai/sdk/v2" +import { AssistantMessage } from "@opencode-ai/sdk/v2/client" export function SessionContextUsage() { const sync = useSync() @@ -35,19 +35,13 @@ export function SessionContextUsage() { {(ctx) => ( -
- Tokens - {ctx().tokens} -
-
- Usage - {ctx().percentage ?? 0}% -
-
- Cost - {cost()} -
+
+ Tokens + {ctx().tokens} + Usage + {ctx().percentage ?? 0}% + Cost + {cost()}
} placement="top" From 9c8bc64138cbef081b82836cc3fc677faafdfab0 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 29 Dec 2025 02:57:28 -0600 Subject: [PATCH 10/78] fix(desktop): sync last agent and model when changing session --- packages/app/src/pages/session.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 019cc305c1aa..ec55acf1e1f8 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -91,6 +91,18 @@ export default function Page() { }) const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1)) + createEffect( + on( + () => lastUserMessage()?.id, + () => { + const msg = lastUserMessage() + if (!msg) return + if (msg.agent) local.agent.set(msg.agent) + if (msg.model) local.model.set(msg.model) + }, + ), + ) + const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, From 1658a3ff59b9ebacb95221fab69b4d3cecca08fd Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 29 Dec 2025 12:05:06 +0000 Subject: [PATCH 11/78] ignore: update download stats 2025-12-29 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index 6c155da05ada..757a03db5e3c 100644 --- a/STATS.md +++ b/STATS.md @@ -184,3 +184,4 @@ | 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) | | 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) | | 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) | +| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) | From 94e851c2a2c38b1bde8fb30fd3f18acc5426c43b Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 29 Dec 2025 07:45:28 -0500 Subject: [PATCH 12/78] docs: add MiniMax integration instructions to providers documentation --- packages/web/src/content/docs/providers.mdx | 29 ++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index e38c0dff16c7..d1a6bb4995a0 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -869,6 +869,33 @@ To use Kimi K2 from Moonshot AI: --- +### MiniMax + +1. Head over to the [MiniMax console](https://www.minimax.io/), create an account, and generate an API key. + +2. Run the `/connect` command and search for **MiniMax**. + + ```txt + /connect + ``` + +3. Enter your MiniMax API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select a model like _M2.1_. + + ```txt + /models + ``` + +--- + ### Nebius Token Factory 1. Head over to the [Nebius Token Factory console](https://tokenfactory.nebius.com/), create an account, and click **Add Key**. @@ -1277,7 +1304,7 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon └ enter ``` -4. Run the `/models` command to select a model like _GLM-4.5_. +4. Run the `/models` command to select a model like _GLM-4.7_. ```txt /models From 5b02a3029e33f614aa886dde0b72ca27bf601f19 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 29 Dec 2025 06:31:57 -0600 Subject: [PATCH 13/78] fix(desktop): max height on edit tool calls --- packages/ui/src/components/message-part.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index cf784fd52ec0..a58e76890cf9 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -245,6 +245,14 @@ [data-component="edit-content"] { border-top: 1px solid var(--border-weaker-base); + max-height: 420px; + overflow-y: auto; + + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } } [data-component="write-content"] { From 98fd53fd5f4e66140a2eb267e6cf7547836988e5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 29 Dec 2025 07:25:55 -0600 Subject: [PATCH 14/78] fix(core): preserve imperative statements in summary --- packages/opencode/src/agent/prompt/summary.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/agent/prompt/summary.txt b/packages/opencode/src/agent/prompt/summary.txt index c9264db18072..1cb2aedbd7e3 100644 --- a/packages/opencode/src/agent/prompt/summary.txt +++ b/packages/opencode/src/agent/prompt/summary.txt @@ -7,4 +7,5 @@ Rules: - Do not explain what the user asked for - Write in first person (I added..., I fixed...) - Never ask questions or add new questions -- Only exception: if the conversation ends with an unanswered question to the user, preserve that exact question +- If the conversation ends with an unanswered question to the user, preserve that exact question +- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary From c0f9b136309f870e45412fdc733e1b989e8146c7 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:21:32 -0600 Subject: [PATCH 15/78] fix(desktop): more fine-grained state updates --- packages/app/src/context/global-sync.tsx | 5 +- packages/app/src/context/sync.tsx | 60 +++++++--- packages/app/src/pages/session.tsx | 8 +- packages/ui/src/components/session-turn.tsx | 119 +++++++++++++------- 4 files changed, 126 insertions(+), 66 deletions(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index a496d59cb603..0296dfad3794 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -71,21 +71,19 @@ function createGlobalSync() { project: Project[] provider: ProviderListResponse provider_auth: ProviderAuthResponse - children: Record }>({ ready: false, path: { state: "", config: "", worktree: "", directory: "", home: "" }, project: [], provider: { all: [], connected: [], default: {} }, provider_auth: {}, - children: {}, }) const children: Record>> = {} function child(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { - setGlobalStore("children", directory, { + children[directory] = createStore({ project: "", provider: { all: [], connected: [], default: {} }, config: {}, @@ -105,7 +103,6 @@ function createGlobalSync() { message: {}, part: {}, }) - children[directory] = createStore(globalStore.children[directory]) bootstrapInstance(directory) } return children[directory] diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 941b8b629f36..dad035fb3cc0 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,5 +1,5 @@ -import { produce } from "solid-js/store" -import { createMemo } from "solid-js" +import { batch, createMemo } from "solid-js" +import { produce, reconcile } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" @@ -67,22 +67,46 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ retry(() => sdk.client.session.todo({ sessionID })), retry(() => sdk.client.session.diff({ sessionID })), ]) - setStore( - produce((draft) => { - const match = Binary.search(draft.session, sessionID, (s) => s.id) - if (match.found) draft.session[match.index] = session.data! - if (!match.found) draft.session.splice(match.index, 0, session.data!) - draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages - .data!.map((x) => x.info) - .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.session_diff[sessionID] = diff.data ?? [] - }), - ) + + batch(() => { + setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (s) => s.id) + if (match.found) { + draft[match.index] = session.data! + return + } + draft.splice(match.index, 0, session.data!) + }), + ) + + setStore("todo", sessionID, reconcile(todo.data ?? [])) + setStore( + "message", + sessionID, + reconcile( + (messages.data ?? []) + .map((x) => x.info) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + + for (const message of messages.data ?? []) { + setStore( + "part", + message.info.id, + reconcile( + message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + + setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) + }) }, fetch: async (count = 10) => { setStore("limit", (x) => x + count) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index ec55acf1e1f8..6bc39daca2b5 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -79,11 +79,7 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const userMessages = createMemo(() => - messages() - .filter((m) => m.role === "user") - .sort((a, b) => a.id.localeCompare(b.id)), - ) + const userMessages = createMemo(() => messages().filter((m) => m.role === "user")) const visibleUserMessages = createMemo(() => { const revert = revertMessageID() if (!revert) return userMessages() @@ -587,6 +583,7 @@ export default function Page() { setStore("mobileStepsExpanded", message.id, (x) => !x)} onUserInteracted={() => setStore("userInteracted", true)} @@ -643,6 +640,7 @@ export default function Page() { setStore("stepsExpanded", (x) => !x)} onUserInteracted={() => setStore("userInteracted", true)} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 8cb4263872e0..8e9aa9f2363f 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,6 +3,7 @@ import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" +import { Binary } from "@opencode-ai/util/binary" import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" @@ -62,24 +63,29 @@ function computeStatusFromPart(part: PartType | undefined): string | undefined { function AssistantMessageItem(props: { message: AssistantMessage - summary: string | undefined - response: string | undefined - lastTextPartId: string | undefined - working: boolean + responsePartId: string | undefined + hideResponsePart: boolean }) { const data = useData() const msgParts = createMemo(() => data.store.part[props.message.id] ?? []) - const lastTextPart = createMemo(() => - msgParts() - .filter((p) => p?.type === "text") - .at(-1), - ) + const lastTextPart = createMemo(() => { + const parts = msgParts() + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i] + if (part?.type === "text") return part as TextPart + } + return undefined + }) const filteredParts = createMemo(() => { - if (!props.working && !props.summary && props.response && props.lastTextPartId === lastTextPart()?.id) { - return msgParts().filter((p) => p?.id !== lastTextPart()?.id) - } - return msgParts() + const parts = msgParts() + if (!props.hideResponsePart) return parts + + const responsePartId = props.responsePartId + if (!responsePartId) return parts + if (responsePartId !== lastTextPart()?.id) return parts + + return parts.filter((part) => part?.id !== responsePartId) }) return @@ -89,6 +95,7 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string + lastUserMessageID?: string stepsExpanded?: boolean onStepsExpandedToggle?: () => void onUserInteracted?: () => void @@ -103,14 +110,30 @@ export function SessionTurn( const diffComponent = useDiffComponent() const allMessages = createMemo(() => data.store.message[props.sessionID] ?? []) - const userMessages = createMemo(() => - allMessages() - .filter((m) => m.role === "user") - .sort((a, b) => a.id.localeCompare(b.id)), - ) - const message = createMemo(() => userMessages().find((m) => m.id === props.messageID)) - const isLastUserMessage = createMemo(() => message()?.id === userMessages().at(-1)?.id) + const message = createMemo(() => { + const messages = allMessages() + const result = Binary.search(messages, props.messageID, (m) => m.id) + if (!result.found) return undefined + + const msg = messages[result.index] + if (msg.role !== "user") return undefined + + return msg + }) + + const lastUserMessageID = createMemo(() => { + if (props.lastUserMessageID) return props.lastUserMessageID + + const messages = allMessages() + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg?.role === "user") return msg.id + } + return undefined + }) + + const isLastUserMessage = createMemo(() => props.messageID === lastUserMessageID()) const parts = createMemo(() => { const msg = message() @@ -118,10 +141,29 @@ export function SessionTurn( return data.store.part[msg.id] ?? [] }) + const messageIndex = createMemo(() => { + const messages = allMessages() + const result = Binary.search(messages, props.messageID, (m) => m.id) + if (!result.found) return -1 + return result.index + }) + const assistantMessages = createMemo(() => { const msg = message() if (!msg) return [] as AssistantMessage[] - return allMessages().filter((m) => m.role === "assistant" && m.parentID === msg.id) as AssistantMessage[] + + const messages = allMessages() + const index = messageIndex() + if (index < 0) return [] as AssistantMessage[] + + const result: AssistantMessage[] = [] + for (let i = index + 1; i < messages.length; i++) { + const item = messages[i] + if (!item) continue + if (item.role === "user") break + if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage) + } + return result }) const lastAssistantMessage = createMemo(() => assistantMessages().at(-1)) @@ -152,16 +194,18 @@ export function SessionTurn( }) const permissionParts = createMemo(() => { - const result: { part: ToolPart; message: AssistantMessage }[] = [] const permissions = data.store.permission?.[props.sessionID] ?? [] - if (!permissions.length) return result + if (!permissions.length) return [] as { part: ToolPart; message: AssistantMessage }[] - for (const m of assistantMessages()) { - const msgParts = data.store.part[m.id] ?? [] - for (const p of msgParts) { - if (p?.type === "tool" && permissions.some((perm) => perm.callID === (p as ToolPart).callID)) { - result.push({ part: p as ToolPart, message: m }) - } + const ids = new Set(permissions.map((perm) => perm.callID)) + const result: { part: ToolPart; message: AssistantMessage }[] = [] + + for (const message of assistantMessages()) { + const parts = data.store.part[message.id] ?? [] + for (const part of parts) { + if (part?.type !== "tool") continue + const tool = part as ToolPart + if (ids.has(tool.callID)) result.push({ part: tool, message }) } } return result @@ -245,12 +289,11 @@ export function SessionTurn( return s }) - const summary = () => message()?.summary?.body - const response = () => { - const part = lastTextPart() - return part?.type === "text" ? (part as TextPart).text : undefined - } - const hasDiffs = () => message()?.summary?.diffs?.length + const summary = createMemo(() => message()?.summary?.body) + const response = createMemo(() => lastTextPart()?.text) + const responsePartId = createMemo(() => lastTextPart()?.id) + const hasDiffs = createMemo(() => message()?.summary?.diffs?.length) + const hideResponsePart = createMemo(() => !working() && !summary() && !!responsePartId()) function duration() { const msg = message() @@ -477,10 +520,8 @@ export function SessionTurn( {(assistantMessage) => ( )} From a24549fce77b12f025f0583063df2c7f23ad3ffc Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 29 Dec 2025 09:28:35 -0500 Subject: [PATCH 16/78] docs: update MiniMax console link in integration instructions --- packages/web/src/content/docs/providers.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index d1a6bb4995a0..c939f6b60c74 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -871,7 +871,7 @@ To use Kimi K2 from Moonshot AI: ### MiniMax -1. Head over to the [MiniMax console](https://www.minimax.io/), create an account, and generate an API key. +1. Head over to the [MiniMax API Console](https://platform.minimax.io/login), create an account, and generate an API key. 2. Run the `/connect` command and search for **MiniMax**. From c4930eb6b219a2011cddc70a9b76f41809418114 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:47:27 -0600 Subject: [PATCH 17/78] fix(desktop): more fine-grained state updates for permissions --- packages/app/src/context/global-sync.tsx | 65 ++++++++++++++------- packages/ui/src/components/message-part.tsx | 9 ++- packages/ui/src/components/session-turn.tsx | 23 +++++--- 3 files changed, 67 insertions(+), 30 deletions(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 0296dfad3794..e1dcf15fc22b 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -23,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { useGlobalSDK } from "./global-sdk" import { ErrorPage, type InitError } from "../pages/error" -import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js" +import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/util/path" @@ -135,7 +135,7 @@ function createGlobalSync() { async function bootstrapInstance(directory: string) { if (!directory) return - const [, setStore] = child(directory) + const [store, setStore] = child(directory) const sdk = createOpencodeClient({ baseUrl: globalSDK.url, directory, @@ -167,12 +167,32 @@ function createGlobalSync() { vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)), permission: () => sdk.permission.list().then((x) => { - const grouped: Record = {} + const grouped: Record = {} for (const perm of x.data ?? []) { - grouped[perm.sessionID] = grouped[perm.sessionID] ?? [] - grouped[perm.sessionID]!.push(perm) + const existing = grouped[perm.sessionID] + if (existing) { + existing.push(perm) + continue + } + grouped[perm.sessionID] = [perm] } - setStore("permission", grouped) + + batch(() => { + for (const sessionID of Object.keys(store.permission)) { + if (grouped[sessionID]) continue + setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + setStore( + "permission", + sessionID, + reconcile( + permissions.slice().sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + }) }), } await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) @@ -325,23 +345,26 @@ function createGlobalSync() { break } case "permission.updated": { - const permissions = store.permission[event.properties.sessionID] + const sessionID = event.properties.sessionID + const permissions = store.permission[sessionID] if (!permissions) { - setStore("permission", event.properties.sessionID, [event.properties]) - } else { - const result = Binary.search(permissions, event.properties.id, (p) => p.id) - setStore( - "permission", - event.properties.sessionID, - produce((draft) => { - if (result.found) { - draft[result.index] = event.properties - return - } - draft.push(event.properties) - }), - ) + setStore("permission", sessionID, [event.properties]) + break } + + const result = Binary.search(permissions, event.properties.id, (p) => p.id) + if (result.found) { + setStore("permission", sessionID, result.index, reconcile(event.properties)) + break + } + + setStore( + "permission", + sessionID, + produce((draft) => { + draft.splice(result.index, 0, event.properties) + }), + ) break } case "permission.replied": { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 2d39207ec96b..83811494c5f7 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -623,7 +623,14 @@ ToolRegistry.register({ const sessionId = childSessionId() if (!sessionId) return undefined const permissions = data.store.permission?.[sessionId] ?? [] - return permissions.toSorted((a, b) => a.id.localeCompare(b.id))[0] + return permissions.reduce( + (result, perm) => { + if (!result) return perm + if (perm.id < result.id) return perm + return result + }, + undefined as (typeof permissions)[number] | undefined, + ) }) const childToolPart = createMemo(() => { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 8e9aa9f2363f..e1a392bf0be9 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -4,7 +4,7 @@ import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { Binary } from "@opencode-ai/util/binary" -import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" +import { createEffect, createMemo, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" @@ -193,11 +193,16 @@ export function SessionTurn( return false }) + const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? []) + const permissionCount = createMemo(() => permissions().length) + const permissionParts = createMemo(() => { - const permissions = data.store.permission?.[props.sessionID] ?? [] - if (!permissions.length) return [] as { part: ToolPart; message: AssistantMessage }[] + if (props.stepsExpanded) return [] as { part: ToolPart; message: AssistantMessage }[] + + const items = permissions() + if (!items.length) return [] as { part: ToolPart; message: AssistantMessage }[] - const ids = new Set(permissions.map((perm) => perm.callID)) + const ids = new Set(items.map((perm) => perm.callID)) const result: { part: ToolPart; message: AssistantMessage }[] = [] for (const message of assistantMessages()) { @@ -371,11 +376,13 @@ export function SessionTurn( } }) - createEffect(() => { - if (permissionParts().length > 0) { + createEffect( + on(permissionCount, (count, prev) => { + if (!count) return + if (prev !== undefined && count <= prev) return autoScroll.forceScrollToBottom() - } - }) + }), + ) createEffect(() => { if (working() || !isLastUserMessage()) return From 0c6da69f39490f96e03759dc0b638718662abe70 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Mon, 29 Dec 2025 11:54:49 -0300 Subject: [PATCH 18/78] Desktop: Edit Project (#6360) --- .../src/components/dialog-edit-project.tsx | 180 ++++++++++++++++++ packages/app/src/context/layout.tsx | 1 + packages/app/src/pages/layout.tsx | 12 +- 3 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/components/dialog-edit-project.tsx diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx new file mode 100644 index 000000000000..27ce3903cdc0 --- /dev/null +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -0,0 +1,180 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" +import { Icon } from "@opencode-ai/ui/icon" +import { createMemo, createSignal, For, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useGlobalSDK } from "@/context/global-sdk" +import { type LocalProject, getAvatarColors } from "@/context/layout" +import { Avatar } from "@opencode-ai/ui/avatar" + +const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const + +function getFilename(input: string) { + const parts = input.split("/") + return parts[parts.length - 1] || input +} + +export function DialogEditProject(props: { project: LocalProject }) { + const dialog = useDialog() + const globalSDK = useGlobalSDK() + + const folderName = createMemo(() => getFilename(props.project.worktree)) + const defaultName = createMemo(() => props.project.name || folderName()) + + const [store, setStore] = createStore({ + name: defaultName(), + color: props.project.icon?.color || "pink", + iconUrl: props.project.icon?.url || "", + saving: false, + }) + + const [dragOver, setDragOver] = createSignal(false) + + function handleFileSelect(file: File) { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (e) => setStore("iconUrl", e.target?.result as string) + reader.readAsDataURL(file) + } + + function handleDrop(e: DragEvent) { + e.preventDefault() + setDragOver(false) + const file = e.dataTransfer?.files[0] + if (file) handleFileSelect(file) + } + + function handleDragOver(e: DragEvent) { + e.preventDefault() + setDragOver(true) + } + + function handleDragLeave() { + setDragOver(false) + } + + function handleInputChange(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (file) handleFileSelect(file) + } + + function clearIcon() { + setStore("iconUrl", "") + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + if (!props.project.id) return + + setStore("saving", true) + const name = store.name.trim() === folderName() ? "" : store.name.trim() + await globalSDK.client.project.update({ + projectID: props.project.id, + name, + icon: { color: store.color, url: store.iconUrl }, + }) + setStore("saving", false) + dialog.close() + } + + return ( + +
+
+ setStore("name", v)} + /> + +
+ +
+
+
document.getElementById("icon-upload")?.click()} + > + + +
+ } + > + Project icon + +
+ + + +
+ +
+ Click or drag an image + Recommended: 128x128px +
+
+
+ + +
+ +
+ + {(color) => ( + + )} + +
+
+
+ + +
+ + +
+
+
+ ) +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index c6ba5fef5a14..4ccab98e3ff5 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -70,6 +70,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( { ...project, ...(metadata ?? {}), + icon: { url: metadata?.icon?.url, color: metadata?.icon?.color }, }, ] } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2bc0c3131492..0b9178948098 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -49,6 +49,7 @@ import { Header } from "@/components/header" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" +import { DialogEditProject } from "@/components/dialog-edit-project" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" @@ -522,7 +523,7 @@ export default function Layout(props: ParentProps) { const notification = useNotification() const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" @@ -558,7 +559,7 @@ export default function Layout(props: ParentProps) { } const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => { - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const current = createMemo(() => base64Decode(params.dir ?? "")) return ( @@ -701,7 +702,7 @@ export default function Layout(props: ParentProps) { const sortable = createSortable(props.project.worktree) const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) const slug = createMemo(() => base64Encode(props.project.worktree)) - const name = createMemo(() => getFilename(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const [store, setProjectStore] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session.toSorted(sortSessions)) const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) @@ -747,6 +748,11 @@ export default function Layout(props: ParentProps) { + dialog.show(() => )} + > + Edit project + closeProject(props.project.worktree)}> Close project From a687d7c15f2c47bbe505df651cd0b7bab9561bdb Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:07:31 -0600 Subject: [PATCH 19/78] fix(desktop): one permission at a time --- packages/ui/src/components/message-part.css | 5 +++-- packages/ui/src/components/message-part.tsx | 11 ++++++++++- packages/ui/src/components/session-turn.tsx | 22 ++++++++++++++------- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index a58e76890cf9..ce479d8c1e84 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -378,9 +378,9 @@ &[data-permission="true"] { position: sticky; - top: var(--sticky-header-height, 80px); + top: calc(2px + var(--sticky-header-height, 40px)); bottom: 0px; - z-index: 10; + z-index: 20; border-radius: 6px; border: none; box-shadow: var(--shadow-xs-border-base); @@ -391,6 +391,7 @@ content: ""; position: absolute; inset: -1.5px; + top: -5px; border-radius: 7.5px; border: 1.5px solid transparent; background: diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 83811494c5f7..e43ffc322387 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -364,7 +364,16 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const permission = createMemo(() => { const sessionID = props.message.sessionID const permissions = data.store.permission?.[sessionID] ?? [] - return permissions.find((p) => p.callID === part.callID) + const next = permissions.reduce( + (result, perm) => { + if (!result) return perm + if (perm.id < result.id) return perm + return result + }, + undefined as (typeof permissions)[number] | undefined, + ) + if (!next) return undefined + return next.callID === part.callID ? next : undefined }) const [forceOpen, setForceOpen] = createSignal(false) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index e1a392bf0be9..a3ac5b0b45d8 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -195,25 +195,33 @@ export function SessionTurn( const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? []) const permissionCount = createMemo(() => permissions().length) + const nextPermission = createMemo(() => { + const items = permissions() + return items.reduce( + (result, perm) => { + if (!result) return perm + if (perm.id < result.id) return perm + return result + }, + undefined as ReturnType[number] | undefined, + ) + }) const permissionParts = createMemo(() => { if (props.stepsExpanded) return [] as { part: ToolPart; message: AssistantMessage }[] - const items = permissions() - if (!items.length) return [] as { part: ToolPart; message: AssistantMessage }[] - - const ids = new Set(items.map((perm) => perm.callID)) - const result: { part: ToolPart; message: AssistantMessage }[] = [] + const next = nextPermission() + if (!next) return [] as { part: ToolPart; message: AssistantMessage }[] for (const message of assistantMessages()) { const parts = data.store.part[message.id] ?? [] for (const part of parts) { if (part?.type !== "tool") continue const tool = part as ToolPart - if (ids.has(tool.callID)) result.push({ part: tool, message }) + if (tool.callID === next.callID) return [{ part: tool, message }] } } - return result + return [] as { part: ToolPart; message: AssistantMessage }[] }) const shellModePart = createMemo(() => { From c6c29b3dcfe6d3fca56f4273f113ee93467de256 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 29 Dec 2025 10:16:29 -0500 Subject: [PATCH 20/78] zen: minimax m2.1 --- packages/web/src/content/docs/zen.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index a8bfa3b06d0f..d4b86655b102 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -77,10 +77,11 @@ You can also access our models through the following API endpoints. | Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| GLM 4.7 | glm-4.7-free | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| MiniMax M2.1 | minimax-m2.1-free | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | | GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 4.7 | glm-4.7-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -111,6 +112,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | --------------------------------- | ------ | ------ | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | Grok Code Fast 1 | Free | Free | Free | - | +| MiniMax M2.1 | Free | Free | Free | - | | GLM 4.7 | Free | Free | Free | - | | GLM 4.6 | $0.60 | $2.20 | $0.10 | - | | Kimi K2 | $0.40 | $2.50 | - | - | @@ -145,6 +147,7 @@ The free models: - Grok Code Fast 1 is currently free on OpenCode for a limited time. The xAI team is using this time to collect feedback and improve Grok Code. - GLM 4.7 is currently free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. +- MiniMax M2.1 is currently free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. Contact us if you have any questions. @@ -157,6 +160,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic - Grok Code Fast 1: During its free period, collected data may be used to improve Grok Code. - GLM 4.7: During its free period, collected data may be used to improve the model. +- MiniMax M2.1: During its free period, collected data may be used to improve the model. - Big Pickle: During its free period, collected data may be used to improve the model. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). From 3adbbc1b230add8ffec4e0fba725f43ba6b32770 Mon Sep 17 00:00:00 2001 From: Zeno Jiricek Date: Tue, 30 Dec 2025 02:02:44 +1030 Subject: [PATCH 21/78] docs: add opencode-skillful plugin to ecosystem page (#6333) --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 222a77e151aa..ee67b06ad247 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -35,6 +35,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-notificator](https://github.com/panta/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions | | [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events | | [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context | +| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection | --- From 5de73abd820d9425e82f85f95f50568332ca7cdc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:47:42 -0600 Subject: [PATCH 22/78] fix(desktop): markdown styles --- packages/ui/src/components/markdown.css | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 534e93e462e7..dd0fd21a3db0 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -6,7 +6,7 @@ color: var(--text-base); font-family: var(--font-family-sans); font-size: var(--font-size-base); /* 14px */ - line-height: var(--line-height-large); + line-height: var(--line-height-x-large); /* Spacing for flow */ > *:first-child { @@ -65,7 +65,7 @@ } li { - margin-bottom: 0.25rem; + margin-bottom: 1.5rem; } li::marker { @@ -117,13 +117,15 @@ :not(pre) > code { font-family: var(--font-family-mono); font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: 13px; + color: var(--syntax-string); + font-weight: var(--font-weight-medium); + /* font-size: 13px; */ - padding: 2px 2px; - margin: 0 1.5px; - border-radius: 2px; - background: var(--surface-base); - box-shadow: 0 0 0 0.5px var(--border-weak-base); + /* padding: 2px 2px; */ + /* margin: 0 1.5px; */ + /* border-radius: 2px; */ + /* background: var(--surface-base); */ + /* box-shadow: 0 0 0 0.5px var(--border-weak-base); */ } /* Tables */ From 831e9bce51c035ec22ce9562bf0257d6b59b2fe4 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:47:53 -0600 Subject: [PATCH 23/78] fix(desktop): jankiness --- packages/app/src/app.tsx | 2 +- packages/app/src/context/global-sync.tsx | 4 +- packages/app/src/context/sync.tsx | 4 +- packages/app/src/pages/session.tsx | 20 +++++--- packages/ui/src/components/list.tsx | 55 +++++++++++---------- packages/ui/src/components/message-part.tsx | 11 +---- packages/ui/src/components/session-turn.tsx | 23 +++++---- packages/ui/src/hooks/use-filtered-list.tsx | 10 +++- 8 files changed, 69 insertions(+), 60 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index bf5ba956622e..9e38d5e98de4 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -69,7 +69,7 @@ export function App() { ( - + diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index e1dcf15fc22b..a6851aecbf10 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -124,7 +124,7 @@ function createGlobalSync() { const updated = new Date(s.time.updated).getTime() return updated > fourHoursAgo }) - setStore("session", sessions) + setStore("session", reconcile(sessions, { key: "id" })) }) .catch((err) => { console.error("Failed to load sessions", err) @@ -263,7 +263,7 @@ function createGlobalSync() { setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" })) break case "todo.updated": - setStore("todo", event.properties.sessionID, reconcile(event.properties.todos)) + setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" })) break case "session.status": { setStore("session_status", event.properties.sessionID, reconcile(event.properties.status)) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index dad035fb3cc0..05156613a8ce 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -81,7 +81,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) - setStore("todo", sessionID, reconcile(todo.data ?? [])) + setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" })) setStore( "message", sessionID, @@ -115,7 +115,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .slice() .sort((a, b) => a.id.localeCompare(b.id)) .slice(0, store.limit) - setStore("session", sessions) + setStore("session", reconcile(sessions, { key: "id" })) }) }, more: createMemo(() => store.session.length >= store.limit), diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6bc39daca2b5..a6dc804d14e6 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -167,14 +167,18 @@ export default function Page() { ), ) - createEffect(() => { - params.id - const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" } - batch(() => { - setStore("userInteracted", false) - setStore("stepsExpanded", status.type !== "idle") - }) - }) + createEffect( + on( + () => params.id, + (id) => { + const status = sync.data.session_status[id ?? ""] ?? { type: "idle" } + batch(() => { + setStore("userInteracted", false) + setStore("stepsExpanded", status.type !== "idle") + }) + }, + ), + ) const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" }) const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id) diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 808c9b032c8d..a549c19e93ab 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -1,5 +1,5 @@ import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, createSignal, For, type JSX, on, Show } from "solid-js" +import { createEffect, createSignal, For, onCleanup, type JSX, on, Show } from "solid-js" import { createStore } from "solid-js/store" import { Icon, type IconProps } from "./icon" import { IconButton } from "./icon-button" @@ -116,6 +116,33 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) setScrollRef, }) + function GroupHeader(props: { category: string }): JSX.Element { + const [stuck, setStuck] = createSignal(false) + const [header, setHeader] = createSignal(undefined) + + createEffect(() => { + const scroll = scrollRef() + const node = header() + if (!scroll || !node) return + + const handler = () => { + const rect = node.getBoundingClientRect() + const scrollRect = scroll.getBoundingClientRect() + setStuck(rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0) + } + + scroll.addEventListener("scroll", handler, { passive: true }) + handler() + onCleanup(() => scroll.removeEventListener("scroll", handler)) + }) + + return ( +
+ {props.category} +
+ ) + } + return (
@@ -157,31 +184,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {(group) => (
- {(() => { - const [stuck, setStuck] = createSignal(false) - return ( -
{ - createEffect(() => { - const scroll = scrollRef() - if (!scroll) return - const handler = () => { - const rect = el.getBoundingClientRect() - const scrollRect = scroll.getBoundingClientRect() - setStuck(rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0) - } - scroll.addEventListener("scroll", handler, { passive: true }) - handler() - return () => scroll.removeEventListener("scroll", handler) - }) - }} - > - {group.category} -
- ) - })()} +
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index e43ffc322387..ea865d7ceab6 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -364,16 +364,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const permission = createMemo(() => { const sessionID = props.message.sessionID const permissions = data.store.permission?.[sessionID] ?? [] - const next = permissions.reduce( - (result, perm) => { - if (!result) return perm - if (perm.id < result.id) return perm - return result - }, - undefined as (typeof permissions)[number] | undefined, - ) - if (!next) return undefined - return next.callID === part.callID ? next : undefined + return permissions.find((perm) => perm.callID === part.callID) }) const [forceOpen, setForceOpen] = createSignal(false) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a3ac5b0b45d8..0a1ee5c4fc02 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -111,13 +111,23 @@ export function SessionTurn( const allMessages = createMemo(() => data.store.message[props.sessionID] ?? []) - const message = createMemo(() => { + const messageIndex = createMemo(() => { const messages = allMessages() const result = Binary.search(messages, props.messageID, (m) => m.id) - if (!result.found) return undefined + if (!result.found) return -1 const msg = messages[result.index] - if (msg.role !== "user") return undefined + if (msg.role !== "user") return -1 + + return result.index + }) + + const message = createMemo(() => { + const index = messageIndex() + if (index < 0) return undefined + + const msg = allMessages()[index] + if (!msg || msg.role !== "user") return undefined return msg }) @@ -141,13 +151,6 @@ export function SessionTurn( return data.store.part[msg.id] ?? [] }) - const messageIndex = createMemo(() => { - const messages = allMessages() - const result = Binary.search(messages, props.messageID, (m) => m.id) - if (!result.found) return -1 - return result.index - }) - const assistantMessages = createMemo(() => { const msg = message() if (!msg) return [] as AssistantMessage[] diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index 76a5ae84f375..416f030ef498 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -51,9 +51,17 @@ export function useFilteredList(props: FilteredListProps) { ) }) + function initialActive() { + if (props.current) return props.key(props.current) + + const items = flat() + if (items.length === 0) return "" + return props.key(items[0]) + } + const list = createList({ items: () => flat().map(props.key), - initialActive: props.current ? props.key(props.current) : props.key(flat()[0]), + initialActive: initialActive(), loop: true, }) From 7376c3f8e7ba8a6657836af734d88a2c630e25ed Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:54:22 -0600 Subject: [PATCH 24/78] feat(desktop): latex support --- bun.lock | 11 +++++++++++ packages/ui/package.json | 11 +++++++---- packages/ui/src/context/marked.tsx | 4 ++++ packages/ui/src/styles/index.css | 1 + 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index b8e6b86e9f66..0736caecea18 100644 --- a/bun.lock +++ b/bun.lock @@ -403,8 +403,10 @@ "@solidjs/meta": "catalog:", "@typescript/native-preview": "catalog:", "fuzzysort": "catalog:", + "katex": "0.16.27", "luxon": "catalog:", "marked": "catalog:", + "marked-katex-extension": "5.1.6", "marked-shiki": "catalog:", "remeda": "catalog:", "shiki": "catalog:", @@ -416,6 +418,7 @@ "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", + "@types/katex": "0.16.7", "@types/luxon": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", @@ -1774,6 +1777,8 @@ "@types/jsonwebtoken": ["@types/jsonwebtoken@8.5.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg=="], + "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], + "@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], @@ -2786,6 +2791,8 @@ "jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="], + "katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -2876,6 +2883,8 @@ "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], + "marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -4284,6 +4293,8 @@ "jsonwebtoken/jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 3e8422bcd4db..95156a113ea6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -25,15 +25,16 @@ "generate:tailwind": "bun run script/tailwind.ts" }, "devDependencies": { + "@tailwindcss/vite": "catalog:", + "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", + "@types/katex": "0.16.7", "@types/luxon": "catalog:", - "@tsconfig/node22": "catalog:", + "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", - "vite-plugin-solid": "catalog:", - "tailwindcss": "catalog:", - "@tailwindcss/vite": "catalog:" + "vite-plugin-solid": "catalog:" }, "dependencies": { "@kobalte/core": "catalog:", @@ -46,8 +47,10 @@ "@solidjs/meta": "catalog:", "@typescript/native-preview": "catalog:", "fuzzysort": "catalog:", + "katex": "0.16.27", "luxon": "catalog:", "marked": "catalog:", + "marked-katex-extension": "5.1.6", "marked-shiki": "catalog:", "remeda": "catalog:", "shiki": "catalog:", diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index f4d85519d43b..3bd6cb0768e8 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -1,4 +1,5 @@ import { marked } from "marked" +import markedKatex from "marked-katex-extension" import markedShiki from "marked-shiki" import { bundledLanguages, type BundledLanguage } from "shiki" import { createSimpleContext } from "./helper" @@ -378,6 +379,9 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( name: "Marked", init: () => { return marked.use( + markedKatex({ + throwOnError: false, + }), markedShiki({ async highlight(code, lang) { const highlighter = await getSharedHighlighter({ themes: ["OpenCode"], langs: [] }) diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 5782d2a2929b..9bf3e4f696a2 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -4,6 +4,7 @@ @import "./theme.css" layer(theme); @import "./base.css" layer(base); +@import "katex/dist/katex.min.css" layer(base); @import "../components/accordion.css" layer(components); @import "../components/avatar.css" layer(components); From 48d4b4192758fa044ef3205b1c55674119d51e01 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Mon, 29 Dec 2025 12:55:15 -0300 Subject: [PATCH 25/78] Desktop: Sidebar subsessions support --- packages/app/src/context/layout.tsx | 30 +++++ packages/app/src/context/local.tsx | 2 +- packages/app/src/pages/layout.tsx | 179 +++++++++++++++++----------- 3 files changed, 142 insertions(+), 69 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 4ccab98e3ff5..4869503baa0f 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -29,6 +29,8 @@ type SessionTabs = { export type LocalProject = Partial & { worktree: string; expanded: boolean } +export type ExpandedSessions = Record + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -53,6 +55,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( width: 600, }, sessionTabs: {} as Record, + expandedSessions: {} as ExpandedSessions, }), ) @@ -184,6 +187,33 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } }, }, + sessions: { + expanded: createMemo(() => store.expandedSessions ?? {}), + isExpanded(sessionID: string) { + return store.expandedSessions?.[sessionID] ?? false + }, + expand(sessionID: string) { + if (!store.expandedSessions) { + setStore("expandedSessions", { [sessionID]: true }) + } else { + setStore("expandedSessions", sessionID, true) + } + }, + collapse(sessionID: string) { + if (!store.expandedSessions) { + setStore("expandedSessions", { [sessionID]: false }) + } else { + setStore("expandedSessions", sessionID, false) + } + }, + toggle(sessionID: string) { + if (!store.expandedSessions) { + setStore("expandedSessions", { [sessionID]: true }) + } else { + setStore("expandedSessions", sessionID, !store.expandedSessions[sessionID]) + } + }, + }, tabs(sessionKey: string) { const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) return { diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 49217b82be84..7703de621ee8 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -72,7 +72,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return { list, current() { - return list().find((x) => x.name === store.current)! + return list().find((x) => x.name === store.current) ?? list()[0] }, set(name: string | undefined) { setStore("current", name ?? list()[0].name) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 0b9178948098..c644562e81d9 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -596,17 +596,24 @@ export default function Layout(props: ParentProps) { slug: string project: LocalProject mobile?: boolean + depth?: number + allSessions: Session[] }): JSX.Element => { const notification = useNotification() + const depth = () => props.depth ?? 0 const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const childSessions = createMemo(() => + props.allSessions.filter((s) => s.parentID === props.session.id).toSorted(sortSessions), + ) + const hasChildren = createMemo(() => childSessions().length > 0) + const isExpanded = createMemo(() => layout.sessions.isExpanded(props.session.id)) const hasPermissions = createMemo(() => { const store = globalSync.child(props.project.worktree)[0] const permissions = store.permission?.[props.session.id] ?? [] if (permissions.length > 0) return true - const childSessions = store.session.filter((s) => s.parentID === props.session.id) - for (const child of childSessions) { + for (const child of childSessions()) { const childPermissions = store.permission?.[child.id] ?? [] if (childPermissions.length > 0) return true } @@ -618,20 +625,30 @@ export default function Layout(props: ParentProps) { const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) - return ( - <> -
- - -
+ + const SessionRow = () => ( +
hasChildren() && layout.sessions.toggle(props.session.id)} + > + + +
+
+ }> + + {props.session.title} -
- - - - - -
- - -
- - 0}> -
- - - - {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")} - - - -
- -
- {`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`} - {(summary) => } -
-
-
+
+ + + + + +
+ + +
+ + 0}> +
+ + + + {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")} + + + +
+
+ +
+ {`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`} + {(summary) => } +
+
+ + + + } + > + archiveSession(props.session)} /> - - } - > - archiveSession(props.session)} /> - -
+
+ ) + + return ( + <> + + + + {(child) => ( + + )} + + ) } @@ -776,7 +813,13 @@ export default function Layout(props: ParentProps) {