From ac254fb44271e9589c766eac6c3a78e24e2da3f8 Mon Sep 17 00:00:00 2001 From: Alex Yaroshuk <34632190+alexyaroshuk@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:53:49 +0800 Subject: [PATCH 001/148] fix(app): session header 'share' button to hug content (#11371) --- packages/app/src/components/session/session-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 449f2091bd6..5b00f80c053 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -167,7 +167,7 @@ export function SessionHeader() { triggerAs={Button} triggerProps={{ variant: "secondary", - class: "rounded-sm w-[60px] h-[24px]", + class: "rounded-sm h-[24px] px-3", classList: { "rounded-r-none": shareUrl() !== undefined }, style: { scale: 1 }, }} From f73f88fb56381f0ea6746964bbd4a6496f7ec229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E5=A5=95=E4=B8=9E?= <01luyicheng@gmail.com> Date: Sat, 31 Jan 2026 22:55:34 +0800 Subject: [PATCH 002/148] fix(pty): Add UTF-8 encoding defaults for Windows PTY (#11459) --- packages/opencode/src/pty/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 73474ed4f87..d01b2b02e91 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -108,6 +108,12 @@ export namespace Pty { TERM: "xterm-256color", OPENCODE_TERMINAL: "1", } as Record + + if (process.platform === "win32") { + env.LC_ALL = "C.UTF-8" + env.LC_CTYPE = "C.UTF-8" + env.LANG = "C.UTF-8" + } log.info("creating session", { id, cmd: command, args, cwd }) const spawn = await pty() From 786ae0a584688214c99d613f18b6dc1b4ccefb9e Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Sat, 31 Jan 2026 09:59:28 -0500 Subject: [PATCH 003/148] feat(app): add skill slash commands (#11369) --- packages/app/src/components/prompt-input.tsx | 36 +++++++++++++++++-- packages/app/src/context/global-sync.tsx | 6 ++++ packages/app/src/i18n/en.ts | 1 + .../cmd/tui/component/prompt/autocomplete.tsx | 14 ++++++++ .../opencode/src/cli/cmd/tui/context/sync.tsx | 4 +++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5c1d417eb08..2bf9acf326a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -111,7 +111,7 @@ interface SlashCommand { title: string description?: string keybind?: string - type: "builtin" | "custom" + type: "builtin" | "custom" | "skill" } export const PromptInput: Component = (props) => { @@ -519,7 +519,15 @@ export const PromptInput: Component = (props) => { type: "custom" as const, })) - return [...custom, ...builtin] + const skills = sync.data.skill.map((skill) => ({ + id: `skill.${skill.name}`, + trigger: `skill:${skill.name}`, + title: skill.name, + description: skill.description, + type: "skill" as const, + })) + + return [...skills, ...custom, ...builtin] }) const handleSlashSelect = (cmd: SlashCommand | undefined) => { @@ -543,6 +551,25 @@ export const PromptInput: Component = (props) => { return } + if (cmd.type === "skill") { + // Extract skill name from the id (skill.{name}) + const skillName = cmd.id.replace("skill.", "") + const text = `Load the "${skillName}" skill and follow its instructions.` + editorRef.innerHTML = "" + editorRef.textContent = text + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const sel = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + sel?.removeAllRanges() + sel?.addRange(range) + }) + return + } + editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) command.trigger(cmd.id, "slash") @@ -1706,6 +1733,11 @@ export const PromptInput: Component = (props) => { {language.t("prompt.slash.badge.custom")} + + + {language.t("prompt.slash.badge.skill")} + + {command.keybind(cmd.id)} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ad3d124b2c3..6977b86a323 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -17,6 +17,7 @@ import { type VcsInfo, type PermissionRequest, type QuestionRequest, + type AppSkillsResponse, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" @@ -56,10 +57,13 @@ type ProjectMeta = { } } +export type Skill = AppSkillsResponse[number] + type State = { status: "loading" | "partial" | "complete" agent: Agent[] command: Command[] + skill: Skill[] project: string projectMeta: ProjectMeta | undefined icon: string | undefined @@ -388,6 +392,7 @@ function createGlobalSync() { status: "loading" as const, agent: [], command: [], + skill: [], session: [], sessionTotal: 0, session_status: {}, @@ -528,6 +533,7 @@ function createGlobalSync() { Promise.all([ sdk.path.get().then((x) => setStore("path", x.data!)), sdk.command.list().then((x) => setStore("command", x.data ?? [])), + sdk.app.skills().then((x) => setStore("skill", x.data ?? [])), sdk.session.status().then((x) => setStore("session_status", x.data!)), loadSessions(directory), sdk.mcp.status().then((x) => setStore("mcp", x.data!)), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index a6a50506a09..7b18f54aff7 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -216,6 +216,7 @@ export const dict = { "prompt.popover.emptyCommands": "No matching commands", "prompt.dropzone.label": "Drop images or PDFs here", "prompt.slash.badge.custom": "custom", + "prompt.slash.badge.skill": "skill", "prompt.context.active": "active", "prompt.context.includeActiveFile": "Include active file", "prompt.context.removeActiveFile": "Remove active file from context", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index bd000e2ab07..0c2ef61a62d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -359,6 +359,20 @@ export function Autocomplete(props: { }) } + for (const skill of sync.data.skill) { + results.push({ + display: "/skill:" + skill.name, + description: skill.description, + onSelect: () => { + const newText = `Load the "${skill.name}" skill and follow its instructions.` + const cursor = props.input().logicalCursor + props.input().deleteRange(0, 0, cursor.row, cursor.col) + props.input().insertText(newText) + props.input().cursorOffset = Bun.stringWidth(newText) + }, + }) + } + results.sort((a, b) => a.display.localeCompare(b.display)) const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index eb8ed2d9bba..e7fedd6c354 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -17,6 +17,7 @@ import type { ProviderListResponse, ProviderAuthMethod, VcsInfo, + AppSkillsResponse, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" @@ -40,6 +41,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ provider_auth: Record agent: Agent[] command: Command[] + skill: AppSkillsResponse permission: { [sessionID: string]: PermissionRequest[] } @@ -86,6 +88,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ permission: {}, question: {}, command: [], + skill: [], provider: [], provider_default: {}, session: [], @@ -385,6 +388,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ Promise.all([ ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), + sdk.client.app.skills().then((x) => setStore("skill", reconcile(x.data ?? []))), sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), From 53f118c57afa57464c561114693648fc65b05554 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Sat, 31 Jan 2026 10:20:23 -0500 Subject: [PATCH 004/148] Revert "feat(app): add skill slash commands" (#11484) --- packages/app/src/components/prompt-input.tsx | 36 ++----------------- packages/app/src/context/global-sync.tsx | 6 ---- packages/app/src/i18n/en.ts | 1 - .../cmd/tui/component/prompt/autocomplete.tsx | 14 -------- .../opencode/src/cli/cmd/tui/context/sync.tsx | 4 --- 5 files changed, 2 insertions(+), 59 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2bf9acf326a..5c1d417eb08 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -111,7 +111,7 @@ interface SlashCommand { title: string description?: string keybind?: string - type: "builtin" | "custom" | "skill" + type: "builtin" | "custom" } export const PromptInput: Component = (props) => { @@ -519,15 +519,7 @@ export const PromptInput: Component = (props) => { type: "custom" as const, })) - const skills = sync.data.skill.map((skill) => ({ - id: `skill.${skill.name}`, - trigger: `skill:${skill.name}`, - title: skill.name, - description: skill.description, - type: "skill" as const, - })) - - return [...skills, ...custom, ...builtin] + return [...custom, ...builtin] }) const handleSlashSelect = (cmd: SlashCommand | undefined) => { @@ -551,25 +543,6 @@ export const PromptInput: Component = (props) => { return } - if (cmd.type === "skill") { - // Extract skill name from the id (skill.{name}) - const skillName = cmd.id.replace("skill.", "") - const text = `Load the "${skillName}" skill and follow its instructions.` - editorRef.innerHTML = "" - editorRef.textContent = text - prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - requestAnimationFrame(() => { - editorRef.focus() - const range = document.createRange() - const sel = window.getSelection() - range.selectNodeContents(editorRef) - range.collapse(false) - sel?.removeAllRanges() - sel?.addRange(range) - }) - return - } - editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) command.trigger(cmd.id, "slash") @@ -1733,11 +1706,6 @@ export const PromptInput: Component = (props) => { {language.t("prompt.slash.badge.custom")} - - - {language.t("prompt.slash.badge.skill")} - - {command.keybind(cmd.id)} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 6977b86a323..ad3d124b2c3 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -17,7 +17,6 @@ import { type VcsInfo, type PermissionRequest, type QuestionRequest, - type AppSkillsResponse, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" @@ -57,13 +56,10 @@ type ProjectMeta = { } } -export type Skill = AppSkillsResponse[number] - type State = { status: "loading" | "partial" | "complete" agent: Agent[] command: Command[] - skill: Skill[] project: string projectMeta: ProjectMeta | undefined icon: string | undefined @@ -392,7 +388,6 @@ function createGlobalSync() { status: "loading" as const, agent: [], command: [], - skill: [], session: [], sessionTotal: 0, session_status: {}, @@ -533,7 +528,6 @@ function createGlobalSync() { Promise.all([ sdk.path.get().then((x) => setStore("path", x.data!)), sdk.command.list().then((x) => setStore("command", x.data ?? [])), - sdk.app.skills().then((x) => setStore("skill", x.data ?? [])), sdk.session.status().then((x) => setStore("session_status", x.data!)), loadSessions(directory), sdk.mcp.status().then((x) => setStore("mcp", x.data!)), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7b18f54aff7..a6a50506a09 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -216,7 +216,6 @@ export const dict = { "prompt.popover.emptyCommands": "No matching commands", "prompt.dropzone.label": "Drop images or PDFs here", "prompt.slash.badge.custom": "custom", - "prompt.slash.badge.skill": "skill", "prompt.context.active": "active", "prompt.context.includeActiveFile": "Include active file", "prompt.context.removeActiveFile": "Remove active file from context", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 0c2ef61a62d..bd000e2ab07 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -359,20 +359,6 @@ export function Autocomplete(props: { }) } - for (const skill of sync.data.skill) { - results.push({ - display: "/skill:" + skill.name, - description: skill.description, - onSelect: () => { - const newText = `Load the "${skill.name}" skill and follow its instructions.` - const cursor = props.input().logicalCursor - props.input().deleteRange(0, 0, cursor.row, cursor.col) - props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) - }, - }) - } - results.sort((a, b) => a.display.localeCompare(b.display)) const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e7fedd6c354..eb8ed2d9bba 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -17,7 +17,6 @@ import type { ProviderListResponse, ProviderAuthMethod, VcsInfo, - AppSkillsResponse, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" @@ -41,7 +40,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ provider_auth: Record agent: Agent[] command: Command[] - skill: AppSkillsResponse permission: { [sessionID: string]: PermissionRequest[] } @@ -88,7 +86,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ permission: {}, question: {}, command: [], - skill: [], provider: [], provider_default: {}, session: [], @@ -388,7 +385,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ Promise.all([ ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), - sdk.client.app.skills().then((x) => setStore("skill", reconcile(x.data ?? []))), sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), From feca42b0255b5a099477ee7ce60b51eb5911a055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 31 Jan 2026 16:26:23 +0100 Subject: [PATCH 005/148] feat(opencode): add reasoning variants support for SAP AI Core (#8753) Co-authored-by: Github Action --- packages/opencode/src/provider/transform.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 05e2272415b..f5fe419db90 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -575,6 +575,26 @@ export namespace ProviderTransform { case "@ai-sdk/perplexity": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity return {} + + case "@mymediset/sap-ai-provider": + case "@jerome-benoit/sap-ai-provider-v2": + if (model.api.id.includes("anthropic")) { + return { + high: { + thinking: { + type: "enabled", + budgetTokens: 16000, + }, + }, + max: { + thinking: { + type: "enabled", + budgetTokens: 31999, + }, + }, + } + } + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) } return {} } From 35f64b80fa6825f8c14fd1654a1331b19db94a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E7=8C=AB=E5=AD=90?= Date: Sun, 1 Feb 2026 02:30:45 +0800 Subject: [PATCH 006/148] docs: fix documentation issues (#11435) Co-authored-by: damaozi <1811866786@qq.com> --- .../src/cli/cmd/tui/component/tips.tsx | 2 +- packages/web/src/content/docs/lsp.mdx | 44 +++++++++++++++++++ packages/web/src/content/docs/providers.mdx | 4 ++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.tsx b/packages/opencode/src/cli/cmd/tui/component/tips.tsx index 3f0318e2690..7870ab2ea44 100644 --- a/packages/opencode/src/cli/cmd/tui/component/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/tips.tsx @@ -100,7 +100,7 @@ const TIPS = [ 'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting', "Define custom formatter commands with file extensions in config", "OpenCode uses LSP servers for intelligent code analysis", - "Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools", + "Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools", "Tool definitions can invoke scripts written in Python, Go, etc", "Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks", "Use plugins to send OS notifications when sessions complete", diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index c1d23d4f50a..707af84a01d 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -87,6 +87,50 @@ Let's look at some examples. --- +### Environment variables + +Use the `env` property to set environment variables when starting the LSP server: + +```json title="opencode.json" {5-7} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "rust": { + "env": { + "RUST_LOG": "debug" + } + } + } +} +``` + +--- + +### Initialization options + +Use the `initialization` property to pass initialization options to the LSP server. These are server-specific settings sent during the LSP `initialize` request: + +```json title="opencode.json" {5-9} +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "typescript": { + "initialization": { + "preferences": { + "importModuleSpecifierPreference": "relative" + } + } + } + } +} +``` + +:::note +Initialization options vary by LSP server. Check your LSP server's documentation for available options. +::: + +--- + ### Disabling LSP servers To disable **all** LSP servers globally, set `lsp` to `false`: diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 2a803945288..6852672149f 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1222,6 +1222,10 @@ To use Kimi K2 from Moonshot AI: You can configure opencode to use local models through Ollama. +:::tip +Ollama can automatically configure itself for OpenCode. See the [Ollama integration docs](https://docs.ollama.com/integrations/opencode) for details. +::: + ```json title="opencode.json" "ollama" {5, 6, 8, 10-14} { "$schema": "https://opencode.ai/config.json", From 121d6a72c0bc62c8a0699db87fb4f11b1bb73a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 31 Jan 2026 19:32:11 +0100 Subject: [PATCH 007/148] fix(nix): restore native runners for darwin hash computation (#11495) --- .github/workflows/nix-hashes.yml | 167 +++++++++++++++++-------------- flake.nix | 23 +---- nix/node_modules.nix | 11 +- 3 files changed, 99 insertions(+), 102 deletions(-) diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 061b4ada8d9..bb4db70882b 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -21,11 +21,68 @@ on: - ".github/workflows/nix-hashes.yml" jobs: - nix-hashes: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + # Native runners required: bun install cross-compilation flags (--os/--cpu) + # do not produce byte-identical node_modules as native installs. + compute-hash: + strategy: + fail-fast: false + matrix: + include: + - system: x86_64-linux + runner: blacksmith-4vcpu-ubuntu-2404 + - system: aarch64-linux + runner: blacksmith-4vcpu-ubuntu-2404-arm + - system: x86_64-darwin + runner: macos-15-intel + - system: aarch64-darwin + runner: macos-latest + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Nix + uses: nixbuild/nix-quick-install-action@v34 + + - name: Compute node_modules hash + id: hash + env: + SYSTEM: ${{ matrix.system }} + run: | + set -euo pipefail + + BUILD_LOG=$(mktemp) + trap 'rm -f "$BUILD_LOG"' EXIT + + # Build with fakeHash to trigger hash mismatch and reveal correct hash + nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true + + HASH="$(grep -E 'got:\s+sha256-' "$BUILD_LOG" | sed -E 's/.*got:\s+(sha256-[A-Za-z0-9+/=]+).*/\1/' | head -n1 || true)" + if [ -z "$HASH" ]; then + HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | sed -E 's/.*got:\s+(sha256-[A-Za-z0-9+/=]+).*/\1/' | head -n1 || true)" + fi + + if [ -z "$HASH" ]; then + echo "::error::Failed to compute hash for ${SYSTEM}" + cat "$BUILD_LOG" + exit 1 + fi + + echo "$HASH" > hash.txt + echo "Computed hash for ${SYSTEM}: $HASH" + + - name: Upload hash + uses: actions/upload-artifact@v4 + with: + name: hash-${{ matrix.system }} + path: hash.txt + retention-days: 1 + + update-hashes: + needs: compute-hash + if: github.event_name != 'pull_request' runs-on: blacksmith-4vcpu-ubuntu-2404 - env: - TITLE: node_modules hashes steps: - name: Checkout repository @@ -33,108 +90,64 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - ref: ${{ github.head_ref || github.ref_name }} - repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.ref_name }} - name: Setup git committer - id: committer uses: ./.github/actions/setup-git-committer with: opencode-app-id: ${{ vars.OPENCODE_APP_ID }} opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 - - name: Pull latest changes - env: - TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} run: | - BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - git pull --rebase --autostash origin "$BRANCH" + git pull --rebase --autostash origin "$GITHUB_REF_NAME" - - name: Compute all node_modules hashes + - name: Download hash artifacts + uses: actions/download-artifact@v4 + with: + path: hashes + pattern: hash-* + + - name: Update hashes.json run: | set -euo pipefail HASH_FILE="nix/hashes.json" - SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin" - if [ ! -f "$HASH_FILE" ]; then - mkdir -p "$(dirname "$HASH_FILE")" - echo '{"nodeModules":{}}' > "$HASH_FILE" - fi + [ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE" - for SYSTEM in $SYSTEMS; do - echo "Computing hash for ${SYSTEM}..." - BUILD_LOG=$(mktemp) - trap 'rm -f "$BUILD_LOG"' EXIT - - # The updater derivations use fakeHash, so they will fail and reveal the correct hash - UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules" - - nix build "$UPDATER_ATTR" --no-link 2>&1 | tee "$BUILD_LOG" || true - - CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)" - - if [ -z "$CORRECT_HASH" ]; then - CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)" + for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do + FILE="hashes/hash-${SYSTEM}/hash.txt" + if [ -f "$FILE" ]; then + HASH="$(tr -d '[:space:]' < "$FILE")" + echo "${SYSTEM}: ${HASH}" + jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json + mv tmp.json "$HASH_FILE" + else + echo "::warning::Missing hash for ${SYSTEM}" fi - - if [ -z "$CORRECT_HASH" ]; then - echo "Failed to determine correct node_modules hash for ${SYSTEM}." - cat "$BUILD_LOG" - exit 1 - fi - - echo " ${SYSTEM}: ${CORRECT_HASH}" - jq --arg sys "$SYSTEM" --arg h "$CORRECT_HASH" \ - '.nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp" - mv "${HASH_FILE}.tmp" "$HASH_FILE" done - echo "All hashes computed:" cat "$HASH_FILE" - - name: Commit ${{ env.TITLE }} changes - env: - TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} + - name: Commit changes run: | set -euo pipefail HASH_FILE="nix/hashes.json" - echo "Checking for changes..." - summarize() { - local status="$1" - { - echo "### Nix $TITLE" - echo "" - echo "- ref: ${GITHUB_REF_NAME}" - echo "- status: ${status}" - } >> "$GITHUB_STEP_SUMMARY" - if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then - echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY" - fi - echo "" >> "$GITHUB_STEP_SUMMARY" - } - - FILES=("$HASH_FILE") - STATUS="$(git status --short -- "${FILES[@]}" || true)" - if [ -z "$STATUS" ]; then - echo "No changes detected." - summarize "no changes" + if [ -z "$(git status --short -- "$HASH_FILE")" ]; then + echo "No changes to commit" + echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY" + echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY" exit 0 fi - echo "Changes detected:" - echo "$STATUS" - git add "${FILES[@]}" + git add "$HASH_FILE" git commit -m "chore: update nix node_modules hashes" - BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - git pull --rebase --autostash origin "$BRANCH" - git push origin HEAD:"$BRANCH" - echo "Changes pushed successfully" + git pull --rebase --autostash origin "$GITHUB_REF_NAME" + git push origin HEAD:"$GITHUB_REF_NAME" - summarize "committed $(git rev-parse --short HEAD)" + echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY" + echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY" diff --git a/flake.nix b/flake.nix index e4d214a0b93..ea78b1a4348 100644 --- a/flake.nix +++ b/flake.nix @@ -42,28 +42,15 @@ desktop = pkgs.callPackage ./nix/desktop.nix { inherit opencode; }; - # nixpkgs cpu naming to bun cpu naming - cpuMap = { x86_64 = "x64"; aarch64 = "arm64"; }; - # matrix of node_modules builds - these will always fail due to fakeHash usage - # but allow computation of the correct hash from any build machine for any cpu/os - # see the update-nix-hashes workflow for usage - moduleUpdaters = pkgs.lib.listToAttrs ( - pkgs.lib.concatMap (cpu: - map (os: { - name = "${cpu}-${os}_node_modules"; - value = node_modules.override { - bunCpu = cpuMap.${cpu}; - bunOs = os; - hash = pkgs.lib.fakeHash; - }; - }) [ "linux" "darwin" ] - ) [ "x86_64" "aarch64" ] - ); in { default = opencode; inherit opencode desktop; - } // moduleUpdaters + # Updater derivation with fakeHash - build fails and reveals correct hash + node_modules_updater = node_modules.override { + hash = pkgs.lib.fakeHash; + }; + } ); }; } diff --git a/nix/node_modules.nix b/nix/node_modules.nix index 981a60ef9ba..6d75b9e7505 100644 --- a/nix/node_modules.nix +++ b/nix/node_modules.nix @@ -2,8 +2,6 @@ lib, stdenvNoCC, bun, - bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64", - bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin", rev ? "dirty", hash ? (lib.pipe ./hashes.json [ @@ -16,6 +14,9 @@ let builtins.readFile builtins.fromJSON ]; + platform = stdenvNoCC.hostPlatform; + bunCpu = if platform.isAarch64 then "arm64" else "x64"; + bunOs = if platform.isLinux then "linux" else "darwin"; in stdenvNoCC.mkDerivation { pname = "opencode-node_modules"; @@ -39,9 +40,7 @@ stdenvNoCC.mkDerivation { "SOCKS_SERVER" ]; - nativeBuildInputs = [ - bun - ]; + nativeBuildInputs = [ bun ]; dontConfigure = true; @@ -63,10 +62,8 @@ stdenvNoCC.mkDerivation { installPhase = '' runHook preInstall - mkdir -p $out find . -type d -name node_modules -exec cp -R --parents {} $out \; - runHook postInstall ''; From a19ef17bcbedc42b802c4de1edf5df4991d942f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 31 Jan 2026 19:35:23 +0100 Subject: [PATCH 008/148] fix(provider): use process.env directly for runtime env mutations (#11482) --- packages/opencode/src/provider/provider.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e79cb170894..e01c583ff34 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -195,11 +195,13 @@ export namespace Provider { const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID") + // TODO: Using process.env directly because Env.set only updates a process.env shallow copy, + // until the scope of the Env API is clarified (test only or runtime?) const awsBearerToken = iife(() => { - const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK") + const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK if (envToken) return envToken if (auth?.type === "api") { - Env.set("AWS_BEARER_TOKEN_BEDROCK", auth.key) + process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key return auth.key } return undefined @@ -376,17 +378,19 @@ export namespace Provider { }, "sap-ai-core": async () => { const auth = await Auth.get("sap-ai-core") + // TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env), + // until the scope of the Env API is clarified (test only or runtime?) const envServiceKey = iife(() => { - const envAICoreServiceKey = Env.get("AICORE_SERVICE_KEY") + const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY if (envAICoreServiceKey) return envAICoreServiceKey if (auth?.type === "api") { - Env.set("AICORE_SERVICE_KEY", auth.key) + process.env.AICORE_SERVICE_KEY = auth.key return auth.key } return undefined }) - const deploymentId = Env.get("AICORE_DEPLOYMENT_ID") - const resourceGroup = Env.get("AICORE_RESOURCE_GROUP") + const deploymentId = process.env.AICORE_DEPLOYMENT_ID + const resourceGroup = process.env.AICORE_RESOURCE_GROUP return { autoload: !!envServiceKey, From da7c874808b82544a9eb5981eb9ba83106b74378 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 31 Jan 2026 13:15:42 -0600 Subject: [PATCH 009/148] tweak: show actual retry error message instead of generic error msg (#11520) --- packages/opencode/src/session/retry.ts | 8 +------- packages/opencode/test/session/retry.test.ts | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index dddeae1cf3d..a71a6a38241 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -89,13 +89,7 @@ export namespace SessionRetry { if (json.type === "error" && json.error?.code?.includes("rate_limit")) { return "Rate Limited" } - if ( - json.error?.message?.includes("no_kv_space") || - (json.type === "error" && json.error?.type === "server_error") || - !!json.error - ) { - return "Provider Server Error" - } + return JSON.stringify(json) } catch { return undefined } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 9cfdd7fca53..a483a015271 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -99,7 +99,7 @@ describe("session.retry.retryable", () => { test("handles json messages without code", () => { const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } })) - expect(SessionRetry.retryable(error)).toBe("Provider Server Error") + expect(SessionRetry.retryable(error)).toBe(`{"error":{"message":"no_kv_space"}}`) }) test("does not throw on numeric error codes", () => { From e70d984320571597f89421d85c2f74009951027c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 31 Jan 2026 14:42:36 -0500 Subject: [PATCH 010/148] tui: enable password authentication for remote session attachment Allow users to authenticate when attaching to a remote OpenCode session by supporting basic auth via a password flag or OPENCODE_SERVER_PASSWORD environment variable --- packages/opencode/src/cli/cmd/tui/app.tsx | 2 ++ packages/opencode/src/cli/cmd/tui/attach.ts | 21 +++++++++++++++---- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 9 +++++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 10d7a25f88f..713def2e5af 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -104,6 +104,7 @@ export function tui(input: { args: Args directory?: string fetch?: typeof fetch + headers?: RequestInit["headers"] events?: EventSource onExit?: () => Promise }) { @@ -130,6 +131,7 @@ export function tui(input: { url={input.url} directory={input.directory} fetch={input.fetch} + headers={input.headers} events={input.events} > diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index f641ff09884..e852cb73d4c 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -19,21 +19,34 @@ export const AttachCommand = cmd({ alias: ["s"], type: "string", describe: "session id to continue", + }) + .option("password", { + alias: ["p"], + type: "string", + describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", }), handler: async (args) => { - let directory = args.dir - if (args.dir) { + const directory = (() => { + if (!args.dir) return undefined try { process.chdir(args.dir) - directory = process.cwd() + return process.cwd() } catch { // If the directory doesn't exist locally (remote attach), pass it through. + return args.dir } - } + })() + const headers = (() => { + const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` + return { Authorization: auth } + })() await tui({ url: args.url, args: { sessionID: args.session }, directory, + headers, }) }, }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 3339e7b00d2..7fa7e05c3d2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -9,13 +9,20 @@ export type EventSource = { export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", - init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => { + init: (props: { + url: string + directory?: string + fetch?: typeof fetch + headers?: RequestInit["headers"] + events?: EventSource + }) => { const abort = new AbortController() const sdk = createOpencodeClient({ baseUrl: props.url, signal: abort.signal, directory: props.directory, fetch: props.fetch, + headers: props.headers, }) const emitter = createGlobalEmitter<{ From 33252a65b408ddec45bcd063c535c2170ce413c6 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Sat, 31 Jan 2026 21:01:21 +0100 Subject: [PATCH 011/148] test(app): general settings, shortcuts, providers and status popover (#11517) --- packages/app/e2e/AGENTS.md | 176 ++++++++++ packages/app/e2e/actions.ts | 22 ++ packages/app/e2e/fixtures.ts | 3 + packages/app/e2e/selectors.ts | 13 + .../e2e/settings/settings-keybinds.spec.ts | 317 ++++++++++++++++++ .../e2e/settings/settings-language.spec.ts | 28 -- .../app/e2e/settings/settings-models.spec.ts | 122 +++++++ .../e2e/settings/settings-providers.spec.ts | 138 +++++++- packages/app/e2e/settings/settings.spec.ts | 275 ++++++++++++++- .../app/e2e/status/status-popover.spec.ts | 94 ++++++ .../app/src/components/settings-general.tsx | 58 ++-- .../app/src/components/settings-keybinds.tsx | 1 + .../app/src/components/settings-providers.tsx | 7 +- 13 files changed, 1186 insertions(+), 68 deletions(-) create mode 100644 packages/app/e2e/AGENTS.md create mode 100644 packages/app/e2e/settings/settings-keybinds.spec.ts delete mode 100644 packages/app/e2e/settings/settings-language.spec.ts create mode 100644 packages/app/e2e/settings/settings-models.spec.ts create mode 100644 packages/app/e2e/status/status-popover.spec.ts diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md new file mode 100644 index 00000000000..59662dbea56 --- /dev/null +++ b/packages/app/e2e/AGENTS.md @@ -0,0 +1,176 @@ +# E2E Testing Guide + +## Build/Lint/Test Commands + +```bash +# Run all e2e tests +bun test:e2e + +# Run specific test file +bun test:e2e -- app/home.spec.ts + +# Run single test by title +bun test:e2e -- -g "home renders and shows core entrypoints" + +# Run tests with UI mode (for debugging) +bun test:e2e:ui + +# Run tests locally with full server setup +bun test:e2e:local + +# View test report +bun test:e2e:report + +# Typecheck +bun typecheck +``` + +## Test Structure + +All tests live in `packages/app/e2e/`: + +``` +e2e/ +├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk) +├── actions.ts # Reusable action helpers +├── selectors.ts # DOM selectors +├── utils.ts # Utilities (serverUrl, modKey, path helpers) +└── [feature]/ + └── *.spec.ts # Test files +``` + +## Test Patterns + +### Basic Test Structure + +```typescript +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { withSession } from "../actions" + +test("test description", async ({ page, sdk, gotoSession }) => { + await gotoSession() // or gotoSession(sessionID) + + // Your test code + await expect(page.locator(promptSelector)).toBeVisible() +}) +``` + +### Using Fixtures + +- `page` - Playwright page +- `sdk` - OpenCode SDK client for API calls +- `gotoSession(sessionID?)` - Navigate to session + +### Helper Functions + +**Actions** (`actions.ts`): + +- `openPalette(page)` - Open command palette +- `openSettings(page)` - Open settings dialog +- `closeDialog(page, dialog)` - Close any dialog +- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar +- `withSession(sdk, title, callback)` - Create temp session +- `clickListItem(container, filter)` - Click list item by key/text + +**Selectors** (`selectors.ts`): + +- `promptSelector` - Prompt input +- `terminalSelector` - Terminal panel +- `sessionItemSelector(id)` - Session in sidebar +- `listItemSelector` - Generic list items + +**Utils** (`utils.ts`): + +- `modKey` - Meta (Mac) or Control (Linux/Win) +- `serverUrl` - Backend server URL +- `sessionPath(dir, id?)` - Build session URL + +## Code Style Guidelines + +### Imports + +Always import from `../fixtures`, not `@playwright/test`: + +```typescript +// ✅ Good +import { test, expect } from "../fixtures" + +// ❌ Bad +import { test, expect } from "@playwright/test" +``` + +### Naming Conventions + +- Test files: `feature-name.spec.ts` +- Test names: lowercase, descriptive: `"sidebar can be toggled"` +- Variables: camelCase +- Constants: SCREAMING_SNAKE_CASE + +### Error Handling + +Tests should clean up after themselves: + +```typescript +test("test with cleanup", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "test session", async (session) => { + await gotoSession(session.id) + // Test code... + }) // Auto-deletes session +}) +``` + +### Timeouts + +Default: 60s per test, 10s per assertion. Override when needed: + +```typescript +test.setTimeout(120_000) // For long LLM operations +test("slow test", async () => { + await expect.poll(() => check(), { timeout: 90_000 }).toBe(true) +}) +``` + +### Selectors + +Use `data-component`, `data-action`, or semantic roles: + +```typescript +// ✅ Good +await page.locator('[data-component="prompt-input"]').click() +await page.getByRole("button", { name: "Open settings" }).click() + +// ❌ Bad +await page.locator(".css-class-name").click() +await page.locator("#id-name").click() +``` + +### Keyboard Shortcuts + +Use `modKey` for cross-platform compatibility: + +```typescript +import { modKey } from "../utils" + +await page.keyboard.press(`${modKey}+B`) // Toggle sidebar +await page.keyboard.press(`${modKey}+Comma`) // Open settings +``` + +## Writing New Tests + +1. Choose appropriate folder or create new one +2. Import from `../fixtures` +3. Use helper functions from `../actions` and `../selectors` +4. Clean up any created resources +5. Use specific selectors (avoid CSS classes) +6. Test one feature per test file + +## Local Development + +For UI debugging, use: + +```bash +bun test:e2e:ui +``` + +This opens Playwright's interactive UI for step-through debugging. diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 7308adc7e69..1eb2da1db7b 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -269,3 +269,25 @@ export async function withSession( await sdk.session.delete({ sessionID: session.id }).catch(() => undefined) } } + +export async function openStatusPopover(page: Page) { + await defocus(page) + + const rightSection = page.locator(titlebarRightSelector) + const trigger = rightSection.getByRole("button", { name: /status/i }).first() + + const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') }) + + const opened = await popoverBody + .isVisible() + .then((x) => x) + .catch(() => false) + + if (!opened) { + await expect(trigger).toBeVisible() + await trigger.click() + await expect(popoverBody).toBeVisible() + } + + return { rightSection, popoverBody } +} diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 0c3150609b3..e28b4cef45a 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -3,6 +3,9 @@ import { seedProjects } from "./actions" import { promptSelector } from "./selectors" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" + +export const settingsKey = "settings.v3" + type TestFixtures = { sdk: ReturnType gotoSession: (sessionID?: string) => Promise diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 8beade57303..90cfef8db98 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -3,6 +3,17 @@ export const terminalSelector = '[data-component="terminal"]' export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' export const settingsLanguageSelectSelector = '[data-action="settings-language"]' +export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]' +export const settingsThemeSelector = '[data-action="settings-theme"]' +export const settingsFontSelector = '[data-action="settings-font"]' +export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]' +export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]' +export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]' +export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]' +export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]' +export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]' +export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]' +export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]' export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]' @@ -33,3 +44,5 @@ export const listItemSelector = '[data-slot="list-item"]' export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]` export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]` + +export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]` diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts new file mode 100644 index 00000000000..eceb82b7414 --- /dev/null +++ b/packages/app/e2e/settings/settings-keybinds.spec.ts @@ -0,0 +1,317 @@ +import { test, expect } from "../fixtures" +import { openSettings, closeDialog, withSession } from "../actions" +import { keybindButtonSelector } from "../selectors" +import { modKey } from "../utils" + +test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + await dialog.getByRole("tab", { name: "Shortcuts" }).click() + + const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")) + await expect(keybindButton).toBeVisible() + + const initialKeybind = await keybindButton.textContent() + expect(initialKeybind).toContain("B") + + await keybindButton.click() + await expect(keybindButton).toHaveText(/press/i) + + await page.keyboard.press(`${modKey}+Shift+KeyH`) + await page.waitForTimeout(100) + + const newKeybind = await keybindButton.textContent() + expect(newKeybind).toContain("H") + + const stored = await page.evaluate(() => { + const raw = localStorage.getItem("settings.v3") + return raw ? JSON.parse(raw) : null + }) + expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h") + + await closeDialog(page, dialog) + + const main = page.locator("main") + const initialClasses = (await main.getAttribute("class")) ?? "" + const initiallyClosed = initialClasses.includes("xl:border-l") + + await page.keyboard.press(`${modKey}+Shift+H`) + await page.waitForTimeout(100) + + const afterToggleClasses = (await main.getAttribute("class")) ?? "" + const afterToggleClosed = afterToggleClasses.includes("xl:border-l") + expect(afterToggleClosed).toBe(!initiallyClosed) + + await page.keyboard.press(`${modKey}+Shift+H`) + await page.waitForTimeout(100) + + const finalClasses = (await main.getAttribute("class")) ?? "" + const finalClosed = finalClasses.includes("xl:border-l") + expect(finalClosed).toBe(initiallyClosed) +}) + +test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => { + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } })) + }) + + await gotoSession() + + const dialog = await openSettings(page) + await dialog.getByRole("tab", { name: "Shortcuts" }).click() + + const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")) + await expect(keybindButton).toBeVisible() + + const customKeybind = await keybindButton.textContent() + expect(customKeybind).toContain("X") + + const resetButton = dialog.getByRole("button", { name: "Reset to defaults" }) + await expect(resetButton).toBeVisible() + await expect(resetButton).toBeEnabled() + await resetButton.click() + await page.waitForTimeout(100) + + const restoredKeybind = await keybindButton.textContent() + expect(restoredKeybind).toContain("B") + + const stored = await page.evaluate(() => { + const raw = localStorage.getItem("settings.v3") + return raw ? JSON.parse(raw) : null + }) + expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined() + + await closeDialog(page, dialog) +}) + +test("clearing a keybind works", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + await dialog.getByRole("tab", { name: "Shortcuts" }).click() + + const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")) + await expect(keybindButton).toBeVisible() + + const initialKeybind = await keybindButton.textContent() + expect(initialKeybind).toContain("B") + + await keybindButton.click() + await expect(keybindButton).toHaveText(/press/i) + + await page.keyboard.press("Delete") + await page.waitForTimeout(100) + + const clearedKeybind = await keybindButton.textContent() + expect(clearedKeybind).toMatch(/unassigned|press/i) + + const stored = await page.evaluate(() => { + const raw = localStorage.getItem("settings.v3") + return raw ? JSON.parse(raw) : null + }) + expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none") + + await closeDialog(page, dialog) + + await page.keyboard.press(`${modKey}+B`) + await page.waitForTimeout(100) + + const stillOnSession = page.url().includes("/session") + expect(stillOnSession).toBe(true) +}) + +test("changing settings open keybind works", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + await dialog.getByRole("tab", { name: "Shortcuts" }).click() + + const keybindButton = dialog.locator(keybindButtonSelector("settings.open")) + await expect(keybindButton).toBeVisible() + + const initialKeybind = await keybindButton.textContent() + expect(initialKeybind).toContain(",") + + await keybindButton.click() + await expect(keybindButton).toHaveText(/press/i) + + await page.keyboard.press(`${modKey}+Slash`) + await page.waitForTimeout(100) + + const newKeybind = await keybindButton.textContent() + expect(newKeybind).toContain("/") + + const stored = await page.evaluate(() => { + const raw = localStorage.getItem("settings.v3") + return raw ? JSON.parse(raw) : null + }) + expect(stored?.keybinds?.["settings.open"]).toBe("mod+/") + + await closeDialog(page, dialog) + + const settingsDialog = page.getByRole("dialog") + await expect(settingsDialog).toHaveCount(0) + + await page.keyboard.press(`${modKey}+Slash`) + await page.waitForTimeout(100) + + await expect(settingsDialog).toBeVisible() + + await closeDialog(page, settingsDialog) +}) + +test("changing new session keybind works", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "test session for keybind", async (session) => { + await gotoSession(session.id) + + const initialUrl = page.url() + expect(initialUrl).toContain(`/session/${session.id}`) + + const dialog = await openSettings(page) + await dialog.getByRole("tab", { name: "Shortcuts" }).click() + + const keybindButton = dialog.locator(keybindButtonSelector("session.new")) + await expect(keybindButton).toBeVisible() + + await keybindButton.click() + await expect(keybindButton).toHaveText(/press/i) + + await page.keyboard.press(`${modKey}+Shift+KeyN`) + await page.waitForTimeout(100) + + const newKeybind = await keybindButton.textContent() + expect(newKeybind).toContain("N") + + const stored = await page.evaluate(() => { + const raw = localStorage.getItem("settings.v3") + return raw ? JSON.parse(raw) : null + }) + expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n") + + await closeDialog(page, dialog) + + await page.keyboard.press(`${modKey}+Shift+N`) + await page.waitForTimeout(200) + + const newUrl = page.url() + expect(newUrl).toMatch(/\/session\/?$/) + expect(newUrl).not.toContain(session.id) + }) +}) + +test("changing file open keybind works", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + await dialog.getByRole("tab", { name: "Shortcuts" }).click() + + const keybindButton = dialog.locator(keybindButtonSelector("file.open")) + await expect(keybindButton).toBeVisible() + + const initialKeybind = await keybindButton.textContent() + expect(initialKeybind).toContain("P") + + await keybindButton.click() + await expect(keybindButton).toHaveText(/press/i) + + await page.keyboard.press(`${modKey}+Shift+KeyF`) + await page.waitForTimeout(100) + + const newKeybind = await keybindButton.textContent() + expect(newKeybind).toContain("F") + + const stored = await page.evaluate(() => { + const raw = localStorage.getItem("settings.v3") + return raw ? JSON.parse(raw) : null + }) + expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f") + + await closeDialog(page, dialog) + + const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) }) + await expect(filePickerDialog).toHaveCount(0) + + await page.keyboard.press(`${modKey}+Shift+F`) + await page.waitForTimeout(100) + + await expect(filePickerDialog).toBeVisible() + + await page.keyboard.press("Escape") + await expect(filePickerDialog).toHaveCount(0) +}) + +test("changing terminal toggle keybind works", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + await dialog.getByRole("tab", { name: "Shortcuts" }).click() + + const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle")) + await expect(keybindButton).toBeVisible() + + await keybindButton.click() + await expect(keybindButton).toHaveText(/press/i) + + await page.keyboard.press(`${modKey}+KeyY`) + await page.waitForTimeout(100) + + const newKeybind = await keybindButton.textContent() + expect(newKeybind).toContain("Y") + + const stored = await page.evaluate(() => { + const raw = localStorage.getItem("settings.v3") + return raw ? JSON.parse(raw) : null + }) + expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y") + + await closeDialog(page, dialog) + + await page.keyboard.press(`${modKey}+Y`) + await page.waitForTimeout(100) + + const pageStable = await page.evaluate(() => document.readyState === "complete") + expect(pageStable).toBe(true) +}) + +test("changing command palette keybind works", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + await dialog.getByRole("tab", { name: "Shortcuts" }).click() + + const keybindButton = dialog.locator(keybindButtonSelector("command.palette")) + await expect(keybindButton).toBeVisible() + + const initialKeybind = await keybindButton.textContent() + expect(initialKeybind).toContain("P") + + await keybindButton.click() + await expect(keybindButton).toHaveText(/press/i) + + await page.keyboard.press(`${modKey}+Shift+KeyK`) + await page.waitForTimeout(100) + + const newKeybind = await keybindButton.textContent() + expect(newKeybind).toContain("K") + + const stored = await page.evaluate(() => { + const raw = localStorage.getItem("settings.v3") + return raw ? JSON.parse(raw) : null + }) + expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k") + + await closeDialog(page, dialog) + + const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() }) + await expect(palette).toHaveCount(0) + + await page.keyboard.press(`${modKey}+Shift+K`) + await page.waitForTimeout(100) + + await expect(palette).toBeVisible() + await expect(palette.getByRole("textbox").first()).toBeVisible() + + await page.keyboard.press("Escape") + await expect(palette).toHaveCount(0) +}) diff --git a/packages/app/e2e/settings/settings-language.spec.ts b/packages/app/e2e/settings/settings-language.spec.ts deleted file mode 100644 index b326a7d81ca..00000000000 --- a/packages/app/e2e/settings/settings-language.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { test, expect } from "../fixtures" -import { settingsLanguageSelectSelector } from "../selectors" -import { openSettings } from "../actions" - -test("smoke changing language updates settings labels", async ({ page, gotoSession }) => { - await page.addInitScript(() => { - localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" })) - }) - - await gotoSession() - - const dialog = await openSettings(page) - - const heading = dialog.getByRole("heading", { level: 2 }) - await expect(heading).toHaveText("General") - - const select = dialog.locator(settingsLanguageSelectSelector) - await expect(select).toBeVisible() - await select.locator('[data-slot="select-select-trigger"]').click() - - await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click() - - await expect(heading).toHaveText("Allgemein") - - await select.locator('[data-slot="select-select-trigger"]').click() - await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click() - await expect(heading).toHaveText("General") -}) diff --git a/packages/app/e2e/settings/settings-models.spec.ts b/packages/app/e2e/settings/settings-models.spec.ts new file mode 100644 index 00000000000..f7397abe867 --- /dev/null +++ b/packages/app/e2e/settings/settings-models.spec.ts @@ -0,0 +1,122 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { closeDialog, openSettings } from "../actions" + +test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => { + await gotoSession() + + await page.locator(promptSelector).click() + await page.keyboard.type("/model") + + const command = page.locator('[data-slash-id="model.choose"]') + await expect(command).toBeVisible() + await command.hover() + await page.keyboard.press("Enter") + + const picker = page.getByRole("dialog") + await expect(picker).toBeVisible() + + const target = picker.locator('[data-slot="list-item"]').first() + await expect(target).toBeVisible() + + const key = await target.getAttribute("data-key") + if (!key) throw new Error("Failed to resolve model key from list item") + + const name = (await target.locator("span").first().innerText()).trim() + if (!name) throw new Error("Failed to resolve model name from list item") + + await page.keyboard.press("Escape") + await expect(picker).toHaveCount(0) + + const settings = await openSettings(page) + + await settings.getByRole("tab", { name: "Models" }).click() + const search = settings.getByPlaceholder("Search models") + await expect(search).toBeVisible() + await search.fill(name) + + const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first() + const input = toggle.locator('[data-slot="switch-input"]') + await expect(toggle).toBeVisible() + await expect(input).toHaveAttribute("aria-checked", "true") + await toggle.locator('[data-slot="switch-control"]').click() + await expect(input).toHaveAttribute("aria-checked", "false") + + await closeDialog(page, settings) + + await page.locator(promptSelector).click() + await page.keyboard.type("/model") + await expect(command).toBeVisible() + await command.hover() + await page.keyboard.press("Enter") + + const pickerAgain = page.getByRole("dialog") + await expect(pickerAgain).toBeVisible() + await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible() + + await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0) + + await page.keyboard.press("Escape") + await expect(pickerAgain).toHaveCount(0) +}) + +test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => { + await gotoSession() + + await page.locator(promptSelector).click() + await page.keyboard.type("/model") + + const command = page.locator('[data-slash-id="model.choose"]') + await expect(command).toBeVisible() + await command.hover() + await page.keyboard.press("Enter") + + const picker = page.getByRole("dialog") + await expect(picker).toBeVisible() + + const target = picker.locator('[data-slot="list-item"]').first() + await expect(target).toBeVisible() + + const key = await target.getAttribute("data-key") + if (!key) throw new Error("Failed to resolve model key from list item") + + const name = (await target.locator("span").first().innerText()).trim() + if (!name) throw new Error("Failed to resolve model name from list item") + + await page.keyboard.press("Escape") + await expect(picker).toHaveCount(0) + + const settings = await openSettings(page) + + await settings.getByRole("tab", { name: "Models" }).click() + const search = settings.getByPlaceholder("Search models") + await expect(search).toBeVisible() + await search.fill(name) + + const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first() + const input = toggle.locator('[data-slot="switch-input"]') + await expect(toggle).toBeVisible() + await expect(input).toHaveAttribute("aria-checked", "true") + + await toggle.locator('[data-slot="switch-control"]').click() + await expect(input).toHaveAttribute("aria-checked", "false") + + await toggle.locator('[data-slot="switch-control"]').click() + await expect(input).toHaveAttribute("aria-checked", "true") + + await closeDialog(page, settings) + + await page.locator(promptSelector).click() + await page.keyboard.type("/model") + await expect(command).toBeVisible() + await command.hover() + await page.keyboard.press("Enter") + + const pickerAgain = page.getByRole("dialog") + await expect(pickerAgain).toBeVisible() + + await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible() + + await page.keyboard.press("Escape") + await expect(pickerAgain).toHaveCount(0) +}) diff --git a/packages/app/e2e/settings/settings-providers.spec.ts b/packages/app/e2e/settings/settings-providers.spec.ts index 2bd2616bc9a..a55eb34981e 100644 --- a/packages/app/e2e/settings/settings-providers.spec.ts +++ b/packages/app/e2e/settings/settings-providers.spec.ts @@ -1,30 +1,136 @@ import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" -import { closeDialog, openSettings, clickListItem } from "../actions" +import { closeDialog, openSettings } from "../actions" -test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => { +test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => { await gotoSession() - const dialog = await openSettings(page) + const settings = await openSettings(page) + await settings.getByRole("tab", { name: "Providers" }).click() - await dialog.getByRole("tab", { name: "Providers" }).click() - await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible() - await expect(dialog.getByText("Popular providers", { exact: true })).toBeVisible() + const customProviderSection = settings.locator('[data-component="custom-provider-section"]') + await expect(customProviderSection).toBeVisible() - await dialog.getByRole("button", { name: "Show more providers" }).click() - - const providerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder("Search providers") }) + const connectButton = customProviderSection.getByRole("button", { name: "Connect" }) + await connectButton.click() + const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") }) await expect(providerDialog).toBeVisible() - await expect(providerDialog.getByPlaceholder("Search providers")).toBeVisible() - await expect(providerDialog.locator('[data-slot="list-item"]').first()).toBeVisible() + + await providerDialog.getByLabel("Provider ID").fill("test-provider") + await providerDialog.getByLabel("Display name").fill("Test Provider") + await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake") + await providerDialog.getByLabel("API key").fill("fake-key") + + await providerDialog.getByPlaceholder("model-id").first().fill("test-model") + await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model") + + await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider") + await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider") + await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake") + await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key") + await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model") + await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model") await page.keyboard.press("Escape") await expect(providerDialog).toHaveCount(0) - await expect(page.locator(promptSelector)).toBeVisible() - const stillOpen = await dialog.isVisible().catch(() => false) - if (!stillOpen) return + await closeDialog(page, settings) +}) - await closeDialog(page, dialog) +test("custom provider form shows validation errors", async ({ page, gotoSession }) => { + await gotoSession() + + const settings = await openSettings(page) + await settings.getByRole("tab", { name: "Providers" }).click() + + const customProviderSection = settings.locator('[data-component="custom-provider-section"]') + await customProviderSection.getByRole("button", { name: "Connect" }).click() + + const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") }) + await expect(providerDialog).toBeVisible() + + await providerDialog.getByLabel("Provider ID").fill("invalid provider id") + await providerDialog.getByLabel("Base URL").fill("not-a-url") + + await providerDialog.getByRole("button", { name: /submit|save/i }).click() + + await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible() + await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible() + + await page.keyboard.press("Escape") + await expect(providerDialog).toHaveCount(0) + + await closeDialog(page, settings) +}) + +test("custom provider form can add and remove models", async ({ page, gotoSession }) => { + await gotoSession() + + const settings = await openSettings(page) + await settings.getByRole("tab", { name: "Providers" }).click() + + const customProviderSection = settings.locator('[data-component="custom-provider-section"]') + await customProviderSection.getByRole("button", { name: "Connect" }).click() + + const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") }) + await expect(providerDialog).toBeVisible() + + await providerDialog.getByLabel("Provider ID").fill("multi-model-test") + await providerDialog.getByLabel("Display name").fill("Multi Model Test") + await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi") + + await providerDialog.getByPlaceholder("model-id").first().fill("model-1") + await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1") + + const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count() + await providerDialog.getByRole("button", { name: "Add model" }).click() + const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count() + expect(idInputsAfter).toBe(idInputsBefore + 1) + + await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2") + await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2") + + await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2") + await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2") + + await page.keyboard.press("Escape") + await expect(providerDialog).toHaveCount(0) + + await closeDialog(page, settings) +}) + +test("custom provider form can add and remove headers", async ({ page, gotoSession }) => { + await gotoSession() + + const settings = await openSettings(page) + await settings.getByRole("tab", { name: "Providers" }).click() + + const customProviderSection = settings.locator('[data-component="custom-provider-section"]') + await customProviderSection.getByRole("button", { name: "Connect" }).click() + + const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") }) + await expect(providerDialog).toBeVisible() + + await providerDialog.getByLabel("Provider ID").fill("header-test") + await providerDialog.getByLabel("Display name").fill("Header Test") + await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers") + + await providerDialog.getByPlaceholder("model-id").first().fill("model-x") + await providerDialog.getByPlaceholder("Display Name").first().fill("Model X") + + const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count() + await providerDialog.getByRole("button", { name: "Add header" }).click() + const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count() + expect(headerInputsAfter).toBe(headerInputsBefore + 1) + + await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization") + await providerDialog.getByPlaceholder("value").first().fill("Bearer token123") + + await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization") + await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123") + + await page.keyboard.press("Escape") + await expect(providerDialog).toHaveCount(0) + + await closeDialog(page, settings) }) diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index 55b76707636..d9cd449b4a6 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "../fixtures" +import { test, expect, settingsKey } from "../fixtures" import { closeDialog, openSettings } from "../actions" +import { settingsColorSchemeSelector, settingsFontSelector, settingsLanguageSelectSelector, settingsNotificationsAgentSelector, settingsNotificationsErrorsSelector, settingsNotificationsPermissionsSelector, settingsReleaseNotesSelector, settingsSoundsAgentSelector, settingsThemeSelector, settingsUpdatesStartupSelector } from "../selectors" test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => { await gotoSession() @@ -12,3 +13,275 @@ test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSe await closeDialog(page, dialog) }) + + +test("changing language updates settings labels", async ({ page, gotoSession }) => { + await page.addInitScript(() => { + localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" })) + }) + + await gotoSession() + + const dialog = await openSettings(page) + + const heading = dialog.getByRole("heading", { level: 2 }) + await expect(heading).toHaveText("General") + + const select = dialog.locator(settingsLanguageSelectSelector) + await expect(select).toBeVisible() + await select.locator('[data-slot="select-select-trigger"]').click() + + await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click() + + await expect(heading).toHaveText("Allgemein") + + await select.locator('[data-slot="select-select-trigger"]').click() + await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click() + await expect(heading).toHaveText("General") +}) + +test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + const select = dialog.locator(settingsColorSchemeSelector) + await expect(select).toBeVisible() + + await select.locator('[data-slot="select-select-trigger"]').click() + await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click() + + const colorScheme = await page.evaluate(() => { + return document.documentElement.getAttribute("data-color-scheme") + }) + expect(colorScheme).toBe("dark") + + await select.locator('[data-slot="select-select-trigger"]').click() + await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click() + + const lightColorScheme = await page.evaluate(() => { + return document.documentElement.getAttribute("data-color-scheme") + }) + expect(lightColorScheme).toBe("light") +}) + +test("changing theme persists in localStorage", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + const select = dialog.locator(settingsThemeSelector) + await expect(select).toBeVisible() + + await select.locator('[data-slot="select-select-trigger"]').click() + + const items = page.locator('[data-slot="select-select-item"]') + const count = await items.count() + expect(count).toBeGreaterThan(1) + + const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent() + expect(firstTheme).toBeTruthy() + + await items.nth(1).click() + + await page.keyboard.press("Escape") + + const storedThemeId = await page.evaluate(() => { + return localStorage.getItem("opencode-theme-id") + }) + + expect(storedThemeId).not.toBeNull() + expect(storedThemeId).not.toBe("oc-1") + + const dataTheme = await page.evaluate(() => { + return document.documentElement.getAttribute("data-theme") + }) + expect(dataTheme).toBe(storedThemeId) +}) + +test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => { + + await gotoSession() + + const dialog = await openSettings(page) + const select = dialog.locator(settingsFontSelector) + await expect(select).toBeVisible() + + const initialFontFamily = await page.evaluate(() => { + return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono") + }) + expect(initialFontFamily).toContain("IBM Plex Mono") + + await select.locator('[data-slot="select-select-trigger"]').click() + + const items = page.locator('[data-slot="select-select-item"]') + await items.nth(2).click() + + await page.waitForTimeout(100) + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, settingsKey) + + expect(stored?.appearance?.font).not.toBe("ibm-plex-mono") + + const newFontFamily = await page.evaluate(() => { + return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono") + }) + expect(newFontFamily).not.toBe(initialFontFamily) +}) + +test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + const switchContainer = dialog.locator(settingsNotificationsAgentSelector) + await expect(switchContainer).toBeVisible() + + const toggleInput = switchContainer.locator('[data-slot="switch-input"]') + const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) + expect(initialState).toBe(true) + + await switchContainer.locator('[data-slot="switch-control"]').click() + await page.waitForTimeout(100) + + const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) + expect(newState).toBe(false) + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, settingsKey) + + expect(stored?.notifications?.agent).toBe(false) +}) + +test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => { + + await gotoSession() + + const dialog = await openSettings(page) + const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector) + await expect(switchContainer).toBeVisible() + + const toggleInput = switchContainer.locator('[data-slot="switch-input"]') + const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) + expect(initialState).toBe(true) + + await switchContainer.locator('[data-slot="switch-control"]').click() + await page.waitForTimeout(100) + + const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) + expect(newState).toBe(false) + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, settingsKey) + + expect(stored?.notifications?.permissions).toBe(false) +}) + +test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + const switchContainer = dialog.locator(settingsNotificationsErrorsSelector) + await expect(switchContainer).toBeVisible() + + const toggleInput = switchContainer.locator('[data-slot="switch-input"]') + const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) + expect(initialState).toBe(false) + + await switchContainer.locator('[data-slot="switch-control"]').click() + await page.waitForTimeout(100) + + const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) + expect(newState).toBe(true) + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, settingsKey) + + expect(stored?.notifications?.errors).toBe(true) +}) + +test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => { + + await gotoSession() + + const dialog = await openSettings(page) + const select = dialog.locator(settingsSoundsAgentSelector) + await expect(select).toBeVisible() + + await select.locator('[data-slot="select-select-trigger"]').click() + + const items = page.locator('[data-slot="select-select-item"]') + await items.nth(2).click() + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, settingsKey) + + expect(stored?.sounds?.agent).not.toBe("staplebops-01") +}) + +test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => { + + await gotoSession() + + const dialog = await openSettings(page) + const switchContainer = dialog.locator(settingsUpdatesStartupSelector) + await expect(switchContainer).toBeVisible() + + const toggleInput = switchContainer.locator('[data-slot="switch-input"]') + + const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled) + if (isDisabled) { + test.skip() + return + } + + const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) + expect(initialState).toBe(true) + + await switchContainer.locator('[data-slot="switch-control"]').click() + await page.waitForTimeout(100) + + const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) + expect(newState).toBe(false) + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, settingsKey) + + expect(stored?.updates?.startup).toBe(false) +}) + +test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => { + + await gotoSession() + + const dialog = await openSettings(page) + const switchContainer = dialog.locator(settingsReleaseNotesSelector) + await expect(switchContainer).toBeVisible() + + const toggleInput = switchContainer.locator('[data-slot="switch-input"]') + const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) + expect(initialState).toBe(true) + + await switchContainer.locator('[data-slot="switch-control"]').click() + await page.waitForTimeout(100) + + const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) + expect(newState).toBe(false) + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, settingsKey) + + expect(stored?.general?.releaseNotes).toBe(false) +}) diff --git a/packages/app/e2e/status/status-popover.spec.ts b/packages/app/e2e/status/status-popover.spec.ts new file mode 100644 index 00000000000..4334cecb44d --- /dev/null +++ b/packages/app/e2e/status/status-popover.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from "../fixtures" +import { openStatusPopover, defocus } from "../actions" + +test("status popover opens and shows tabs", async ({ page, gotoSession }) => { + await gotoSession() + + const { popoverBody } = await openStatusPopover(page) + + await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible() + await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible() + await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible() + await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible() + + await page.keyboard.press("Escape") + await expect(popoverBody).toHaveCount(0) +}) + +test("status popover servers tab shows current server", async ({ page, gotoSession }) => { + await gotoSession() + + const { popoverBody } = await openStatusPopover(page) + + const serversTab = popoverBody.getByRole("tab", { name: /servers/i }) + await expect(serversTab).toHaveAttribute("aria-selected", "true") + + const serverList = popoverBody.locator('[role="tabpanel"]').first() + await expect(serverList.locator("button").first()).toBeVisible() +}) + +test("status popover can switch to mcp tab", async ({ page, gotoSession }) => { + await gotoSession() + + const { popoverBody } = await openStatusPopover(page) + + const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i }) + await mcpTab.click() + + const ariaSelected = await mcpTab.getAttribute("aria-selected") + expect(ariaSelected).toBe("true") + + const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first() + await expect(mcpContent).toBeVisible() +}) + +test("status popover can switch to lsp tab", async ({ page, gotoSession }) => { + await gotoSession() + + const { popoverBody } = await openStatusPopover(page) + + const lspTab = popoverBody.getByRole("tab", { name: /lsp/i }) + await lspTab.click() + + const ariaSelected = await lspTab.getAttribute("aria-selected") + expect(ariaSelected).toBe("true") + + const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first() + await expect(lspContent).toBeVisible() +}) + +test("status popover can switch to plugins tab", async ({ page, gotoSession }) => { + await gotoSession() + + const { popoverBody } = await openStatusPopover(page) + + const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i }) + await pluginsTab.click() + + const ariaSelected = await pluginsTab.getAttribute("aria-selected") + expect(ariaSelected).toBe("true") + + const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first() + await expect(pluginsContent).toBeVisible() +}) + +test("status popover closes on escape", async ({ page, gotoSession }) => { + await gotoSession() + + const { popoverBody } = await openStatusPopover(page) + await expect(popoverBody).toBeVisible() + + await page.keyboard.press("Escape") + await expect(popoverBody).toHaveCount(0) +}) + +test("status popover closes when clicking outside", async ({ page, gotoSession }) => { + await gotoSession() + + const { popoverBody } = await openStatusPopover(page) + await expect(popoverBody).toBeVisible() + + await defocus(page) + + await expect(popoverBody).toHaveCount(0) +}) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b26f6ba2296..b31cfb6cc79 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -165,6 +165,7 @@ export const SettingsGeneral: Component = () => { description={language.t("settings.general.row.appearance.description")} > o.id === theme.themeId())} value={(o) => o.id} @@ -215,6 +217,7 @@ export const SettingsGeneral: Component = () => { description={language.t("settings.general.row.font.description")} > o.id === settings.sounds.agent())} value={(o) => o.id} @@ -306,6 +316,7 @@ export const SettingsGeneral: Component = () => { description={language.t("settings.general.sounds.permissions.description")} > o.id === settings.sounds.errors())} value={(o) => o.id} @@ -360,21 +372,25 @@ export const SettingsGeneral: Component = () => { title={language.t("settings.updates.row.startup.title")} description={language.t("settings.updates.row.startup.description")} > - settings.updates.setStartup(checked)} - /> +
+ settings.updates.setStartup(checked)} + /> +
- settings.general.setReleaseNotes(checked)} - /> +
+ settings.general.setReleaseNotes(checked)} + /> +
{ {title(id)} } @@ -1937,12 +1968,16 @@ export const PromptInput: Component = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + + {(open) => ( + <> + + + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + + + )} @@ -1955,10 +1990,13 @@ export const PromptInput: Component = (props) => { @@ -1972,7 +2010,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} classList={{ - "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, + "_hidden group-hover/prompt-input:flex items-center justify-center": true, "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), }} @@ -1994,7 +2032,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
@@ -2036,7 +2075,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.action.send")} - +
@@ -2047,7 +2086,7 @@ export const PromptInput: Component = (props) => { disabled={!prompt.dirty() && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="h-6 w-4.5" + class="h-6 w-5.5" aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> @@ -2162,4 +2201,4 @@ function setCursorPosition(parent: HTMLElement, position: number) { fallbackRange.collapse(false) fallbackSelection?.removeAllRanges() fallbackSelection?.addRange(fallbackRange) -} +} \ No newline at end of file diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b31cfb6cc79..e43b82e4a8e 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -60,24 +60,24 @@ export const SettingsGeneral: Component = () => { const actions = platform.update && platform.restart ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() - }, + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] showToast({ persistent: true, @@ -226,7 +226,7 @@ export const SettingsGeneral: Component = () => { variant="secondary" size="small" triggerVariant="settings" - triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} + triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }} > {(option) => ( diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index d9b34592304..3e5d21d1de8 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -9,7 +9,13 @@ user-select: none; cursor: default; outline: none; + padding: 4px 8px; white-space: nowrap; + transition-property: background-color, border-color, color, box-shadow, opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + outline: none; + line-height: 20px; &[data-variant="primary"] { background-color: var(--button-primary-base); @@ -94,7 +100,6 @@ &:active:not(:disabled) { background-color: var(--button-secondary-base); scale: 0.99; - transition: all 150ms ease-out; } &:disabled { border-color: var(--border-disabled); @@ -109,34 +114,31 @@ } &[data-size="small"] { - height: 22px; - padding: 0 8px; + padding: 4px 8px; &[data-icon] { - padding: 0 12px 0 4px; + padding: 4px 12px 4px 4px; } - font-size: var(--font-size-small); - line-height: var(--line-height-large); gap: 4px; /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); } &[data-size="normal"] { - height: 24px; - line-height: 24px; - padding: 0 6px; + padding: 4px 6px; &[data-icon] { - padding: 0 12px 0 4px; + padding: 4px 12px 4px 4px; + } + + &[aria-haspopup] { + padding: 4px 6px 4px 8px; } - font-size: var(--font-size-small); gap: 6px; /* text-12-medium */ @@ -148,7 +150,6 @@ } &[data-size="large"] { - height: 32px; padding: 6px 12px; &[data-icon] { diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 7f974b2f76e..1b110ca8afc 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon" export interface ButtonProps extends ComponentProps, - Pick, "class" | "classList" | "children"> { + Pick, "class" | "classList" | "children" | "style"> { size?: "small" | "normal" | "large" variant?: "primary" | "secondary" | "ghost" icon?: IconProps["name"] diff --git a/packages/ui/src/components/cycle-label.css b/packages/ui/src/components/cycle-label.css new file mode 100644 index 00000000000..3c98fcd261e --- /dev/null +++ b/packages/ui/src/components/cycle-label.css @@ -0,0 +1,49 @@ +.cycle-label { + --c-duration: 200ms; + --c-stagger: 30ms; + --c-opacity-start: 0; + --c-opacity-end: 1; + --c-blur-start: 0px; + --c-blur-end: 0px; + --c-skew: 10deg; + + display: inline-flex; + position: relative; + + transform-style: preserve-3d; + perspective: 500px; + transition: width var(--transition-duration) var(--transition-easing); + will-change: width; + overflow: hidden; + + .cycle-char { + display: inline-block; + transform-style: preserve-3d; + min-width: 0.25em; + backface-visibility: hidden; + + transition-property: transform, opacity, filter; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + transition-delay: calc(var(--i, 0) * var(--c-stagger)); + + &.enter { + opacity: var(--c-opacity-end); + filter: blur(var(--c-blur-end)); + transform: translateY(0) rotateX(0) skewX(0); + } + + &.exit { + opacity: var(--c-opacity-start); + filter: blur(var(--c-blur-start)); + transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew)); + } + + &.pre { + opacity: var(--c-opacity-start); + filter: blur(var(--c-blur-start)); + transition: none; + transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1)); + } + } +} diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx new file mode 100644 index 00000000000..f1eaa88f31c --- /dev/null +++ b/packages/ui/src/components/cycle-label.tsx @@ -0,0 +1,132 @@ +import "./cycle-label.css" +import { createEffect, createSignal, JSX, on } from "solid-js" + +export interface CycleLabelProps extends JSX.HTMLAttributes { + value: string + onValueChange?: (value: string) => void + duration?: number | ((value: string) => number) + stagger?: number + opacity?: [number, number] + blur?: [number, number] + skewX?: number + onAnimationStart?: () => void + onAnimationEnd?: () => void +} + +const segmenter = + typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null + +const getChars = (text: string): string[] => + segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("") + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +export function CycleLabel(props: CycleLabelProps) { + const getDuration = (text: string) => { + const d = props.duration ?? Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? 200 + return typeof d === "function" ? d(text) : d + } + const stagger = () => props?.stagger ?? 30 + const opacity = () => props?.opacity ?? [0, 1] + const blur = () => props?.blur ?? [0, 0] + const skewX = () => props?.skewX ?? 10 + + let containerRef: HTMLSpanElement | undefined + let isAnimating = false + const [currentText, setCurrentText] = createSignal(props.value) + + const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => { + el.innerHTML = "" + const chars = getChars(text) + chars.forEach((char, i) => { + const span = document.createElement("span") + span.textContent = char === " " ? "\u00A0" : char + span.className = `cycle-char ${state}` + span.style.setProperty("--i", String(i)) + el.appendChild(span) + }) + } + + const animateToText = async (newText: string) => { + if (!containerRef || isAnimating) return + if (newText === currentText()) return + + isAnimating = true + props.onAnimationStart?.() + + const dur = getDuration(newText) + const stag = stagger() + + containerRef.style.width = containerRef.offsetWidth + "px" + + const oldChars = containerRef.querySelectorAll(".cycle-char") + oldChars.forEach((c) => c.classList.replace("enter", "exit")) + + const clone = containerRef.cloneNode(false) as HTMLElement + Object.assign(clone.style, { + position: "absolute", + visibility: "hidden", + width: "auto", + transition: "none", + }) + setChars(clone, newText) + document.body.appendChild(clone) + const nextWidth = clone.offsetWidth + clone.remove() + + const exitTime = oldChars.length * stag + dur + await wait(exitTime * 0.3) + + containerRef.style.width = nextWidth + "px" + + const widthDur = 200 + await wait(widthDur * 0.3) + + setChars(containerRef, newText, "pre") + containerRef.offsetWidth + + Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter")) + setCurrentText(newText) + props.onValueChange?.(newText) + + const enterTime = getChars(newText).length * stag + dur + await wait(enterTime) + + containerRef.style.width = "" + isAnimating = false + props.onAnimationEnd?.() + } + + createEffect( + on( + () => props.value, + (newValue) => { + if (newValue !== currentText()) { + animateToText(newValue) + } + }, + ), + ) + + const initRef = (el: HTMLSpanElement) => { + containerRef = el + setChars(el, props.value) + } + + return ( + + ) +} \ No newline at end of file diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css index cba041613ea..18266ac1a1c 100644 --- a/packages/ui/src/components/dropdown-menu.css +++ b/packages/ui/src/components/dropdown-menu.css @@ -2,26 +2,29 @@ [data-component="dropdown-menu-sub-content"] { min-width: 8rem; overflow: hidden; + border: none; border-radius: var(--radius-md); - border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); + box-shadow: var(--shadow-xs-border); background-clip: padding-box; background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; - box-shadow: var(--shadow-md); - z-index: 50; + z-index: 100; transform-origin: var(--kb-menu-content-transform-origin); - &:focus, - &:focus-visible { + &:focus-within, + &:focus { outline: none; } - &[data-closed] { - animation: dropdown-menu-close 0.15s ease-out; + animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; } &[data-expanded] { - animation: dropdown-menu-open 0.15s ease-out; + pointer-events: auto; + animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards; } } @@ -38,18 +41,22 @@ padding: 4px 8px; border-radius: var(--radius-sm); cursor: default; - user-select: none; outline: none; font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: var(--font-size-base); font-weight: var(--font-weight-medium); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); - &[data-highlighted] { - background: var(--surface-raised-base-hover); + transition-property: background-color, color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + user-select: none; + + &:hover { + background-color: var(--surface-raised-base-hover); } &[data-disabled] { @@ -61,6 +68,8 @@ [data-slot="dropdown-menu-sub-trigger"] { &[data-expanded] { background: var(--surface-raised-base-hover); + outline: none; + border: none; } } @@ -102,24 +111,24 @@ } } -@keyframes dropdown-menu-open { +@keyframes dropdownMenuContentShow { from { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); } } -@keyframes dropdown-menu-close { +@keyframes dropdownMenuContentHide { from { opacity: 1; - transform: scale(1); + transform: scaleY(1); } to { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); } } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 544c6abdd21..f23357293ee 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -80,13 +80,13 @@ const icons = { export interface IconProps extends ComponentProps<"svg"> { name: keyof typeof icons - size?: "small" | "normal" | "medium" | "large" + size?: "small" | "normal" | "medium" | "large" | number } export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) return ( -
+
- +
m.id === perm.tool!.messageID) + const message = messages.findLast((m) => m.id === perm.tool!.messageID) if (!message) return undefined const parts = data.store.part[message.id] ?? [] for (const part of parts) { diff --git a/packages/ui/src/components/morph-chevron.css b/packages/ui/src/components/morph-chevron.css new file mode 100644 index 00000000000..f6edb3f649a --- /dev/null +++ b/packages/ui/src/components/morph-chevron.css @@ -0,0 +1,10 @@ +[data-slot="morph-chevron-svg"] { + width: 16px; + height: 16px; + display: block; + fill: none; + stroke-width: 1.5; + stroke: currentcolor; + stroke-linecap: round; + stroke-linejoin: round; +} diff --git a/packages/ui/src/components/morph-chevron.tsx b/packages/ui/src/components/morph-chevron.tsx new file mode 100644 index 00000000000..a14a2252b6a --- /dev/null +++ b/packages/ui/src/components/morph-chevron.tsx @@ -0,0 +1,73 @@ +import { createEffect, createUniqueId, on } from "solid-js" + +export interface MorphChevronProps { + expanded: boolean + class?: string +} + +const COLLAPSED = "M4 6L8 10L12 6" +const EXPANDED = "M4 10L8 6L12 10" + +export function MorphChevron(props: MorphChevronProps) { + const id = createUniqueId() + let path: SVGPathElement | undefined + let expandAnim: SVGAnimateElement | undefined + let collapseAnim: SVGAnimateElement | undefined + + createEffect( + on( + () => props.expanded, + (expanded, prev) => { + if (prev === undefined) { + // Set initial state without animation + path?.setAttribute("d", expanded ? EXPANDED : COLLAPSED) + return + } + if (expanded) { + expandAnim?.beginElement() + } else { + collapseAnim?.beginElement() + } + }, + ), + ) + + return ( + + ) +} \ No newline at end of file diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index b49542afd9b..d200fe8b247 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -15,16 +15,35 @@ transform-origin: var(--kb-popover-content-transform-origin); - &:focus-within { - outline: none; - } + animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; - &[data-closed] { - animation: popover-close 0.15s ease-out; + @starting-style { + animation: none; } &[data-expanded] { - animation: popover-open 0.15s ease-out; + pointer-events: auto; + animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; + } + + [data-origin-top-right] { + transform-origin: top right; + } + + [data-origin-top-left] { + transform-origin: top left; + } + + [data-origin-bottom-right] { + transform-origin: bottom right; + } + + [data-origin-bottom-left] { + transform-origin: bottom left; + } + + &:focus-within { + outline: none; } [data-slot="popover-header"] { @@ -75,24 +94,39 @@ } } -@keyframes popover-open { +@keyframes popoverContentShow { from { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); } } -@keyframes popover-close { +@keyframes popoverContentHide { from { opacity: 1; - transform: scale(1); + transform: scaleY(1); } to { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); + } +} + +[data-component="model-popover-content"] { + transform-origin: var(--kb-popper-content-transform-origin); + pointer-events: none; + animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; + } + + &[data-expanded] { + pointer-events: auto; + animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; } } diff --git a/packages/ui/src/components/reasoning-icon.css b/packages/ui/src/components/reasoning-icon.css new file mode 100644 index 00000000000..26fbc014489 --- /dev/null +++ b/packages/ui/src/components/reasoning-icon.css @@ -0,0 +1,9 @@ +[data-component="reasoning-icon"] { + color: var(--icon-strong-base); + + [data-slot="reasoning-icon-percentage"] { + transition: clip-path 200ms cubic-bezier(0.25, 0, 0.5, 1); + clip-path: inset(calc(100% - var(--reasoning-icon-percentage) * 100%) 0 0 0); + opacity: calc(var(--reasoning-icon-percentage) * 0.75); + } +} diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx new file mode 100644 index 00000000000..ba753b3b843 --- /dev/null +++ b/packages/ui/src/components/reasoning-icon.tsx @@ -0,0 +1,32 @@ +import { type ComponentProps, splitProps } from "solid-js" + +export interface ReasoningIconProps extends Pick, "class" | "classList"> { + percentage: number + size?: number + strokeWidth?: number +} + +export function ReasoningIcon(props: ReasoningIconProps) { + const [split, rest] = splitProps(props, ["percentage", "size", "strokeWidth", "class", "classList"]) + + const size = () => split.size || 16 + const strokeWidth = () => split.strokeWidth || 1.25 + + return ( + + + + + ) +} \ No newline at end of file diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index 25dd2eb40b6..eaba6fd6d2a 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -1,7 +1,13 @@ [data-component="select"] { [data-slot="select-select-trigger"] { - padding: 0 4px 0 8px; + display: flex; + padding: 4px 8px !important; + align-items: center; + justify-content: space-between; box-shadow: none; + transition-property: background-color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); [data-slot="select-select-trigger-value"] { overflow: hidden; @@ -15,10 +21,10 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: var(--text-weak); - transition: transform 0.1s ease-in-out; + color: var(--icon-base); } + &:hover, &[data-expanded] { &[data-variant="secondary"] { background-color: var(--button-secondary-hover); @@ -30,13 +36,13 @@ background-color: var(--icon-strong-active); } } - + &:not([data-expanded]):focus, &:not([data-expanded]):focus-visible { &[data-variant="secondary"] { background-color: var(--button-secondary-base); } &[data-variant="ghost"] { - background-color: var(--surface-raised-base-hover); + background-color: transparent; } &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -46,10 +52,10 @@ &[data-trigger-style="settings"] { [data-slot="select-select-trigger"] { - padding: 6px 6px 6px 12px; + padding: 6px 6px 6px 10px; box-shadow: none; border-radius: 6px; - min-width: 160px; + field-sizing: content; height: 32px; justify-content: flex-end; gap: 12px; @@ -61,6 +67,7 @@ white-space: nowrap; font-size: var(--font-size-base); font-weight: var(--font-weight-regular); + padding: 4px 8px 4px 4px; } [data-slot="select-select-trigger-icon"] { width: 16px; @@ -91,17 +98,26 @@ } [data-component="select-content"] { - min-width: 104px; + min-width: 8rem; max-width: 23rem; overflow: hidden; border-radius: var(--radius-md); background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; box-shadow: var(--shadow-xs-border); - z-index: 60; + z-index: 50; + transform-origin: var(--kb-popper-content-transform-origin); + pointer-events: none; + + animation: selectContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; + } &[data-expanded] { - animation: select-open 0.15s ease-out; + pointer-events: auto; + animation: selectContentShow var(--transition-duration) var(--transition-easing) forwards; } [data-slot="select-select-content-list"] { @@ -111,43 +127,38 @@ overflow-x: hidden; display: flex; flex-direction: column; - &:focus { outline: none; } - > *:not([role="presentation"]) + *:not([role="presentation"]) { margin-top: 2px; } } - [data-slot="select-select-item"] { position: relative; display: flex; align-items: center; - padding: 2px 8px; + padding: 4px 8px; gap: 12px; - border-radius: 4px; - cursor: default; + border-radius: var(--radius-sm); /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); - color: var(--text-strong); - transition: - background-color 0.2s ease-in-out, - color 0.2s ease-in-out; + transition-property: background-color, color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); outline: none; user-select: none; - &[data-highlighted] { - background: var(--surface-raised-base-hover); + &:hover { + background-color: var(--surface-raised-base-hover); } &[data-disabled] { background-color: var(--surface-raised-base); @@ -160,6 +171,11 @@ margin-left: auto; width: 16px; height: 16px; + color: var(--icon-strong-base); + + svg { + color: var(--icon-strong-base); + } } &:focus { outline: none; @@ -171,13 +187,9 @@ } [data-component="select-content"][data-trigger-style="settings"] { - min-width: 160px; + field-sizing: content; border-radius: 8px; - padding: 0; - - [data-slot="select-select-content-list"] { - padding: 4px; - } + padding: 0 0 0 4px; [data-slot="select-select-item"] { /* text-14-regular */ @@ -190,13 +202,24 @@ } } -@keyframes select-open { +@keyframes selectContentShow { from { opacity: 0; - transform: scale(0.95); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); + } +} + +@keyframes selectContentHide { + from { + opacity: 1; + transform: scaleY(1); + } + to { + opacity: 0; + transform: scaleY(0.95); } } diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 0386c329ec4..66f48e69b10 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,8 +1,10 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" +import { createMemo, createSignal, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" +import { Show } from "solid-js" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" +import { MorphChevron } from "./morph-chevron" export type SelectProps = Omit>, "value" | "onSelect" | "children"> & { placeholder?: string @@ -38,6 +40,8 @@ export function Select(props: SelectProps & Omit) "triggerVariant", ]) + const [isOpen, setIsOpen] = createSignal(false) + const state = { key: undefined as string | undefined, cleanup: undefined as (() => void) | void, @@ -85,7 +89,7 @@ export function Select(props: SelectProps & Omit) data-component="select" data-trigger-style={local.triggerVariant} placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"} - gutter={4} + gutter={8} value={local.current} options={grouped()} optionValue={(x) => (local.value ? local.value(x) : (x as string))} @@ -115,7 +119,7 @@ export function Select(props: SelectProps & Omit) : (itemProps.item.rawValue as string)} - + )} @@ -124,6 +128,7 @@ export function Select(props: SelectProps & Omit) stop() }} onOpenChange={(open) => { + setIsOpen(open) local.onOpenChange?.(open) if (!open) stop() }} @@ -149,7 +154,12 @@ export function Select(props: SelectProps & Omit) }} - + + + + + + @@ -166,4 +176,4 @@ export function Select(props: SelectProps & Omit) ) -} +} \ No newline at end of file diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 3ed0310ef2b..cae48137ba6 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -48,6 +48,8 @@ @import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); +@import "../components/morph-chevron.css" layer(components); +@import "../components/reasoning-icon.css" layer(components); @import "./utilities.css" layer(utilities); @import "./animations.css" layer(utilities); diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 8c954f1fe4e..82a913c8830 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -1,6 +1,17 @@ :root { interpolate-size: allow-keywords; + /* Transition tokens */ + --transition-duration: 200ms; + --transition-easing: cubic-bezier(0.25, 0, 0.5, 1); + --transition-fast: 150ms; + --transition-slow: 300ms; + + /* Allow height transitions from 0 to auto */ + @supports (interpolate-size: allow-keywords) { + interpolate-size: allow-keywords; + } + [data-popper-positioner] { pointer-events: none; } @@ -129,3 +140,34 @@ line-height: var(--line-height-x-large); /* 120% */ letter-spacing: var(--letter-spacing-tightest); } + +/* Transition utility classes */ +.transition-colors { + transition-property: background-color, border-color, color, fill, stroke; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-opacity { + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-transform { + transition-property: transform; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-shadow { + transition-property: box-shadow; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-interactive { + transition-property: background-color, border-color, color, box-shadow, opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} From e84d441b823463162cada18e1a8b5383820c695a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 00:17:54 +0000 Subject: [PATCH 049/148] chore: generate --- .../src/components/dialog-select-model.tsx | 2 +- packages/app/src/components/prompt-input.tsx | 30 +++++++++-------- .../app/src/components/settings-general.tsx | 32 +++++++++---------- packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/cycle-label.tsx | 7 ++-- packages/ui/src/components/icon.tsx | 5 ++- packages/ui/src/components/morph-chevron.tsx | 2 +- packages/ui/src/components/reasoning-icon.tsx | 20 ++++++++++-- packages/ui/src/components/select.tsx | 2 +- 9 files changed, 62 insertions(+), 40 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 00e654d8efe..2135b1edf44 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -92,7 +92,7 @@ export function ModelSelectorPopover(props: { provider?: string children?: JSX.Element | ((open: boolean) => JSX.Element) triggerAs?: T - triggerProps?: ComponentProps, + triggerProps?: ComponentProps gutter?: number }) { const [store, setStore] = createStore<{ diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 0c16bc89350..c175824deba 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -925,7 +925,7 @@ export const PromptInput: Component = (props) => { .abort({ sessionID, }) - .catch(() => { }) + .catch(() => {}) } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { @@ -1351,18 +1351,18 @@ export const PromptInput: Component = (props) => { const contextParts: Array< | { - id: string - type: "text" - text: string - synthetic?: boolean - } + id: string + type: "text" + text: string + synthetic?: boolean + } | { - id: string - type: "file" - mime: string - url: string - filename?: string - } + id: string + type: "file" + mime: string + url: string + filename?: string + } > = [] const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { @@ -1958,7 +1958,9 @@ export const PromptInput: Component = (props) => { {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + } @@ -2201,4 +2203,4 @@ function setCursorPosition(parent: HTMLElement, position: number) { fallbackRange.collapse(false) fallbackSelection?.removeAllRanges() fallbackSelection?.addRange(fallbackRange) -} \ No newline at end of file +} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index e43b82e4a8e..94813871e4c 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -60,24 +60,24 @@ export const SettingsGeneral: Component = () => { const actions = platform.update && platform.restart ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() + }, }, - }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] showToast({ persistent: true, diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 1b110ca8afc..b2d2004d3c8 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon" export interface ButtonProps extends ComponentProps, - Pick, "class" | "classList" | "children" | "style"> { + Pick, "class" | "classList" | "children" | "style"> { size?: "small" | "normal" | "large" variant?: "primary" | "secondary" | "ghost" icon?: IconProps["name"] diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx index f1eaa88f31c..dc12bd75c87 100644 --- a/packages/ui/src/components/cycle-label.tsx +++ b/packages/ui/src/components/cycle-label.tsx @@ -23,7 +23,10 @@ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) export function CycleLabel(props: CycleLabelProps) { const getDuration = (text: string) => { - const d = props.duration ?? Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? 200 + const d = + props.duration ?? + Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? + 200 return typeof d === "function" ? d(text) : d } const stagger = () => props?.stagger ?? 30 @@ -129,4 +132,4 @@ export function CycleLabel(props: CycleLabelProps) { }} /> ) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index f23357293ee..97488a42f0f 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -86,7 +86,10 @@ export interface IconProps extends ComponentProps<"svg"> { export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) return ( -
+
) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx index ba753b3b843..7bac49ffd2e 100644 --- a/packages/ui/src/components/reasoning-icon.tsx +++ b/packages/ui/src/components/reasoning-icon.tsx @@ -25,8 +25,22 @@ export function ReasoningIcon(props: ReasoningIconProps) { [split.class ?? ""]: !!split.class, }} > - - + + ) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 66f48e69b10..fef00500a71 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -176,4 +176,4 @@ export function Select(props: SelectProps & Omit) ) -} \ No newline at end of file +} From e445dc07464d75c893756f6e256c1755d9e2285e Mon Sep 17 00:00:00 2001 From: Aaron Iker Date: Mon, 2 Feb 2026 01:18:06 +0100 Subject: [PATCH 050/148] feat(ui): Smooth fading out on scroll, style fixes (#11683) --- .../app/src/components/settings-general.tsx | 37 ++-- .../app/src/components/settings-keybinds.tsx | 10 +- .../app/src/components/settings-models.tsx | 5 +- .../app/src/components/settings-providers.tsx | 5 +- packages/ui/src/components/list.css | 50 ++--- packages/ui/src/components/list.tsx | 13 +- packages/ui/src/components/scroll-fade.css | 82 +++++++ packages/ui/src/components/scroll-fade.tsx | 206 ++++++++++++++++++ packages/ui/src/components/scroll-reveal.tsx | 141 ++++++++++++ packages/ui/src/styles/index.css | 1 + 10 files changed, 490 insertions(+), 60 deletions(-) create mode 100644 packages/ui/src/components/scroll-fade.css create mode 100644 packages/ui/src/components/scroll-fade.tsx create mode 100644 packages/ui/src/components/scroll-reveal.tsx diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 94813871e4c..1d82a24c303 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" @@ -60,24 +61,24 @@ export const SettingsGeneral: Component = () => { const actions = platform.update && platform.restart ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() - }, + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] showToast({ persistent: true, @@ -130,7 +131,7 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] return ( -
+

{language.t("settings.tab.general")}

@@ -411,7 +412,7 @@ export const SettingsGeneral: Component = () => {
-
+ ) } diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index a24db13f5c5..8655bca34b2 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -5,6 +5,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import fuzzysort from "fuzzysort" import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -352,7 +353,12 @@ export const SettingsKeybinds: Component = () => { }) return ( -
+
@@ -430,6 +436,6 @@ export const SettingsKeybinds: Component = () => {
-
+
) } diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 1807d561eac..c9453ddf1b9 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -9,6 +9,7 @@ import { type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" import { popularProviders } from "@/hooks/use-providers" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ModelItem = ReturnType["list"]>[number] @@ -39,7 +40,7 @@ export const SettingsModels: Component = () => { }) return ( -
+

{language.t("settings.models.title")}

@@ -125,6 +126,6 @@ export const SettingsModels: Component = () => {
-
+
) } diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index dcc597139e3..71a0bb6ad86 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -12,6 +12,7 @@ import { useGlobalSync } from "@/context/global-sync" import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ProviderSource = "env" | "api" | "config" | "custom" type ProviderMeta = { source?: ProviderSource } @@ -115,7 +116,7 @@ export const SettingsProviders: Component = () => { } return ( -
+

{language.t("settings.providers.title")}

@@ -261,6 +262,6 @@ export const SettingsProviders: Component = () => {
-
+ ) } diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index b12d304151d..7b365c288ad 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -1,25 +1,7 @@ -@property --bottom-fade { - syntax: ""; - inherits: false; - initial-value: 0px; -} - -@keyframes scroll { - 0% { - --bottom-fade: 20px; - } - 90% { - --bottom-fade: 20px; - } - 100% { - --bottom-fade: 0; - } -} - [data-component="list"] { display: flex; flex-direction: column; - gap: 12px; + gap: 8px; overflow: hidden; padding: 0 12px; @@ -37,7 +19,9 @@ flex-shrink: 0; background-color: transparent; opacity: 0.5; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -88,7 +72,9 @@ height: 20px; background-color: transparent; opacity: 0.5; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -131,15 +117,6 @@ gap: 12px; overflow-y: auto; overscroll-behavior: contain; - mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000); - animation: scroll; - animation-timeline: --scroll; - scroll-timeline: --scroll y; - scrollbar-width: none; - -ms-overflow-style: none; - &::-webkit-scrollbar { - display: none; - } [data-slot="list-empty-state"] { display: flex; @@ -215,7 +192,9 @@ background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent); pointer-events: none; opacity: 0; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); } &[data-stuck="true"]::after { @@ -251,17 +230,22 @@ align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1/1; + aspect-ratio: 1 / 1; [data-component="icon"] { color: var(--icon-strong-base); } } + + [name="check"] { + color: var(--icon-strong-base); + } + [data-slot="list-item-active-icon"] { display: none; align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1/1; + aspect-ratio: 1 / 1; [data-component="icon"] { color: var(--icon-strong-base); } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 2132897f7cf..950ad3d2038 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -5,6 +5,7 @@ import { useI18n } from "../context/i18n" import { Icon, type IconProps } from "./icon" import { IconButton } from "./icon-button" import { TextField } from "./text-field" +import { ScrollFade } from "./scroll-fade" function findByKey(container: HTMLElement, key: string) { const nodes = container.querySelectorAll('[data-slot="list-item"][data-key]') @@ -267,7 +268,13 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {searchAction()}
-
+ 0 || showAdd()} fallback={ @@ -339,7 +346,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
) -} +} \ No newline at end of file diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css new file mode 100644 index 00000000000..ede5fabec45 --- /dev/null +++ b/packages/ui/src/components/scroll-fade.css @@ -0,0 +1,82 @@ +[data-component="scroll-fade"] { + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: none; + box-sizing: border-box; + color: inherit; + font: inherit; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + &[data-direction="horizontal"] { + overflow-x: auto; + overflow-y: hidden; + + /* Both fades */ + &[data-fade-start][data-fade-end] { + mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + + /* Only start fade */ + &[data-fade-start]:not([data-fade-end]) { + mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); + -webkit-mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); + } + + /* Only end fade */ + &:not([data-fade-start])[data-fade-end] { + mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + -webkit-mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + } + } + + &[data-direction="vertical"] { + overflow-y: auto; + overflow-x: hidden; + + &[data-fade-start][data-fade-end] { + mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + + /* Only start fade */ + &[data-fade-start]:not([data-fade-end]) { + mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); + -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); + } + + /* Only end fade */ + &:not([data-fade-start])[data-fade-end] { + mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + } + } +} diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx new file mode 100644 index 00000000000..d190d67c039 --- /dev/null +++ b/packages/ui/src/components/scroll-fade.tsx @@ -0,0 +1,206 @@ +import { type JSX, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" + +export interface ScrollFadeProps extends JSX.HTMLAttributes { + direction?: "horizontal" | "vertical" + fadeStartSize?: number + fadeEndSize?: number + trackTransformSelector?: string + ref?: (el: HTMLDivElement) => void +} + +export function ScrollFade(props: ScrollFadeProps) { + const [local, others] = splitProps(props, [ + "children", + "direction", + "fadeStartSize", + "fadeEndSize", + "trackTransformSelector", + "class", + "style", + "ref", + ]) + + const direction = () => local.direction ?? "vertical" + const fadeStartSize = () => local.fadeStartSize ?? 20 + const fadeEndSize = () => local.fadeEndSize ?? 20 + + const getTransformOffset = (element: Element): number => { + const style = getComputedStyle(element) + const transform = style.transform + if (!transform || transform === "none") return 0 + + const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/) + if (!match) return 0 + + const values = match[1].split(",").map((v) => parseFloat(v.trim())) + const isHorizontal = direction() === "horizontal" + + if (transform.startsWith("matrix3d")) { + return isHorizontal ? -(values[12] || 0) : -(values[13] || 0) + } else { + return isHorizontal ? -(values[4] || 0) : -(values[5] || 0) + } + } + + let containerRef: HTMLDivElement | undefined + + const [fadeStart, setFadeStart] = createSignal(0) + const [fadeEnd, setFadeEnd] = createSignal(0) + const [isScrollable, setIsScrollable] = createSignal(false) + + let lastScrollPos = 0 + let lastTransformPos = 0 + let lastScrollSize = 0 + let lastClientSize = 0 + + const updateFade = () => { + if (!containerRef) return + + const isHorizontal = direction() === "horizontal" + const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop + const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight + const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight + + let transformPos = 0 + if (local.trackTransformSelector) { + const transformElement = containerRef.querySelector(local.trackTransformSelector) + if (transformElement) { + transformPos = getTransformOffset(transformElement) + } + } + + const effectiveScrollPos = Math.max(scrollPos, transformPos) + + if ( + effectiveScrollPos === lastScrollPos && + transformPos === lastTransformPos && + scrollSize === lastScrollSize && + clientSize === lastClientSize + ) { + return + } + + lastScrollPos = effectiveScrollPos + lastTransformPos = transformPos + lastScrollSize = scrollSize + lastClientSize = clientSize + + const maxScroll = scrollSize - clientSize + const canScroll = maxScroll > 1 + + setIsScrollable(canScroll) + + if (!canScroll) { + setFadeStart(0) + setFadeEnd(0) + return + } + + const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0 + + const startProgress = Math.min(progress / 0.1, 1) + setFadeStart(startProgress * fadeStartSize()) + + const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1 + setFadeEnd(Math.max(0, endProgress) * fadeEndSize()) + } + + onMount(() => { + if (!containerRef) return + + updateFade() + + let rafId: number | undefined + let isPolling = false + let pollTimeout: ReturnType | undefined + + const startPolling = () => { + if (isPolling) return + isPolling = true + + const pollScroll = () => { + updateFade() + rafId = requestAnimationFrame(pollScroll) + } + rafId = requestAnimationFrame(pollScroll) + } + + const stopPolling = () => { + if (!isPolling) return + isPolling = false + if (rafId !== undefined) { + cancelAnimationFrame(rafId) + rafId = undefined + } + } + + const schedulePollingStop = () => { + if (pollTimeout !== undefined) clearTimeout(pollTimeout) + pollTimeout = setTimeout(stopPolling, 1000) + } + + const onActivity = () => { + updateFade() + if (local.trackTransformSelector) { + startPolling() + schedulePollingStop() + } + } + + containerRef.addEventListener("scroll", onActivity, { passive: true }) + + const resizeObserver = new ResizeObserver(() => { + lastScrollSize = 0 + lastClientSize = 0 + onActivity() + }) + resizeObserver.observe(containerRef) + + const mutationObserver = new MutationObserver(() => { + lastScrollSize = 0 + lastClientSize = 0 + requestAnimationFrame(onActivity) + }) + mutationObserver.observe(containerRef, { + childList: true, + subtree: true, + characterData: true, + }) + + onCleanup(() => { + containerRef?.removeEventListener("scroll", onActivity) + resizeObserver.disconnect() + mutationObserver.disconnect() + stopPolling() + if (pollTimeout !== undefined) clearTimeout(pollTimeout) + }) + }) + + createEffect(() => { + local.children + requestAnimationFrame(updateFade) + }) + + return ( +
{ + containerRef = el + local.ref?.(el) + }} + data-component="scroll-fade" + data-direction={direction()} + data-scrollable={isScrollable() || undefined} + data-fade-start={fadeStart() > 0 || undefined} + data-fade-end={fadeEnd() > 0 || undefined} + class={local.class} + style={{ + ...(typeof local.style === "object" ? local.style : {}), + "--scroll-fade-start": `${fadeStart()}px`, + "--scroll-fade-end": `${fadeEnd()}px`, + }} + {...others} + > + {local.children} +
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx new file mode 100644 index 00000000000..7eb7ff37fa1 --- /dev/null +++ b/packages/ui/src/components/scroll-reveal.tsx @@ -0,0 +1,141 @@ +import { type JSX, onCleanup, splitProps } from "solid-js" +import { ScrollFade, type ScrollFadeProps } from './scroll-fade' + +const SCROLL_SPEED = 60 +const PAUSE_DURATION = 800 + +type ScrollAnimationState = { + rafId: number | null + startTime: number + running: boolean +} + +const startScrollAnimation = (containerEl: HTMLElement): ScrollAnimationState | null => { + containerEl.offsetHeight + + const extraWidth = containerEl.scrollWidth - containerEl.clientWidth + + if (extraWidth <= 0) { + return null + } + + const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000 + const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION + + const state: ScrollAnimationState = { + rafId: null, + startTime: performance.now(), + running: true, + } + + const animate = (currentTime: number) => { + if (!state.running) return + + const elapsed = currentTime - state.startTime + const progress = (elapsed % totalDuration) / totalDuration + + const pausePercent = PAUSE_DURATION / totalDuration + const scrollPercent = scrollDuration / totalDuration + + const pauseEnd1 = pausePercent + const scrollEnd1 = pauseEnd1 + scrollPercent + const pauseEnd2 = scrollEnd1 + pausePercent + const scrollEnd2 = pauseEnd2 + scrollPercent + + let scrollPos = 0 + + if (progress < pauseEnd1) { + scrollPos = 0 + } else if (progress < scrollEnd1) { + const scrollProgress = (progress - pauseEnd1) / scrollPercent + scrollPos = scrollProgress * extraWidth + } else if (progress < pauseEnd2) { + scrollPos = extraWidth + } else if (progress < scrollEnd2) { + const scrollProgress = (progress - pauseEnd2) / scrollPercent + scrollPos = extraWidth * (1 - scrollProgress) + } else { + scrollPos = 0 + } + + containerEl.scrollLeft = scrollPos + state.rafId = requestAnimationFrame(animate) + } + + state.rafId = requestAnimationFrame(animate) + return state +} + +const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => { + if (state) { + state.running = false + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId) + } + } + if (containerEl) { + containerEl.scrollLeft = 0 + } +} + +export interface ScrollRevealProps extends Omit { + hoverDelay?: number +} + +export function ScrollReveal(props: ScrollRevealProps) { + const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"]) + + const hoverDelay = () => local.hoverDelay ?? 300 + + let containerRef: HTMLDivElement | undefined + let hoverTimeout: ReturnType | undefined + let scrollAnimationState: ScrollAnimationState | null = null + + const handleMouseEnter: JSX.EventHandler = () => { + hoverTimeout = setTimeout(() => { + if (!containerRef) return + + containerRef.offsetHeight + + const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1 + + if (isScrollable) { + stopScrollAnimation(scrollAnimationState, containerRef) + scrollAnimationState = startScrollAnimation(containerRef) + } + }, hoverDelay()) + } + + const handleMouseLeave: JSX.EventHandler = () => { + if (hoverTimeout) { + clearTimeout(hoverTimeout) + hoverTimeout = undefined + } + stopScrollAnimation(scrollAnimationState, containerRef) + scrollAnimationState = null + } + + onCleanup(() => { + if (hoverTimeout) { + clearTimeout(hoverTimeout) + } + stopScrollAnimation(scrollAnimationState, containerRef) + }) + + return ( + { + containerRef = el + local.ref?.(el) + }} + fadeStartSize={8} + fadeEndSize={8} + direction="horizontal" + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + {...others} + > + {local.children} + + ) +} \ No newline at end of file diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index cae48137ba6..2a8171f98c0 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -40,6 +40,7 @@ @import "../components/select.css" layer(components); @import "../components/spinner.css" layer(components); @import "../components/switch.css" layer(components); +@import "../components/scroll-fade.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); From fa75d922ed9e9c7e11ae43baec933674d5d3012d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 00:18:42 +0000 Subject: [PATCH 051/148] chore: generate --- .../app/src/components/settings-general.tsx | 39 +++++++++++-------- .../app/src/components/settings-models.tsx | 7 +++- .../app/src/components/settings-providers.tsx | 7 +++- packages/ui/src/components/list.tsx | 10 +---- packages/ui/src/components/scroll-fade.tsx | 2 +- packages/ui/src/components/scroll-reveal.tsx | 4 +- 6 files changed, 39 insertions(+), 30 deletions(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 1d82a24c303..a0251ed41b6 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -61,24 +61,24 @@ export const SettingsGeneral: Component = () => { const actions = platform.update && platform.restart ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() + }, }, - }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] showToast({ persistent: true, @@ -131,7 +131,12 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] return ( - +

{language.t("settings.tab.general")}

diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index c9453ddf1b9..0ee5caf73d9 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -40,7 +40,12 @@ export const SettingsModels: Component = () => { }) return ( - +

{language.t("settings.models.title")}

diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 71a0bb6ad86..2460534c05c 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -116,7 +116,12 @@ export const SettingsProviders: Component = () => { } return ( - +

{language.t("settings.providers.title")}

diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 950ad3d2038..15854180e4b 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -268,13 +268,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {searchAction()}
- + 0 || showAdd()} fallback={ @@ -349,4 +343,4 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx index d190d67c039..97f0339e829 100644 --- a/packages/ui/src/components/scroll-fade.tsx +++ b/packages/ui/src/components/scroll-fade.tsx @@ -203,4 +203,4 @@ export function ScrollFade(props: ScrollFadeProps) { {local.children}
) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx index 7eb7ff37fa1..6e5072dc81e 100644 --- a/packages/ui/src/components/scroll-reveal.tsx +++ b/packages/ui/src/components/scroll-reveal.tsx @@ -1,5 +1,5 @@ import { type JSX, onCleanup, splitProps } from "solid-js" -import { ScrollFade, type ScrollFadeProps } from './scroll-fade' +import { ScrollFade, type ScrollFadeProps } from "./scroll-fade" const SCROLL_SPEED = 60 const PAUSE_DURATION = 800 @@ -138,4 +138,4 @@ export function ScrollReveal(props: ScrollRevealProps) { {local.children} ) -} \ No newline at end of file +} From 12b8c4238768d4fc3aa57c7023c9724c4f705db2 Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Sat, 31 Jan 2026 10:27:27 -0500 Subject: [PATCH 052/148] feat(app): show skill/mcp badges for slash commands Display 'skill' or 'mcp' badge instead of 'custom' for slash commands based on their source type. This provides better clarity to users about where each command comes from. --- packages/app/src/components/prompt-input.tsx | 8 +++++++- packages/app/src/i18n/ar.ts | 2 ++ packages/app/src/i18n/br.ts | 2 ++ packages/app/src/i18n/da.ts | 2 ++ packages/app/src/i18n/de.ts | 2 ++ packages/app/src/i18n/en.ts | 2 ++ packages/app/src/i18n/es.ts | 2 ++ packages/app/src/i18n/fr.ts | 2 ++ packages/app/src/i18n/ja.ts | 2 ++ packages/app/src/i18n/ko.ts | 2 ++ packages/app/src/i18n/no.ts | 2 ++ packages/app/src/i18n/pl.ts | 2 ++ packages/app/src/i18n/ru.ts | 2 ++ packages/app/src/i18n/th.ts | 2 ++ packages/app/src/i18n/zh.ts | 2 ++ packages/app/src/i18n/zht.ts | 2 ++ 16 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c175824deba..9a9d589a7ec 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -115,6 +115,7 @@ interface SlashCommand { description?: string keybind?: string type: "builtin" | "custom" + source?: "command" | "mcp" | "skill" } export const PromptInput: Component = (props) => { @@ -520,6 +521,7 @@ export const PromptInput: Component = (props) => { title: cmd.name, description: cmd.description, type: "custom" as const, + source: cmd.source, })) return [...custom, ...builtin] @@ -1728,7 +1730,11 @@ export const PromptInput: Component = (props) => {
- {language.t("prompt.slash.badge.custom")} + {cmd.source === "skill" + ? language.t("prompt.slash.badge.skill") + : cmd.source === "mcp" + ? language.t("prompt.slash.badge.mcp") + : language.t("prompt.slash.badge.custom")} diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 80179144a85..e3831e23c40 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة", "prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا", "prompt.slash.badge.custom": "مخصص", + "prompt.slash.badge.skill": "مهارة", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "نشط", "prompt.context.includeActiveFile": "تضمين الملف النشط", "prompt.context.removeActiveFile": "إزالة الملف النشط من السياق", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index c874a4376b8..f930a66aff3 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Nenhum comando correspondente", "prompt.dropzone.label": "Solte imagens ou PDFs aqui", "prompt.slash.badge.custom": "personalizado", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "ativo", "prompt.context.includeActiveFile": "Incluir arquivo ativo", "prompt.context.removeActiveFile": "Remover arquivo ativo do contexto", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 555990a9c44..2b7d77456d5 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slip billeder eller PDF'er her", "prompt.slash.badge.custom": "brugerdefineret", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Inkluder aktiv fil", "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index e56081c9089..4648ad9c412 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -214,6 +214,8 @@ export const dict = { "prompt.popover.emptyCommands": "Keine passenden Befehle", "prompt.dropzone.label": "Bilder oder PDFs hier ablegen", "prompt.slash.badge.custom": "benutzerdefiniert", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Aktive Datei einbeziehen", "prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 4254860ac93..12ddcb4cd8e 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -216,6 +216,8 @@ export const dict = { "prompt.popover.emptyCommands": "No matching commands", "prompt.dropzone.label": "Drop images or PDFs here", "prompt.slash.badge.custom": "custom", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "active", "prompt.context.includeActiveFile": "Include active file", "prompt.context.removeActiveFile": "Remove active file from context", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index e928f03cec7..5d396f0b4f2 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Sin comandos coincidentes", "prompt.dropzone.label": "Suelta imágenes o PDFs aquí", "prompt.slash.badge.custom": "personalizado", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "activo", "prompt.context.includeActiveFile": "Incluir archivo activo", "prompt.context.removeActiveFile": "Eliminar archivo activo del contexto", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 31000cd17ad..4226d0c7e20 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Aucune commande correspondante", "prompt.dropzone.label": "Déposez des images ou des PDF ici", "prompt.slash.badge.custom": "personnalisé", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "actif", "prompt.context.includeActiveFile": "Inclure le fichier actif", "prompt.context.removeActiveFile": "Retirer le fichier actif du contexte", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 80efc5c2aa1..28a925a0d32 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -209,6 +209,8 @@ export const dict = { "prompt.popover.emptyCommands": "一致するコマンドがありません", "prompt.dropzone.label": "画像またはPDFをここにドロップ", "prompt.slash.badge.custom": "カスタム", + "prompt.slash.badge.skill": "スキル", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "アクティブ", "prompt.context.includeActiveFile": "アクティブなファイルを含める", "prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 014092d07f2..1be4e1eb4b6 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -213,6 +213,8 @@ export const dict = { "prompt.popover.emptyCommands": "일치하는 명령어 없음", "prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요", "prompt.slash.badge.custom": "사용자 지정", + "prompt.slash.badge.skill": "스킬", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "활성", "prompt.context.includeActiveFile": "활성 파일 포함", "prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 400ce37d352..0a3b398856a 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -213,6 +213,8 @@ export const dict = { "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slipp bilder eller PDF-er her", "prompt.slash.badge.custom": "egendefinert", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Inkluder aktiv fil", "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 5a05809829c..f4457c6acf8 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Brak pasujących poleceń", "prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj", "prompt.slash.badge.custom": "własne", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktywny", "prompt.context.includeActiveFile": "Dołącz aktywny plik", "prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 4277368f5da..d5a4014d36a 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Нет совпадающих команд", "prompt.dropzone.label": "Перетащите изображения или PDF сюда", "prompt.slash.badge.custom": "своё", + "prompt.slash.badge.skill": "навык", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "активно", "prompt.context.includeActiveFile": "Включить активный файл", "prompt.context.removeActiveFile": "Удалить активный файл из контекста", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index e2eabd7ad81..1914b8e5bdf 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -215,6 +215,8 @@ export const dict = { "prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน", "prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่", "prompt.slash.badge.custom": "กำหนดเอง", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "ใช้งานอยู่", "prompt.context.includeActiveFile": "รวมไฟล์ที่ใช้งานอยู่", "prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 118e03ce47b..b9d53957302 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -214,6 +214,8 @@ export const dict = { "prompt.popover.emptyCommands": "没有匹配的命令", "prompt.dropzone.label": "将图片或 PDF 拖到这里", "prompt.slash.badge.custom": "自定义", + "prompt.slash.badge.skill": "技能", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "当前", "prompt.context.includeActiveFile": "包含当前文件", "prompt.context.removeActiveFile": "从上下文移除活动文件", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 45a789df4c7..23d3d80e136 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -211,6 +211,8 @@ export const dict = { "prompt.popover.emptyCommands": "沒有符合的命令", "prompt.dropzone.label": "將圖片或 PDF 拖到這裡", "prompt.slash.badge.custom": "自訂", + "prompt.slash.badge.skill": "技能", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "作用中", "prompt.context.includeActiveFile": "包含作用中檔案", "prompt.context.removeActiveFile": "從上下文移除目前檔案", From cb6ec0a564cfc332e7af3c844157e145c1d86a60 Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Sat, 31 Jan 2026 12:11:30 -0500 Subject: [PATCH 053/148] fix(app): hide badge for builtin slash commands Add source: 'command' to builtin and config-defined commands so they don't show a 'custom' badge. Only MCP and skill commands show badges. --- packages/app/src/components/prompt-input.tsx | 2 +- packages/opencode/src/command/index.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9a9d589a7ec..2b63b6f5fd3 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1728,7 +1728,7 @@ export const PromptInput: Component = (props) => {
- + {cmd.source === "skill" ? language.t("prompt.slash.badge.skill") diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 14dbeb6794e..dce7ac8bbc3 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -63,6 +63,7 @@ export namespace Command { [Default.INIT]: { name: Default.INIT, description: "create/update AGENTS.md", + source: "command", get template() { return PROMPT_INITIALIZE.replace("${path}", Instance.worktree) }, @@ -71,6 +72,7 @@ export namespace Command { [Default.REVIEW]: { name: Default.REVIEW, description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", get template() { return PROMPT_REVIEW.replace("${path}", Instance.worktree) }, @@ -85,6 +87,7 @@ export namespace Command { agent: command.agent, model: command.model, description: command.description, + source: "command", get template() { return command.template }, From 612b656d3670f252541be79f96bfda31d78dcf73 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 30 Jan 2026 15:55:00 -0600 Subject: [PATCH 054/148] fix: adjust resolve parts so that when messages with multiple @ references occur, the tool calls are properly ordered --- packages/opencode/src/session/processor.ts | 10 +++- packages/opencode/src/session/prompt.ts | 69 ++++++++++++++-------- packages/opencode/src/tool/batch.ts | 8 ++- packages/opencode/src/tool/read.ts | 4 -- packages/opencode/src/tool/tool.ts | 2 +- 5 files changed, 60 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 27071056180..ada6f83145b 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -172,6 +172,14 @@ export namespace SessionProcessor { case "tool-result": { const match = toolcalls[value.toolCallId] if (match && match.state.status === "running") { + const attachments = value.output.attachments?.map( + (attachment: Omit) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: match.messageID, + sessionID: match.sessionID, + }), + ) await Session.updatePart({ ...match, state: { @@ -184,7 +192,7 @@ export namespace SessionProcessor { start: match.state.time.start, end: Date.now(), }, - attachments: value.output.attachments, + attachments, }, }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ba77cd7ca3e..c6d040f2fab 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -187,13 +187,17 @@ export namespace SessionPrompt { text: template, }, ] - const files = ConfigMarkdown.files(template) + const matches = ConfigMarkdown.files(template) const seen = new Set() - await Promise.all( - files.map(async (match) => { - const name = match[1] - if (seen.has(name)) return + const names = matches + .map((match) => match[1]) + .filter((name) => { + if (seen.has(name)) return false seen.add(name) + return true + }) + const resolved = await Promise.all( + names.map(async (name) => { const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) : path.resolve(Instance.worktree, name) @@ -201,33 +205,34 @@ export namespace SessionPrompt { const stats = await fs.stat(filepath).catch(() => undefined) if (!stats) { const agent = await Agent.get(name) - if (agent) { - parts.push({ - type: "agent", - name: agent.name, - }) - } - return + if (!agent) return undefined + return { + type: "agent", + name: agent.name, + } satisfies PromptInput["parts"][number] } if (stats.isDirectory()) { - parts.push({ + return { type: "file", url: `file://${filepath}`, filename: name, mime: "application/x-directory", - }) - return + } satisfies PromptInput["parts"][number] } - parts.push({ + return { type: "file", url: `file://${filepath}`, filename: name, mime: "text/plain", - }) + } satisfies PromptInput["parts"][number] }), ) + for (const item of resolved) { + if (!item) continue + parts.push(item) + } return parts } @@ -424,6 +429,12 @@ export namespace SessionPrompt { assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) if (result && part.state.status === "running") { + const attachments = result.attachments?.map((attachment) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: assistantMessage.id, + sessionID: assistantMessage.sessionID, + })) await Session.updatePart({ ...part, state: { @@ -432,7 +443,7 @@ export namespace SessionPrompt { title: result.title, metadata: result.metadata, output: result.output, - attachments: result.attachments, + attachments, time: { ...part.state.time, end: Date.now(), @@ -771,16 +782,13 @@ export namespace SessionPrompt { ) const textParts: string[] = [] - const attachments: MessageV2.FilePart[] = [] + const attachments: Omit[] = [] for (const contentItem of result.content) { if (contentItem.type === "text") { textParts.push(contentItem.text) } else if (contentItem.type === "image") { attachments.push({ - id: Identifier.ascending("part"), - sessionID: input.session.id, - messageID: input.processor.message.id, type: "file", mime: contentItem.mimeType, url: `data:${contentItem.mimeType};base64,${contentItem.data}`, @@ -792,9 +800,6 @@ export namespace SessionPrompt { } if (resource.blob) { attachments.push({ - id: Identifier.ascending("part"), - sessionID: input.session.id, - messageID: input.processor.message.id, type: "file", mime: resource.mimeType ?? "application/octet-stream", url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, @@ -1043,6 +1048,7 @@ export namespace SessionPrompt { pieces.push( ...result.attachments.map((attachment) => ({ ...attachment, + id: Identifier.ascending("part"), synthetic: true, filename: attachment.filename ?? part.filename, messageID: info.id, @@ -1180,7 +1186,18 @@ export namespace SessionPrompt { }, ] }), - ).then((x) => x.flat()) + ) + .then((x) => x.flat()) + .then((drafts) => + drafts.map( + (part): MessageV2.Part => ({ + ...part, + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + }), + ), + ) await Plugin.trigger( "chat.message", diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index ba34eb48f5c..b5c3ad0a12b 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -77,6 +77,12 @@ export const BatchTool = Tool.define("batch", async () => { }) const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) + const attachments = result.attachments?.map((attachment) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: ctx.messageID, + sessionID: ctx.sessionID, + })) await Session.updatePart({ id: partID, @@ -91,7 +97,7 @@ export const BatchTool = Tool.define("batch", async () => { output: result.output, title: result.title, metadata: result.metadata, - attachments: result.attachments, + attachments, time: { start: callStartTime, end: Date.now(), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index f230cdf44cb..13236d44dd4 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -6,7 +6,6 @@ import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" -import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" @@ -79,9 +78,6 @@ export const ReadTool = Tool.define("read", { }, attachments: [ { - id: Identifier.ascending("part"), - sessionID: ctx.sessionID, - messageID: ctx.messageID, type: "file", mime, url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 3d17ea192d3..0e78ba665cf 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -36,7 +36,7 @@ export namespace Tool { title: string metadata: M output: string - attachments?: MessageV2.FilePart[] + attachments?: Omit[] }> formatValidationError?(error: z.ZodError): string }> From 5db089070a24d66063f55d4f5baf0da20883daf9 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 30 Jan 2026 16:04:07 -0600 Subject: [PATCH 055/148] test: add unit test --- packages/opencode/test/session/prompt.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 packages/opencode/test/session/prompt.test.ts diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts new file mode 100644 index 00000000000..e778bfe5146 --- /dev/null +++ b/packages/opencode/test/session/prompt.test.ts @@ -0,0 +1,62 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageV2 } from "../../src/session/message-v2" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("SessionPrompt ordering", () => { + test("keeps @file order with read output parts", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "28\n") + await Bun.write(path.join(dir, "b.txt"), "42\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const template = "What numbers are written in files @a.txt and @b.txt ?" + const parts = await SessionPrompt.resolvePromptParts(template) + const fileParts = parts.filter((part) => part.type === "file") + + expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"]) + + const message = await SessionPrompt.prompt({ + sessionID: session.id, + parts, + noReply: true, + }) + const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const items = stored.parts + const aPath = path.join(tmp.path, "a.txt") + const bPath = path.join(tmp.path, "b.txt") + const sequence = items.flatMap((part) => { + if (part.type === "text") { + if (part.text.includes(aPath)) return ["input:a"] + if (part.text.includes(bPath)) return ["input:b"] + if (part.text.includes("00001| 28")) return ["output:a"] + if (part.text.includes("00001| 42")) return ["output:b"] + return [] + } + if (part.type === "file") { + if (part.filename === "a.txt") return ["file:a"] + if (part.filename === "b.txt") return ["file:b"] + } + return [] + }) + + expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"]) + + await Session.remove(session.id) + }, + }) + }) +}) From 7417e6eb38e96c5e3904a4503740a305475d154a Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Fri, 30 Jan 2026 16:13:27 -0500 Subject: [PATCH 056/148] fix(plugin): correct exports to point to dist instead of src The package.json exports were pointing to ./src/*.ts but the published package only includes the dist/ folder. This caused 'Cannot find module' errors when custom tools tried to import @opencode-ai/plugin. Changed exports from: ".": "./src/index.ts" "./tool": "./src/tool.ts" To: ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } "./tool": { "types": "./dist/tool.d.ts", "import": "./dist/tool.js" } --- packages/plugin/package.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 5a4afbae439..160ce6a8266 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -9,8 +9,14 @@ "build": "tsc" }, "exports": { - ".": "./src/index.ts", - "./tool": "./src/tool.ts" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./tool": { + "types": "./dist/tool.d.ts", + "import": "./dist/tool.js" + } }, "files": [ "dist" From d35956fd9272e302201d99548a1389315a20cdaf Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:26:21 -0500 Subject: [PATCH 057/148] ci: prevent rate limit errors when fetching team PRs for beta releases --- script/beta.ts | 52 ++++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 7a3dfcccf41..a17f02a0913 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,8 +1,11 @@ #!/usr/bin/env bun +import { Script } from "@opencode-ai/script" + interface PR { number: number title: string + author: { login: string } } interface RunResult { @@ -12,15 +15,29 @@ interface RunResult { } async function main() { - console.log("Fetching open contributor PRs...") + console.log("Fetching open PRs from team members...") - const prsResult = await $`gh pr list --label contributor --state open --json number,title --limit 100`.nothrow() - if (prsResult.exitCode !== 0) { - throw new Error(`Failed to fetch PRs: ${prsResult.stderr}`) + const allPrs: PR[] = [] + for (const member of Script.team) { + const result = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.nothrow() + if (result.exitCode !== 0) continue + const memberPrs: PR[] = JSON.parse(result.stdout) + allPrs.push(...memberPrs) } - const prs: PR[] = JSON.parse(prsResult.stdout) - console.log(`Found ${prs.length} open contributor PRs`) + const seen = new Set() + const prs = allPrs.filter((pr) => { + if (seen.has(pr.number)) return false + seen.add(pr.number) + return true + }) + + console.log(`Found ${prs.length} open PRs from team members`) + + if (prs.length === 0) { + console.log("No team PRs to merge") + return + } console.log("Fetching latest dev branch...") const fetchDev = await $`git fetch origin dev`.nothrow() @@ -35,7 +52,6 @@ async function main() { } const applied: number[] = [] - const skipped: Array<{ number: number; reason: string }> = [] for (const pr of prs) { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) @@ -43,9 +59,7 @@ async function main() { console.log(" Fetching PR head...") const fetch = await run(["git", "fetch", "origin", `pull/${pr.number}/head:pr/${pr.number}`]) if (fetch.exitCode !== 0) { - console.log(` Failed to fetch PR head: ${fetch.stderr}`) - skipped.push({ number: pr.number, reason: `Fetch failed: ${fetch.stderr}` }) - continue + throw new Error(`Failed to fetch PR #${pr.number}: ${fetch.stderr}`) } console.log(" Merging...") @@ -55,34 +69,24 @@ async function main() { await $`git merge --abort`.nothrow() await $`git checkout -- .`.nothrow() await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: "Has conflicts" }) - continue + throw new Error(`Failed to merge PR #${pr.number}: Has conflicts`) } const mergeHead = await $`git rev-parse -q --verify MERGE_HEAD`.nothrow() if (mergeHead.exitCode !== 0) { console.log(" No changes, skipping") - skipped.push({ number: pr.number, reason: "No changes" }) continue } const add = await $`git add -A`.nothrow() if (add.exitCode !== 0) { - console.log(" Failed to stage") - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: "Failed to stage" }) - continue + throw new Error(`Failed to stage changes for PR #${pr.number}`) } const commitMsg = `Apply PR #${pr.number}: ${pr.title}` const commit = await run(["git", "commit", "-m", commitMsg]) if (commit.exitCode !== 0) { - console.log(` Failed to commit: ${commit.stderr}`) - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: `Commit failed: ${commit.stderr}` }) - continue + throw new Error(`Failed to commit PR #${pr.number}: ${commit.stderr}`) } console.log(" Applied successfully") @@ -92,8 +96,6 @@ async function main() { console.log("\n--- Summary ---") console.log(`Applied: ${applied.length} PRs`) applied.forEach((num) => console.log(` - PR #${num}`)) - console.log(`Skipped: ${skipped.length} PRs`) - skipped.forEach((x) => console.log(` - PR #${x.number}: ${x.reason}`)) console.log("\nForce pushing beta branch...") const push = await $`git push origin beta --force --no-verify`.nothrow() From c9891fe0718551daa9dddccf0aacec1647850786 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:36:40 -0500 Subject: [PATCH 058/148] ci: collect all failed PR merges and notify Discord --- script/beta.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index a17f02a0913..5648c8b8a51 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -14,6 +14,42 @@ interface RunResult { stderr: string } +interface FailedPR { + number: number + title: string + reason: string +} + +async function postToDiscord(failures: FailedPR[]) { + const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL + if (!webhookUrl) { + console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") + return + } + + const message = `**Beta Branch Merge Failures** + +The following team PRs failed to merge into the beta branch: + +${failures.map((f) => `- **#${f.number}**: ${f.title} - ${f.reason}`).join("\n")} + +Please resolve these conflicts manually.` + + const content = JSON.stringify({ content: message }) + + const response = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: content, + }) + + if (!response.ok) { + console.error("Failed to post to Discord:", await response.text()) + } else { + console.log("Posted failures to Discord") + } +} + async function main() { console.log("Fetching open PRs from team members...") @@ -52,6 +88,7 @@ async function main() { } const applied: number[] = [] + const failed: FailedPR[] = [] for (const pr of prs) { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) @@ -59,7 +96,9 @@ async function main() { console.log(" Fetching PR head...") const fetch = await run(["git", "fetch", "origin", `pull/${pr.number}/head:pr/${pr.number}`]) if (fetch.exitCode !== 0) { - throw new Error(`Failed to fetch PR #${pr.number}: ${fetch.stderr}`) + console.log(` Failed to fetch: ${fetch.stderr}`) + failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" }) + continue } console.log(" Merging...") @@ -69,7 +108,8 @@ async function main() { await $`git merge --abort`.nothrow() await $`git checkout -- .`.nothrow() await $`git clean -fd`.nothrow() - throw new Error(`Failed to merge PR #${pr.number}: Has conflicts`) + failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) + continue } const mergeHead = await $`git rev-parse -q --verify MERGE_HEAD`.nothrow() @@ -80,13 +120,17 @@ async function main() { const add = await $`git add -A`.nothrow() if (add.exitCode !== 0) { - throw new Error(`Failed to stage changes for PR #${pr.number}`) + console.log(" Failed to stage changes") + failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" }) + continue } const commitMsg = `Apply PR #${pr.number}: ${pr.title}` const commit = await run(["git", "commit", "-m", commitMsg]) if (commit.exitCode !== 0) { - throw new Error(`Failed to commit PR #${pr.number}: ${commit.stderr}`) + console.log(` Failed to commit: ${commit.stderr}`) + failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" }) + continue } console.log(" Applied successfully") @@ -97,6 +141,15 @@ async function main() { console.log(`Applied: ${applied.length} PRs`) applied.forEach((num) => console.log(` - PR #${num}`)) + if (failed.length > 0) { + console.log(`Failed: ${failed.length} PRs`) + failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) + + await postToDiscord(failed) + + throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) + } + console.log("\nForce pushing beta branch...") const push = await $`git push origin beta --force --no-verify`.nothrow() if (push.exitCode !== 0) { From 2f63152af302ed41ab769f12f79c264ee872f063 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:37:09 -0500 Subject: [PATCH 059/148] ci: add DISCORD_ISSUES_WEBHOOK_URL secret to beta workflow --- .github/workflows/beta.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index a793bfb0374..6edbb4326ce 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -29,4 +29,5 @@ jobs: - name: Sync beta branch env: GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }} + DISCORD_ISSUES_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} run: bun script/beta.ts From 5dee3328d466708cfd42b2cf5ef77bd40d574d17 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:37:43 -0500 Subject: [PATCH 060/148] ci: add --discord-webhook / -d CLI option for custom Discord webhook URL --- script/beta.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 5648c8b8a51..fff1e91e0d9 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { Script } from "@opencode-ai/script" +import { parseArgs } from "util" interface PR { number: number @@ -20,10 +21,10 @@ interface FailedPR { reason: string } -async function postToDiscord(failures: FailedPR[]) { - const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL - if (!webhookUrl) { - console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") +async function postToDiscord(failures: FailedPR[], webhookUrl?: string) { + const url = webhookUrl || process.env.DISCORD_ISSUES_WEBHOOK_URL + if (!url) { + console.log("Warning: No Discord webhook URL provided, skipping notification") return } @@ -37,7 +38,7 @@ Please resolve these conflicts manually.` const content = JSON.stringify({ content: message }) - const response = await fetch(webhookUrl, { + const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: content, @@ -51,6 +52,15 @@ Please resolve these conflicts manually.` } async function main() { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + "discord-webhook": { type: "string", short: "d" }, + }, + }) + + const discordWebhook = values["discord-webhook"] as string | undefined + console.log("Fetching open PRs from team members...") const allPrs: PR[] = [] @@ -145,7 +155,7 @@ async function main() { console.log(`Failed: ${failed.length} PRs`) failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) - await postToDiscord(failed) + await postToDiscord(failed, discordWebhook) throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) } From e9f8e6aeecbe9d993ddb77129e285b3d31cb4c4a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:38:20 -0500 Subject: [PATCH 061/148] ci: remove parseArgs CLI option and use environment variable directly --- script/beta.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index fff1e91e0d9..5648c8b8a51 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,7 +1,6 @@ #!/usr/bin/env bun import { Script } from "@opencode-ai/script" -import { parseArgs } from "util" interface PR { number: number @@ -21,10 +20,10 @@ interface FailedPR { reason: string } -async function postToDiscord(failures: FailedPR[], webhookUrl?: string) { - const url = webhookUrl || process.env.DISCORD_ISSUES_WEBHOOK_URL - if (!url) { - console.log("Warning: No Discord webhook URL provided, skipping notification") +async function postToDiscord(failures: FailedPR[]) { + const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL + if (!webhookUrl) { + console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") return } @@ -38,7 +37,7 @@ Please resolve these conflicts manually.` const content = JSON.stringify({ content: message }) - const response = await fetch(url, { + const response = await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: content, @@ -52,15 +51,6 @@ Please resolve these conflicts manually.` } async function main() { - const { values } = parseArgs({ - args: Bun.argv.slice(2), - options: { - "discord-webhook": { type: "string", short: "d" }, - }, - }) - - const discordWebhook = values["discord-webhook"] as string | undefined - console.log("Fetching open PRs from team members...") const allPrs: PR[] = [] @@ -155,7 +145,7 @@ async function main() { console.log(`Failed: ${failed.length} PRs`) failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) - await postToDiscord(failed, discordWebhook) + await postToDiscord(failed) throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) } From 744fb6aed0c29712a3e058ae12cde144fcf026db Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:39:38 -0500 Subject: [PATCH 062/148] ci: rewrite beta script to use proper Bun shell patterns --- script/beta.ts | 102 ++++++++++++++++++------------------------------- 1 file changed, 37 insertions(+), 65 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 5648c8b8a51..d2d6e9ed41b 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { $ } from "bun" import { Script } from "@opencode-ai/script" interface PR { @@ -8,12 +9,6 @@ interface PR { author: { login: string } } -interface RunResult { - exitCode: number - stdout: string - stderr: string -} - interface FailedPR { number: number title: string @@ -55,10 +50,13 @@ async function main() { const allPrs: PR[] = [] for (const member of Script.team) { - const result = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.nothrow() - if (result.exitCode !== 0) continue - const memberPrs: PR[] = JSON.parse(result.stdout) - allPrs.push(...memberPrs) + try { + const stdout = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.text() + const memberPrs: PR[] = JSON.parse(stdout) + allPrs.push(...memberPrs) + } catch { + // Skip member on error + } } const seen = new Set() @@ -76,16 +74,10 @@ async function main() { } console.log("Fetching latest dev branch...") - const fetchDev = await $`git fetch origin dev`.nothrow() - if (fetchDev.exitCode !== 0) { - throw new Error(`Failed to fetch dev branch: ${fetchDev.stderr}`) - } + await $`git fetch origin dev` console.log("Checking out beta branch...") - const checkoutBeta = await $`git checkout -B beta origin/dev`.nothrow() - if (checkoutBeta.exitCode !== 0) { - throw new Error(`Failed to checkout beta branch: ${checkoutBeta.stderr}`) - } + await $`git checkout -B beta origin/dev` const applied: number[] = [] const failed: FailedPR[] = [] @@ -94,41 +86,52 @@ async function main() { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) console.log(" Fetching PR head...") - const fetch = await run(["git", "fetch", "origin", `pull/${pr.number}/head:pr/${pr.number}`]) - if (fetch.exitCode !== 0) { - console.log(` Failed to fetch: ${fetch.stderr}`) + try { + await $`git fetch origin pull/${pr.number}/head:pr/${pr.number}` + } catch (err) { + console.log(` Failed to fetch: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" }) continue } console.log(" Merging...") - const merge = await run(["git", "merge", "--no-commit", "--no-ff", `pr/${pr.number}`]) - if (merge.exitCode !== 0) { + try { + await $`git merge --no-commit --no-ff pr/${pr.number}` + } catch { console.log(" Failed to merge (conflicts)") - await $`git merge --abort`.nothrow() - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() + try { + await $`git merge --abort` + } catch {} + try { + await $`git checkout -- .` + } catch {} + try { + await $`git clean -fd` + } catch {} failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) continue } - const mergeHead = await $`git rev-parse -q --verify MERGE_HEAD`.nothrow() - if (mergeHead.exitCode !== 0) { + try { + await $`git rev-parse -q --verify MERGE_HEAD`.text() + } catch { console.log(" No changes, skipping") continue } - const add = await $`git add -A`.nothrow() - if (add.exitCode !== 0) { + try { + await $`git add -A` + } catch { console.log(" Failed to stage changes") failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" }) continue } const commitMsg = `Apply PR #${pr.number}: ${pr.title}` - const commit = await run(["git", "commit", "-m", commitMsg]) - if (commit.exitCode !== 0) { - console.log(` Failed to commit: ${commit.stderr}`) + try { + await $`git commit -m ${commitMsg}` + } catch (err) { + console.log(` Failed to commit: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" }) continue } @@ -151,10 +154,7 @@ async function main() { } console.log("\nForce pushing beta branch...") - const push = await $`git push origin beta --force --no-verify`.nothrow() - if (push.exitCode !== 0) { - throw new Error(`Failed to push beta branch: ${push.stderr}`) - } + await $`git push origin beta --force --no-verify` console.log("Successfully synced beta branch") } @@ -163,31 +163,3 @@ main().catch((err) => { console.error("Error:", err) process.exit(1) }) - -async function run(args: string[], stdin?: Uint8Array): Promise { - const proc = Bun.spawn(args, { - stdin: stdin ?? "inherit", - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - const stderr = await new Response(proc.stderr).text() - return { exitCode, stdout, stderr } -} - -function $(strings: TemplateStringsArray, ...values: unknown[]) { - const cmd = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "") - return { - async nothrow() { - const proc = Bun.spawn(cmd.split(" "), { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - const stderr = await new Response(proc.stderr).text() - return { exitCode, stdout, stderr } - }, - } -} From 425abe2fbfc1a432f6b4c19dd89e59f2d7981fff Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:55:40 -0500 Subject: [PATCH 063/148] ci: post PR comments when beta merge fails instead of Discord notifications --- script/beta.ts | 42 ++++++++++++++---------------------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index d2d6e9ed41b..264fa9bc61b 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -15,33 +15,18 @@ interface FailedPR { reason: string } -async function postToDiscord(failures: FailedPR[]) { - const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL - if (!webhookUrl) { - console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") - return - } +async function commentOnPR(prNumber: number, reason: string) { + const body = `⚠️ **Blocking Beta Release** - const message = `**Beta Branch Merge Failures** +This PR cannot be merged into the beta branch due to: **${reason}** -The following team PRs failed to merge into the beta branch: +Please resolve this issue to include this PR in the next beta release.` -${failures.map((f) => `- **#${f.number}**: ${f.title} - ${f.reason}`).join("\n")} - -Please resolve these conflicts manually.` - - const content = JSON.stringify({ content: message }) - - const response = await fetch(webhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: content, - }) - - if (!response.ok) { - console.error("Failed to post to Discord:", await response.text()) - } else { - console.log("Posted failures to Discord") + try { + await $`gh pr comment ${prNumber} --body ${body}` + console.log(` Posted comment on PR #${prNumber}`) + } catch (err) { + console.log(` Failed to post comment on PR #${prNumber}: ${err}`) } } @@ -91,6 +76,7 @@ async function main() { } catch (err) { console.log(` Failed to fetch: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" }) + await commentOnPR(pr.number, "Fetch failed") continue } @@ -109,6 +95,7 @@ async function main() { await $`git clean -fd` } catch {} failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) + await commentOnPR(pr.number, "Merge conflicts with dev branch") continue } @@ -124,6 +111,7 @@ async function main() { } catch { console.log(" Failed to stage changes") failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" }) + await commentOnPR(pr.number, "Failed to stage changes") continue } @@ -133,6 +121,7 @@ async function main() { } catch (err) { console.log(` Failed to commit: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" }) + await commentOnPR(pr.number, "Failed to commit changes") continue } @@ -147,10 +136,7 @@ async function main() { if (failed.length > 0) { console.log(`Failed: ${failed.length} PRs`) failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) - - await postToDiscord(failed) - - throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) + throw new Error(`${failed.length} PR(s) failed to merge`) } console.log("\nForce pushing beta branch...") From 4158d7cda87863a9b5a4eea29a40270b8a60a68f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:57:49 -0500 Subject: [PATCH 064/148] ci: add --label beta filter to only process PRs with beta label --- script/beta.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 264fa9bc61b..50dc2ad783e 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -7,6 +7,7 @@ interface PR { number: number title: string author: { login: string } + labels: Array<{ name: string }> } interface FailedPR { @@ -31,12 +32,13 @@ Please resolve this issue to include this PR in the next beta release.` } async function main() { - console.log("Fetching open PRs from team members...") + console.log("Fetching open PRs with beta label from team members...") const allPrs: PR[] = [] for (const member of Script.team) { try { - const stdout = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.text() + const stdout = + await $`gh pr list --state open --author ${member} --label beta --json number,title,author,labels --limit 100`.text() const memberPrs: PR[] = JSON.parse(stdout) allPrs.push(...memberPrs) } catch { @@ -51,7 +53,7 @@ async function main() { return true }) - console.log(`Found ${prs.length} open PRs from team members`) + console.log(`Found ${prs.length} open PRs with beta label from team members`) if (prs.length === 0) { console.log("No team PRs to merge") From 372dcc033c71d0789db25dc7059a5dc9ea3f4cf6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:58:46 -0500 Subject: [PATCH 065/148] ci: change trigger from scheduled cron to PR labeled events with beta label condition --- .github/workflows/beta.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 6edbb4326ce..4b418471f81 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -2,14 +2,16 @@ name: beta on: workflow_dispatch: - schedule: - - cron: "0 * * * *" + pull_request: + types: [labeled] jobs: sync: + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'beta' runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write + pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v4 @@ -29,5 +31,4 @@ jobs: - name: Sync beta branch env: GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }} - DISCORD_ISSUES_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} run: bun script/beta.ts From 7837bbc639c79d74ade3e6c397da3cb34a58b198 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:59:05 -0500 Subject: [PATCH 066/148] ci: add synchronize event and check for beta label using contains() --- .github/workflows/beta.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 4b418471f81..c0526ac6766 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -3,11 +3,11 @@ name: beta on: workflow_dispatch: pull_request: - types: [labeled] + types: [labeled, synchronize] jobs: sync: - if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'beta' + if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'beta') runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write From f390ac251dc2d7d64a5b1a97b9133fcf65c0a2b1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:17:15 -0500 Subject: [PATCH 067/148] ci: centralize team list in @opencode-ai/script package and use beta label filter --- packages/script/src/index.ts | 17 +++++++++++++++++ script/beta.ts | 25 ++++--------------------- script/changelog.ts | 19 +++---------------- 3 files changed, 24 insertions(+), 37 deletions(-) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 2d991ff0c37..496bdede2d8 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -46,6 +46,20 @@ const VERSION = await (async () => { return `${major}.${minor}.${patch + 1}` })() +const team = [ + "actions-user", + "opencode", + "rekram1-node", + "thdxr", + "kommander", + "jayair", + "fwang", + "adamdotdevin", + "iamdavidhill", + "opencode-agent[bot]", + "R44VC0RP", +] + export const Script = { get channel() { return CHANNEL @@ -59,5 +73,8 @@ export const Script = { get release() { return env.OPENCODE_RELEASE }, + get team() { + return team + }, } console.log(`opencode script`, JSON.stringify(Script, null, 2)) diff --git a/script/beta.ts b/script/beta.ts index 50dc2ad783e..53329e4dce5 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,7 +1,6 @@ #!/usr/bin/env bun import { $ } from "bun" -import { Script } from "@opencode-ai/script" interface PR { number: number @@ -32,28 +31,12 @@ Please resolve this issue to include this PR in the next beta release.` } async function main() { - console.log("Fetching open PRs with beta label from team members...") + console.log("Fetching open PRs with beta label...") - const allPrs: PR[] = [] - for (const member of Script.team) { - try { - const stdout = - await $`gh pr list --state open --author ${member} --label beta --json number,title,author,labels --limit 100`.text() - const memberPrs: PR[] = JSON.parse(stdout) - allPrs.push(...memberPrs) - } catch { - // Skip member on error - } - } + const stdout = await $`gh pr list --state open --label beta --json number,title,author,labels --limit 100`.text() + const prs: PR[] = JSON.parse(stdout) - const seen = new Set() - const prs = allPrs.filter((pr) => { - if (seen.has(pr.number)) return false - seen.add(pr.number) - return true - }) - - console.log(`Found ${prs.length} open PRs with beta label from team members`) + console.log(`Found ${prs.length} open PRs with beta label`) if (prs.length === 0) { console.log("No team PRs to merge") diff --git a/script/changelog.ts b/script/changelog.ts index 0043cd3d626..5fc30a228bf 100755 --- a/script/changelog.ts +++ b/script/changelog.ts @@ -3,20 +3,7 @@ import { $ } from "bun" import { createOpencode } from "@opencode-ai/sdk/v2" import { parseArgs } from "util" - -export const team = [ - "actions-user", - "opencode", - "rekram1-node", - "thdxr", - "kommander", - "jayair", - "fwang", - "adamdotdevin", - "iamdavidhill", - "opencode-agent[bot]", - "R44VC0RP", -] +import { Script } from "@opencode-ai/script" type Release = { tag_name: string @@ -191,7 +178,7 @@ export async function generateChangelog(commits: Commit[], opencode: Awaited Date: Sun, 1 Feb 2026 20:17:50 -0500 Subject: [PATCH 068/148] ci: run beta workflow on hourly schedule only --- .github/workflows/beta.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index c0526ac6766..1ba25bce400 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -1,13 +1,11 @@ name: beta on: - workflow_dispatch: - pull_request: - types: [labeled, synchronize] + schedule: + - cron: "0 * * * *" jobs: sync: - if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'beta') runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write From d3d783e23db89fd020430359979a8c815053a80b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:18:12 -0500 Subject: [PATCH 069/148] ci: allow manual dispatch for beta workflow --- .github/workflows/beta.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 1ba25bce400..20d2bc18d82 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -1,6 +1,7 @@ name: beta on: + workflow_dispatch: schedule: - cron: "0 * * * *" From 3ab41d548f0c2352d6fd351e16d8151bc4c25f00 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:35:02 -0500 Subject: [PATCH 070/148] ci: skip force push when beta branch is unchanged --- script/beta.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/script/beta.ts b/script/beta.ts index 53329e4dce5..4355c5879cc 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -124,7 +124,18 @@ async function main() { throw new Error(`${failed.length} PR(s) failed to merge`) } - console.log("\nForce pushing beta branch...") + console.log("\nChecking if beta branch has changes...") + await $`git fetch origin beta` + + const localTree = await $`git rev-parse beta^{tree}`.text() + const remoteTree = await $`git rev-parse origin/beta^{tree}`.text() + + if (localTree.trim() === remoteTree.trim()) { + console.log("Beta branch has identical contents, no push needed") + return + } + + console.log("Force pushing beta branch...") await $`git push origin beta --force --no-verify` console.log("Successfully synced beta branch") From 6c9b2c37a5e12f0e79ce5a18af33073c9e646cc1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:39:58 -0500 Subject: [PATCH 071/148] core: allow starting new sessions after errors by fixing stuck session status --- packages/opencode/src/session/processor.ts | 1 + packages/opencode/src/session/prompt.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index ada6f83145b..24b4a4f9fbc 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -368,6 +368,7 @@ export namespace SessionProcessor { sessionID: input.assistantMessage.sessionID, error: input.assistantMessage.error, }) + SessionStatus.set(input.sessionID, { type: "idle" }) } if (snapshot) { const patch = await Snapshot.patch(snapshot) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c6d040f2fab..98dce97ba90 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -62,7 +62,7 @@ export namespace SessionPrompt { abort: AbortController callbacks: { resolve(input: MessageV2.WithParts): void - reject(): void + reject(reason?: any): void }[] } > = {} @@ -72,7 +72,7 @@ export namespace SessionPrompt { for (const item of Object.values(current)) { item.abort.abort() for (const callback of item.callbacks) { - callback.reject() + callback.reject(new DOMException("Aborted", "AbortError")) } } }, @@ -251,10 +251,13 @@ export namespace SessionPrompt { log.info("cancel", { sessionID }) const s = state() const match = s[sessionID] - if (!match) return + if (!match) { + SessionStatus.set(sessionID, { type: "idle" }) + return + } match.abort.abort() for (const item of match.callbacks) { - item.reject() + item.reject(new DOMException("Aborted", "AbortError")) } delete s[sessionID] SessionStatus.set(sessionID, { type: "idle" }) From 83d0e48e3854a4a9f8c28ec17398a8554c988d39 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:52:17 -0500 Subject: [PATCH 072/148] tui: fix task status to show current tool state from message store --- AGENTS.md | 1 + .../src/cli/cmd/tui/routes/session/index.tsx | 28 ++++++++++++++----- packages/opencode/src/tool/task.ts | 1 - 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8cfe768da39..3df71f64190 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ - Prefer single word variable names where possible - Use Bun APIs when possible, like `Bun.file()` - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity +- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream ### Avoid let statements diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index ada84e487b5..209469bad82 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1799,9 +1799,19 @@ function Task(props: ToolProps) { const keybind = useKeybind() const { navigate } = useRoute() const local = useLocal() + const sync = useSync() - const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending")) - const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown")) + const tools = createMemo(() => { + const sessionID = props.metadata.sessionId + const msgs = sync.data.message[sessionID ?? ""] ?? [] + return msgs.flatMap((msg) => + (sync.data.part[msg.id] ?? []) + .filter((part): part is ToolPart => part.type === "tool") + .map((part) => ({ tool: part.tool, state: part.state })), + ) + }) + + const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending")) return ( @@ -1817,13 +1827,17 @@ function Task(props: ToolProps) { > - {props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls) + {props.input.description} ({tools().length} toolcalls) - - └ {Locale.titlecase(current()!.tool)}{" "} - {current()!.state.status === "completed" ? current()!.state.title : ""} - + {(item) => { + const title = item().state.status === "completed" ? (item().state as any).title : "" + return ( + + └ {Locale.titlecase(item().tool)} {title} + + ) + }} diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index ad4268b7b0d..ac28d9f322f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -130,7 +130,6 @@ export const TaskTool = Tool.define("task", async (ctx) => { ctx.metadata({ title: params.description, metadata: { - summary: Object.values(parts).sort((a, b) => a.id.localeCompare(b.id)), sessionId: session.id, model, }, From 0f3630d936f8bf8e53edc8050c37696f3a098d10 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 21:01:57 -0500 Subject: [PATCH 073/148] ci: skip unicode filename test due to inconsistent behavior causing CI failures --- packages/opencode/test/snapshot/snapshot.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index de58f4f85e6..ef6271ed5da 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -292,7 +292,7 @@ test("unicode filenames", async () => { }) }) -test("unicode filenames modification and restore", async () => { +test.skip("unicode filenames modification and restore", async () => { await using tmp = await bootstrap() await Instance.provide({ directory: tmp.path, From d1f884033f2325ffd344da53b93d89da2ca296ca Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 21:02:26 -0500 Subject: [PATCH 074/148] ignore: switch commit model to kimi-k2.5 for improved command execution reliability --- .opencode/command/commit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/command/commit.md b/.opencode/command/commit.md index 8260029195e..d8a420b1737 100644 --- a/.opencode/command/commit.md +++ b/.opencode/command/commit.md @@ -1,6 +1,6 @@ --- description: git commit and push -model: opencode/glm-4.7 +model: opencode/kimi-k2.5 subtask: true --- From 826664b559c3d42f60c8f48fa5dddce997cc0cf0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 21:16:13 -0500 Subject: [PATCH 075/148] ci: restrict nix-hashes workflow to dev branch pushes only Remove pull_request trigger and limit push trigger to dev branch to prevent unnecessary workflow runs on feature branches and PRs. The workflow will now only execute when dependency files change on the dev branch. --- .github/workflows/nix-hashes.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index cc16d81844b..5446f9212fb 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -6,13 +6,7 @@ permissions: on: workflow_dispatch: push: - paths: - - "bun.lock" - - "package.json" - - "packages/*/package.json" - - "flake.lock" - - ".github/workflows/nix-hashes.yml" - pull_request: + branches: [dev] paths: - "bun.lock" - "package.json" From 8fbba8de73461b82dfa72c870b8686c9e177ead5 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 1 Feb 2026 21:39:24 -0500 Subject: [PATCH 076/148] ci: Fix Pulumi version conflict in deploy workflow Added a workaround to fix Pulumi version conflict in the deployment workflow. --- .github/workflows/deploy.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 25466a63e06..c08d7edf3b1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,6 +21,15 @@ jobs: with: node-version: "24" + # Workaround for Pulumi version conflict: + # GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag + # from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065). + # SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict. + # Removing the system language plugin forces SST to use its bundled compatible version. + # TODO: Remove when sst supports Pulumi >3.210.0 + - name: Fix Pulumi version conflict + run: sudo rm -f /usr/local/bin/pulumi-language-nodejs + - run: bun sst deploy --stage=${{ github.ref_name }} env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} From eade8ee07ba36ab3ee2e409135172c5503ae1c11 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 22:28:00 -0500 Subject: [PATCH 077/148] docs: Restructure AGENTS.md style guide with organized sections and code examples --- AGENTS.md | 96 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 62 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3df71f64190..eeec0c3418d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,9 @@ ## Style Guide +### General Principles + - Keep things in one function unless composable or reusable -- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context - Avoid `try`/`catch` where possible - Avoid using the `any` type - Prefer single word variable names where possible @@ -14,70 +15,97 @@ - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity - Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream -### Avoid let statements +### Naming -We don't like `let` statements, especially combined with if/else statements. -Prefer `const`. - -Good: +Prefer single word names for variables and functions. Only use multiple words if necessary. ```ts -const foo = condition ? 1 : 2 +// Good +const foo = 1 +function journal(dir: string) {} + +// Bad +const fooBar = 1 +function prepareJournal(dir: string) {} ``` -Bad: +Reduce total variable count by inlining when a value is only used once. ```ts -let foo +// Good +const journal = await Bun.file(path.join(dir, "journal.json")).json() +// Bad +const journalPath = path.join(dir, "journal.json") +const journal = await Bun.file(journalPath).json() +``` + +### Destructuring + +Avoid unnecessary destructuring. Use dot notation to preserve context. + +```ts +// Good +obj.a +obj.b + +// Bad +const { a, b } = obj +``` + +### Variables + +Prefer `const` over `let`. Use ternaries or early returns instead of reassignment. + +```ts +// Good +const foo = condition ? 1 : 2 + +// Bad +let foo if (condition) foo = 1 else foo = 2 ``` -### Avoid else statements +### Control Flow -Prefer early returns or using an `iife` to avoid else statements. - -Good: +Avoid `else` statements. Prefer early returns. ```ts +// Good function foo() { if (condition) return 1 return 2 } -``` -Bad: - -```ts +// Bad function foo() { if (condition) return 1 else return 2 } ``` -### Prefer single word naming +### Schema Definitions (Drizzle) -Try your best to find a single word name for your variables, functions, etc. -Only use multiple words if you cannot. - -Good: +Use snake_case for field names so column names don't need to be redefined as strings. ```ts -const foo = 1 -const bar = 2 -const baz = 3 -``` +// Good +const table = sqliteTable("session", { + id: text().primaryKey(), + project_id: text().notNull(), + created_at: integer().notNull(), +}) -Bad: - -```ts -const fooBar = 1 -const barBaz = 2 -const bazFoo = 3 +// Bad +const table = sqliteTable("session", { + id: text("id").primaryKey(), + projectID: text("project_id").notNull(), + createdAt: integer("created_at").notNull(), +}) ``` ## Testing -You MUST avoid using `mocks` as much as possible. -Tests MUST test actual implementation, do not duplicate logic into a test. +- Avoid mocks as much as possible +- Test actual implementation, do not duplicate logic into tests From c5dc075a8866e72ba710ade780044505d96d359e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 22:29:45 -0500 Subject: [PATCH 078/148] Revert "fix(plugin): correct exports to point to dist instead of src" This reverts commit 7417e6eb38e96c5e3904a4503740a305475d154a. --- packages/plugin/package.json | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 160ce6a8266..5a4afbae439 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -9,14 +9,8 @@ "build": "tsc" }, "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./tool": { - "types": "./dist/tool.d.ts", - "import": "./dist/tool.js" - } + ".": "./src/index.ts", + "./tool": "./src/tool.ts" }, "files": [ "dist" From a4d31b6f950117b6ad241a0d191759d37d3302f4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 22:32:45 -0500 Subject: [PATCH 079/148] ci: enable typecheck on push to dev branch to catch type errors immediately after merge --- .github/workflows/typecheck.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 011e23f5f6f..b247d24b40d 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -1,6 +1,8 @@ name: typecheck on: + push: + branches: [dev] pull_request: branches: [dev] workflow_dispatch: From 8e985e0a75ca5f2cb859434fe82dee7ea81cb59f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 2 Feb 2026 04:48:29 +0100 Subject: [PATCH 080/148] Use opentui OSC52 clipboard (#11718) Co-authored-by: opencode-agent[bot] --- bun.lock | 20 +++++++------- nix/hashes.json | 8 +++--- packages/opencode/package.json | 4 +-- packages/opencode/src/cli/cmd/tui/app.tsx | 1 + .../src/cli/cmd/tui/util/clipboard.ts | 26 ++++++++----------- 5 files changed, 28 insertions(+), 31 deletions(-) diff --git a/bun.lock b/bun.lock index 0857283634a..ace27359910 100644 --- a/bun.lock +++ b/bun.lock @@ -298,8 +298,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.75", - "@opentui/solid": "0.1.75", + "@opentui/core": "0.1.76", + "@opentui/solid": "0.1.76", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -1227,21 +1227,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.75", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="], + "@opentui/core": ["@opentui/core@0.1.76", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.76", "@opentui/core-darwin-x64": "0.1.76", "@opentui/core-linux-arm64": "0.1.76", "@opentui/core-linux-x64": "0.1.76", "@opentui/core-win32-arm64": "0.1.76", "@opentui/core-win32-x64": "0.1.76", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Y4f4KH6Mbj0J6+MorcvtHSeT+Lbs3YDPEQcTRTWsPOqWz3A0F5/+OPtZKho1EtLWQqJflCWdf/JQj5A3We3qRg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.76", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aRYNOPRKL6URovSPhRvXtBV7SqdmR7s6hmEBSdXiYo39AozTcvKviF8gJWXQATcKDEcOtRir6TsASzDq5Coheg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.76", "", { "os": "darwin", "cpu": "x64" }, "sha512-KFaRvVQ0Wr1PgaexUkF3KYt41pYmxGJW3otENeE6WDa/nXe2AElibPFRjqSEH54YrY5Q84SDI77/wGP4LZ/Wyg=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.76", "", { "os": "linux", "cpu": "arm64" }, "sha512-s7v+GDwavfieZg8xZV4V07fXFrHfFq4UZ2JpYFDUgNs9vFp+++WUjh3pfbfE+2ldbhcG2iOtuiV9aG1tVCbTEg=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.76", "", { "os": "linux", "cpu": "x64" }, "sha512-ugwuHpmvdKRHXKVsrC3zRYY6bg2JxVCzAQ1NOiWRLq3N3N4ha6BHAkHMCeHgR/ZI4R8MSRB6vtJRVI1F9VHxjA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.76", "", { "os": "win32", "cpu": "arm64" }, "sha512-wjpRWrerPItb5E1fP4SAcNMxQp1yEukbgvP4Azip836/ixxbghL6y0P57Ya/rv7QYLrkNZXoQ+tr9oXhPH5BVA=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.76", "", { "os": "win32", "cpu": "x64" }, "sha512-2YjtZJdd3iO+SY9NKocE4/Pm9VolzAthUOXjpK4Pv5pnR9hBpPvX7FFSXJTfASj7y2j1tATWrlQLocZCFP/oMA=="], - "@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="], + "@opentui/solid": ["@opentui/solid@0.1.76", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.76", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-PiD62FGoPoVLFpY4g08i4UYlx4sGR2OmHUPj6CuZZwy2UJD4fKn1RYV+kAPHfUW4qN/88I1k/w/Dniz1WvXrAQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/nix/hashes.json b/nix/hashes.json index 6fe8f61d3d1..9843699345d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=", - "aarch64-linux": "sha256-88Qai5RkSenCZkakOg52b6xU2ok+h/Ns4/5L3+55sFY=", - "aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=", - "x86_64-darwin": "sha256-FkLDqorfIfOw+tB7SW5vgyhOIoI0IV9lqPW1iEmvUiI=" + "x86_64-linux": "sha256-aRFzPzgu32XgNSk8S2z4glTlgHqEmOLZHlBQSIYIMvY=", + "aarch64-linux": "sha256-aCZLkmRrCa0bli0jgsaLcC5GlZdjQPbb6xD6Fc03eX8=", + "aarch64-darwin": "sha256-oZOOR6k8MmabNVDQNY5ywR06rRycdnXZL+gUucKSQ+g=", + "x86_64-darwin": "sha256-LXIcLnjn+1eTFWIsQ9W0U2orGm59P/L470O0KFFkRHg=" } } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 4afb7243006..45074b96c98 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -82,8 +82,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.75", - "@opentui/solid": "0.1.75", + "@opentui/core": "0.1.76", + "@opentui/solid": "0.1.76", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 713def2e5af..2e1ffa4f008 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -186,6 +186,7 @@ function App() { const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() + Clipboard.setRenderer(renderer) renderer.disableStdoutInterception() const dialog = useDialog() const local = useLocal() diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 0e287fbc41a..5c27a26cd03 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -1,24 +1,12 @@ import { $ } from "bun" +import type { CliRenderer } from "@opentui/core" import { platform, release } from "os" import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" -/** - * Writes text to clipboard via OSC 52 escape sequence. - * This allows clipboard operations to work over SSH by having - * the terminal emulator handle the clipboard locally. - */ -function writeOsc52(text: string): void { - if (!process.stdout.isTTY) return - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - // tmux and screen require DCS passthrough wrapping - const passthrough = process.env["TMUX"] || process.env["STY"] - const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - process.stdout.write(sequence) -} +const rendererRef = { current: undefined as CliRenderer | undefined } export namespace Clipboard { export interface Content { @@ -26,6 +14,10 @@ export namespace Clipboard { mime: string } + export function setRenderer(renderer: CliRenderer | undefined): void { + rendererRef.current = renderer + } + export async function read(): Promise { const os = platform() @@ -154,7 +146,11 @@ export namespace Clipboard { }) export async function copy(text: string): Promise { - writeOsc52(text) + const renderer = rendererRef.current + if (renderer) { + const copied = renderer.copyToClipboardOSC52(text) + if (copied) return + } await getCopyMethod()(text) } } From 0dc80df6fd1f4c89cebf9ed20cd9d9291f996236 Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 1 Feb 2026 22:51:55 -0500 Subject: [PATCH 081/148] Add spinner animation for Task tool (#11725) --- .../cmd/tui/component/dialog-session-list.tsx | 10 ++------ .../src/cli/cmd/tui/component/spinner.tsx | 24 ++++++++++++++++++ .../src/cli/cmd/tui/routes/session/index.tsx | 25 ++++++++++++++++--- 3 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/spinner.tsx diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 85c174c1dcb..775969bfcb3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -10,7 +10,7 @@ import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" import { useKV } from "../context/kv" import { createDebouncedSignal } from "../util/signal" -import "opentui-spinner/solid" +import { Spinner } from "./spinner" export function DialogSessionList() { const dialog = useDialog() @@ -32,8 +32,6 @@ export function DialogSessionList() { const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - const sessions = createMemo(() => searchResults() ?? sync.data.session) const options = createMemo(() => { @@ -56,11 +54,7 @@ export function DialogSessionList() { value: x.id, category, footer: Locale.time(x.time.updated), - gutter: isWorking ? ( - [⋯]}> - - - ) : undefined, + gutter: isWorking ? : undefined, } }) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/spinner.tsx b/packages/opencode/src/cli/cmd/tui/component/spinner.tsx new file mode 100644 index 00000000000..8dc54555043 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/spinner.tsx @@ -0,0 +1,24 @@ +import { Show } from "solid-js" +import { useTheme } from "../context/theme" +import { useKV } from "../context/kv" +import type { JSX } from "@opentui/solid" +import type { RGBA } from "@opentui/core" +import "opentui-spinner/solid" + +const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + +export function Spinner(props: { children?: JSX.Element; color?: RGBA }) { + const { theme } = useTheme() + const kv = useKV() + const color = () => props.color ?? theme.textMuted + return ( + ⋯ {props.children}}> + + + + {props.children} + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 209469bad82..8316d112c9d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -16,6 +16,7 @@ import path from "path" import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { SplitBorder } from "@tui/component/border" +import { Spinner } from "@tui/component/spinner" import { useTheme } from "@tui/context/theme" import { BoxRenderable, @@ -1559,7 +1560,13 @@ function InlineTool(props: { ) } -function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void; part?: ToolPart }) { +function BlockTool(props: { + title: string + children: JSX.Element + onClick?: () => void + part?: ToolPart + spinner?: boolean +}) { const { theme } = useTheme() const renderer = useRenderer() const [hover, setHover] = createSignal(false) @@ -1582,9 +1589,16 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () = props.onClick?.() }} > - - {props.title} - + + {props.title} + + } + > + {props.title.replace(/^# /, "")} + {props.children} {error()} @@ -1813,6 +1827,8 @@ function Task(props: ToolProps) { const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending")) + const isRunning = createMemo(() => props.part.state.status === "running") + return ( @@ -1824,6 +1840,7 @@ function Task(props: ToolProps) { : undefined } part={props.part} + spinner={isRunning()} > From c69474846f079317c5d6a422c577793c5ba65b8d Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 1 Feb 2026 23:37:07 -0500 Subject: [PATCH 082/148] Simplify directory tree output for prompts (#11731) --- packages/opencode/src/file/ripgrep.ts | 110 +++++++----------------- packages/opencode/src/session/system.ts | 6 +- 2 files changed, 36 insertions(+), 80 deletions(-) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 463a9fb362f..c1e5113bf89 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -275,100 +275,56 @@ export namespace Ripgrep { log.info("tree", input) const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal })) interface Node { - path: string[] - children: Node[] + name: string + children: Map } - function getPath(node: Node, parts: string[], create: boolean) { - if (parts.length === 0) return node - let current = node - for (const part of parts) { - let existing = current.children.find((x) => x.path.at(-1) === part) - if (!existing) { - if (!create) return - existing = { - path: current.path.concat(part), - children: [], - } - current.children.push(existing) - } - current = existing - } - return current + function dir(node: Node, name: string) { + const existing = node.children.get(name) + if (existing) return existing + const next = { name, children: new Map() } + node.children.set(name, next) + return next } - const root: Node = { - path: [], - children: [], - } + const root: Node = { name: "", children: new Map() } for (const file of files) { if (file.includes(".opencode")) continue const parts = file.split(path.sep) - getPath(root, parts, true) - } - - function sort(node: Node) { - node.children.sort((a, b) => { - if (!a.children.length && b.children.length) return 1 - if (!b.children.length && a.children.length) return -1 - return a.path.at(-1)!.localeCompare(b.path.at(-1)!) - }) - for (const child of node.children) { - sort(child) + if (parts.length < 2) continue + let node = root + for (const part of parts.slice(0, -1)) { + node = dir(node, part) } } - sort(root) - let current = [root] - const result: Node = { - path: [], - children: [], - } - - let processed = 0 - const limit = input.limit ?? 50 - while (current.length > 0) { - const next = [] - for (const node of current) { - if (node.children.length) next.push(...node.children) + function count(node: Node): number { + let total = 0 + for (const child of node.children.values()) { + total += 1 + count(child) } - const max = Math.max(...current.map((x) => x.children.length)) - for (let i = 0; i < max && processed < limit; i++) { - for (const node of current) { - const child = node.children[i] - if (!child) continue - getPath(result, child.path, true) - processed++ - if (processed >= limit) break - } - } - if (processed >= limit) { - for (const node of [...current, ...next]) { - const compare = getPath(result, node.path, false) - if (!compare) continue - if (compare?.children.length !== node.children.length) { - const diff = node.children.length - compare.children.length - compare.children.push({ - path: compare.path.concat(`[${diff} truncated]`), - children: [], - }) - } - } - break - } - current = next + return total } + const total = count(root) + const limit = input.limit ?? total const lines: string[] = [] + const queue: { node: Node; path: string }[] = [] + for (const child of Array.from(root.children.values()).sort((a, b) => a.name.localeCompare(b.name))) { + queue.push({ node: child, path: child.name }) + } - function render(node: Node, depth: number) { - const indent = "\t".repeat(depth) - lines.push(indent + node.path.at(-1) + (node.children.length ? "/" : "")) - for (const child of node.children) { - render(child, depth + 1) + let used = 0 + for (let i = 0; i < queue.length && used < limit; i++) { + const { node, path } = queue[i] + lines.push(path) + used++ + for (const child of Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name))) { + queue.push({ node: child, path: `${path}/${child.name}` }) } } - result.children.map((x) => render(x, 0)) + + if (total > used) lines.push(`[${total - used} truncated]`) return lines.join("\n") } diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index d34a086fe44..b02267bf8ac 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -36,16 +36,16 @@ export namespace SystemPrompt { ` Platform: ${process.platform}`, ` Today's date: ${new Date().toDateString()}`, ``, - ``, + ``, ` ${ project.vcs === "git" && false ? await Ripgrep.tree({ cwd: Instance.directory, - limit: 200, + limit: 50, }) : "" }`, - ``, + ``, ].join("\n"), ] } From bd9d7b3221ae144ff098c5fbb688edc75e755d3b Mon Sep 17 00:00:00 2001 From: Mathias Beugnon Date: Mon, 2 Feb 2026 06:10:53 +0100 Subject: [PATCH 083/148] fix: session title generation with OpenAI models. (#11678) --- packages/opencode/src/provider/transform.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index ded416e66d9..c05bf75c46f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -658,11 +658,18 @@ export namespace ProviderTransform { } export function smallOptions(model: Provider.Model) { - if (model.providerID === "openai" || model.api.id.includes("gpt-5")) { - if (model.api.id.includes("5.")) { - return { reasoningEffort: "low" } + if ( + model.providerID === "openai" || + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/github-copilot" + ) { + if (model.api.id.includes("gpt-5")) { + if (model.api.id.includes("5.")) { + return { store: false, reasoningEffort: "low" } + } + return { store: false, reasoningEffort: "minimal" } } - return { reasoningEffort: "minimal" } + return { store: false } } if (model.providerID === "google") { // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget From f02499fa44ea3563fec3d0c456550b17dcef4ce5 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 2 Feb 2026 06:11:25 +0100 Subject: [PATCH 084/148] fix(opencode): give OPENCODE_CONFIG_CONTENT proper priority for setting config based on docs (#11670) --- packages/opencode/src/config/config.ts | 29 ++++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 98970ba392d..b0164e8aa86 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -62,8 +62,14 @@ export namespace Config { export const state = Instance.state(async () => { const auth = await Auth.all() - // Load remote/well-known config first as the base layer (lowest precedence) - // This allows organizations to provide default configs that users can override + // Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order + // 1) Remote .well-known/opencode (org defaults) + // 2) Global config (~/.config/opencode/opencode.json{,c}) + // 3) Custom config (OPENCODE_CONFIG) + // 4) Project config (opencode.json{,c}) + // 5) .opencode directories (.opencode/agents/, .opencode/commands/, .opencode/plugins/, .opencode/opencode.json{,c}) + // 6) Inline config (OPENCODE_CONFIG_CONTENT) + // Managed config directory is enterprise-only and always overrides everything above. let result: Info = {} for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { @@ -85,16 +91,16 @@ export namespace Config { } } - // Global user config overrides remote config + // Global user config overrides remote config. result = mergeConfigConcatArrays(result, await global()) - // Custom config path overrides global + // Custom config path overrides global config. if (Flag.OPENCODE_CONFIG) { result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG)) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) } - // Project config has highest precedence (overrides global and remote) + // Project config overrides global and remote config. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { for (const file of ["opencode.jsonc", "opencode.json"]) { const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) @@ -104,12 +110,6 @@ export namespace Config { } } - // Inline config content has highest precedence - if (Flag.OPENCODE_CONFIG_CONTENT) { - result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT)) - log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") - } - result.agent = result.agent || {} result.mode = result.mode || {} result.plugin = result.plugin || [] @@ -136,6 +136,7 @@ export namespace Config { )), ] + // .opencode directory config overrides (project and global) config sources. if (Flag.OPENCODE_CONFIG_DIR) { directories.push(Flag.OPENCODE_CONFIG_DIR) log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) @@ -163,6 +164,12 @@ export namespace Config { result.plugin.push(...(await loadPlugin(dir))) } + // Inline config content overrides all non-managed config sources. + if (Flag.OPENCODE_CONFIG_CONTENT) { + result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT)) + log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") + } + // Load managed config files last (highest priority) - enterprise admin-controlled // Kept separate from directories array to avoid write operations when installing plugins // which would fail on system directories requiring elevated permissions From cfbe9d329f50126fa6d7cca3c25e43c48f96adc3 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 00:13:02 -0500 Subject: [PATCH 085/148] Revert "Use opentui OSC52 clipboard (#11718)" This reverts commit 8e985e0a75ca5f2cb859434fe82dee7ea81cb59f. --- bun.lock | 20 +++++++------- nix/hashes.json | 8 +++--- packages/opencode/package.json | 4 +-- packages/opencode/src/cli/cmd/tui/app.tsx | 1 - .../src/cli/cmd/tui/util/clipboard.ts | 26 +++++++++++-------- 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/bun.lock b/bun.lock index ace27359910..0857283634a 100644 --- a/bun.lock +++ b/bun.lock @@ -298,8 +298,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.76", - "@opentui/solid": "0.1.76", + "@opentui/core": "0.1.75", + "@opentui/solid": "0.1.75", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -1227,21 +1227,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.76", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.76", "@opentui/core-darwin-x64": "0.1.76", "@opentui/core-linux-arm64": "0.1.76", "@opentui/core-linux-x64": "0.1.76", "@opentui/core-win32-arm64": "0.1.76", "@opentui/core-win32-x64": "0.1.76", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Y4f4KH6Mbj0J6+MorcvtHSeT+Lbs3YDPEQcTRTWsPOqWz3A0F5/+OPtZKho1EtLWQqJflCWdf/JQj5A3We3qRg=="], + "@opentui/core": ["@opentui/core@0.1.75", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.76", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aRYNOPRKL6URovSPhRvXtBV7SqdmR7s6hmEBSdXiYo39AozTcvKviF8gJWXQATcKDEcOtRir6TsASzDq5Coheg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.76", "", { "os": "darwin", "cpu": "x64" }, "sha512-KFaRvVQ0Wr1PgaexUkF3KYt41pYmxGJW3otENeE6WDa/nXe2AElibPFRjqSEH54YrY5Q84SDI77/wGP4LZ/Wyg=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.76", "", { "os": "linux", "cpu": "arm64" }, "sha512-s7v+GDwavfieZg8xZV4V07fXFrHfFq4UZ2JpYFDUgNs9vFp+++WUjh3pfbfE+2ldbhcG2iOtuiV9aG1tVCbTEg=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.76", "", { "os": "linux", "cpu": "x64" }, "sha512-ugwuHpmvdKRHXKVsrC3zRYY6bg2JxVCzAQ1NOiWRLq3N3N4ha6BHAkHMCeHgR/ZI4R8MSRB6vtJRVI1F9VHxjA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.76", "", { "os": "win32", "cpu": "arm64" }, "sha512-wjpRWrerPItb5E1fP4SAcNMxQp1yEukbgvP4Azip836/ixxbghL6y0P57Ya/rv7QYLrkNZXoQ+tr9oXhPH5BVA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.76", "", { "os": "win32", "cpu": "x64" }, "sha512-2YjtZJdd3iO+SY9NKocE4/Pm9VolzAthUOXjpK4Pv5pnR9hBpPvX7FFSXJTfASj7y2j1tATWrlQLocZCFP/oMA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="], - "@opentui/solid": ["@opentui/solid@0.1.76", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.76", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-PiD62FGoPoVLFpY4g08i4UYlx4sGR2OmHUPj6CuZZwy2UJD4fKn1RYV+kAPHfUW4qN/88I1k/w/Dniz1WvXrAQ=="], + "@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/nix/hashes.json b/nix/hashes.json index 9843699345d..6fe8f61d3d1 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-aRFzPzgu32XgNSk8S2z4glTlgHqEmOLZHlBQSIYIMvY=", - "aarch64-linux": "sha256-aCZLkmRrCa0bli0jgsaLcC5GlZdjQPbb6xD6Fc03eX8=", - "aarch64-darwin": "sha256-oZOOR6k8MmabNVDQNY5ywR06rRycdnXZL+gUucKSQ+g=", - "x86_64-darwin": "sha256-LXIcLnjn+1eTFWIsQ9W0U2orGm59P/L470O0KFFkRHg=" + "x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=", + "aarch64-linux": "sha256-88Qai5RkSenCZkakOg52b6xU2ok+h/Ns4/5L3+55sFY=", + "aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=", + "x86_64-darwin": "sha256-FkLDqorfIfOw+tB7SW5vgyhOIoI0IV9lqPW1iEmvUiI=" } } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 45074b96c98..4afb7243006 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -82,8 +82,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.76", - "@opentui/solid": "0.1.76", + "@opentui/core": "0.1.75", + "@opentui/solid": "0.1.75", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2e1ffa4f008..713def2e5af 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -186,7 +186,6 @@ function App() { const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() - Clipboard.setRenderer(renderer) renderer.disableStdoutInterception() const dialog = useDialog() const local = useLocal() diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 5c27a26cd03..0e287fbc41a 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -1,12 +1,24 @@ import { $ } from "bun" -import type { CliRenderer } from "@opentui/core" import { platform, release } from "os" import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" -const rendererRef = { current: undefined as CliRenderer | undefined } +/** + * Writes text to clipboard via OSC 52 escape sequence. + * This allows clipboard operations to work over SSH by having + * the terminal emulator handle the clipboard locally. + */ +function writeOsc52(text: string): void { + if (!process.stdout.isTTY) return + const base64 = Buffer.from(text).toString("base64") + const osc52 = `\x1b]52;c;${base64}\x07` + // tmux and screen require DCS passthrough wrapping + const passthrough = process.env["TMUX"] || process.env["STY"] + const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 + process.stdout.write(sequence) +} export namespace Clipboard { export interface Content { @@ -14,10 +26,6 @@ export namespace Clipboard { mime: string } - export function setRenderer(renderer: CliRenderer | undefined): void { - rendererRef.current = renderer - } - export async function read(): Promise { const os = platform() @@ -146,11 +154,7 @@ export namespace Clipboard { }) export async function copy(text: string): Promise { - const renderer = rendererRef.current - if (renderer) { - const copied = renderer.copyToClipboardOSC52(text) - if (copied) return - } + writeOsc52(text) await getCopyMethod()(text) } } From 7a9290dc9b24f8fd905d68143a17528e708a9fe4 Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 2 Feb 2026 00:13:47 -0500 Subject: [PATCH 086/148] tui: show exit message banner (#11733) --- .../opencode/src/cli/cmd/tui/context/exit.tsx | 51 +++++++++++++++---- .../src/cli/cmd/tui/routes/session/index.tsx | 13 +++++ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx index 414cb1a41d0..2aac152204d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx @@ -1,23 +1,52 @@ import { useRenderer } from "@opentui/solid" import { createSimpleContext } from "./helper" import { FormatError, FormatUnknownError } from "@/cli/error" +type Exit = ((reason?: unknown) => Promise) & { + message: { + set: (value?: string) => () => void + clear: () => void + get: () => string | undefined + } +} export const { use: useExit, provider: ExitProvider } = createSimpleContext({ name: "Exit", init: (input: { onExit?: () => Promise }) => { const renderer = useRenderer() - return async (reason?: any) => { - // Reset window title before destroying renderer - renderer.setTerminalTitle("") - renderer.destroy() - await input.onExit?.() - if (reason) { - const formatted = FormatError(reason) ?? FormatUnknownError(reason) - if (formatted) { - process.stderr.write(formatted + "\n") + let message: string | undefined + const store = { + set: (value?: string) => { + const prev = message + message = value + return () => { + message = prev } - } - process.exit(0) + }, + clear: () => { + message = undefined + }, + get: () => message, } + const exit: Exit = Object.assign( + async (reason?: unknown) => { + // Reset window title before destroying renderer + renderer.setTerminalTitle("") + renderer.destroy() + await input.onExit?.() + if (reason) { + const formatted = FormatError(reason) ?? FormatUnknownError(reason) + if (formatted) { + process.stderr.write(formatted + "\n") + } + } + const text = store.get() + if (text) process.stdout.write(text + "\n") + process.exit(0) + }, + { + message: store, + }, + ) + return exit }, }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 8316d112c9d..f7d83b05554 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -77,6 +77,7 @@ import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" +import { UI } from "@/cli/ui.ts" addDefaultParsers(parsers.parsers) @@ -222,6 +223,18 @@ export function Session() { // Allow exit when in child session (prompt is hidden) const exit = useExit() + + createEffect(() => { + return exit.message.set( + [ + ``, + ` █▀▀█ ${UI.Style.TEXT_DIM}${session()?.title}${UI.Style.TEXT_NORMAL}`, + ` █ █ ${UI.Style.TEXT_DIM}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`, + ` ▀▀▀▀ `, + ].join("\n"), + ) + }) + useKeyboard((evt) => { if (!session()?.parentID) return if (keybind.match("app_exit", evt)) { From 43354eeabd0497ffdbd0f5d4d457205ed7f03537 Mon Sep 17 00:00:00 2001 From: Jigar Date: Mon, 2 Feb 2026 10:58:28 +0530 Subject: [PATCH 087/148] fix: convert system message content to string for Copilot provider (#11600) Co-authored-by: Claude Opus 4.5 --- ...nvert-to-openai-compatible-chat-messages.ts | 7 +------ .../convert-to-copilot-messages.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts index d6f7cb34bb5..e1e3ed4c201 100644 --- a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts +++ b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts @@ -18,12 +18,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro case "system": { messages.push({ role: "system", - content: [ - { - type: "text", - text: content, - }, - ], + content: content, ...metadata, }) break diff --git a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts index 9f305123afb..6f874db6d2e 100644 --- a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts +++ b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts @@ -1,6 +1,24 @@ import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages" import { describe, test, expect } from "bun:test" +describe("system messages", () => { + test("should convert system message content to string", () => { + const result = convertToCopilotMessages([ + { + role: "system", + content: "You are a helpful assistant with AGENTS.md instructions.", + }, + ]) + + expect(result).toEqual([ + { + role: "system", + content: "You are a helpful assistant with AGENTS.md instructions.", + }, + ]) + }) +}) + describe("user messages", () => { test("should convert messages with only a text part to a string content", () => { const result = convertToCopilotMessages([ From 4850ecc41961eeda77b1c338fb366e795f23a59d Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Feb 2026 00:29:52 -0500 Subject: [PATCH 088/148] zen: rate limit (#11735) --- .../app/src/routes/zen/util/rateLimiter.ts | 17 ++++++++++++++--- packages/console/core/src/model.ts | 7 ++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/rateLimiter.ts index 244db072c6d..d54bd0306d9 100644 --- a/packages/console/app/src/routes/zen/util/rateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/rateLimiter.ts @@ -2,13 +2,17 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" import { RateLimitError } from "./error" import { logger } from "./logger" +import { ZenData } from "@opencode-ai/console-core/model.js" -export function createRateLimiter(limit: number | undefined, rawIp: string) { +export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string) { if (!limit) return const ip = !rawIp.length ? "unknown" : rawIp const now = Date.now() - const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)] + const intervals = + limit.period === "day" + ? [buildYYYYMMDD(now)] + : [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)] return { track: async () => { @@ -28,11 +32,18 @@ export function createRateLimiter(limit: number | undefined, rawIp: string) { ) const total = rows.reduce((sum, r) => sum + r.count, 0) logger.debug(`rate limit total: ${total}`) - if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) + if (total >= limit.value) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) }, } } +function buildYYYYMMDD(timestamp: number) { + return new Date(timestamp) + .toISOString() + .replace(/[^0-9]/g, "") + .substring(0, 8) +} + function buildYYYYMMDDHH(timestamp: number) { return new Date(timestamp) .toISOString() diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 880c63a1909..fc9674cedbc 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -18,8 +18,13 @@ export namespace ZenData { }), ), }) + const RateLimitSchema = z.object({ + period: z.enum(["day", "rolling"]), + value: z.number().int(), + }) export type Format = z.infer export type Trial = z.infer + export type RateLimit = z.infer const ModelCostSchema = z.object({ input: z.number(), @@ -37,7 +42,7 @@ export namespace ZenData { byokProvider: z.enum(["openai", "anthropic", "google"]).optional(), stickyProvider: z.enum(["strict", "prefer"]).optional(), trial: TrialSchema.optional(), - rateLimit: z.number().optional(), + rateLimit: RateLimitSchema.optional(), fallbackProvider: z.string().optional(), providers: z.array( z.object({ From 76745d05943d63e39c6ba9cff863757fbb3a575f Mon Sep 17 00:00:00 2001 From: mohammad Date: Mon, 2 Feb 2026 11:00:46 +0530 Subject: [PATCH 089/148] fix(desktop): kill zombie server process on startup timeout (#11602) Co-authored-by: Brendan Allan --- packages/desktop/src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 29ac86f29a4..0958481ad2e 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -526,6 +526,7 @@ async fn spawn_local_server( let timestamp = Instant::now(); loop { if timestamp.elapsed() > Duration::from_secs(30) { + let _ = child.kill(); break Err(format!( "Failed to spawn OpenCode Server. Logs:\n{}", get_logs(app.clone()).await.unwrap() From 3982c7d99a1fc47a70c6b5436a85220992300f2b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 2 Feb 2026 08:12:50 +0100 Subject: [PATCH 090/148] Use opentui OSC52 clipboard, again (#11744) --- bun.lock | 20 +++++++------- packages/opencode/package.json | 4 +-- packages/opencode/src/cli/cmd/tui/app.tsx | 2 ++ .../src/cli/cmd/tui/util/clipboard.ts | 26 ++++++++----------- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index 0857283634a..c98144251c6 100644 --- a/bun.lock +++ b/bun.lock @@ -298,8 +298,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.75", - "@opentui/solid": "0.1.75", + "@opentui/core": "0.1.77", + "@opentui/solid": "0.1.77", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -1227,21 +1227,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.75", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="], + "@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="], - "@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="], + "@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 4afb7243006..c86aa734d8f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -82,8 +82,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.75", - "@opentui/solid": "0.1.75", + "@opentui/core": "0.1.77", + "@opentui/solid": "0.1.77", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 713def2e5af..c5f88a7c672 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -169,6 +169,7 @@ export function tui(input: { gatherStats: false, exitOnCtrlC: false, useKittyKeyboard: {}, + autoFocus: false, consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], onCopySelection: (text) => { @@ -186,6 +187,7 @@ function App() { const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() + Clipboard.setRenderer(renderer) renderer.disableStdoutInterception() const dialog = useDialog() const local = useLocal() diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 0e287fbc41a..5c27a26cd03 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -1,24 +1,12 @@ import { $ } from "bun" +import type { CliRenderer } from "@opentui/core" import { platform, release } from "os" import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" -/** - * Writes text to clipboard via OSC 52 escape sequence. - * This allows clipboard operations to work over SSH by having - * the terminal emulator handle the clipboard locally. - */ -function writeOsc52(text: string): void { - if (!process.stdout.isTTY) return - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - // tmux and screen require DCS passthrough wrapping - const passthrough = process.env["TMUX"] || process.env["STY"] - const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - process.stdout.write(sequence) -} +const rendererRef = { current: undefined as CliRenderer | undefined } export namespace Clipboard { export interface Content { @@ -26,6 +14,10 @@ export namespace Clipboard { mime: string } + export function setRenderer(renderer: CliRenderer | undefined): void { + rendererRef.current = renderer + } + export async function read(): Promise { const os = platform() @@ -154,7 +146,11 @@ export namespace Clipboard { }) export async function copy(text: string): Promise { - writeOsc52(text) + const renderer = rendererRef.current + if (renderer) { + const copied = renderer.copyToClipboardOSC52(text) + if (copied) return + } await getCopyMethod()(text) } } From 141fdef5886cc5e161fb9e857b61c5081519d2f6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 07:21:25 +0000 Subject: [PATCH 091/148] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 6fe8f61d3d1..51802195337 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=", - "aarch64-linux": "sha256-88Qai5RkSenCZkakOg52b6xU2ok+h/Ns4/5L3+55sFY=", - "aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=", - "x86_64-darwin": "sha256-FkLDqorfIfOw+tB7SW5vgyhOIoI0IV9lqPW1iEmvUiI=" + "x86_64-linux": "sha256-4I0lpBnbAi7IZMURTMLysjrqdsNvXJf8802NrJnpdks=", + "aarch64-linux": "sha256-WOGKsPlcQVSbL8TDr1JYO/2ucPTV2Hy0TXJKWv8EoVw=", + "aarch64-darwin": "sha256-LuvjwGm1QsHoLxuvSSp4VsDIv02Z/rTONsU32arQMuw=", + "x86_64-darwin": "sha256-AbglfgCWj/r+wHfle+e+D3b/xPcwwg4IK7j5iwn9nzw=" } } From c02dd067b2ae62553c63b087b7b48a0f46628747 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 15:54:25 +0800 Subject: [PATCH 092/148] fix(desktop): keep mac titlebar stable under zoom (#11747) --- packages/app/src/components/titlebar.tsx | 5 +++- packages/app/src/context/platform.tsx | 4 +++ packages/desktop/src/index.tsx | 4 ++- packages/desktop/src/webview-zoom.ts | 38 ++++++++++++++---------- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 001f7a56796..affb6854116 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -24,6 +24,8 @@ export function Titlebar() { const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const web = createMemo(() => platform.platform === "web") + const zoom = () => platform.webviewZoom?.() ?? 1 + const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined) const [history, setHistory] = createStore({ stack: [] as string[], @@ -134,6 +136,7 @@ export function Titlebar() { return (
-
+
+ + /** Webview zoom level (desktop only) */ + webviewZoom?: Accessor } export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 2e7ca136acd..505a00b16a6 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,5 +1,5 @@ // @refresh reload -import "./webview-zoom" +import { webviewZoom } from "./webview-zoom" import { render } from "solid-js/web" import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app" import { open, save } from "@tauri-apps/plugin-dialog" @@ -346,6 +346,8 @@ const createPlatform = (password: Accessor): Platform => ({ parseMarkdown: async (markdown: string) => { return invoke("parse_markdown_command", { markdown }) }, + + webviewZoom, }) createMenu() diff --git a/packages/desktop/src/webview-zoom.ts b/packages/desktop/src/webview-zoom.ts index 9fa9bb9ed92..06f46a3afd3 100644 --- a/packages/desktop/src/webview-zoom.ts +++ b/packages/desktop/src/webview-zoom.ts @@ -4,28 +4,34 @@ import { invoke } from "@tauri-apps/api/core" import { type as ostype } from "@tauri-apps/plugin-os" +import { createSignal } from "solid-js" const OS_NAME = ostype() -let zoomLevel = 1 +const [webviewZoom, setWebviewZoom] = createSignal(1) const MAX_ZOOM_LEVEL = 10 const MIN_ZOOM_LEVEL = 0.2 +const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) + +const applyZoom = (next: number) => { + setWebviewZoom(next) + invoke("plugin:webview|set_webview_zoom", { + value: next, + }) +} + window.addEventListener("keydown", (event) => { - if (OS_NAME === "macos" ? event.metaKey : event.ctrlKey) { - if (event.key === "-") { - zoomLevel -= 0.2 - } else if (event.key === "=" || event.key === "+") { - zoomLevel += 0.2 - } else if (event.key === "0") { - zoomLevel = 1 - } else { - return - } - zoomLevel = Math.min(Math.max(zoomLevel, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) - invoke("plugin:webview|set_webview_zoom", { - value: zoomLevel, - }) - } + if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return + + let newZoom = webviewZoom() + + if (event.key === "-") newZoom -= 0.2 + if (event.key === "=" || event.key === "+") newZoom += 0.2 + if (event.key === "0") newZoom = 1 + + applyZoom(clamp(newZoom)) }) + +export { webviewZoom } From 04aef44fc30d599f11ea2ada60ed63c4856a18ff Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 15:58:08 +0800 Subject: [PATCH 093/148] chore(desktop): integrate tauri-specta (#11740) --- packages/desktop/src-tauri/Cargo.lock | 247 +++++++++++++++++---- packages/desktop/src-tauri/Cargo.toml | 14 +- packages/desktop/src-tauri/src/cli.rs | 1 + packages/desktop/src-tauri/src/lib.rs | 41 +++- packages/desktop/src-tauri/src/markdown.rs | 5 +- packages/desktop/src/bindings.ts | 20 ++ packages/desktop/src/cli.ts | 4 +- packages/desktop/src/index.tsx | 20 +- packages/desktop/src/menu.ts | 4 +- packages/desktop/src/updater.ts | 6 +- 10 files changed, 289 insertions(+), 73 deletions(-) create mode 100644 packages/desktop/src/bindings.ts diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 51e5e004905..14030218edb 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + [[package]] name = "adler2" version = "2.0.1" @@ -1994,9 +2000,9 @@ dependencies = [ [[package]] name = "ico" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", "png 0.17.16", @@ -3065,12 +3071,14 @@ dependencies = [ "listeners", "objc2 0.6.3", "objc2-web-kit", - "reqwest", + "reqwest 0.12.24", "semver", "serde", "serde_json", + "specta", + "specta-typescript", "tauri", - "tauri-build", + "tauri-build 2.5.2", "tauri-plugin-clipboard-manager", "tauri-plugin-decorum", "tauri-plugin-deep-link", @@ -3085,6 +3093,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", + "tauri-specta", "tokio", "uuid", "webkit2gtk", @@ -3221,6 +3230,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3947,6 +3962,40 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "rfd" version = "0.15.4" @@ -4497,6 +4546,44 @@ dependencies = [ "system-deps", ] +[[package]] +name = "specta" +version = "2.0.0-rc.22" +source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb" +dependencies = [ + "paste", + "rustc_version", + "specta-macros", +] + +[[package]] +name = "specta-macros" +version = "2.0.0-rc.18" +source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "specta-serde" +version = "0.0.9" +source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb" +dependencies = [ + "specta", +] + +[[package]] +name = "specta-typescript" +version = "0.0.9" +source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb" +dependencies = [ + "specta", + "specta-serde", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4712,9 +4799,8 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e492485dd390b35f7497401f67694f46161a2a00ffd800938d5dd3c898fb9d8" +version = "2.9.5" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "anyhow", "bytes", @@ -4740,17 +4826,18 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.1", "serde", "serde_json", "serde_repr", "serialize-to-javascript", + "specta", "swift-rs", - "tauri-build", + "tauri-build 2.5.3", "tauri-macros", "tauri-runtime", "tauri-runtime-wry", - "tauri-utils", + "tauri-utils 2.8.1", "thiserror 2.0.17", "tokio", "tray-icon", @@ -4777,7 +4864,28 @@ dependencies = [ "semver", "serde", "serde_json", - "tauri-utils", + "tauri-utils 2.8.0", + "tauri-winres", + "toml 0.9.8", + "walkdir", +] + +[[package]] +name = "tauri-build" +version = "2.5.3" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils 2.8.1", "tauri-winres", "toml 0.9.8", "walkdir", @@ -4785,9 +4893,8 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f" +version = "2.5.2" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "base64 0.22.1", "brotli", @@ -4802,7 +4909,7 @@ dependencies = [ "serde_json", "sha2", "syn 2.0.110", - "tauri-utils", + "tauri-utils 2.8.1", "thiserror 2.0.17", "time", "url", @@ -4812,16 +4919,15 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d" +version = "2.5.2" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.110", "tauri-codegen", - "tauri-utils", + "tauri-utils 2.8.1", ] [[package]] @@ -4836,7 +4942,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "tauri-utils", + "tauri-utils 2.8.0", "toml 0.9.8", "walkdir", ] @@ -4886,7 +4992,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "tauri-utils", + "tauri-utils 2.8.0", "thiserror 2.0.17", "tracing", "url", @@ -4928,7 +5034,7 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "tauri-utils", + "tauri-utils 2.8.0", "thiserror 2.0.17", "toml 0.9.8", "url", @@ -4945,7 +5051,7 @@ dependencies = [ "data-url", "http", "regex", - "reqwest", + "reqwest 0.12.24", "schemars 0.8.22", "serde", "serde_json", @@ -5096,7 +5202,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.12.24", "semver", "serde", "serde_json", @@ -5129,9 +5235,8 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926" +version = "2.9.2" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "cookie", "dpi", @@ -5144,7 +5249,7 @@ dependencies = [ "raw-window-handle", "serde", "serde_json", - "tauri-utils", + "tauri-utils 2.8.1", "thiserror 2.0.17", "url", "webkit2gtk", @@ -5154,9 +5259,8 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93" +version = "2.9.3" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "gtk", "http", @@ -5171,7 +5275,7 @@ dependencies = [ "softbuffer", "tao", "tauri-runtime", - "tauri-utils", + "tauri-utils 2.8.1", "url", "webkit2gtk", "webview2-com", @@ -5179,11 +5283,74 @@ dependencies = [ "wry", ] +[[package]] +name = "tauri-specta" +version = "2.0.0-rc.21" +source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b" +dependencies = [ + "heck 0.5.0", + "serde", + "serde_json", + "specta", + "specta-typescript", + "tauri", + "tauri-specta-macros", + "thiserror 2.0.17", +] + +[[package]] +name = "tauri-specta-macros" +version = "2.0.0-rc.16" +source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b" +dependencies = [ + "darling", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "tauri-utils" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673" +dependencies = [ + "anyhow", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.17", + "toml 0.9.8", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-utils" +version = "2.8.1" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "anyhow", "brotli", @@ -5547,9 +5714,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", @@ -6034,9 +6201,9 @@ dependencies = [ [[package]] name = "webkit2gtk" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" dependencies = [ "bitflags 1.3.2", "cairo-rs", @@ -6058,9 +6225,9 @@ dependencies = [ [[package]] name = "webkit2gtk-sys" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" dependencies = [ "bitflags 1.3.2", "cairo-sys-rs", @@ -6719,9 +6886,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.53.5" +version = "0.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0" dependencies = [ "base64 0.22.1", "block2 0.6.2", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index e87bf77c2eb..2efa484e82b 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["macos-private-api", "devtools"] } +tauri = { version = "2.9.5", features = ["macos-private-api", "devtools"] } tauri-plugin-opener = "2" tauri-plugin-deep-link = "2.4.6" tauri-plugin-shell = "2" @@ -43,10 +43,13 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" uuid = { version = "1.19.0", features = ["v4"] } tauri-plugin-decorum = "1.1.1" comrak = { version = "0.50", default-features = false } +specta = "=2.0.0-rc.22" +specta-typescript = "0.0.9" +tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" -webkit2gtk = "=2.0.1" +webkit2gtk = "=2.0.2" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" @@ -59,3 +62,10 @@ windows = { version = "0.61", features = [ "Win32_System_Threading", "Win32_Security" ] } + +[patch.crates-io] +specta = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" } +specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" } +tauri-specta = { git = "https://github.com/specta-rs/tauri-specta", rev = "6720b2848eff9a3e40af54c48d65f6d56b640c0b" } +# TODO: https://github.com/tauri-apps/tauri/pull/14812 +tauri = { git = "https://github.com/tauri-apps/tauri", rev = "4d5d78daf636feaac20c5bc48a6071491c4291ee" } diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index f64beed6a1a..16e4bfec993 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -51,6 +51,7 @@ fn is_cli_installed() -> bool { const INSTALL_SCRIPT: &str = include_str!("../../../../install"); #[tauri::command] +#[specta::specta] pub fn install_cli(app: tauri::AppHandle) -> Result { if cfg!(not(unix)) { return Err("CLI installation is only supported on macOS & Linux".to_string()); diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 0958481ad2e..2fe7c4aa176 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -16,10 +16,10 @@ use std::{ time::{Duration, Instant}, }; use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder}; -#[cfg(any(target_os = "linux", all(debug_assertions, windows)))] -use tauri_plugin_deep_link::DeepLinkExt; #[cfg(windows)] use tauri_plugin_decorum::WebviewWindowExt; +#[cfg(any(target_os = "linux", all(debug_assertions, windows)))] +use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_store::StoreExt; @@ -30,7 +30,7 @@ use crate::window_customizer::PinchZoomDisablePlugin; const SETTINGS_STORE: &str = "opencode.settings.dat"; const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; -#[derive(Clone, serde::Serialize)] +#[derive(Clone, serde::Serialize, specta::Type)] struct ServerReadyData { url: String, password: Option, @@ -64,6 +64,7 @@ struct LogState(Arc>>); const MAX_LOG_ENTRIES: usize = 200; #[tauri::command] +#[specta::specta] fn kill_sidecar(app: AppHandle) { let Some(server_state) = app.try_state::() else { println!("Server not running"); @@ -97,6 +98,7 @@ async fn get_logs(app: AppHandle) -> Result { } #[tauri::command] +#[specta::specta] async fn ensure_server_ready(state: State<'_, ServerState>) -> Result { state .status @@ -106,6 +108,7 @@ async fn ensure_server_ready(state: State<'_, ServerState>) -> Result Result, String> { let store = app .store(SETTINGS_STORE) @@ -119,6 +122,7 @@ fn get_default_server_url(app: AppHandle) -> Result, String> { } #[tauri::command] +#[specta::specta] async fn set_default_server_url(app: AppHandle, url: Option) -> Result<(), String> { let store = app .store(SETTINGS_STORE) @@ -252,6 +256,26 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool { pub fn run() { let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); + let builder = tauri_specta::Builder::::new() + // Then register them (separated by a comma) + .commands(tauri_specta::collect_commands![ + kill_sidecar, + install_cli, + ensure_server_ready, + get_default_server_url, + set_default_server_url, + markdown::parse_markdown_command + ]) + .error_handling(tauri_specta::ErrorHandlingMode::Throw); + + #[cfg(debug_assertions)] // <- Only export on non-release builds + builder + .export( + specta_typescript::Typescript::default(), + "../src/bindings.ts", + ) + .expect("Failed to export typescript bindings"); + #[cfg(all(target_os = "macos", not(debug_assertions)))] let _ = std::process::Command::new("killall") .arg("opencode-cli") @@ -285,15 +309,10 @@ pub fn run() { .plugin(tauri_plugin_notification::init()) .plugin(PinchZoomDisablePlugin) .plugin(tauri_plugin_decorum::init()) - .invoke_handler(tauri::generate_handler![ - kill_sidecar, - install_cli, - ensure_server_ready, - get_default_server_url, - set_default_server_url, - markdown::parse_markdown_command - ]) + .invoke_handler(builder.invoke_handler()) .setup(move |app| { + builder.mount_events(app); + #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] app.deep_link().register_all().ok(); diff --git a/packages/desktop/src-tauri/src/markdown.rs b/packages/desktop/src-tauri/src/markdown.rs index c3ca73857e2..39a64a4318c 100644 --- a/packages/desktop/src-tauri/src/markdown.rs +++ b/packages/desktop/src-tauri/src/markdown.rs @@ -1,4 +1,6 @@ -use comrak::{create_formatter, parse_document, Arena, Options, html::ChildRendering, nodes::NodeValue}; +use comrak::{ + Arena, Options, create_formatter, html::ChildRendering, nodes::NodeValue, parse_document, +}; use std::fmt::Write; create_formatter!(ExternalLinkFormatter, { @@ -55,6 +57,7 @@ pub fn parse_markdown(input: &str) -> String { } #[tauri::command] +#[specta::specta] pub async fn parse_markdown_command(markdown: String) -> Result { Ok(parse_markdown(&markdown)) } diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts new file mode 100644 index 00000000000..eb5498fa68a --- /dev/null +++ b/packages/desktop/src/bindings.ts @@ -0,0 +1,20 @@ +// This file has been generated by Tauri Specta. Do not edit this file manually. + +import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core'; + +/** Commands */ +export const commands = { + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +}; + +/* Types */ +export type ServerReadyData = { + url: string, + password: string | null, + }; + diff --git a/packages/desktop/src/cli.ts b/packages/desktop/src/cli.ts index 5a8875cf893..28623bdf78d 100644 --- a/packages/desktop/src/cli.ts +++ b/packages/desktop/src/cli.ts @@ -1,13 +1,13 @@ -import { invoke } from "@tauri-apps/api/core" import { message } from "@tauri-apps/plugin-dialog" import { initI18n, t } from "./i18n" +import { commands } from "./bindings" export async function installCli(): Promise { await initI18n() try { - const path = await invoke("install_cli") + const path = await commands.installCli() await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") }) } catch (e) { await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") }) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 505a00b16a6..2b440616262 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -7,7 +7,6 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" import { open as shellOpen } from "@tauri-apps/plugin-shell" import { type as ostype } from "@tauri-apps/plugin-os" import { check, Update } from "@tauri-apps/plugin-updater" -import { invoke } from "@tauri-apps/api/core" import { getCurrentWindow } from "@tauri-apps/api/window" import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { relaunch } from "@tauri-apps/plugin-process" @@ -22,6 +21,7 @@ import { createMenu } from "./menu" import { initI18n, t } from "./i18n" import pkg from "../package.json" import "./styles.css" +import { commands } from "./bindings" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { @@ -274,12 +274,12 @@ const createPlatform = (password: Accessor): Platform => ({ update: async () => { if (!UPDATER_ENABLED || !update) return - if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined) + if (ostype() === "windows") await commands.killSidecar().catch(() => undefined) await update.install().catch(() => undefined) }, restart: async () => { - await invoke("kill_sidecar").catch(() => undefined) + await commands.killSidecar().catch(() => undefined) await relaunch() }, @@ -335,17 +335,13 @@ const createPlatform = (password: Accessor): Platform => ({ }, getDefaultServerUrl: async () => { - const result = await invoke("get_default_server_url").catch(() => null) + const result = await commands.getDefaultServerUrl().catch(() => null) return result }, - setDefaultServerUrl: async (url: string | null) => { - await invoke("set_default_server_url", { url }) - }, + setDefaultServerUrl: async (url: string | null) => { await commands.setDefaultServerUrl(url) }, - parseMarkdown: async (markdown: string) => { - return invoke("parse_markdown_command", { markdown }) - }, + parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), webviewZoom, }) @@ -394,7 +390,7 @@ type ServerReadyData = { url: string; password: string | null } // Gate component that waits for the server to be ready function ServerGate(props: { children: (data: Accessor) => JSX.Element }) { const [serverData] = createResource(() => - invoke("ensure_server_ready").then((v) => { + commands.ensureServerReady().then((v) => { return new Promise((res) => setTimeout(() => res(v as ServerReadyData), 2000)) }), ) @@ -408,7 +404,7 @@ function ServerGate(props: { children: (data: Accessor) => JSX. } const restartApp = async () => { - await invoke("kill_sidecar").catch(() => undefined) + await commands.killSidecar().catch(() => undefined) await relaunch().catch(() => undefined) } diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 2edeff42b25..d4108440428 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -1,11 +1,11 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu" import { type as ostype } from "@tauri-apps/plugin-os" -import { invoke } from "@tauri-apps/api/core" import { relaunch } from "@tauri-apps/plugin-process" import { runUpdater, UPDATER_ENABLED } from "./updater" import { installCli } from "./cli" import { initI18n, t } from "./i18n" +import { commands } from "./bindings" export async function createMenu() { if (ostype() !== "macos") return @@ -35,7 +35,7 @@ export async function createMenu() { }), await MenuItem.new({ action: async () => { - await invoke("kill_sidecar").catch(() => undefined) + await commands.killSidecar().catch(() => undefined) await relaunch().catch(() => undefined) }, text: t("desktop.menu.restart"), diff --git a/packages/desktop/src/updater.ts b/packages/desktop/src/updater.ts index b48bb6be025..73266963388 100644 --- a/packages/desktop/src/updater.ts +++ b/packages/desktop/src/updater.ts @@ -1,10 +1,10 @@ import { check } from "@tauri-apps/plugin-updater" import { relaunch } from "@tauri-apps/plugin-process" import { ask, message } from "@tauri-apps/plugin-dialog" -import { invoke } from "@tauri-apps/api/core" import { type as ostype } from "@tauri-apps/plugin-os" import { initI18n, t } from "./i18n" +import { commands } from "./bindings" export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false @@ -39,13 +39,13 @@ export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { if (!shouldUpdate) return try { - if (ostype() === "windows") await invoke("kill_sidecar") + if (ostype() === "windows") await commands.killSidecar() await update.install() } catch { await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") }) return } - await invoke("kill_sidecar") + await commands.killSidecar() await relaunch() } From 784a17f7b3026be2a8abebd59ee4132270bdf6a0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 07:58:52 +0000 Subject: [PATCH 094/148] chore: generate --- packages/desktop/src/bindings.ts | 23 +++++++++++------------ packages/desktop/src/index.tsx | 4 +++- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index eb5498fa68a..440e138b4f9 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -1,20 +1,19 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core'; +import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core" /** Commands */ export const commands = { - killSidecar: () => __TAURI_INVOKE("kill_sidecar"), - installCli: () => __TAURI_INVOKE("install_cli"), - ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), - getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), - setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), - parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), -}; + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +} /* Types */ export type ServerReadyData = { - url: string, - password: string | null, - }; - + url: string + password: string | null +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 2b440616262..bb3265db88e 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -339,7 +339,9 @@ const createPlatform = (password: Accessor): Platform => ({ return result }, - setDefaultServerUrl: async (url: string | null) => { await commands.setDefaultServerUrl(url) }, + setDefaultServerUrl: async (url: string | null) => { + await commands.setDefaultServerUrl(url) + }, parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), From e6d8315e29a8b7a34fd6639393e4dcc12bf9fa6c Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 17:54:57 +0800 Subject: [PATCH 095/148] fix(desktop): throttle window state persistence (#11746) --- packages/desktop/src-tauri/src/lib.rs | 45 ++++++++++++++++--- .../src-tauri/src/window_customizer.rs | 4 +- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 2fe7c4aa176..a177e6ceedd 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -23,13 +23,18 @@ use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_store::StoreExt; -use tokio::sync::oneshot; +use tauri_plugin_window_state::{AppHandleExt, StateFlags}; +use tokio::sync::{mpsc, oneshot}; use crate::window_customizer::PinchZoomDisablePlugin; const SETTINGS_STORE: &str = "opencode.settings.dat"; const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; +fn window_state_flags() -> StateFlags { + StateFlags::all() - StateFlags::DECORATIONS +} + #[derive(Clone, serde::Serialize, specta::Type)] struct ServerReadyData { url: String, @@ -293,10 +298,7 @@ pub fn run() { .plugin(tauri_plugin_os::init()) .plugin( tauri_plugin_window_state::Builder::new() - .with_state_flags( - tauri_plugin_window_state::StateFlags::all() - - tauri_plugin_window_state::StateFlags::DECORATIONS, - ) + .with_state_flags(window_state_flags()) .build(), ) .plugin(tauri_plugin_store::Builder::new().build()) @@ -365,6 +367,8 @@ pub fn run() { let window = window_builder.build().expect("Failed to create window"); + setup_window_state_listener(&app, &window); + #[cfg(windows)] let _ = window.create_overlay_titlebar(); @@ -560,3 +564,34 @@ async fn spawn_local_server( } } } + +fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWindow) { + let (tx, mut rx) = mpsc::channel::<()>(1); + + window.on_window_event(move |event| { + if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) { + return; + } + let _ = tx.try_send(()); + }); + + tauri::async_runtime::spawn({ + let app = app.clone(); + + async move { + let save = || { + let handle = app.clone(); + let app = app.clone(); + let _ = handle.run_on_main_thread(move || { + let _ = app.save_window_state(window_state_flags()); + }); + }; + + while rx.recv().await.is_some() { + tokio::time::sleep(Duration::from_millis(200)).await; + + save(); + } + } + }); +} diff --git a/packages/desktop/src-tauri/src/window_customizer.rs b/packages/desktop/src-tauri/src/window_customizer.rs index 682f57f2471..d73662120ac 100644 --- a/packages/desktop/src-tauri/src/window_customizer.rs +++ b/packages/desktop/src-tauri/src/window_customizer.rs @@ -1,4 +1,4 @@ -use tauri::{plugin::Plugin, Manager, Runtime, Window}; +use tauri::{Manager, Runtime, Window, plugin::Plugin}; pub struct PinchZoomDisablePlugin; @@ -21,8 +21,8 @@ impl Plugin for PinchZoomDisablePlugin { let _ = webview_window.with_webview(|_webview| { #[cfg(target_os = "linux")] unsafe { - use gtk::glib::ObjectExt; use gtk::GestureZoom; + use gtk::glib::ObjectExt; use webkit2gtk::glib::gobject_ffi; if let Some(data) = _webview.inner().data::("wk-view-zoom-gesture") { From 1832eeffc97430edea9ab62818153bfedc6aea17 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 18:19:49 +0800 Subject: [PATCH 096/148] fix(desktop): remove unnecessary setTimeout --- packages/desktop/src/index.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index bb3265db88e..9ef680ed869 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -391,11 +391,7 @@ type ServerReadyData = { url: string; password: string | null } // Gate component that waits for the server to be ready function ServerGate(props: { children: (data: Accessor) => JSX.Element }) { - const [serverData] = createResource(() => - commands.ensureServerReady().then((v) => { - return new Promise((res) => setTimeout(() => res(v as ServerReadyData), 2000)) - }), - ) + const [serverData] = createResource(() => commands.ensureServerReady()) const errorMessage = () => { const error = serverData.error From 9564c1d6be6a7d83abb6dd665b34a6572518fbab Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 18:52:57 +0800 Subject: [PATCH 097/148] desktop: fix rust build + bindings formatting --- .prettierignore | 3 ++- packages/desktop/src-tauri/src/lib.rs | 1 + packages/desktop/src/bindings.ts | 23 ++++++++++++----------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.prettierignore b/.prettierignore index aa3a7ce2381..5f86f710fbf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -sst-env.d.ts \ No newline at end of file +sst-env.d.ts +desktop/src/bindings.ts diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index a177e6ceedd..d309b918b66 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -569,6 +569,7 @@ fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWi let (tx, mut rx) = mpsc::channel::<()>(1); window.on_window_event(move |event| { + use tauri::WindowEvent; if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) { return; } diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 440e138b4f9..eb5498fa68a 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -1,19 +1,20 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core" +import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core'; /** Commands */ export const commands = { - killSidecar: () => __TAURI_INVOKE("kill_sidecar"), - installCli: () => __TAURI_INVOKE("install_cli"), - ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), - getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), - setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), - parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), -} + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +}; /* Types */ export type ServerReadyData = { - url: string - password: string | null -} + url: string, + password: string | null, + }; + From 1cabeb00d0a391cf83495bf4e3544aa53f155ef4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 10:53:46 +0000 Subject: [PATCH 098/148] chore: generate --- packages/desktop/src/bindings.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index eb5498fa68a..440e138b4f9 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -1,20 +1,19 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core'; +import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core" /** Commands */ export const commands = { - killSidecar: () => __TAURI_INVOKE("kill_sidecar"), - installCli: () => __TAURI_INVOKE("install_cli"), - ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), - getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), - setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), - parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), -}; + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +} /* Types */ export type ServerReadyData = { - url: string, - password: string | null, - }; - + url: string + password: string | null +} From 52eb8a7a8c2ceefa0de8a1a37d5f8754f08cfcff Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 19:05:47 +0800 Subject: [PATCH 099/148] feat(app): unread session navigation keybinds (#11750) --- packages/app/src/i18n/ar.ts | 2 ++ packages/app/src/i18n/br.ts | 2 ++ packages/app/src/i18n/da.ts | 2 ++ packages/app/src/i18n/de.ts | 2 ++ packages/app/src/i18n/en.ts | 2 ++ packages/app/src/i18n/es.ts | 2 ++ packages/app/src/i18n/fr.ts | 2 ++ packages/app/src/i18n/ja.ts | 2 ++ packages/app/src/i18n/ko.ts | 2 ++ packages/app/src/i18n/no.ts | 2 ++ packages/app/src/i18n/pl.ts | 2 ++ packages/app/src/i18n/ru.ts | 2 ++ packages/app/src/i18n/th.ts | 2 ++ packages/app/src/i18n/zh.ts | 2 ++ packages/app/src/i18n/zht.ts | 2 ++ packages/app/src/pages/layout.tsx | 60 +++++++++++++++++++++++++++++++ 16 files changed, 90 insertions(+) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index e3831e23c40..3718303e5a1 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "فتح الإعدادات", "command.session.previous": "الجلسة السابقة", "command.session.next": "الجلسة التالية", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "أرشفة الجلسة", "command.palette": "لوحة الأوامر", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index f930a66aff3..43336f8d6fe 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Abrir configurações", "command.session.previous": "Sessão anterior", "command.session.next": "Próxima sessão", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arquivar sessão", "command.palette": "Paleta de comandos", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 2b7d77456d5..69e8e8114f6 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Åbn indstillinger", "command.session.previous": "Forrige session", "command.session.next": "Næste session", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arkivér session", "command.palette": "Kommandopalette", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 4648ad9c412..1c28e4a16e4 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "Einstellungen öffnen", "command.session.previous": "Vorherige Sitzung", "command.session.next": "Nächste Sitzung", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Sitzung archivieren", "command.palette": "Befehlspalette", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 12ddcb4cd8e..5589337e563 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Open settings", "command.session.previous": "Previous session", "command.session.next": "Next session", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archive session", "command.palette": "Command palette", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 5d396f0b4f2..6e3eac0dd35 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Abrir ajustes", "command.session.previous": "Sesión anterior", "command.session.next": "Siguiente sesión", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archivar sesión", "command.palette": "Paleta de comandos", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 4226d0c7e20..fa3dccd9afa 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Ouvrir les paramètres", "command.session.previous": "Session précédente", "command.session.next": "Session suivante", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archiver la session", "command.palette": "Palette de commandes", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 28a925a0d32..4fccbd77e78 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "設定を開く", "command.session.previous": "前のセッション", "command.session.next": "次のセッション", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "セッションをアーカイブ", "command.palette": "コマンドパレット", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 1be4e1eb4b6..5b5d29c0e08 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "설정 열기", "command.session.previous": "이전 세션", "command.session.next": "다음 세션", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "세션 보관", "command.palette": "명령 팔레트", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0a3b398856a..89614ce853d 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -31,6 +31,8 @@ export const dict = { "command.settings.open": "Åpne innstillinger", "command.session.previous": "Forrige sesjon", "command.session.next": "Neste sesjon", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arkiver sesjon", "command.palette": "Kommandopalett", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index f4457c6acf8..b89921a9bc6 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Otwórz ustawienia", "command.session.previous": "Poprzednia sesja", "command.session.next": "Następna sesja", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Zarchiwizuj sesję", "command.palette": "Paleta poleceń", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index d5a4014d36a..e99abbd0819 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Открыть настройки", "command.session.previous": "Предыдущая сессия", "command.session.next": "Следующая сессия", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Архивировать сессию", "command.palette": "Палитра команд", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 1914b8e5bdf..0da6f9acc75 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "เปิดการตั้งค่า", "command.session.previous": "เซสชันก่อนหน้า", "command.session.next": "เซสชันถัดไป", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "จัดเก็บเซสชัน", "command.palette": "คำสั่งค้นหา", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index b9d53957302..a7e1659ec3e 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "打开设置", "command.session.previous": "上一个会话", "command.session.next": "下一个会话", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "归档会话", "command.palette": "命令面板", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 23d3d80e136..7b8849b9a0c 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "開啟設定", "command.session.previous": "上一個工作階段", "command.session.next": "下一個工作階段", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "封存工作階段", "command.palette": "命令面板", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 845a4fc834d..a970bf667e4 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -886,6 +886,52 @@ export default function Layout(props: ParentProps) { queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) } + function navigateSessionByUnseen(offset: number) { + const sessions = currentSessions() + if (sessions.length === 0) return + + const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0) + if (!hasUnseen) return + + const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 + const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex + + for (let i = 1; i <= sessions.length; i++) { + const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length + const session = sessions[index] + if (!session) continue + if (notification.session.unseen(session.id).length === 0) continue + + prefetchSession(session, "high") + + const next = sessions[(index + 1) % sessions.length] + const prev = sessions[(index - 1 + sessions.length) % sessions.length] + + if (offset > 0) { + if (next) prefetchSession(next, "high") + if (prev) prefetchSession(prev) + } + + if (offset < 0) { + if (prev) prefetchSession(prev, "high") + if (next) prefetchSession(next) + } + + if (import.meta.env.DEV) { + navStart({ + dir: base64Encode(session.directory), + from: params.id, + to: session.id, + trigger: offset > 0 ? "shift+alt+arrowdown" : "shift+alt+arrowup", + }) + } + + navigateToSession(session) + queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) + return + } + } + async function archiveSession(session: Session) { const [store, setStore] = globalSync.child(session.directory) const sessions = store.session ?? [] @@ -1024,6 +1070,20 @@ export default function Layout(props: ParentProps) { keybind: "alt+arrowdown", onSelect: () => navigateSessionByOffset(1), }, + { + id: "session.previous.unseen", + title: language.t("command.session.previous.unseen"), + category: language.t("command.category.session"), + keybind: "shift+alt+arrowup", + onSelect: () => navigateSessionByUnseen(-1), + }, + { + id: "session.next.unseen", + title: language.t("command.session.next.unseen"), + category: language.t("command.category.session"), + keybind: "shift+alt+arrowdown", + onSelect: () => navigateSessionByUnseen(1), + }, { id: "session.archive", title: language.t("command.session.archive"), From 985090ef3cf5b5cbda97c7d1f280371a28e50b3c Mon Sep 17 00:00:00 2001 From: Lucio Delelis Date: Mon, 2 Feb 2026 08:20:30 -0300 Subject: [PATCH 100/148] fix(ui): adjusts alignment of elements to prevent incomplete scroll (#11649) --- packages/ui/src/components/message-nav.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-nav.css b/packages/ui/src/components/message-nav.css index b1454ad4259..79bfdc0b34c 100644 --- a/packages/ui/src/components/message-nav.css +++ b/packages/ui/src/components/message-nav.css @@ -103,7 +103,7 @@ display: flex; padding: 4px 4px 6px 4px; justify-content: center; - align-items: center; + align-items: start; border-radius: var(--radius-md); background: var(--surface-raised-stronger-non-alpha); max-height: calc(100vh - 6rem); From 43bb389e354fe5b631036f658c30421d4a5f1f5a Mon Sep 17 00:00:00 2001 From: Sam Huckaby Date: Mon, 2 Feb 2026 06:30:44 -0500 Subject: [PATCH 101/148] Fix(app): the Vesper theme's light mode (#9892) --- packages/ui/src/theme/themes/vesper.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/theme/themes/vesper.json b/packages/ui/src/theme/themes/vesper.json index 3c5e44cbd51..040bdc049ba 100644 --- a/packages/ui/src/theme/themes/vesper.json +++ b/packages/ui/src/theme/themes/vesper.json @@ -18,8 +18,7 @@ "background-base": "#FFF", "background-weak": "#F8F8F8", "background-strong": "#F0F0F0", - "background-stronger": "#E8E8E8", - "border-weak-base": "#E8E8E8", + "background-stronger": "#FBFBFB", "border-weak-hover": "#E0E0E0", "border-weak-active": "#D8D8D8", "border-weak-selected": "#D0D0D0", @@ -41,14 +40,15 @@ "surface-diff-delete-base": "#f5e8e8", "surface-diff-hidden-base": "#F0F0F0", "text-base": "#101010", - "text-weak": "#A0A0A0", + "text-invert-strong": "var(--smoke-dark-alpha-12)", + "text-weak": "#606060", "text-strong": "#000000", - "syntax-string": "#99FFE4", - "syntax-primitive": "#FF8080", - "syntax-property": "#FFC799", - "syntax-type": "#FFC799", - "syntax-constant": "#A0A0A0", - "syntax-info": "#A0A0A0", + "syntax-string": "#0D5C4F", + "syntax-primitive": "#B30000", + "syntax-property": "#C66C00", + "syntax-type": "#9C5C12", + "syntax-constant": "#404040", + "syntax-info": "#606060", "markdown-heading": "#FFC799", "markdown-text": "#101010", "markdown-link": "#FFC799", From 26197ec95bac8560a637fb496ce34c14bde7bca5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 05:42:55 -0600 Subject: [PATCH 102/148] chore: update website stats --- packages/console/app/src/config.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 1d99def1b9a..be53ad909b5 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -9,8 +9,8 @@ export const config = { github: { repoUrl: "https://github.com/anomalyco/opencode", starsFormatted: { - compact: "80K", - full: "80,000", + compact: "95K", + full: "95,000", }, }, @@ -22,8 +22,8 @@ export const config = { // Static stats (used on landing page) stats: { - contributors: "600", - commits: "7,500", - monthlyUsers: "1.5M", + contributors: "650", + commits: "8,500", + monthlyUsers: "2.5M", }, } as const From 52006c2fd93b00c216b4fa9f47f0e85ab8a43753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20S=C3=BAkup?= Date: Mon, 2 Feb 2026 13:01:49 +0100 Subject: [PATCH 103/148] feat(opencode): ormolu code formatter for haskell (#10274) --- packages/opencode/src/format/formatter.ts | 9 +++++++++ packages/web/src/content/docs/formatters.mdx | 1 + packages/web/src/content/docs/lsp.mdx | 1 + 3 files changed, 11 insertions(+) diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 5f0624d6c9d..9e97fae9dfc 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -355,3 +355,12 @@ export const pint: Info = { return false }, } + +export const ormolu: Info = { + name: "ormolu", + command: ["ormolu", "-i", "$FILE"], + extensions: [".hs"], + async enabled() { + return Bun.which("ormolu") !== null + }, +} diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index 225875f6252..54f36e0cd0e 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -36,6 +36,7 @@ OpenCode comes with several built-in formatters for popular languages and framew | shfmt | .sh, .bash | `shfmt` command available | | pint | .php | `laravel/pint` dependency in `composer.json` | | oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) | +| ormolu | .hs | `ormolu` command available | So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index 707af84a01d..ac788fc6000 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -25,6 +25,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | fsharp | .fs, .fsi, .fsx, .fsscript | `.NET SDK` installed | | gleam | .gleam | `gleam` command available | | gopls | .go | `go` command available | +| hls | .hs, .lhs | `haskell-language-server-wrapper` command available | jdtls | .java | `Java SDK (version 21+)` installed | | kotlin-ls | .kt, .kts | Auto-installs for Kotlin projects | | lua-ls | .lua | Auto-installs for Lua projects | From 6b17645f2eadb3d66d9ecd94e04d0ba85ff5d335 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 12:02:35 +0000 Subject: [PATCH 104/148] chore: generate --- packages/web/src/content/docs/lsp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index ac788fc6000..95c306fcc0e 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -25,7 +25,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | fsharp | .fs, .fsi, .fsx, .fsscript | `.NET SDK` installed | | gleam | .gleam | `gleam` command available | | gopls | .go | `go` command available | -| hls | .hs, .lhs | `haskell-language-server-wrapper` command available +| hls | .hs, .lhs | `haskell-language-server-wrapper` command available | | jdtls | .java | `Java SDK (version 21+)` installed | | kotlin-ls | .kt, .kts | Auto-installs for Kotlin projects | | lua-ls | .lua | Auto-installs for Lua projects | From 50b5168c16c44093d176cebb342c86d005ec14f5 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:03:55 +0100 Subject: [PATCH 105/148] fix(desktop): added inverted svg for steps expanded for nice UX (#10462) Co-authored-by: opencode Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/ui/src/components/session-turn.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 48d6337edba..29c5566a658 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -610,7 +610,7 @@ export function SessionTurn( - + + + + + + From 37979ea44fd3afb99f6c110aed55e93ffb877b59 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:06:45 +0100 Subject: [PATCH 106/148] feat(app): enhance responsive design with additional breakpoints for larger screen layout adjustments (#10459) Co-authored-by: opencode Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/app/src/pages/session.tsx | 11 +++++++---- packages/ui/src/styles/tailwind/index.css | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d3e74072a86..d03c4186c5d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1940,7 +1940,8 @@ export default function Page() { "sticky top-0 z-30 bg-background-stronger": true, "w-full": true, "px-4 md:px-6": true, - "md:max-w-200 md:mx-auto": centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": + centered(), }} >
@@ -1968,7 +1969,8 @@ export default function Page() { class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" classList={{ "w-full": true, - "md:max-w-200 md:mx-auto": centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": + centered(), "mt-0.5": centered(), "mt-0": !centered(), }} @@ -2021,7 +2023,8 @@ export default function Page() { data-message-id={message.id} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200": centered(), + "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": + centered(), }} > diff --git a/packages/ui/src/styles/tailwind/index.css b/packages/ui/src/styles/tailwind/index.css index f7ce21ad966..d8b0b2a1a0c 100644 --- a/packages/ui/src/styles/tailwind/index.css +++ b/packages/ui/src/styles/tailwind/index.css @@ -17,6 +17,9 @@ --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; + --breakpoint-3xl: 112rem; + --breakpoint-4xl: 128rem; + --breakpoint-5xl: 144rem; --container-3xs: 16rem; --container-2xs: 18rem; From 34c58af796befb22cd557012ec70d3e520b393b9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 12:07:56 +0000 Subject: [PATCH 107/148] chore: generate --- packages/app/src/pages/session.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d03c4186c5d..da12be8f567 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1941,7 +1941,7 @@ export default function Page() { "w-full": true, "px-4 md:px-6": true, "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": - centered(), + centered(), }} >
@@ -1970,7 +1970,7 @@ export default function Page() { classList={{ "w-full": true, "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": - centered(), + centered(), "mt-0.5": centered(), "mt-0": !centered(), }} @@ -2023,8 +2023,7 @@ export default function Page() { data-message-id={message.id} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": - centered(), + "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(), }} > Date: Mon, 2 Feb 2026 18:20:06 +0530 Subject: [PATCH 108/148] feat(app): add tab close keybind (#11780) --- .../src/components/session/session-sortable-tab.tsx | 12 +++++++++--- packages/app/src/i18n/en.ts | 1 + packages/app/src/pages/session.tsx | 12 ++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index 06609fcfb83..516f3c8edeb 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -3,11 +3,12 @@ import type { JSX } from "solid-js" import { createSortable } from "@thisbeyond/solid-dnd" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip } from "@opencode-ai/ui/tooltip" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" import { getFilename } from "@opencode-ai/util/path" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { return ( @@ -27,6 +28,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element { const file = useFile() const language = useLanguage() + const command = useCommand() const sortable = createSortable(props.tab) const path = createMemo(() => file.pathFromTab(props.tab)) return ( @@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v + v onClick={() => props.onTabClose(props.tab)} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 5589337e563..169d09cd38c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -45,6 +45,7 @@ export const dict = { "command.session.new": "New session", "command.file.open": "Open file", "command.file.open.description": "Search files and commands", + "command.tab.close": "Close tab", "command.context.addSelection": "Add selection to context", "command.context.addSelection.description": "Add selected lines from the current file", "command.terminal.toggle": "Toggle terminal", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index da12be8f567..772ad063ba7 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -689,6 +689,18 @@ export default function Page() { slash: "open", onSelect: () => dialog.show(() => showAllFiles()} />), }, + { + id: "tab.close", + title: language.t("command.tab.close"), + category: language.t("command.category.file"), + keybind: "mod+w", + disabled: !tabs().active(), + onSelect: () => { + const active = tabs().active() + if (!active) return + tabs().close(active) + }, + }, { id: "context.addSelection", title: language.t("command.context.addSelection"), From 4369d796368b0681f93c0da28725e147a263f56b Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 2 Feb 2026 09:30:10 -0500 Subject: [PATCH 109/148] tui: truncate session title in exit banner (#11797) --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f7d83b05554..9a000f953c0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -225,10 +225,11 @@ export function Session() { const exit = useExit() createEffect(() => { + const title = Locale.truncate(session()?.title ?? "", 50) return exit.message.set( [ ``, - ` █▀▀█ ${UI.Style.TEXT_DIM}${session()?.title}${UI.Style.TEXT_NORMAL}`, + ` █▀▀█ ${UI.Style.TEXT_DIM}${title}${UI.Style.TEXT_NORMAL}`, ` █ █ ${UI.Style.TEXT_DIM}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`, ` ▀▀▀▀ `, ].join("\n"), From d63ed3bbe3d45842c7dcac623b6fda9d1b8d7630 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 09:37:34 -0500 Subject: [PATCH 110/148] ci --- .github/workflows/publish.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a1b492258b7..d7cb86cdf73 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -148,6 +148,12 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - name: install cross toolchain (aarch64) + if: matrix.settings.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross + - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: @@ -192,6 +198,8 @@ jobs: releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && 'aarch64-linux-gnu-gcc' || '' }} + PKG_CONFIG_ALLOW_CROSS: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && '1' || '' }} TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} From 8de9e47a5b50f3f1c7d51d3ce17da7bd7c72a500 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 09:37:39 -0500 Subject: [PATCH 111/148] ci From 423778c93a9a976f3755c31a0398766b2d0c1e3f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 09:44:19 -0500 Subject: [PATCH 112/148] ci: reduce aarch64 build runner to 4 vcpu to lower infrastructure costs --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d7cb86cdf73..c610e3957df 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -103,7 +103,7 @@ jobs: target: x86_64-pc-windows-msvc - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu - - host: blacksmith-8vcpu-ubuntu-2404-arm + - host: blacksmith-4vcpu-ubuntu-2404 target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: From 06d63ca54cacfce5af7fdab216ffe7f35d778642 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 10:06:21 -0500 Subject: [PATCH 113/148] ci: use native ARM runner for faster Linux ARM builds Switch from cross-compilation on x86_64 to native ARM runner, which improves build speed and reliability for Linux ARM binary distribution. --- .github/workflows/publish.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c610e3957df..a1b492258b7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -103,7 +103,7 @@ jobs: target: x86_64-pc-windows-msvc - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu - - host: blacksmith-4vcpu-ubuntu-2404 + - host: blacksmith-8vcpu-ubuntu-2404-arm target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: @@ -148,12 +148,6 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - - name: install cross toolchain (aarch64) - if: matrix.settings.target == 'aarch64-unknown-linux-gnu' - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross - - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: @@ -198,8 +192,6 @@ jobs: releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && 'aarch64-linux-gnu-gcc' || '' }} - PKG_CONFIG_ALLOW_CROSS: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && '1' || '' }} TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} From 1bd5dc5382cfa8b57dc470970bcdfa6a3dbd8dfb Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Mon, 2 Feb 2026 18:13:48 +0200 Subject: [PATCH 114/148] ci: add ratelimits handling for close-stale-prs.yml (#11578) --- .github/workflows/close-stale-prs.yml | 132 ++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 19 deletions(-) diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index e1ff4241c98..e0e571b4691 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -18,6 +18,7 @@ permissions: jobs: close-stale-prs: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Close inactive PRs uses: actions/github-script@v8 @@ -25,6 +26,15 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const DAYS_INACTIVE = 60 + const MAX_RETRIES = 3 + + // Adaptive delay: fast for small batches, slower for large to respect + // GitHub's 80 content-generating requests/minute limit + const SMALL_BATCH_THRESHOLD = 10 + const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs) + const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit + + const startTime = Date.now() const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) const { owner, repo } = context.repo const dryRun = context.payload.inputs?.dryRun === "true" @@ -32,6 +42,42 @@ jobs: core.info(`Dry run mode: ${dryRun}`) core.info(`Cutoff date: ${cutoff.toISOString()}`) + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async function withRetry(fn, description = 'API call') { + let lastError + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const result = await fn() + return result + } catch (error) { + lastError = error + const isRateLimited = error.status === 403 && + (error.message?.includes('rate limit') || error.message?.includes('secondary')) + + if (!isRateLimited) { + throw error + } + + // Parse retry-after header, default to 60 seconds + const retryAfter = error.response?.headers?.['retry-after'] + ? parseInt(error.response.headers['retry-after']) + : 60 + + // Exponential backoff: retryAfter * 2^attempt + const backoffMs = retryAfter * 1000 * Math.pow(2, attempt) + + core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`) + + await sleep(backoffMs) + } + } + core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`) + throw lastError + } + const query = ` query($owner: String!, $repo: String!, $cursor: String) { repository(owner: $owner, name: $repo) { @@ -73,17 +119,27 @@ jobs: const allPrs = [] let cursor = null let hasNextPage = true + let pageCount = 0 while (hasNextPage) { - const result = await github.graphql(query, { - owner, - repo, - cursor, - }) + pageCount++ + core.info(`Fetching page ${pageCount} of open PRs...`) + + const result = await withRetry( + () => github.graphql(query, { owner, repo, cursor }), + `GraphQL page ${pageCount}` + ) allPrs.push(...result.repository.pullRequests.nodes) hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage cursor = result.repository.pullRequests.pageInfo.endCursor + + core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`) + + // Delay between pagination requests (use small batch delay for reads) + if (hasNextPage) { + await sleep(SMALL_BATCH_DELAY_MS) + } } core.info(`Found ${allPrs.length} open pull requests`) @@ -114,28 +170,66 @@ jobs: core.info(`Found ${stalePrs.length} stale pull requests`) + // ============================================ + // Close stale PRs + // ============================================ + const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD + ? LARGE_BATCH_DELAY_MS + : SMALL_BATCH_DELAY_MS + + core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) + + let closedCount = 0 + let skippedCount = 0 + for (const pr of stalePrs) { const issue_number = pr.number const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.` if (dryRun) { - core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`) + core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) continue } - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body: closeComment, - }) + try { + // Add comment + await withRetry( + () => github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: closeComment, + }), + `Comment on PR #${issue_number}` + ) - await github.rest.pulls.update({ - owner, - repo, - pull_number: issue_number, - state: "closed", - }) + // Close PR + await withRetry( + () => github.rest.pulls.update({ + owner, + repo, + pull_number: issue_number, + state: "closed", + }), + `Close PR #${issue_number}` + ) - core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`) + closedCount++ + core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) + + // Delay before processing next PR + await sleep(requestDelayMs) + } catch (error) { + skippedCount++ + core.error(`Failed to close PR #${issue_number}: ${error.message}`) + } } + + const elapsed = Math.round((Date.now() - startTime) / 1000) + core.info(`\n========== Summary ==========`) + core.info(`Total open PRs found: ${allPrs.length}`) + core.info(`Stale PRs identified: ${stalePrs.length}`) + core.info(`PRs closed: ${closedCount}`) + core.info(`PRs skipped (errors): ${skippedCount}`) + core.info(`Elapsed time: ${elapsed}s`) + core.info(`=============================`) From cf8b033be1cbe9f20bc0921d9920a66c0d95c704 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Mon, 2 Feb 2026 17:41:02 +0100 Subject: [PATCH 115/148] feat(provider): add User-Agent header for GitLab AI Gateway requests (#11818) --- bun.lock | 4 ++-- packages/opencode/package.json | 2 +- packages/opencode/src/provider/provider.ts | 11 ++++++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index c98144251c6..b1a34e5fa49 100644 --- a/bun.lock +++ b/bun.lock @@ -286,7 +286,7 @@ "@ai-sdk/vercel": "1.0.33", "@ai-sdk/xai": "2.0.56", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.3.1", + "@gitlab/gitlab-ai-provider": "3.4.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -925,7 +925,7 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c86aa734d8f..cc0e84d9f28 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -70,7 +70,7 @@ "@ai-sdk/vercel": "1.0.33", "@ai-sdk/xai": "2.0.56", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.3.1", + "@gitlab/gitlab-ai-provider": "3.4.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e01c583ff34..27a86a2fccb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,4 +1,5 @@ import z from "zod" +import os from "os" import fuzzysort from "fuzzysort" import { Config } from "../config/config" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" @@ -35,8 +36,9 @@ import { createGateway } from "@ai-sdk/gateway" import { createTogetherAI } from "@ai-sdk/togetherai" import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" -import { createGitLab } from "@gitlab/gitlab-ai-provider" +import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider" import { ProviderTransform } from "./transform" +import { Installation } from "../installation" export namespace Provider { const log = Log.create({ service: "provider" }) @@ -424,11 +426,17 @@ export namespace Provider { const config = await Config.get() const providerConfig = config.provider?.["gitlab"] + const aiGatewayHeaders = { + "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, + ...(providerConfig?.options?.aiGatewayHeaders || {}), + } + return { autoload: !!apiKey, options: { instanceUrl, apiKey, + aiGatewayHeaders, featureFlags: { duo_agent_platform_agentic_chat: true, duo_agent_platform: true, @@ -437,6 +445,7 @@ export namespace Provider { }, async getModel(sdk: ReturnType, modelID: string) { return sdk.agenticChat(modelID, { + aiGatewayHeaders, featureFlags: { duo_agent_platform_agentic_chat: true, duo_agent_platform: true, From cf828fff85b50baf8c57cc3811c8789d9adbcae2 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 16:56:31 +0000 Subject: [PATCH 116/148] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 51802195337..431148b1fd5 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-4I0lpBnbAi7IZMURTMLysjrqdsNvXJf8802NrJnpdks=", - "aarch64-linux": "sha256-WOGKsPlcQVSbL8TDr1JYO/2ucPTV2Hy0TXJKWv8EoVw=", - "aarch64-darwin": "sha256-LuvjwGm1QsHoLxuvSSp4VsDIv02Z/rTONsU32arQMuw=", - "x86_64-darwin": "sha256-AbglfgCWj/r+wHfle+e+D3b/xPcwwg4IK7j5iwn9nzw=" + "x86_64-linux": "sha256-yIrljJgOR1GZCAXi5bx+YvrIAjSkTAMTSzlhLFY/ufE=", + "aarch64-linux": "sha256-Xa3BgqbuD5Cx5OpyVSN1v7Klge449hPqR1GY9E9cAX0=", + "aarch64-darwin": "sha256-Q3FKm7+4Jr3PL+TnQngrTtv/xdek2st5HmgeoEOHUis=", + "x86_64-darwin": "sha256-asJ8DBvIgkqh8HhrN48M/L4xj1kwv+uyQMy9bN2HxuM=" } } From 965f32ad634d208bbb34c5a9bb12e501a009378b Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Mon, 2 Feb 2026 18:36:47 +0100 Subject: [PATCH 117/148] fix(tui): respect terminal transparency in system theme (#8467) --- packages/opencode/src/cli/cmd/tui/context/theme.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 7cde1b9648e..41c5a4a831c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -41,7 +41,6 @@ import { useRenderer } from "@opentui/solid" import { createStore, produce } from "solid-js/store" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" -import { useSDK } from "./sdk" type ThemeColors = { primary: RGBA @@ -429,6 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA { function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) + const transparent = RGBA.fromInts(0, 0, 0, 0) const isDark = mode == "dark" const col = (i: number) => { @@ -479,8 +479,8 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs textMuted, selectedListItemText: bg, - // Background colors - background: bg, + // Background colors - use transparent to respect terminal transparency + background: transparent, backgroundPanel: grays[2], backgroundElement: grays[3], backgroundMenu: grays[3], From b9aad20be651050880bf2bc3b4c857f16a970402 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:15:53 +0100 Subject: [PATCH 118/148] fix(app): open project search (#11783) --- .../components/dialog-select-directory.tsx | 182 +++++++++++++++--- packages/ui/src/components/list.tsx | 45 +++-- 2 files changed, 179 insertions(+), 48 deletions(-) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index b9a7d6ed9b1..6e7af3d902d 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" import fuzzysort from "fuzzysort" -import { createMemo } from "solid-js" +import { createMemo, createResource, createSignal } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" +import type { ListRef } from "@opencode-ai/ui/list" interface DialogSelectDirectoryProps { title?: string @@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps { onSelect: (result: string | string[] | null) => void } +type Row = { + absolute: string + search: string +} + export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const sync = useGlobalSync() const sdk = useGlobalSDK() const dialog = useDialog() const language = useLanguage() - const home = createMemo(() => sync.data.path.home) + const [filter, setFilter] = createSignal("") - const start = createMemo(() => sync.data.path.home || sync.data.path.directory) + let list: ListRef | undefined + + const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + + const [fallbackPath] = createResource( + () => (missingBase() ? true : undefined), + async () => { + return sdk.client.path + .get() + .then((x) => x.data) + .catch(() => undefined) + }, + { initialValue: undefined }, + ) + + const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + + const start = createMemo( + () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, + ) const cache = new Map>>() + const clean = (value: string) => { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() + } + function normalize(input: string) { const v = input.replaceAll("\\", "/") if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") @@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return "" } - function display(path: string) { + function parentOf(input: string) { + const v = trimTrailing(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v + + const i = v.lastIndexOf("/") + if (i <= 0) return "/" + if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) + return v.slice(0, i) + } + + function modeOf(input: string) { + const raw = normalizeDriveRoot(input.trim()) + if (!raw) return "relative" as const + if (raw.startsWith("~")) return "tilde" as const + if (rootOf(raw)) return "absolute" as const + return "relative" as const + } + + function display(path: string, input: string) { const full = trimTrailing(path) + if (modeOf(input) === "absolute") return full + + return tildeOf(full) || full + } + + function tildeOf(absolute: string) { + const full = trimTrailing(absolute) const h = home() - if (!h) return full + if (!h) return "" const hn = trimTrailing(h) const lc = full.toLowerCase() const hc = hn.toLowerCase() if (lc === hc) return "~" if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) - return full + return "" } - function scoped(filter: string) { + function row(absolute: string): Row { + const full = trimTrailing(absolute) + const tilde = tildeOf(full) + + const withSlash = (value: string) => { + if (!value) return "" + if (value.endsWith("/")) return value + return value + "/" + } + + const search = Array.from( + new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), + ).join("\n") + return { absolute: full, search } + } + + function scoped(value: string) { const base = start() if (!base) return - const raw = normalizeDriveRoot(filter.trim()) + const raw = normalizeDriveRoot(value) if (!raw) return { directory: trimTrailing(base), path: "" } const h = home() @@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { } const directories = async (filter: string) => { - const input = scoped(filter) - if (!input) return [] as string[] + const value = clean(filter) + const scopedInput = scoped(value) + if (!scopedInput) return [] as string[] - const raw = normalizeDriveRoot(filter.trim()) + const raw = normalizeDriveRoot(value) const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") - const query = normalizeDriveRoot(input.path) + const query = normalizeDriveRoot(scopedInput.path) - if (!isPath) { - const results = await sdk.client.find - .files({ directory: input.directory, query, type: "directory", limit: 50 }) + const find = () => + sdk.client.find + .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) .then((x) => x.data ?? []) .catch(() => []) - return results.map((rel) => join(input.directory, rel)).slice(0, 50) + if (!isPath) { + const results = await find() + + return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50) } const segments = query.replace(/^\/+/, "").split("/") @@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const cap = 12 const branch = 4 - let paths = [input.directory] + let paths = [scopedInput.directory] for (const part of head) { if (part === "..") { - paths = paths.map((p) => { - const v = trimTrailing(p) - if (v === "/") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - const i = v.lastIndexOf("/") - if (i <= 0) return "/" - return v.slice(0, i) - }) + paths = paths.map(parentOf) continue } @@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { } const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() - return Array.from(new Set(out)).slice(0, 50) + const deduped = Array.from(new Set(out)) + const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" + const expand = !raw.endsWith("/") + if (!expand || !tail) { + const items = base ? Array.from(new Set([base, ...deduped])) : deduped + return items.slice(0, 50) + } + + const needle = tail.toLowerCase() + const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle) + const target = exact[0] + if (!target) return deduped.slice(0, 50) + + const children = await match(target, "", 30) + const items = Array.from(new Set([...deduped, ...children])) + return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) + } + + const items = async (value: string) => { + const results = await directories(value) + return results.map(row) } function resolve(absolute: string) { @@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }} emptyMessage={language.t("dialog.directory.empty")} loadingMessage={language.t("common.loading")} - items={directories} - key={(x) => x} + items={items} + key={(x) => x.absolute} + filterKeys={["search"]} + ref={(r) => (list = r)} + onFilter={(value) => setFilter(clean(value))} + onKeyEvent={(e, item) => { + if (e.key !== "Tab") return + if (e.shiftKey) return + if (!item) return + + e.preventDefault() + e.stopPropagation() + + const value = display(item.absolute, filter()) + list?.setFilter(value.endsWith("/") ? value : value + "/") + }} onSelect={(path) => { if (!path) return - resolve(path) + resolve(path.absolute) }} > - {(absolute) => { - const path = display(absolute) + {(item) => { + const path = display(item.absolute, filter()) + if (path === "~") { + return ( +
+
+ +
+ ~ + / +
+
+
+ ) + } return (
- +
{getDirectory(path)} {getFilename(path)} + /
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 15854180e4b..886ac5e6c8e 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -51,6 +51,7 @@ export interface ListProps extends FilteredListProps { export interface ListRef { onKeyDown: (e: KeyboardEvent) => void setScrollRef: (el: HTMLDivElement | undefined) => void + setFilter: (value: string) => void } export function List(props: ListProps & { ref?: (ref: ListRef) => void }) { @@ -80,7 +81,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) container.scrollTop = Math.max(0, Math.min(target, max)) } - const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList(props) + const { filter, grouped, flat, active, setActive, onKeyDown, onInput, refetch } = useFilteredList(props) const searchProps = () => (typeof props.search === "object" ? props.search : {}) const searchAction = () => searchProps().action @@ -89,21 +90,29 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0 - createEffect(() => { - if (props.filter !== undefined) { - onInput(props.filter) - } - }) + const applyFilter = (value: string, options?: { ref?: boolean }) => { + const prev = filter() + setInternalFilter(value) + onInput(value) + props.onFilter?.(value) - createEffect((prev) => { - if (!props.search) return - const current = internalFilter() - if (prev !== current) { - onInput(current) - props.onFilter?.(current) + if (!options?.ref) return + + // Force a refetch even if the value is unchanged. + // This is important for programmatic changes like Tab completion. + if (prev === value) { + refetch() + return } - return current - }, "") + queueMicrotask(() => refetch()) + } + + createEffect(() => { + if (props.filter === undefined) return + if (props.filter === internalFilter()) return + setInternalFilter(props.filter) + onInput(props.filter) + }) createEffect( on( @@ -163,6 +172,8 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const index = selected ? all.indexOf(selected) : -1 props.onKeyEvent?.(e, selected) + if (e.defaultPrevented) return + if (e.key === "Enter" && !e.isComposing) { e.preventDefault() if (selected) handleSelect(selected, index) @@ -174,6 +185,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) props.ref?.({ onKeyDown: handleKey, setScrollRef, + setFilter: (value) => applyFilter(value, { ref: true }), }) const renderAdd = () => { @@ -247,7 +259,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) data-slot="list-search-input" type="text" value={internalFilter()} - onChange={setInternalFilter} + onChange={(value) => applyFilter(value)} onKeyDown={handleKey} placeholder={searchProps().placeholder} spellcheck={false} @@ -260,7 +272,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) setInternalFilter("")} + onClick={() => applyFilter("")} aria-label={i18n.t("ui.list.clearFilter")} /> @@ -295,6 +307,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) data-active={props.key(item) === active()} data-selected={item === props.current} onClick={() => handleSelect(item, i())} + onKeyDown={handleKey} type="button" onMouseMove={(event) => { if (!moved(event)) return From ea1aba4192fd356603e807144edf202328008ee6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 07:02:40 -0600 Subject: [PATCH 119/148] feat(app): project context menu on right-click --- packages/app/src/pages/layout.tsx | 107 +++++-- packages/ui/src/components/context-menu.css | 134 +++++++++ packages/ui/src/components/context-menu.tsx | 308 ++++++++++++++++++++ packages/ui/src/styles/index.css | 1 + 4 files changed, 521 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/components/context-menu.css create mode 100644 packages/ui/src/components/context-menu.tsx diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a970bf667e4..5a8dc0f2eac 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -31,6 +31,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" import { MessageNav } from "@opencode-ai/ui/message-nav" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { ContextMenu } from "@opencode-ai/ui/context-menu" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" @@ -2310,10 +2311,13 @@ export default function Layout(props: ParentProps) { () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(), ) const [open, setOpen] = createSignal(false) + const [menu, setMenu] = createSignal(false) const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) - const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree)) + const active = createMemo( + () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree), + ) createEffect(() => { if (preview()) return @@ -2352,35 +2356,79 @@ export default function Layout(props: ParentProps) { const projectName = () => props.project.name || getFilename(props.project.worktree) const trigger = ( - + { + if (!overlay()) return + globalSync.child(props.project.worktree) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) + }} + onFocus={() => { + if (!overlay()) return + globalSync.child(props.project.worktree) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) + }} + onClick={() => navigateToProject(props.project.worktree)} + onBlur={() => setOpen(false)} + > + + + + + dialog.show(() => )}> + {language.t("common.edit")} + + { + const enabled = layout.sidebar.workspaces(props.project.worktree)() + if (enabled) { + layout.sidebar.toggleWorkspaces(props.project.worktree) + return + } + if (props.project.vcs !== "git") return + layout.sidebar.toggleWorkspaces(props.project.worktree) + }} + > + + {layout.sidebar.workspaces(props.project.worktree)() + ? language.t("sidebar.workspaces.disable") + : language.t("sidebar.workspaces.enable")} + + + + closeProject(props.project.worktree)} + > + {language.t("common.close")} + + + + ) return ( @@ -2388,13 +2436,14 @@ export default function Layout(props: ParentProps) {
{ + if (menu()) return setOpen(value) if (value) setState("hoverSession", undefined) }} diff --git a/packages/ui/src/components/context-menu.css b/packages/ui/src/components/context-menu.css new file mode 100644 index 00000000000..1e366dccd42 --- /dev/null +++ b/packages/ui/src/components/context-menu.css @@ -0,0 +1,134 @@ +[data-component="context-menu-content"], +[data-component="context-menu-sub-content"] { + min-width: 8rem; + overflow: hidden; + border: none; + border-radius: var(--radius-md); + box-shadow: var(--shadow-xs-border); + background-clip: padding-box; + background-color: var(--surface-raised-stronger-non-alpha); + padding: 4px; + z-index: 100; + transform-origin: var(--kb-menu-content-transform-origin); + + &:focus-within, + &:focus { + outline: none; + } + + animation: contextMenuContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; + } + + &[data-expanded] { + pointer-events: auto; + animation: contextMenuContentShow var(--transition-duration) var(--transition-easing) forwards; + } +} + +[data-component="context-menu-content"], +[data-component="context-menu-sub-content"] { + [data-slot="context-menu-item"], + [data-slot="context-menu-checkbox-item"], + [data-slot="context-menu-radio-item"], + [data-slot="context-menu-sub-trigger"] { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: var(--radius-sm); + cursor: default; + outline: none; + + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); + + transition-property: background-color, color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + user-select: none; + + &:hover { + background-color: var(--surface-raised-base-hover); + } + + &[data-disabled] { + color: var(--text-weak); + pointer-events: none; + } + } + + [data-slot="context-menu-sub-trigger"] { + &[data-expanded] { + background: var(--surface-raised-base-hover); + outline: none; + border: none; + } + } + + [data-slot="context-menu-item-indicator"] { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + } + + [data-slot="context-menu-item-label"] { + flex: 1; + } + + [data-slot="context-menu-item-description"] { + font-size: var(--font-size-x-small); + color: var(--text-weak); + } + + [data-slot="context-menu-separator"] { + height: 1px; + margin: 4px -4px; + border-top-color: var(--border-weak-base); + } + + [data-slot="context-menu-group-label"] { + padding: 4px 8px; + font-family: var(--font-family-sans); + font-size: var(--font-size-x-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-weak); + } + + [data-slot="context-menu-arrow"] { + fill: var(--surface-raised-stronger-non-alpha); + } +} + +@keyframes contextMenuContentShow { + from { + opacity: 0; + transform: scaleY(0.95); + } + to { + opacity: 1; + transform: scaleY(1); + } +} + +@keyframes contextMenuContentHide { + from { + opacity: 1; + transform: scaleY(1); + } + to { + opacity: 0; + transform: scaleY(0.95); + } +} diff --git a/packages/ui/src/components/context-menu.tsx b/packages/ui/src/components/context-menu.tsx new file mode 100644 index 00000000000..afdaff7b800 --- /dev/null +++ b/packages/ui/src/components/context-menu.tsx @@ -0,0 +1,308 @@ +import { ContextMenu as Kobalte } from "@kobalte/core/context-menu" +import { splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface ContextMenuProps extends ComponentProps {} +export interface ContextMenuTriggerProps extends ComponentProps {} +export interface ContextMenuIconProps extends ComponentProps {} +export interface ContextMenuPortalProps extends ComponentProps {} +export interface ContextMenuContentProps extends ComponentProps {} +export interface ContextMenuArrowProps extends ComponentProps {} +export interface ContextMenuSeparatorProps extends ComponentProps {} +export interface ContextMenuGroupProps extends ComponentProps {} +export interface ContextMenuGroupLabelProps extends ComponentProps {} +export interface ContextMenuItemProps extends ComponentProps {} +export interface ContextMenuItemLabelProps extends ComponentProps {} +export interface ContextMenuItemDescriptionProps extends ComponentProps {} +export interface ContextMenuItemIndicatorProps extends ComponentProps {} +export interface ContextMenuRadioGroupProps extends ComponentProps {} +export interface ContextMenuRadioItemProps extends ComponentProps {} +export interface ContextMenuCheckboxItemProps extends ComponentProps {} +export interface ContextMenuSubProps extends ComponentProps {} +export interface ContextMenuSubTriggerProps extends ComponentProps {} +export interface ContextMenuSubContentProps extends ComponentProps {} + +function ContextMenuRoot(props: ContextMenuProps) { + return +} + +function ContextMenuTrigger(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuIcon(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuPortal(props: ContextMenuPortalProps) { + return +} + +function ContextMenuContent(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuArrow(props: ContextMenuArrowProps) { + const [local, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function ContextMenuSeparator(props: ContextMenuSeparatorProps) { + const [local, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function ContextMenuGroup(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuGroupLabel(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemLabel(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemDescription(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemIndicator(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuRadioGroup(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuRadioItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuCheckboxItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuSub(props: ContextMenuSubProps) { + return +} + +function ContextMenuSubTrigger(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuSubContent(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +export const ContextMenu = Object.assign(ContextMenuRoot, { + Trigger: ContextMenuTrigger, + Icon: ContextMenuIcon, + Portal: ContextMenuPortal, + Content: ContextMenuContent, + Arrow: ContextMenuArrow, + Separator: ContextMenuSeparator, + Group: ContextMenuGroup, + GroupLabel: ContextMenuGroupLabel, + Item: ContextMenuItem, + ItemLabel: ContextMenuItemLabel, + ItemDescription: ContextMenuItemDescription, + ItemIndicator: ContextMenuItemIndicator, + RadioGroup: ContextMenuRadioGroup, + RadioItem: ContextMenuRadioItem, + CheckboxItem: ContextMenuCheckboxItem, + Sub: ContextMenuSub, + SubTrigger: ContextMenuSubTrigger, + SubContent: ContextMenuSubContent, +}) diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 2a8171f98c0..d5939b2b36a 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -16,6 +16,7 @@ @import "../components/collapsible.css" layer(components); @import "../components/diff.css" layer(components); @import "../components/diff-changes.css" layer(components); +@import "../components/context-menu.css" layer(components); @import "../components/dropdown-menu.css" layer(components); @import "../components/dialog.css" layer(components); @import "../components/file-icon.css" layer(components); From 30a25e4edca0f3476ca63f83dbe95fcee75113e3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:21:08 -0600 Subject: [PATCH 120/148] fix(app): user messages not rendering consistently --- packages/app/src/components/prompt-input.tsx | 4 +- packages/app/src/context/global-sync.tsx | 16 +++--- packages/app/src/context/sync.tsx | 10 ++-- packages/app/src/pages/layout.tsx | 52 +++++++++++++++----- packages/ui/src/components/session-turn.tsx | 8 +-- 5 files changed, 61 insertions(+), 29 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2b63b6f5fd3..1c84c361049 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1463,7 +1463,7 @@ export const PromptInput: Component = (props) => { draft.part[messageID] = optimisticParts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }), ) return @@ -1481,7 +1481,7 @@ export const PromptInput: Component = (props) => { draft.part[messageID] = optimisticParts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }), ) } diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ad3d124b2c3..0facbdfff45 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -119,6 +119,8 @@ type ChildOptions = { bootstrap?: boolean } +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + function normalizeProviderList(input: ProviderListResponse): ProviderListResponse { return { ...input, @@ -297,7 +299,7 @@ function createGlobalSync() { const aUpdated = sessionUpdatedAt(a) const bUpdated = sessionUpdatedAt(b) if (aUpdated !== bUpdated) return bUpdated - aUpdated - return a.id.localeCompare(b.id) + return cmp(a.id, b.id) } function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { @@ -325,7 +327,7 @@ function createGlobalSync() { const all = input .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) const roots = all.filter((s) => !s.parentID) const children = all.filter((s) => !!s.parentID) @@ -342,7 +344,7 @@ function createGlobalSync() { return sessionUpdatedAt(s) > cutoff }) - return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id)) + return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id)) } function ensureChild(directory: string) { @@ -457,7 +459,7 @@ function createGlobalSync() { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) // Read the current limit at resolve-time so callers that bump the limit while // a request is in-flight still get the expanded result. @@ -559,7 +561,7 @@ function createGlobalSync() { "permission", sessionID, reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)), + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -588,7 +590,7 @@ function createGlobalSync() { "question", sessionID, reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)), + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -986,7 +988,7 @@ function createGlobalSync() { .filter((p) => !!p?.id) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) setGlobalStore("project", projects) }), ), diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 5c8e140c396..0c636524501 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -9,6 +9,8 @@ import type { Message, Part } from "@opencode-ai/sdk/v2/client" const keyFor = (directory: string, id: string) => `${directory}\n${id}` +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -59,7 +61,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const next = items .map((x) => x.info) .filter((m) => !!m?.id) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) batch(() => { input.setStore("message", input.sessionID, reconcile(next, { key: "id" })) @@ -69,7 +71,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ "part", message.info.id, reconcile( - message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)), + message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -129,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const result = Binary.search(messages, input.messageID, (m) => m.id) messages.splice(result.index, 0, message) } - draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)) + draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)) }), ) }, @@ -271,7 +273,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ await client.session.list().then((x) => { const sessions = (x.data ?? []) .filter((s) => !!s?.id) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) .slice(0, store.limit) setStore("session", reconcile(sessions, { key: "id" })) }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5a8dc0f2eac..202443ee705 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -499,7 +499,7 @@ export default function Layout(props: ParentProps) { const bUpdated = b.time.updated ?? b.time.created const aRecent = aUpdated > oneMinuteAgo const bRecent = bUpdated > oneMinuteAgo - if (aRecent && bRecent) return a.id.localeCompare(b.id) + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 if (aRecent && !bRecent) return -1 if (!aRecent && bRecent) return 1 return bUpdated - aUpdated @@ -739,7 +739,7 @@ export default function Layout(props: ParentProps) { } async function prefetchMessages(directory: string, sessionID: string, token: number) { - const [, setStore] = globalSync.child(directory, { bootstrap: false }) + const [store, setStore] = globalSync.child(directory, { bootstrap: false }) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) .then((messages) => { @@ -750,23 +750,49 @@ export default function Layout(props: ParentProps) { .map((x) => x.info) .filter((m) => !!m?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + + const current = store.message[sessionID] ?? [] + const merged = (() => { + if (current.length === 0) return next + + const map = new Map() + for (const item of current) { + if (!item?.id) continue + map.set(item.id, item) + } + for (const item of next) { + map.set(item.id, item) + } + return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + })() batch(() => { - setStore("message", sessionID, reconcile(next, { key: "id" })) + setStore("message", sessionID, reconcile(merged, { key: "id" })) for (const message of items) { - setStore( - "part", - message.info.id, - reconcile( - message.parts + const currentParts = store.part[message.info.id] ?? [] + const mergedParts = (() => { + if (currentParts.length === 0) { + return message.parts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)), - { key: "id" }, - ), - ) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + } + + const map = new Map() + for (const item of currentParts) { + if (!item?.id) continue + map.set(item.id, item) + } + for (const item of message.parts) { + if (!item?.id) continue + map.set(item.id, item) + } + return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + })() + + setStore("part", message.info.id, reconcile(mergedParts, { key: "id" })) } }) }) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 29c5566a658..d878bd24569 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -161,12 +161,14 @@ export function SessionTurn( const messageIndex = createMemo(() => { const messages = allMessages() ?? emptyMessages const result = Binary.search(messages, props.messageID, (m) => m.id) - if (!result.found) return -1 - const msg = messages[result.index] + const index = result.found ? result.index : messages.findIndex((m) => m.id === props.messageID) + if (index < 0) return -1 + + const msg = messages[index] if (!msg || msg.role !== "user") return -1 - return result.index + return index }) const message = createMemo(() => { From f1e0c31b8f7c299d2bdc5f69dc30ed55f86918bb Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:29:06 -0600 Subject: [PATCH 121/148] fix(app): button heights --- packages/ui/src/components/button.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 3e5d21d1de8..afff0c476b5 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -114,6 +114,7 @@ } &[data-size="small"] { + height: 22px; padding: 4px 8px; &[data-icon] { padding: 4px 12px 4px 4px; @@ -130,6 +131,7 @@ } &[data-size="normal"] { + height: 24px; padding: 4px 6px; &[data-icon] { padding: 4px 12px 4px 4px; @@ -150,6 +152,7 @@ } &[data-size="large"] { + height: 32px; padding: 6px 12px; &[data-icon] { From 23631a93935a33fb8e44272ba1572e3475a223c2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:53:41 -0600 Subject: [PATCH 122/148] fix(app): navigate to last project on open --- packages/app/src/pages/layout.tsx | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 202443ee705..5d285c5ecca 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -109,7 +109,7 @@ export default function Layout(props: ParentProps) { const command = useCommand() const theme = useTheme() const language = useLanguage() - const initialDir = params.dir + const initialDirectory = decode64(params.dir) const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { @@ -120,7 +120,7 @@ export default function Layout(props: ParentProps) { const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const [state, setState] = createStore({ - autoselect: !params.dir, + autoselect: !initialDirectory, busyWorkspaces: new Set(), hoverSession: undefined as string | undefined, hoverProject: undefined as string | undefined, @@ -180,13 +180,21 @@ export default function Layout(props: ParentProps) { const autoselecting = createMemo(() => { if (params.dir) return false - if (initialDir) return false if (!state.autoselect) return false if (!pageReady()) return true if (!layoutReady()) return true const list = layout.projects.list() - if (list.length === 0) return false - return true + if (list.length > 0) return true + return !!server.projects.last() + }) + + createEffect(() => { + if (!state.autoselect) return + const dir = params.dir + if (!dir) return + const directory = decode64(dir) + if (!directory) return + setState("autoselect", false) }) const editorOpen = (id: string) => editor.active === id @@ -566,11 +574,18 @@ export default function Layout(props: ParentProps) { if (!value.ready) return if (!value.layoutReady) return if (!state.autoselect) return - if (initialDir) return if (value.dir) return - if (value.list.length === 0) return const last = server.projects.last() + + if (value.list.length === 0) { + if (!last) return + setState("autoselect", false) + openProject(last, false) + navigateToProject(last) + return + } + const next = value.list.find((project) => project.worktree === last) ?? value.list[0] if (!next) return setState("autoselect", false) From 3b93e8d95cfc30d1a85fbb76694bdb7f49dff1e9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:55:15 -0600 Subject: [PATCH 123/148] fix(app): added/deleted file status now correctly calculated --- packages/app/src/components/file-tree.tsx | 49 ++++++++++++++++++- packages/app/src/pages/session.tsx | 4 +- packages/opencode/src/snapshot/index.ts | 19 +++++++ .../opencode/test/snapshot/snapshot.test.ts | 46 +++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 5 files changed, 115 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index d43310b195c..19f5e9a3b43 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -130,10 +130,57 @@ export default function FileTree(props: { const nodes = file.tree.children(props.path) const current = filter() if (!current) return nodes - return nodes.filter((node) => { + + const parent = (path: string) => { + const idx = path.lastIndexOf("/") + if (idx === -1) return "" + return path.slice(0, idx) + } + + const leaf = (path: string) => { + const idx = path.lastIndexOf("/") + return idx === -1 ? path : path.slice(idx + 1) + } + + const out = nodes.filter((node) => { if (node.type === "file") return current.files.has(node.path) return current.dirs.has(node.path) }) + + const seen = new Set(out.map((node) => node.path)) + + for (const dir of current.dirs) { + if (parent(dir) !== props.path) continue + if (seen.has(dir)) continue + out.push({ + name: leaf(dir), + path: dir, + absolute: dir, + type: "directory", + ignored: false, + }) + seen.add(dir) + } + + for (const item of current.files) { + if (parent(item) !== props.path) continue + if (seen.has(item)) continue + out.push({ + name: leaf(item), + path: item, + absolute: item, + type: "file", + ignored: false, + }) + seen.add(item) + } + + return out.toSorted((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) }) const Node = ( diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 772ad063ba7..540046c09b9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -500,9 +500,7 @@ export default function Page() { const out = new Map() for (const diff of diffs()) { const file = normalize(diff.file) - const add = diff.additions > 0 - const del = diff.deletions > 0 - const kind = add && del ? "mix" : add ? "add" : del ? "del" : "mix" + const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 1c153909054..b3c8a905c25 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -188,6 +188,7 @@ export namespace Snapshot { after: z.string(), additions: z.number(), deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), }) .meta({ ref: "FileDiff", @@ -196,6 +197,23 @@ export namespace Snapshot { export async function diffFull(from: string, to: string): Promise { const git = gitdir() const result: FileDiff[] = [] + const status = new Map() + + const statuses = + await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .` + .quiet() + .cwd(Instance.directory) + .nothrow() + .text() + + for (const line of statuses.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" + status.set(file, kind) + } + for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` .quiet() .cwd(Instance.directory) @@ -224,6 +242,7 @@ export namespace Snapshot { after, additions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, + status: status.get(file) ?? "modified", }) } return result diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index ef6271ed5da..091469ec761 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -749,6 +749,52 @@ test("revert preserves file that existed in snapshot when deleted then recreated }) }) +test("diffFull sets status based on git change type", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Bun.write(`${tmp.path}/grow.txt`, "one\n") + await Bun.write(`${tmp.path}/trim.txt`, "line1\nline2\n") + await Bun.write(`${tmp.path}/delete.txt`, "gone") + + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Bun.write(`${tmp.path}/grow.txt`, "one\ntwo\n") + await Bun.write(`${tmp.path}/trim.txt`, "line1\n") + await $`rm ${tmp.path}/delete.txt`.quiet() + await Bun.write(`${tmp.path}/added.txt`, "new") + + const after = await Snapshot.track() + expect(after).toBeTruthy() + + const diffs = await Snapshot.diffFull(before!, after!) + expect(diffs.length).toBe(4) + + const added = diffs.find((d) => d.file === "added.txt") + expect(added).toBeDefined() + expect(added!.status).toBe("added") + + const deleted = diffs.find((d) => d.file === "delete.txt") + expect(deleted).toBeDefined() + expect(deleted!.status).toBe("deleted") + + const grow = diffs.find((d) => d.file === "grow.txt") + expect(grow).toBeDefined() + expect(grow!.status).toBe("modified") + expect(grow!.additions).toBeGreaterThan(0) + expect(grow!.deletions).toBe(0) + + const trim = diffs.find((d) => d.file === "trim.txt") + expect(trim).toBeDefined() + expect(trim!.status).toBe("modified") + expect(trim!.additions).toBe(0) + expect(trim!.deletions).toBeGreaterThan(0) + }, + }) +}) + test("diffFull with new file additions", async () => { await using tmp = await bootstrap() await Instance.provide({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0556e1ad945..085c9d9c7ed 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -96,6 +96,7 @@ export type FileDiff = { after: string additions: number deletions: number + status?: "added" | "deleted" | "modified" } export type UserMessage = { From dfd5f38408aa0a905a9cda40f1ce077777dce5e0 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:22:00 -0600 Subject: [PATCH 124/148] fix(app): icon sizes --- packages/app/src/components/prompt-input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 1c84c361049..d31d0b2a376 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1801,7 +1801,7 @@ export const PromptInput: Component = (props) => { }} >
- +
{getFilenameTruncated(item.path, 14)} @@ -1818,7 +1818,7 @@ export const PromptInput: Component = (props) => { type="button" icon="close-small" variant="ghost" - class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all" + class="ml-auto size-3.5 opacity-0 group-hover:opacity-100 transition-all" onClick={(e) => { e.stopPropagation() if (item.commentID) comments.remove(item.path, item.commentID) From 2f76b49df3cfd316069a2b5c292fed369acadbde Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:37:50 -0600 Subject: [PATCH 125/148] Revert "feat(ui): Smooth fading out on scroll, style fixes (#11683)" This reverts commit e445dc07464d75c893756f6e256c1755d9e2285e. --- .../app/src/components/settings-general.tsx | 10 +- .../app/src/components/settings-keybinds.tsx | 10 +- .../app/src/components/settings-models.tsx | 10 +- .../app/src/components/settings-providers.tsx | 10 +- packages/ui/src/components/list.css | 50 +++-- packages/ui/src/components/list.tsx | 5 +- packages/ui/src/components/scroll-fade.css | 82 ------- packages/ui/src/components/scroll-fade.tsx | 206 ------------------ packages/ui/src/components/scroll-reveal.tsx | 141 ------------ packages/ui/src/styles/index.css | 1 - 10 files changed, 43 insertions(+), 482 deletions(-) delete mode 100644 packages/ui/src/components/scroll-fade.css delete mode 100644 packages/ui/src/components/scroll-fade.tsx delete mode 100644 packages/ui/src/components/scroll-reveal.tsx diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a0251ed41b6..94813871e4c 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,7 +5,6 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" @@ -131,12 +130,7 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] return ( - +

{language.t("settings.tab.general")}

@@ -417,7 +411,7 @@ export const SettingsGeneral: Component = () => {
-
+
) } diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 8655bca34b2..a24db13f5c5 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -5,7 +5,6 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import fuzzysort from "fuzzysort" import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -353,12 +352,7 @@ export const SettingsKeybinds: Component = () => { }) return ( - +
@@ -436,6 +430,6 @@ export const SettingsKeybinds: Component = () => {
- +
) } diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 0ee5caf73d9..1807d561eac 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -9,7 +9,6 @@ import { type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" import { popularProviders } from "@/hooks/use-providers" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ModelItem = ReturnType["list"]>[number] @@ -40,12 +39,7 @@ export const SettingsModels: Component = () => { }) return ( - +

{language.t("settings.models.title")}

@@ -131,6 +125,6 @@ export const SettingsModels: Component = () => {
- +
) } diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 2460534c05c..dcc597139e3 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -12,7 +12,6 @@ import { useGlobalSync } from "@/context/global-sync" import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ProviderSource = "env" | "api" | "config" | "custom" type ProviderMeta = { source?: ProviderSource } @@ -116,12 +115,7 @@ export const SettingsProviders: Component = () => { } return ( - +

{language.t("settings.providers.title")}

@@ -267,6 +261,6 @@ export const SettingsProviders: Component = () => {
- +
) } diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 7b365c288ad..b12d304151d 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -1,7 +1,25 @@ +@property --bottom-fade { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@keyframes scroll { + 0% { + --bottom-fade: 20px; + } + 90% { + --bottom-fade: 20px; + } + 100% { + --bottom-fade: 0; + } +} + [data-component="list"] { display: flex; flex-direction: column; - gap: 8px; + gap: 12px; overflow: hidden; padding: 0 12px; @@ -19,9 +37,7 @@ flex-shrink: 0; background-color: transparent; opacity: 0.5; - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: opacity 0.15s ease; &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -72,9 +88,7 @@ height: 20px; background-color: transparent; opacity: 0.5; - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: opacity 0.15s ease; &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -117,6 +131,15 @@ gap: 12px; overflow-y: auto; overscroll-behavior: contain; + mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000); + animation: scroll; + animation-timeline: --scroll; + scroll-timeline: --scroll y; + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } [data-slot="list-empty-state"] { display: flex; @@ -192,9 +215,7 @@ background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent); pointer-events: none; opacity: 0; - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: opacity 0.15s ease; } &[data-stuck="true"]::after { @@ -230,22 +251,17 @@ align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1 / 1; + aspect-ratio: 1/1; [data-component="icon"] { color: var(--icon-strong-base); } } - - [name="check"] { - color: var(--icon-strong-base); - } - [data-slot="list-item-active-icon"] { display: none; align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1 / 1; + aspect-ratio: 1/1; [data-component="icon"] { color: var(--icon-strong-base); } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 886ac5e6c8e..6c654cbb7d6 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -5,7 +5,6 @@ import { useI18n } from "../context/i18n" import { Icon, type IconProps } from "./icon" import { IconButton } from "./icon-button" import { TextField } from "./text-field" -import { ScrollFade } from "./scroll-fade" function findByKey(container: HTMLElement, key: string) { const nodes = container.querySelectorAll('[data-slot="list-item"][data-key]') @@ -280,7 +279,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {searchAction()}
- +
0 || showAdd()} fallback={ @@ -353,7 +352,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
) } diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css deleted file mode 100644 index ede5fabec45..00000000000 --- a/packages/ui/src/components/scroll-fade.css +++ /dev/null @@ -1,82 +0,0 @@ -[data-component="scroll-fade"] { - overflow: auto; - overscroll-behavior: contain; - scrollbar-width: none; - box-sizing: border-box; - color: inherit; - font: inherit; - -ms-overflow-style: none; - - &::-webkit-scrollbar { - display: none; - } - - &[data-direction="horizontal"] { - overflow-x: auto; - overflow-y: hidden; - - /* Both fades */ - &[data-fade-start][data-fade-end] { - mask-image: linear-gradient( - to right, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - -webkit-mask-image: linear-gradient( - to right, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - } - - /* Only start fade */ - &[data-fade-start]:not([data-fade-end]) { - mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); - -webkit-mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); - } - - /* Only end fade */ - &:not([data-fade-start])[data-fade-end] { - mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - -webkit-mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - } - } - - &[data-direction="vertical"] { - overflow-y: auto; - overflow-x: hidden; - - &[data-fade-start][data-fade-end] { - mask-image: linear-gradient( - to bottom, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - -webkit-mask-image: linear-gradient( - to bottom, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - } - - /* Only start fade */ - &[data-fade-start]:not([data-fade-end]) { - mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); - -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); - } - - /* Only end fade */ - &:not([data-fade-start])[data-fade-end] { - mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - } - } -} diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx deleted file mode 100644 index 97f0339e829..00000000000 --- a/packages/ui/src/components/scroll-fade.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { type JSX, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" - -export interface ScrollFadeProps extends JSX.HTMLAttributes { - direction?: "horizontal" | "vertical" - fadeStartSize?: number - fadeEndSize?: number - trackTransformSelector?: string - ref?: (el: HTMLDivElement) => void -} - -export function ScrollFade(props: ScrollFadeProps) { - const [local, others] = splitProps(props, [ - "children", - "direction", - "fadeStartSize", - "fadeEndSize", - "trackTransformSelector", - "class", - "style", - "ref", - ]) - - const direction = () => local.direction ?? "vertical" - const fadeStartSize = () => local.fadeStartSize ?? 20 - const fadeEndSize = () => local.fadeEndSize ?? 20 - - const getTransformOffset = (element: Element): number => { - const style = getComputedStyle(element) - const transform = style.transform - if (!transform || transform === "none") return 0 - - const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/) - if (!match) return 0 - - const values = match[1].split(",").map((v) => parseFloat(v.trim())) - const isHorizontal = direction() === "horizontal" - - if (transform.startsWith("matrix3d")) { - return isHorizontal ? -(values[12] || 0) : -(values[13] || 0) - } else { - return isHorizontal ? -(values[4] || 0) : -(values[5] || 0) - } - } - - let containerRef: HTMLDivElement | undefined - - const [fadeStart, setFadeStart] = createSignal(0) - const [fadeEnd, setFadeEnd] = createSignal(0) - const [isScrollable, setIsScrollable] = createSignal(false) - - let lastScrollPos = 0 - let lastTransformPos = 0 - let lastScrollSize = 0 - let lastClientSize = 0 - - const updateFade = () => { - if (!containerRef) return - - const isHorizontal = direction() === "horizontal" - const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop - const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight - const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight - - let transformPos = 0 - if (local.trackTransformSelector) { - const transformElement = containerRef.querySelector(local.trackTransformSelector) - if (transformElement) { - transformPos = getTransformOffset(transformElement) - } - } - - const effectiveScrollPos = Math.max(scrollPos, transformPos) - - if ( - effectiveScrollPos === lastScrollPos && - transformPos === lastTransformPos && - scrollSize === lastScrollSize && - clientSize === lastClientSize - ) { - return - } - - lastScrollPos = effectiveScrollPos - lastTransformPos = transformPos - lastScrollSize = scrollSize - lastClientSize = clientSize - - const maxScroll = scrollSize - clientSize - const canScroll = maxScroll > 1 - - setIsScrollable(canScroll) - - if (!canScroll) { - setFadeStart(0) - setFadeEnd(0) - return - } - - const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0 - - const startProgress = Math.min(progress / 0.1, 1) - setFadeStart(startProgress * fadeStartSize()) - - const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1 - setFadeEnd(Math.max(0, endProgress) * fadeEndSize()) - } - - onMount(() => { - if (!containerRef) return - - updateFade() - - let rafId: number | undefined - let isPolling = false - let pollTimeout: ReturnType | undefined - - const startPolling = () => { - if (isPolling) return - isPolling = true - - const pollScroll = () => { - updateFade() - rafId = requestAnimationFrame(pollScroll) - } - rafId = requestAnimationFrame(pollScroll) - } - - const stopPolling = () => { - if (!isPolling) return - isPolling = false - if (rafId !== undefined) { - cancelAnimationFrame(rafId) - rafId = undefined - } - } - - const schedulePollingStop = () => { - if (pollTimeout !== undefined) clearTimeout(pollTimeout) - pollTimeout = setTimeout(stopPolling, 1000) - } - - const onActivity = () => { - updateFade() - if (local.trackTransformSelector) { - startPolling() - schedulePollingStop() - } - } - - containerRef.addEventListener("scroll", onActivity, { passive: true }) - - const resizeObserver = new ResizeObserver(() => { - lastScrollSize = 0 - lastClientSize = 0 - onActivity() - }) - resizeObserver.observe(containerRef) - - const mutationObserver = new MutationObserver(() => { - lastScrollSize = 0 - lastClientSize = 0 - requestAnimationFrame(onActivity) - }) - mutationObserver.observe(containerRef, { - childList: true, - subtree: true, - characterData: true, - }) - - onCleanup(() => { - containerRef?.removeEventListener("scroll", onActivity) - resizeObserver.disconnect() - mutationObserver.disconnect() - stopPolling() - if (pollTimeout !== undefined) clearTimeout(pollTimeout) - }) - }) - - createEffect(() => { - local.children - requestAnimationFrame(updateFade) - }) - - return ( -
{ - containerRef = el - local.ref?.(el) - }} - data-component="scroll-fade" - data-direction={direction()} - data-scrollable={isScrollable() || undefined} - data-fade-start={fadeStart() > 0 || undefined} - data-fade-end={fadeEnd() > 0 || undefined} - class={local.class} - style={{ - ...(typeof local.style === "object" ? local.style : {}), - "--scroll-fade-start": `${fadeStart()}px`, - "--scroll-fade-end": `${fadeEnd()}px`, - }} - {...others} - > - {local.children} -
- ) -} diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx deleted file mode 100644 index 6e5072dc81e..00000000000 --- a/packages/ui/src/components/scroll-reveal.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { type JSX, onCleanup, splitProps } from "solid-js" -import { ScrollFade, type ScrollFadeProps } from "./scroll-fade" - -const SCROLL_SPEED = 60 -const PAUSE_DURATION = 800 - -type ScrollAnimationState = { - rafId: number | null - startTime: number - running: boolean -} - -const startScrollAnimation = (containerEl: HTMLElement): ScrollAnimationState | null => { - containerEl.offsetHeight - - const extraWidth = containerEl.scrollWidth - containerEl.clientWidth - - if (extraWidth <= 0) { - return null - } - - const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000 - const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION - - const state: ScrollAnimationState = { - rafId: null, - startTime: performance.now(), - running: true, - } - - const animate = (currentTime: number) => { - if (!state.running) return - - const elapsed = currentTime - state.startTime - const progress = (elapsed % totalDuration) / totalDuration - - const pausePercent = PAUSE_DURATION / totalDuration - const scrollPercent = scrollDuration / totalDuration - - const pauseEnd1 = pausePercent - const scrollEnd1 = pauseEnd1 + scrollPercent - const pauseEnd2 = scrollEnd1 + pausePercent - const scrollEnd2 = pauseEnd2 + scrollPercent - - let scrollPos = 0 - - if (progress < pauseEnd1) { - scrollPos = 0 - } else if (progress < scrollEnd1) { - const scrollProgress = (progress - pauseEnd1) / scrollPercent - scrollPos = scrollProgress * extraWidth - } else if (progress < pauseEnd2) { - scrollPos = extraWidth - } else if (progress < scrollEnd2) { - const scrollProgress = (progress - pauseEnd2) / scrollPercent - scrollPos = extraWidth * (1 - scrollProgress) - } else { - scrollPos = 0 - } - - containerEl.scrollLeft = scrollPos - state.rafId = requestAnimationFrame(animate) - } - - state.rafId = requestAnimationFrame(animate) - return state -} - -const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => { - if (state) { - state.running = false - if (state.rafId !== null) { - cancelAnimationFrame(state.rafId) - } - } - if (containerEl) { - containerEl.scrollLeft = 0 - } -} - -export interface ScrollRevealProps extends Omit { - hoverDelay?: number -} - -export function ScrollReveal(props: ScrollRevealProps) { - const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"]) - - const hoverDelay = () => local.hoverDelay ?? 300 - - let containerRef: HTMLDivElement | undefined - let hoverTimeout: ReturnType | undefined - let scrollAnimationState: ScrollAnimationState | null = null - - const handleMouseEnter: JSX.EventHandler = () => { - hoverTimeout = setTimeout(() => { - if (!containerRef) return - - containerRef.offsetHeight - - const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1 - - if (isScrollable) { - stopScrollAnimation(scrollAnimationState, containerRef) - scrollAnimationState = startScrollAnimation(containerRef) - } - }, hoverDelay()) - } - - const handleMouseLeave: JSX.EventHandler = () => { - if (hoverTimeout) { - clearTimeout(hoverTimeout) - hoverTimeout = undefined - } - stopScrollAnimation(scrollAnimationState, containerRef) - scrollAnimationState = null - } - - onCleanup(() => { - if (hoverTimeout) { - clearTimeout(hoverTimeout) - } - stopScrollAnimation(scrollAnimationState, containerRef) - }) - - return ( - { - containerRef = el - local.ref?.(el) - }} - fadeStartSize={8} - fadeEndSize={8} - direction="horizontal" - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - {...others} - > - {local.children} - - ) -} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index d5939b2b36a..55e1a16d18e 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -41,7 +41,6 @@ @import "../components/select.css" layer(components); @import "../components/spinner.css" layer(components); @import "../components/switch.css" layer(components); -@import "../components/scroll-fade.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); From 70cf609ce90a7534349c8dd5ed8441cbd32ebba7 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:46:25 -0600 Subject: [PATCH 126/148] Revert "feat(ui): Select, dropdown, popover styles & transitions (#11675)" This reverts commit 377bf7ff21a4f05807c38675ac70cd08fe67b516. --- .../src/components/dialog-select-model.tsx | 7 +- packages/app/src/components/prompt-input.tsx | 125 ++++++---------- .../app/src/components/settings-general.tsx | 2 +- packages/ui/src/components/button.css | 26 ++-- packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/cycle-label.css | 49 ------- packages/ui/src/components/cycle-label.tsx | 135 ------------------ packages/ui/src/components/dropdown-menu.css | 45 +++--- packages/ui/src/components/icon.tsx | 7 +- packages/ui/src/components/message-part.tsx | 6 +- packages/ui/src/components/morph-chevron.css | 10 -- packages/ui/src/components/morph-chevron.tsx | 73 ---------- packages/ui/src/components/popover.css | 58 ++------ packages/ui/src/components/reasoning-icon.css | 9 -- packages/ui/src/components/reasoning-icon.tsx | 46 ------ packages/ui/src/components/select.css | 87 +++++------ packages/ui/src/components/select.tsx | 18 +-- packages/ui/src/styles/index.css | 2 - packages/ui/src/styles/utilities.css | 42 ------ 19 files changed, 129 insertions(+), 620 deletions(-) delete mode 100644 packages/ui/src/components/cycle-label.css delete mode 100644 packages/ui/src/components/cycle-label.tsx delete mode 100644 packages/ui/src/components/morph-chevron.css delete mode 100644 packages/ui/src/components/morph-chevron.tsx delete mode 100644 packages/ui/src/components/reasoning-icon.css delete mode 100644 packages/ui/src/components/reasoning-icon.tsx diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 2135b1edf44..4f0dcc3ee65 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -90,10 +90,9 @@ const ModelList: Component<{ export function ModelSelectorPopover(props: { provider?: string - children?: JSX.Element | ((open: boolean) => JSX.Element) + children?: JSX.Element triggerAs?: T triggerProps?: ComponentProps - gutter?: number }) { const [store, setStore] = createStore<{ open: boolean @@ -176,14 +175,14 @@ export function ModelSelectorPopover(props: { }} modal={false} placement="top-start" - gutter={props.gutter ?? 8} + gutter={8} > setStore("trigger", el)} as={props.triggerAs ?? "div"} {...(props.triggerProps as any)} > - {typeof props.children === "function" ? props.children(store.open) : props.children} + {props.children} = (props) => { clearInput() client.session .shell({ - sessionID: session?.id || "", + sessionID: session.id, agent, model, command: text, @@ -1280,7 +1277,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .command({ - sessionID: session?.id || "", + sessionID: session.id, command: commandName, arguments: args.join(" "), agent, @@ -1436,13 +1433,13 @@ export const PromptInput: Component = (props) => { const optimisticParts = requestParts.map((part) => ({ ...part, - sessionID: session?.id || "", + sessionID: session.id, messageID, })) as unknown as Part[] const optimisticMessage: Message = { id: messageID, - sessionID: session?.id || "", + sessionID: session.id, role: "user", time: { created: Date.now() }, agent, @@ -1453,9 +1450,9 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (!messages) { - draft.message[session?.id || ""] = [optimisticMessage] + draft.message[session.id] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1471,9 +1468,9 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (!messages) { - draft.message[session?.id || ""] = [optimisticMessage] + draft.message[session.id] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1490,7 +1487,7 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1503,7 +1500,7 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1524,15 +1521,15 @@ export const PromptInput: Component = (props) => { const worktree = WorktreeState.get(sessionDirectory) if (!worktree || worktree.status !== "pending") return true - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "busy" }) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "busy" }) } const controller = new AbortController() const cleanup = () => { - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "idle" }) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) } removeOptimisticMessage() for (const item of commentItems) { @@ -1549,7 +1546,7 @@ export const PromptInput: Component = (props) => { restoreInput() } - pending.set(session?.id || "", { abort: controller, cleanup }) + pending.set(session.id, { abort: controller, cleanup }) const abort = new Promise>>((resolve) => { if (controller.signal.aborted) { @@ -1577,7 +1574,7 @@ export const PromptInput: Component = (props) => { if (timer.id === undefined) return clearTimeout(timer.id) }) - pending.delete(session?.id || "") + pending.delete(session.id) if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) return true @@ -1587,7 +1584,7 @@ export const PromptInput: Component = (props) => { const ok = await waitForWorktree() if (!ok) return await client.session.prompt({ - sessionID: session?.id || "", + sessionID: session.id, agent, model, messageID, @@ -1597,9 +1594,9 @@ export const PromptInput: Component = (props) => { } void send().catch((err) => { - pending.delete(session?.id || "") - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "idle" }) + pending.delete(session.id) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), @@ -1621,28 +1618,6 @@ export const PromptInput: Component = (props) => { }) } - const currrentModelVariant = createMemo(() => { - const modelVariant = local.model.variant.current() ?? "" - return modelVariant === "xhigh" - ? "xHigh" - : modelVariant.length > 0 - ? modelVariant[0].toUpperCase() + modelVariant.slice(1) - : "Default" - }) - - const reasoningPercentage = createMemo(() => { - const variants = local.model.variant.list() - const current = local.model.variant.current() - const totalEntries = variants.length + 1 - - if (totalEntries <= 2 || current === "Default") { - return 0 - } - - const currentIndex = current ? variants.indexOf(current) + 1 : 0 - return ((currentIndex + 1) / totalEntries) * 100 - }, [local.model.variant]) - return (
@@ -1695,7 +1670,7 @@ export const PromptInput: Component = (props) => { } > - + @{(item as { type: "agent"; name: string }).name} @@ -1760,9 +1735,9 @@ export const PromptInput: Component = (props) => { }} > -
+
- + {language.t("prompt.dropzone.label")}
@@ -1848,7 +1823,7 @@ export const PromptInput: Component = (props) => { when={attachment.mime.startsWith("image/")} fallback={
- +
} > @@ -1922,7 +1897,7 @@ export const PromptInput: Component = (props) => {
-
+
@@ -1943,7 +1918,6 @@ export const PromptInput: Component = (props) => { onSelect={local.agent.set} class="capitalize" variant="ghost" - gutter={12} /> = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - } @@ -1976,16 +1943,12 @@ export const PromptInput: Component = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - - {(open) => ( - <> - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - - - )} + + + + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + @@ -1998,13 +1961,10 @@ export const PromptInput: Component = (props) => { @@ -2018,7 +1978,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} classList={{ - "_hidden group-hover/prompt-input:flex items-center justify-center": true, + "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), }} @@ -2040,7 +2000,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
@@ -2083,7 +2042,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.action.send")} - +
@@ -2094,7 +2053,7 @@ export const PromptInput: Component = (props) => { disabled={!prompt.dirty() && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="h-6 w-5.5" + class="h-6 w-4.5" aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 94813871e4c..b31cfb6cc79 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -226,7 +226,7 @@ export const SettingsGeneral: Component = () => { variant="secondary" size="small" triggerVariant="settings" - triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }} + triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} > {(option) => ( diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index afff0c476b5..d9b34592304 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -9,13 +9,7 @@ user-select: none; cursor: default; outline: none; - padding: 4px 8px; white-space: nowrap; - transition-property: background-color, border-color, color, box-shadow, opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); - outline: none; - line-height: 20px; &[data-variant="primary"] { background-color: var(--button-primary-base); @@ -100,6 +94,7 @@ &:active:not(:disabled) { background-color: var(--button-secondary-base); scale: 0.99; + transition: all 150ms ease-out; } &:disabled { border-color: var(--border-disabled); @@ -115,32 +110,33 @@ &[data-size="small"] { height: 22px; - padding: 4px 8px; + padding: 0 8px; &[data-icon] { - padding: 4px 12px 4px 4px; + padding: 0 12px 0 4px; } + font-size: var(--font-size-small); + line-height: var(--line-height-large); gap: 4px; /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); } &[data-size="normal"] { height: 24px; - padding: 4px 6px; + line-height: 24px; + padding: 0 6px; &[data-icon] { - padding: 4px 12px 4px 4px; - } - - &[aria-haspopup] { - padding: 4px 6px 4px 8px; + padding: 0 12px 0 4px; } + font-size: var(--font-size-small); gap: 6px; /* text-12-medium */ diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index b2d2004d3c8..7f974b2f76e 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon" export interface ButtonProps extends ComponentProps, - Pick, "class" | "classList" | "children" | "style"> { + Pick, "class" | "classList" | "children"> { size?: "small" | "normal" | "large" variant?: "primary" | "secondary" | "ghost" icon?: IconProps["name"] diff --git a/packages/ui/src/components/cycle-label.css b/packages/ui/src/components/cycle-label.css deleted file mode 100644 index 3c98fcd261e..00000000000 --- a/packages/ui/src/components/cycle-label.css +++ /dev/null @@ -1,49 +0,0 @@ -.cycle-label { - --c-duration: 200ms; - --c-stagger: 30ms; - --c-opacity-start: 0; - --c-opacity-end: 1; - --c-blur-start: 0px; - --c-blur-end: 0px; - --c-skew: 10deg; - - display: inline-flex; - position: relative; - - transform-style: preserve-3d; - perspective: 500px; - transition: width var(--transition-duration) var(--transition-easing); - will-change: width; - overflow: hidden; - - .cycle-char { - display: inline-block; - transform-style: preserve-3d; - min-width: 0.25em; - backface-visibility: hidden; - - transition-property: transform, opacity, filter; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); - transition-delay: calc(var(--i, 0) * var(--c-stagger)); - - &.enter { - opacity: var(--c-opacity-end); - filter: blur(var(--c-blur-end)); - transform: translateY(0) rotateX(0) skewX(0); - } - - &.exit { - opacity: var(--c-opacity-start); - filter: blur(var(--c-blur-start)); - transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew)); - } - - &.pre { - opacity: var(--c-opacity-start); - filter: blur(var(--c-blur-start)); - transition: none; - transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1)); - } - } -} diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx deleted file mode 100644 index dc12bd75c87..00000000000 --- a/packages/ui/src/components/cycle-label.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import "./cycle-label.css" -import { createEffect, createSignal, JSX, on } from "solid-js" - -export interface CycleLabelProps extends JSX.HTMLAttributes { - value: string - onValueChange?: (value: string) => void - duration?: number | ((value: string) => number) - stagger?: number - opacity?: [number, number] - blur?: [number, number] - skewX?: number - onAnimationStart?: () => void - onAnimationEnd?: () => void -} - -const segmenter = - typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null - -const getChars = (text: string): string[] => - segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("") - -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -export function CycleLabel(props: CycleLabelProps) { - const getDuration = (text: string) => { - const d = - props.duration ?? - Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? - 200 - return typeof d === "function" ? d(text) : d - } - const stagger = () => props?.stagger ?? 30 - const opacity = () => props?.opacity ?? [0, 1] - const blur = () => props?.blur ?? [0, 0] - const skewX = () => props?.skewX ?? 10 - - let containerRef: HTMLSpanElement | undefined - let isAnimating = false - const [currentText, setCurrentText] = createSignal(props.value) - - const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => { - el.innerHTML = "" - const chars = getChars(text) - chars.forEach((char, i) => { - const span = document.createElement("span") - span.textContent = char === " " ? "\u00A0" : char - span.className = `cycle-char ${state}` - span.style.setProperty("--i", String(i)) - el.appendChild(span) - }) - } - - const animateToText = async (newText: string) => { - if (!containerRef || isAnimating) return - if (newText === currentText()) return - - isAnimating = true - props.onAnimationStart?.() - - const dur = getDuration(newText) - const stag = stagger() - - containerRef.style.width = containerRef.offsetWidth + "px" - - const oldChars = containerRef.querySelectorAll(".cycle-char") - oldChars.forEach((c) => c.classList.replace("enter", "exit")) - - const clone = containerRef.cloneNode(false) as HTMLElement - Object.assign(clone.style, { - position: "absolute", - visibility: "hidden", - width: "auto", - transition: "none", - }) - setChars(clone, newText) - document.body.appendChild(clone) - const nextWidth = clone.offsetWidth - clone.remove() - - const exitTime = oldChars.length * stag + dur - await wait(exitTime * 0.3) - - containerRef.style.width = nextWidth + "px" - - const widthDur = 200 - await wait(widthDur * 0.3) - - setChars(containerRef, newText, "pre") - containerRef.offsetWidth - - Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter")) - setCurrentText(newText) - props.onValueChange?.(newText) - - const enterTime = getChars(newText).length * stag + dur - await wait(enterTime) - - containerRef.style.width = "" - isAnimating = false - props.onAnimationEnd?.() - } - - createEffect( - on( - () => props.value, - (newValue) => { - if (newValue !== currentText()) { - animateToText(newValue) - } - }, - ), - ) - - const initRef = (el: HTMLSpanElement) => { - containerRef = el - setChars(el, props.value) - } - - return ( - - ) -} diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css index 18266ac1a1c..cba041613ea 100644 --- a/packages/ui/src/components/dropdown-menu.css +++ b/packages/ui/src/components/dropdown-menu.css @@ -2,29 +2,26 @@ [data-component="dropdown-menu-sub-content"] { min-width: 8rem; overflow: hidden; - border: none; border-radius: var(--radius-md); - box-shadow: var(--shadow-xs-border); + border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); background-clip: padding-box; background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; - z-index: 100; + box-shadow: var(--shadow-md); + z-index: 50; transform-origin: var(--kb-menu-content-transform-origin); - &:focus-within, - &:focus { + &:focus, + &:focus-visible { outline: none; } - animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards; - - @starting-style { - animation: none; + &[data-closed] { + animation: dropdown-menu-close 0.15s ease-out; } &[data-expanded] { - pointer-events: auto; - animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards; + animation: dropdown-menu-open 0.15s ease-out; } } @@ -41,22 +38,18 @@ padding: 4px 8px; border-radius: var(--radius-sm); cursor: default; + user-select: none; outline: none; font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: var(--font-size-small); font-weight: var(--font-weight-medium); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); - transition-property: background-color, color; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); - user-select: none; - - &:hover { - background-color: var(--surface-raised-base-hover); + &[data-highlighted] { + background: var(--surface-raised-base-hover); } &[data-disabled] { @@ -68,8 +61,6 @@ [data-slot="dropdown-menu-sub-trigger"] { &[data-expanded] { background: var(--surface-raised-base-hover); - outline: none; - border: none; } } @@ -111,24 +102,24 @@ } } -@keyframes dropdownMenuContentShow { +@keyframes dropdown-menu-open { from { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.96); } to { opacity: 1; - transform: scaleY(1); + transform: scale(1); } } -@keyframes dropdownMenuContentHide { +@keyframes dropdown-menu-close { from { opacity: 1; - transform: scaleY(1); + transform: scale(1); } to { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.96); } } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 97488a42f0f..544c6abdd21 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -80,16 +80,13 @@ const icons = { export interface IconProps extends ComponentProps<"svg"> { name: keyof typeof icons - size?: "small" | "normal" | "medium" | "large" | number + size?: "small" | "normal" | "medium" | "large" } export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) return ( -
+
- +
m.id === perm.tool!.messageID) + const message = findLast(messages, (m) => m.id === perm.tool!.messageID) if (!message) return undefined const parts = data.store.part[message.id] ?? [] for (const part of parts) { diff --git a/packages/ui/src/components/morph-chevron.css b/packages/ui/src/components/morph-chevron.css deleted file mode 100644 index f6edb3f649a..00000000000 --- a/packages/ui/src/components/morph-chevron.css +++ /dev/null @@ -1,10 +0,0 @@ -[data-slot="morph-chevron-svg"] { - width: 16px; - height: 16px; - display: block; - fill: none; - stroke-width: 1.5; - stroke: currentcolor; - stroke-linecap: round; - stroke-linejoin: round; -} diff --git a/packages/ui/src/components/morph-chevron.tsx b/packages/ui/src/components/morph-chevron.tsx deleted file mode 100644 index 280aeb7e34e..00000000000 --- a/packages/ui/src/components/morph-chevron.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { createEffect, createUniqueId, on } from "solid-js" - -export interface MorphChevronProps { - expanded: boolean - class?: string -} - -const COLLAPSED = "M4 6L8 10L12 6" -const EXPANDED = "M4 10L8 6L12 10" - -export function MorphChevron(props: MorphChevronProps) { - const id = createUniqueId() - let path: SVGPathElement | undefined - let expandAnim: SVGAnimateElement | undefined - let collapseAnim: SVGAnimateElement | undefined - - createEffect( - on( - () => props.expanded, - (expanded, prev) => { - if (prev === undefined) { - // Set initial state without animation - path?.setAttribute("d", expanded ? EXPANDED : COLLAPSED) - return - } - if (expanded) { - expandAnim?.beginElement() - } else { - collapseAnim?.beginElement() - } - }, - ), - ) - - return ( - - ) -} diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index d200fe8b247..b49542afd9b 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -15,35 +15,16 @@ transform-origin: var(--kb-popover-content-transform-origin); - animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; + &:focus-within { + outline: none; + } - @starting-style { - animation: none; + &[data-closed] { + animation: popover-close 0.15s ease-out; } &[data-expanded] { - pointer-events: auto; - animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; - } - - [data-origin-top-right] { - transform-origin: top right; - } - - [data-origin-top-left] { - transform-origin: top left; - } - - [data-origin-bottom-right] { - transform-origin: bottom right; - } - - [data-origin-bottom-left] { - transform-origin: bottom left; - } - - &:focus-within { - outline: none; + animation: popover-open 0.15s ease-out; } [data-slot="popover-header"] { @@ -94,39 +75,24 @@ } } -@keyframes popoverContentShow { +@keyframes popover-open { from { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.96); } to { opacity: 1; - transform: scaleY(1); + transform: scale(1); } } -@keyframes popoverContentHide { +@keyframes popover-close { from { opacity: 1; - transform: scaleY(1); + transform: scale(1); } to { opacity: 0; - transform: scaleY(0.95); - } -} - -[data-component="model-popover-content"] { - transform-origin: var(--kb-popper-content-transform-origin); - pointer-events: none; - animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; - - @starting-style { - animation: none; - } - - &[data-expanded] { - pointer-events: auto; - animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; + transform: scale(0.96); } } diff --git a/packages/ui/src/components/reasoning-icon.css b/packages/ui/src/components/reasoning-icon.css deleted file mode 100644 index 26fbc014489..00000000000 --- a/packages/ui/src/components/reasoning-icon.css +++ /dev/null @@ -1,9 +0,0 @@ -[data-component="reasoning-icon"] { - color: var(--icon-strong-base); - - [data-slot="reasoning-icon-percentage"] { - transition: clip-path 200ms cubic-bezier(0.25, 0, 0.5, 1); - clip-path: inset(calc(100% - var(--reasoning-icon-percentage) * 100%) 0 0 0); - opacity: calc(var(--reasoning-icon-percentage) * 0.75); - } -} diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx deleted file mode 100644 index 7bac49ffd2e..00000000000 --- a/packages/ui/src/components/reasoning-icon.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { type ComponentProps, splitProps } from "solid-js" - -export interface ReasoningIconProps extends Pick, "class" | "classList"> { - percentage: number - size?: number - strokeWidth?: number -} - -export function ReasoningIcon(props: ReasoningIconProps) { - const [split, rest] = splitProps(props, ["percentage", "size", "strokeWidth", "class", "classList"]) - - const size = () => split.size || 16 - const strokeWidth = () => split.strokeWidth || 1.25 - - return ( - - - - - ) -} diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index eaba6fd6d2a..25dd2eb40b6 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -1,13 +1,7 @@ [data-component="select"] { [data-slot="select-select-trigger"] { - display: flex; - padding: 4px 8px !important; - align-items: center; - justify-content: space-between; + padding: 0 4px 0 8px; box-shadow: none; - transition-property: background-color; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); [data-slot="select-select-trigger-value"] { overflow: hidden; @@ -21,10 +15,10 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: var(--icon-base); + color: var(--text-weak); + transition: transform 0.1s ease-in-out; } - &:hover, &[data-expanded] { &[data-variant="secondary"] { background-color: var(--button-secondary-hover); @@ -36,13 +30,13 @@ background-color: var(--icon-strong-active); } } - &:not([data-expanded]):focus, + &:not([data-expanded]):focus-visible { &[data-variant="secondary"] { background-color: var(--button-secondary-base); } &[data-variant="ghost"] { - background-color: transparent; + background-color: var(--surface-raised-base-hover); } &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -52,10 +46,10 @@ &[data-trigger-style="settings"] { [data-slot="select-select-trigger"] { - padding: 6px 6px 6px 10px; + padding: 6px 6px 6px 12px; box-shadow: none; border-radius: 6px; - field-sizing: content; + min-width: 160px; height: 32px; justify-content: flex-end; gap: 12px; @@ -67,7 +61,6 @@ white-space: nowrap; font-size: var(--font-size-base); font-weight: var(--font-weight-regular); - padding: 4px 8px 4px 4px; } [data-slot="select-select-trigger-icon"] { width: 16px; @@ -98,26 +91,17 @@ } [data-component="select-content"] { - min-width: 8rem; + min-width: 104px; max-width: 23rem; overflow: hidden; border-radius: var(--radius-md); background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; box-shadow: var(--shadow-xs-border); - z-index: 50; - transform-origin: var(--kb-popper-content-transform-origin); - pointer-events: none; - - animation: selectContentHide var(--transition-duration) var(--transition-easing) forwards; - - @starting-style { - animation: none; - } + z-index: 60; &[data-expanded] { - pointer-events: auto; - animation: selectContentShow var(--transition-duration) var(--transition-easing) forwards; + animation: select-open 0.15s ease-out; } [data-slot="select-select-content-list"] { @@ -127,38 +111,43 @@ overflow-x: hidden; display: flex; flex-direction: column; + &:focus { outline: none; } + > *:not([role="presentation"]) + *:not([role="presentation"]) { margin-top: 2px; } } + [data-slot="select-select-item"] { position: relative; display: flex; align-items: center; - padding: 4px 8px; + padding: 2px 8px; gap: 12px; - border-radius: var(--radius-sm); + border-radius: 4px; + cursor: default; /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); - transition-property: background-color, color; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: + background-color 0.2s ease-in-out, + color 0.2s ease-in-out; outline: none; user-select: none; - &:hover { - background-color: var(--surface-raised-base-hover); + &[data-highlighted] { + background: var(--surface-raised-base-hover); } &[data-disabled] { background-color: var(--surface-raised-base); @@ -171,11 +160,6 @@ margin-left: auto; width: 16px; height: 16px; - color: var(--icon-strong-base); - - svg { - color: var(--icon-strong-base); - } } &:focus { outline: none; @@ -187,9 +171,13 @@ } [data-component="select-content"][data-trigger-style="settings"] { - field-sizing: content; + min-width: 160px; border-radius: 8px; - padding: 0 0 0 4px; + padding: 0; + + [data-slot="select-select-content-list"] { + padding: 4px; + } [data-slot="select-select-item"] { /* text-14-regular */ @@ -202,24 +190,13 @@ } } -@keyframes selectContentShow { +@keyframes select-open { from { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.95); } to { opacity: 1; - transform: scaleY(1); - } -} - -@keyframes selectContentHide { - from { - opacity: 1; - transform: scaleY(1); - } - to { - opacity: 0; - transform: scaleY(0.95); + transform: scale(1); } } diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index fef00500a71..0386c329ec4 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,10 +1,8 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, createSignal, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" +import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" -import { Show } from "solid-js" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" -import { MorphChevron } from "./morph-chevron" export type SelectProps = Omit>, "value" | "onSelect" | "children"> & { placeholder?: string @@ -40,8 +38,6 @@ export function Select(props: SelectProps & Omit) "triggerVariant", ]) - const [isOpen, setIsOpen] = createSignal(false) - const state = { key: undefined as string | undefined, cleanup: undefined as (() => void) | void, @@ -89,7 +85,7 @@ export function Select(props: SelectProps & Omit) data-component="select" data-trigger-style={local.triggerVariant} placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"} - gutter={8} + gutter={4} value={local.current} options={grouped()} optionValue={(x) => (local.value ? local.value(x) : (x as string))} @@ -119,7 +115,7 @@ export function Select(props: SelectProps & Omit) : (itemProps.item.rawValue as string)} - + )} @@ -128,7 +124,6 @@ export function Select(props: SelectProps & Omit) stop() }} onOpenChange={(open) => { - setIsOpen(open) local.onOpenChange?.(open) if (!open) stop() }} @@ -154,12 +149,7 @@ export function Select(props: SelectProps & Omit) }} - - - - - - + diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 55e1a16d18e..c038f69f671 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -49,8 +49,6 @@ @import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); -@import "../components/morph-chevron.css" layer(components); -@import "../components/reasoning-icon.css" layer(components); @import "./utilities.css" layer(utilities); @import "./animations.css" layer(utilities); diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 82a913c8830..8c954f1fe4e 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -1,17 +1,6 @@ :root { interpolate-size: allow-keywords; - /* Transition tokens */ - --transition-duration: 200ms; - --transition-easing: cubic-bezier(0.25, 0, 0.5, 1); - --transition-fast: 150ms; - --transition-slow: 300ms; - - /* Allow height transitions from 0 to auto */ - @supports (interpolate-size: allow-keywords) { - interpolate-size: allow-keywords; - } - [data-popper-positioner] { pointer-events: none; } @@ -140,34 +129,3 @@ line-height: var(--line-height-x-large); /* 120% */ letter-spacing: var(--letter-spacing-tightest); } - -/* Transition utility classes */ -.transition-colors { - transition-property: background-color, border-color, color, fill, stroke; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-opacity { - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-transform { - transition-property: transform; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-shadow { - transition-property: box-shadow; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-interactive { - transition-property: background-color, border-color, color, box-shadow, opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} From 0405b425f528ce9042ff0eeb511512e239cb1b5f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:40:18 -0600 Subject: [PATCH 127/148] feat(app): file search --- packages/ui/src/components/code.tsx | 462 +++++++++++++++++++++++++++- packages/ui/src/pierre/index.ts | 8 + 2 files changed, 467 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index dbf942dbb6c..38dfcd8380d 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -1,7 +1,8 @@ import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs" -import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js" +import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js" import { createDefaultOptions, styleVariables } from "../pierre" import { getWorkerPool } from "../pierre/worker" +import { Icon } from "./icon" type SelectionSide = "additions" | "deletions" @@ -46,8 +47,88 @@ function findSide(node: Node | null): SelectionSide | undefined { return "additions" } +type FindHost = { + element: () => HTMLElement | undefined + open: () => void + close: () => void + next: (dir: 1 | -1) => void + isOpen: () => boolean +} + +const findHosts = new Set() +let findTarget: FindHost | undefined +let findCurrent: FindHost | undefined +let findInstalled = false + +function isEditable(node: unknown): boolean { + if (!(node instanceof HTMLElement)) return false + if (node.closest("[data-prevent-autofocus]")) return true + if (node.isContentEditable) return true + return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName) +} + +function hostForNode(node: unknown): FindHost | undefined { + if (!(node instanceof Node)) return + for (const host of findHosts) { + const el = host.element() + if (el && el.isConnected && el.contains(node)) return host + } +} + +function installFindShortcuts() { + if (findInstalled) return + if (typeof window === "undefined") return + findInstalled = true + + window.addEventListener( + "keydown", + (event) => { + if (event.defaultPrevented) return + + const mod = event.metaKey || event.ctrlKey + if (!mod) return + + const key = event.key.toLowerCase() + + if (key === "g") { + const host = findCurrent + if (!host || !host.isOpen()) return + event.preventDefault() + event.stopPropagation() + host.next(event.shiftKey ? -1 : 1) + return + } + + if (key !== "f") return + + const current = findCurrent + if (current && current.isOpen()) { + event.preventDefault() + event.stopPropagation() + current.open() + return + } + + if (isEditable(event.target)) return + + const host = hostForNode(document.activeElement) ?? findTarget ?? Array.from(findHosts)[0] + if (!host) return + + event.preventDefault() + event.stopPropagation() + host.open() + }, + { capture: true }, + ) +} + export function Code(props: CodeProps) { + let wrapper!: HTMLDivElement let container!: HTMLDivElement + let findInput: HTMLInputElement | undefined + let findOverlay!: HTMLDivElement + let findOverlayFrame: number | undefined + let findOverlayScroll: HTMLElement[] = [] let observer: MutationObserver | undefined let renderToken = 0 let selectionFrame: number | undefined @@ -70,6 +151,13 @@ export function Code(props: CodeProps) { const [rendered, setRendered] = createSignal(0) + const [findOpen, setFindOpen] = createSignal(false) + const [findQuery, setFindQuery] = createSignal("") + const [findIndex, setFindIndex] = createSignal(0) + const [findCount, setFindCount] = createSignal(0) + let findMode: "highlights" | "overlay" = "overlay" + let findHits: Range[] = [] + const file = createMemo( () => new File( @@ -104,6 +192,296 @@ export function Code(props: CodeProps) { host.removeAttribute("data-color-scheme") } + const supportsHighlights = () => { + const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown } + return typeof g.Highlight === "function" && g.CSS?.highlights != null + } + + const clearHighlightFind = () => { + const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights + if (!api) return + api.delete("opencode-find") + api.delete("opencode-find-current") + } + + const clearOverlayScroll = () => { + for (const el of findOverlayScroll) el.removeEventListener("scroll", scheduleOverlay) + findOverlayScroll = [] + } + + const clearOverlay = () => { + if (findOverlayFrame !== undefined) { + cancelAnimationFrame(findOverlayFrame) + findOverlayFrame = undefined + } + findOverlay.innerHTML = "" + } + + const renderOverlay = () => { + if (findMode !== "overlay") { + clearOverlay() + return + } + + clearOverlay() + if (findHits.length === 0) return + + const base = wrapper.getBoundingClientRect() + const current = findIndex() + + const frag = document.createDocumentFragment() + for (let i = 0; i < findHits.length; i++) { + const range = findHits[i] + const active = i === current + + for (const rect of Array.from(range.getClientRects())) { + if (!rect.width || !rect.height) continue + + const el = document.createElement("div") + el.style.position = "absolute" + el.style.left = `${Math.round(rect.left - base.left)}px` + el.style.top = `${Math.round(rect.top - base.top)}px` + el.style.width = `${Math.round(rect.width)}px` + el.style.height = `${Math.round(rect.height)}px` + el.style.borderRadius = "2px" + el.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)" + el.style.opacity = active ? "0.55" : "0.35" + if (active) el.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)" + frag.appendChild(el) + } + } + + findOverlay.appendChild(frag) + } + + function scheduleOverlay() { + if (findMode !== "overlay") return + if (!findOpen()) return + if (findOverlayFrame !== undefined) return + + findOverlayFrame = requestAnimationFrame(() => { + findOverlayFrame = undefined + renderOverlay() + }) + } + + const syncOverlayScroll = () => { + if (findMode !== "overlay") return + const root = getRoot() + + const next = root + ? Array.from(root.querySelectorAll("[data-code]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + : [] + if (next.length === findOverlayScroll.length && next.every((el, i) => el === findOverlayScroll[i])) return + + clearOverlayScroll() + findOverlayScroll = next + for (const el of findOverlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true }) + } + + const clearFind = () => { + clearHighlightFind() + clearOverlay() + clearOverlayScroll() + findHits = [] + setFindCount(0) + setFindIndex(0) + } + + const scanFind = (root: ShadowRoot, query: string) => { + const needle = query.toLowerCase() + const out: Range[] = [] + + const cols = Array.from(root.querySelectorAll("[data-column-content]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + + for (const col of cols) { + const text = col.textContent + if (!text) continue + + const hay = text.toLowerCase() + let idx = hay.indexOf(needle) + if (idx === -1) continue + + const nodes: Text[] = [] + const ends: number[] = [] + const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT) + let node = walker.nextNode() + let pos = 0 + + while (node) { + if (node instanceof Text) { + pos += node.data.length + nodes.push(node) + ends.push(pos) + } + node = walker.nextNode() + } + + if (nodes.length === 0) continue + + const locate = (at: number) => { + let lo = 0 + let hi = ends.length - 1 + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (ends[mid] >= at) hi = mid + else lo = mid + 1 + } + const prev = lo === 0 ? 0 : ends[lo - 1] + return { node: nodes[lo], offset: at - prev } + } + + while (idx !== -1) { + const start = locate(idx) + const end = locate(idx + query.length) + const range = document.createRange() + range.setStart(start.node, start.offset) + range.setEnd(end.node, end.offset) + out.push(range) + idx = hay.indexOf(needle, idx + query.length) + } + } + + return out + } + + const scrollToRange = (range: Range) => { + const start = range.startContainer + const el = start instanceof Element ? start : start.parentElement + el?.scrollIntoView({ block: "center", inline: "center" }) + } + + const setHighlights = (ranges: Range[], index: number) => { + const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights + const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight + if (!api || typeof Highlight !== "function") return false + + api.delete("opencode-find") + api.delete("opencode-find-current") + + const active = ranges[index] + if (active) api.set("opencode-find-current", new Highlight(active)) + + const rest = ranges.filter((_, i) => i !== index) + if (rest.length > 0) api.set("opencode-find", new Highlight(...rest)) + return true + } + + const applyFind = (opts?: { reset?: boolean; scroll?: boolean }) => { + if (!findOpen()) return + + const query = findQuery().trim() + if (!query) { + clearFind() + return + } + + const root = getRoot() + if (!root) return + + findMode = supportsHighlights() ? "highlights" : "overlay" + + const ranges = scanFind(root, query) + const total = ranges.length + const desired = opts?.reset ? 0 : findIndex() + const index = total ? Math.min(desired, total - 1) : 0 + + findHits = ranges + setFindCount(total) + setFindIndex(index) + + const active = ranges[index] + if (findMode === "highlights") { + clearOverlay() + clearOverlayScroll() + if (!setHighlights(ranges, index)) { + findMode = "overlay" + clearHighlightFind() + syncOverlayScroll() + scheduleOverlay() + } + if (opts?.scroll && active) scrollToRange(active) + return + } + + clearHighlightFind() + syncOverlayScroll() + if (opts?.scroll && active) scrollToRange(active) + scheduleOverlay() + } + + const closeFind = () => { + setFindOpen(false) + clearFind() + if (findCurrent === host) findCurrent = undefined + } + + const stepFind = (dir: 1 | -1) => { + if (!findOpen()) return + const total = findCount() + if (total <= 0) return + + const index = (findIndex() + dir + total) % total + setFindIndex(index) + + const active = findHits[index] + if (!active) return + + if (findMode === "highlights") { + if (!setHighlights(findHits, index)) { + findMode = "overlay" + applyFind({ reset: true, scroll: true }) + return + } + scrollToRange(active) + return + } + + clearHighlightFind() + syncOverlayScroll() + scrollToRange(active) + scheduleOverlay() + } + + const host: FindHost = { + element: () => wrapper, + isOpen: () => findOpen(), + next: stepFind, + open: () => { + if (findCurrent && findCurrent !== host) findCurrent.close() + findCurrent = host + findTarget = host + + if (!findOpen()) setFindOpen(true) + requestAnimationFrame(() => { + findInput?.focus() + findInput?.select() + }) + applyFind({ scroll: true }) + }, + close: closeFind, + } + + onMount(() => { + findMode = supportsHighlights() ? "highlights" : "overlay" + installFindShortcuts() + findHosts.add(host) + if (!findTarget) findTarget = host + + onCleanup(() => { + findHosts.delete(host) + if (findCurrent === host) { + findCurrent = undefined + clearHighlightFind() + } + if (findTarget === host) findTarget = undefined + }) + }) + const applyCommentedLines = (ranges: SelectedLineRange[]) => { const root = getRoot() if (!root) return @@ -189,6 +567,7 @@ export function Code(props: CodeProps) { requestAnimationFrame(() => { if (token !== renderToken) return applySelection(lastSelection) + applyFind({ reset: true }) local.onRendered?.() }) } @@ -466,6 +845,13 @@ export function Code(props: CodeProps) { onCleanup(() => { observer?.disconnect() + clearOverlayScroll() + clearOverlay() + if (findCurrent === host) { + findCurrent = undefined + clearHighlightFind() + } + if (selectionFrame !== undefined) { cancelAnimationFrame(selectionFrame) selectionFrame = undefined @@ -487,11 +873,81 @@ export function Code(props: CodeProps) {
+ ref={wrapper} + tabIndex={0} + onPointerDown={() => { + findTarget = host + wrapper.focus({ preventScroll: true }) + }} + onFocus={() => { + findTarget = host + }} + > +
+
+ +
e.stopPropagation()} + > + + { + setFindQuery(e.currentTarget.value) + setFindIndex(0) + applyFind({ reset: true, scroll: true }) + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault() + closeFind() + return + } + if (e.key !== "Enter") return + e.preventDefault() + stepFind(e.shiftKey ? -1 : 1) + }} + /> +
+ {findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"} +
+ + + +
+
+
) } diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index f0da5197938..f6446f3cc85 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -57,6 +57,14 @@ const unsafeCSS = ` background-color: var(--diffs-bg-selection-text); } +::highlight(opencode-find) { + background-color: rgb(from var(--surface-warning-base) r g b / 0.35); +} + +::highlight(opencode-find-current) { + background-color: rgb(from var(--surface-warning-strong) r g b / 0.55); +} + [data-diffs] [data-comment-selected]:not([data-selected-line]) [data-column-content] { box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection); } From 69f5f657f2b3b98d213a7bedd46624cda0e78bcd Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:42:05 -0600 Subject: [PATCH 128/148] chore: cleanup --- packages/ui/src/components/code.tsx | 118 +++++++++++++++++++++------- 1 file changed, 91 insertions(+), 27 deletions(-) diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index 38dfcd8380d..0b0646f0ee2 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -109,9 +109,8 @@ function installFindShortcuts() { return } - if (isEditable(event.target)) return - - const host = hostForNode(document.activeElement) ?? findTarget ?? Array.from(findHosts)[0] + const host = + hostForNode(document.activeElement) ?? hostForNode(event.target) ?? findTarget ?? Array.from(findHosts)[0] if (!host) return event.preventDefault() @@ -126,9 +125,11 @@ export function Code(props: CodeProps) { let wrapper!: HTMLDivElement let container!: HTMLDivElement let findInput: HTMLInputElement | undefined + let findBar: HTMLDivElement | undefined let findOverlay!: HTMLDivElement let findOverlayFrame: number | undefined let findOverlayScroll: HTMLElement[] = [] + let findPositionFrame: number | undefined let observer: MutationObserver | undefined let renderToken = 0 let selectionFrame: number | undefined @@ -290,6 +291,41 @@ export function Code(props: CodeProps) { setFindIndex(0) } + const getScrollParent = (el: HTMLElement): HTMLElement | null => { + let parent = el.parentElement + while (parent) { + const style = getComputedStyle(parent) + if (style.overflowY === "auto" || style.overflowY === "scroll") return parent + parent = parent.parentElement + } + return null + } + + const positionFindBar = () => { + if (!findBar || !wrapper) return + const scrollParent = getScrollParent(wrapper) + if (!scrollParent) { + findBar.style.position = "absolute" + findBar.style.top = "8px" + findBar.style.right = "8px" + findBar.style.left = "" + return + } + const scrollTop = scrollParent.scrollTop + findBar.style.position = "absolute" + findBar.style.top = `${scrollTop + 8}px` + findBar.style.right = "8px" + findBar.style.left = "" + } + + const scheduleFindPosition = () => { + if (findPositionFrame !== undefined) return + findPositionFrame = requestAnimationFrame(() => { + findPositionFrame = undefined + positionFindBar() + }) + } + const scanFind = (root: ShadowRoot, query: string) => { const needle = query.toLowerCase() const out: Range[] = [] @@ -458,6 +494,7 @@ export function Code(props: CodeProps) { if (!findOpen()) setFindOpen(true) requestAnimationFrame(() => { + positionFindBar() findInput?.focus() findInput?.select() }) @@ -482,6 +519,25 @@ export function Code(props: CodeProps) { }) }) + createEffect(() => { + if (!findOpen()) return + const scrollParent = getScrollParent(wrapper) + const target = scrollParent ?? window + + const handler = () => scheduleFindPosition() + target.addEventListener("scroll", handler, { passive: true }) + window.addEventListener("resize", handler, { passive: true }) + + onCleanup(() => { + target.removeEventListener("scroll", handler) + window.removeEventListener("resize", handler) + if (findPositionFrame !== undefined) { + cancelAnimationFrame(findPositionFrame) + findPositionFrame = undefined + } + }) + }) + const applyCommentedLines = (ranges: SelectedLineRange[]) => { const root = getRoot() if (!root) return @@ -862,6 +918,11 @@ export function Code(props: CodeProps) { dragFrame = undefined } + if (findPositionFrame !== undefined) { + cancelAnimationFrame(findPositionFrame) + findPositionFrame = undefined + } + dragStart = undefined dragEnd = undefined dragMoved = false @@ -888,19 +949,18 @@ export function Code(props: CodeProps) { findTarget = host }} > -
-
e.stopPropagation()} > - + { setFindQuery(e.currentTarget.value) setFindIndex(0) @@ -917,30 +977,32 @@ export function Code(props: CodeProps) { stepFind(e.shiftKey ? -1 : 1) }} /> -
+
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
+
+ + +
- -
+
+
) } From befb5d54fbfd8df9706c49159095b1ef7f2ec23d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:24:27 -0600 Subject: [PATCH 129/148] chore: cleanup --- packages/ui/src/components/code.tsx | 54 +++++++++++------------------ 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index 0b0646f0ee2..e3e1e565202 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -129,7 +129,7 @@ export function Code(props: CodeProps) { let findOverlay!: HTMLDivElement let findOverlayFrame: number | undefined let findOverlayScroll: HTMLElement[] = [] - let findPositionFrame: number | undefined + let findScroll: HTMLElement | undefined let observer: MutationObserver | undefined let renderToken = 0 let selectionFrame: number | undefined @@ -303,29 +303,13 @@ export function Code(props: CodeProps) { const positionFindBar = () => { if (!findBar || !wrapper) return - const scrollParent = getScrollParent(wrapper) - if (!scrollParent) { - findBar.style.position = "absolute" - findBar.style.top = "8px" - findBar.style.right = "8px" - findBar.style.left = "" - return - } - const scrollTop = scrollParent.scrollTop + const scrollTop = findScroll ? findScroll.scrollTop : window.scrollY findBar.style.position = "absolute" findBar.style.top = `${scrollTop + 8}px` findBar.style.right = "8px" findBar.style.left = "" } - const scheduleFindPosition = () => { - if (findPositionFrame !== undefined) return - findPositionFrame = requestAnimationFrame(() => { - findPositionFrame = undefined - positionFindBar() - }) - } - const scanFind = (root: ShadowRoot, query: string) => { const needle = query.toLowerCase() const out: Range[] = [] @@ -440,13 +424,19 @@ export function Code(props: CodeProps) { syncOverlayScroll() scheduleOverlay() } - if (opts?.scroll && active) scrollToRange(active) + if (opts?.scroll && active) { + scrollToRange(active) + positionFindBar() + } return } clearHighlightFind() syncOverlayScroll() - if (opts?.scroll && active) scrollToRange(active) + if (opts?.scroll && active) { + scrollToRange(active) + positionFindBar() + } scheduleOverlay() } @@ -474,12 +464,14 @@ export function Code(props: CodeProps) { return } scrollToRange(active) + positionFindBar() return } clearHighlightFind() syncOverlayScroll() scrollToRange(active) + positionFindBar() scheduleOverlay() } @@ -492,13 +484,14 @@ export function Code(props: CodeProps) { findCurrent = host findTarget = host + findScroll = getScrollParent(wrapper) ?? undefined if (!findOpen()) setFindOpen(true) requestAnimationFrame(() => { + applyFind({ scroll: true }) positionFindBar() findInput?.focus() findInput?.select() }) - applyFind({ scroll: true }) }, close: closeFind, } @@ -521,20 +514,18 @@ export function Code(props: CodeProps) { createEffect(() => { if (!findOpen()) return - const scrollParent = getScrollParent(wrapper) - const target = scrollParent ?? window + findScroll = getScrollParent(wrapper) ?? undefined + const target = findScroll ?? window - const handler = () => scheduleFindPosition() + const handler = () => positionFindBar() target.addEventListener("scroll", handler, { passive: true }) window.addEventListener("resize", handler, { passive: true }) + handler() onCleanup(() => { target.removeEventListener("scroll", handler) window.removeEventListener("resize", handler) - if (findPositionFrame !== undefined) { - cancelAnimationFrame(findPositionFrame) - findPositionFrame = undefined - } + findScroll = undefined }) }) @@ -918,11 +909,6 @@ export function Code(props: CodeProps) { dragFrame = undefined } - if (findPositionFrame !== undefined) { - cancelAnimationFrame(findPositionFrame) - findPositionFrame = undefined - } - dragStart = undefined dragEnd = undefined dragMoved = false @@ -977,7 +963,7 @@ export function Code(props: CodeProps) { stepFind(e.shiftKey ? -1 : 1) }} /> -
+
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
From c002ca03ba7a617090ab104c5d2a07f1c8be2958 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:22:10 -0600 Subject: [PATCH 130/148] feat(app): search through sessions --- packages/app/src/pages/layout.tsx | 410 +++++++++++++++++++++------- packages/ui/src/components/list.tsx | 33 ++- 2 files changed, 343 insertions(+), 100 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5d285c5ecca..fe8618b7391 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -27,6 +27,7 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { InlineInput } from "@opencode-ai/ui/inline-input" +import { List, type ListRef } from "@opencode-ai/ui/list" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" import { MessageNav } from "@opencode-ai/ui/message-nav" @@ -2682,6 +2683,14 @@ export default function Layout(props: ParentProps) { } const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { + type SearchItem = { + id: string + title: string + directory: string + label: string + archived?: number + } + const projectName = createMemo(() => { const project = panelProps.project if (!project) return "" @@ -2697,6 +2706,107 @@ export default function Layout(props: ParentProps) { }) const homedir = createMemo(() => globalSync.data.path.home) + const [search, setSearch] = createStore({ + value: "", + }) + const searching = createMemo(() => search.value.trim().length > 0) + let searchRef: HTMLInputElement | undefined + let listRef: ListRef | undefined + + const token = { value: 0 } + let inflight: Promise | undefined + let all: SearchItem[] | undefined + + const reset = () => { + token.value += 1 + inflight = undefined + all = undefined + setSearch({ value: "" }) + listRef = undefined + } + + const open = (item: SearchItem | undefined) => { + if (!item) return + + const href = `/${base64Encode(item.directory)}/session/${item.id}` + if (!layout.sidebar.opened()) { + setState("hoverSession", undefined) + setState("hoverProject", undefined) + } + reset() + navigate(href) + layout.mobileSidebar.hide() + } + + const items = (filter: string) => { + const query = filter.trim() + if (!query) { + token.value += 1 + inflight = undefined + all = undefined + return [] as SearchItem[] + } + + const project = panelProps.project + if (!project) return [] as SearchItem[] + if (all) return all + if (inflight) return inflight + + const current = token.value + const dirs = workspaceIds(project) + inflight = Promise.all( + dirs.map((input) => { + const directory = workspaceKey(input) + const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) + const label = `${kind} : ${name}` + return globalSDK.client.session + .list({ directory, roots: true }) + .then((x) => + (x.data ?? []) + .filter((s) => !!s?.id) + .map((s) => ({ + id: s.id, + title: s.title ?? language.t("command.session.new"), + directory, + label, + archived: s.time?.archived, + })), + ) + .catch(() => [] as SearchItem[]) + }), + ) + .then((results) => { + if (token.value !== current) return [] as SearchItem[] + + const seen = new Set() + const next = results.flat().filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + all = next + return next + }) + .catch(() => [] as SearchItem[]) + .finally(() => { + inflight = undefined + }) + + return inflight + } + + createEffect( + on( + () => panelProps.project?.worktree, + () => reset(), + { defer: true }, + ), + ) + return (
- + {(p) => ( <>
@@ -2714,7 +2824,7 @@ export default function Layout(props: ParentProps) { renameProject(p, next)} + onSave={(next) => renameProject(p(), next)} class="text-16-medium text-text-strong truncate" displayClass="text-16-medium text-text-strong truncate" stopPropagation @@ -2723,7 +2833,7 @@ export default function Layout(props: ParentProps) { - {p.worktree.replace(homedir(), "~")} + {p().worktree.replace(homedir(), "~")}
@@ -2742,31 +2852,31 @@ export default function Layout(props: ParentProps) { icon="dot-grid" variant="ghost" data-action="project-menu" - data-project={base64Encode(p.worktree)} + data-project={base64Encode(p().worktree)} class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" aria-label={language.t("common.moreOptions")} /> - dialog.show(() => )}> + dialog.show(() => )}> {language.t("common.edit")} { - const enabled = layout.sidebar.workspaces(p.worktree)() + const enabled = layout.sidebar.workspaces(p().worktree)() if (enabled) { - layout.sidebar.toggleWorkspaces(p.worktree) + layout.sidebar.toggleWorkspaces(p().worktree) return } - if (p.vcs !== "git") return - layout.sidebar.toggleWorkspaces(p.worktree) + if (p().vcs !== "git") return + layout.sidebar.toggleWorkspaces(p().worktree) }} > - {layout.sidebar.workspaces(p.worktree)() + {layout.sidebar.workspaces(p().worktree)() ? language.t("sidebar.workspaces.disable") : language.t("sidebar.workspaces.enable")} @@ -2774,8 +2884,8 @@ export default function Layout(props: ParentProps) { closeProject(p.worktree)} + data-project={base64Encode(p().worktree)} + onSelect={() => closeProject(p().worktree)} > {language.t("common.close")} @@ -2785,103 +2895,207 @@ export default function Layout(props: ParentProps) {
- +
{ + const target = event.target + if (!(target instanceof Element)) return + if (target.closest("input, textarea, [contenteditable='true']")) return + searchRef?.focus() + }} + > + + { + searchRef = el + }} + class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak" + style={{ "box-shadow": "none" }} + value={search.value} + onInput={(event) => setSearch("value", event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault() + setSearch("value", "") + queueMicrotask(() => searchRef?.focus()) + return + } + + if (!searching()) return + + if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") { + const ref = listRef + if (!ref) return + event.stopPropagation() + ref.onKeyDown(event) + return + } + + if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { + if (event.key === "n" || event.key === "p") { + const ref = listRef + if (!ref) return + event.stopPropagation() + ref.onKeyDown(event) + } + } + }} + placeholder={language.t("session.header.search.placeholder", { project: projectName() })} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + + { + setSearch("value", "") + queueMicrotask(() => searchRef?.focus()) + }} + /> + +
+
+ + + `${item.directory}:${item.id}`} + onSelect={open} + ref={(ref) => { + listRef = ref + }} + > + {(item) => ( +
+ + {item.title} + + + {item.label} + +
+ )} +
+
+ +
+ +
+ + + +
+
+ +
+ + } + > <> -
+
-
-
- +
+ + + +
{ + if (!panelProps.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + + + {(directory) => ( + + )} + + +
+ + + +
- } - > - <> -
- - - -
-
- - - -
{ - if (!panelProps.mobile) scrollContainerRef = el - }} - class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" - > - - - {(directory) => ( - - )} - - -
- - - -
-
- - + +
)} - 0 && providers.paid().length === 0}> -
-
-
-
{language.t("sidebar.gettingStarted.title")}
-
{language.t("sidebar.gettingStarted.line1")}
-
{language.t("sidebar.gettingStarted.line2")}
-
- + +
0 && providers.paid().length === 0), + }} + > +
+
+
{language.t("sidebar.gettingStarted.title")}
+
{language.t("sidebar.gettingStarted.line1")}
+
{language.t("sidebar.gettingStarted.line2")}
+
- +
) } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 6c654cbb7d6..abd5572207a 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -57,6 +57,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const i18n = useI18n() const [scrollRef, setScrollRef] = createSignal(undefined) const [internalFilter, setInternalFilter] = createSignal("") + let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined const [store, setStore] = createStore({ mouseActive: false, }) @@ -176,6 +177,14 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) if (e.key === "Enter" && !e.isComposing) { e.preventDefault() if (selected) handleSelect(selected, index) + } else if (props.search) { + if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")) { + onKeyDown(e) + return + } + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + onKeyDown(e) + } } else { onKeyDown(e) } @@ -247,7 +256,21 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
{ + const container = event.currentTarget + if (!(container instanceof HTMLElement)) return + + const node = container.querySelector("input, textarea") + const input = node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ? node : inputRef + input?.focus() + + // Prevent global listeners (e.g. dnd sensors) from cancelling focus. + event.stopPropagation() + }} + >
@@ -257,6 +280,9 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) variant="ghost" data-slot="list-search-input" type="text" + ref={(el: HTMLInputElement | HTMLTextAreaElement) => { + inputRef = el + }} value={internalFilter()} onChange={(value) => applyFilter(value)} onKeyDown={handleKey} @@ -271,7 +297,10 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) applyFilter("")} + onClick={() => { + setInternalFilter("") + queueMicrotask(() => inputRef?.focus()) + }} aria-label={i18n.t("ui.list.clearFilter")} /> From 562c9d76d9becbd485af589ab7ddd64f9c9fd31d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 20:27:15 +0000 Subject: [PATCH 131/148] chore: generate --- packages/sdk/openapi.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 95bca32303a..72327a8d72a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6087,6 +6087,10 @@ }, "deletions": { "type": "number" + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] } }, "required": ["file", "before", "after", "additions", "deletions"] From 824165eb792edfd8600d44aac83e1f6bba2a9e62 Mon Sep 17 00:00:00 2001 From: Alex Yaroshuk <34632190+alexyaroshuk@users.noreply.github.com> Date: Tue, 3 Feb 2026 04:36:00 +0800 Subject: [PATCH 132/148] feat(app): add workspace toggle command to command palette and prompt input (#11810) --- packages/app/src/i18n/ar.ts | 6 ++++++ packages/app/src/i18n/br.ts | 6 ++++++ packages/app/src/i18n/da.ts | 6 ++++++ packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 7 +++++++ packages/app/src/i18n/es.ts | 6 ++++++ packages/app/src/i18n/fr.ts | 6 ++++++ packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 6 ++++++ packages/app/src/i18n/no.ts | 6 ++++++ packages/app/src/i18n/pl.ts | 6 ++++++ packages/app/src/i18n/ru.ts | 6 ++++++ packages/app/src/i18n/th.ts | 8 +++++++- packages/app/src/i18n/zh.ts | 8 +++++++- packages/app/src/i18n/zht.ts | 8 +++++++- packages/app/src/pages/layout.tsx | 23 +++++++++++++++++++++++ 16 files changed, 107 insertions(+), 3 deletions(-) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 3718303e5a1..f816c9aca0e 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي", "command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا", "command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا", + "command.workspace.toggle": "تبديل مساحات العمل", "command.session.undo": "تراجع", "command.session.undo.description": "تراجع عن الرسالة الأخيرة", "command.session.redo": "إعادة", @@ -348,6 +349,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا", "toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة", + "toast.workspace.enabled.title": "تم تمكين مساحات العمل", + "toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي", + "toast.workspace.disabled.title": "تم تعطيل مساحات العمل", + "toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي", + "toast.model.none.title": "لم يتم تحديد نموذج", "toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 43336f8d6fe..4bb66e11c91 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Mudar para o próximo nível de esforço", "command.permissions.autoaccept.enable": "Aceitar edições automaticamente", "command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente", + "command.workspace.toggle": "Alternar espaços de trabalho", "command.session.undo": "Desfazer", "command.session.undo.description": "Desfazer a última mensagem", "command.session.redo": "Refazer", @@ -347,6 +348,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente", "toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação", + "toast.workspace.enabled.title": "Espaços de trabalho ativados", + "toast.workspace.enabled.description": "Várias worktrees agora são exibidas na barra lateral", + "toast.workspace.disabled.title": "Espaços de trabalho desativados", + "toast.workspace.disabled.description": "Apenas a worktree principal é exibida na barra lateral", + "toast.model.none.title": "Nenhum modelo selecionado", "toast.model.none.description": "Conecte um provedor para resumir esta sessão", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 69e8e8114f6..95d9f4a0fc2 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Skift til næste indsatsniveau", "command.permissions.autoaccept.enable": "Accepter ændringer automatisk", "command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer", + "command.workspace.toggle": "Skift arbejdsområder", "command.session.undo": "Fortryd", "command.session.undo.description": "Fortryd den sidste besked", "command.session.redo": "Omgør", @@ -349,6 +350,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Stoppede automatisk accept af ændringer", "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetilladelser vil kræve godkendelse", + "toast.workspace.enabled.title": "Arbejdsområder aktiveret", + "toast.workspace.enabled.description": "Flere worktrees vises nu i sidepanelet", + "toast.workspace.disabled.title": "Arbejdsområder deaktiveret", + "toast.workspace.disabled.description": "Kun hoved-worktree vises i sidepanelet", + "toast.model.none.title": "Ingen model valgt", "toast.model.none.description": "Forbind en udbyder for at opsummere denne session", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 1c28e4a16e4..3ead99427d1 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln", "command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren", "command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen", + "command.workspace.toggle": "Arbeitsbereiche umschalten", "command.session.undo": "Rückgängig", "command.session.undo.description": "Letzte Nachricht rückgängig machen", "command.session.redo": "Wiederherstellen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 169d09cd38c..780c19e21c0 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -71,6 +71,8 @@ export const dict = { "command.model.variant.cycle.description": "Switch to the next effort level", "command.permissions.autoaccept.enable": "Auto-accept edits", "command.permissions.autoaccept.disable": "Stop auto-accepting edits", + "command.workspace.toggle": "Toggle workspaces", + "command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar", "command.session.undo": "Undo", "command.session.undo.description": "Undo the last message", "command.session.redo": "Redo", @@ -350,6 +352,11 @@ export const dict = { "toast.theme.title": "Theme switched", "toast.scheme.title": "Color scheme", + "toast.workspace.enabled.title": "Workspaces enabled", + "toast.workspace.enabled.description": "Multiple worktrees are now shown in the sidebar", + "toast.workspace.disabled.title": "Workspaces disabled", + "toast.workspace.disabled.description": "Only the main worktree is shown in the sidebar", + "toast.permissions.autoaccept.on.title": "Auto-accepting edits", "toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved", "toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 6e3eac0dd35..4c5fe30040f 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo", "command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente", "command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente", + "command.workspace.toggle": "Alternar espacios de trabajo", "command.session.undo": "Deshacer", "command.session.undo.description": "Deshacer el último mensaje", "command.session.redo": "Rehacer", @@ -350,6 +351,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Se dejó de aceptar ediciones automáticamente", "toast.permissions.autoaccept.off.description": "Los permisos de edición y escritura requerirán aprobación", + "toast.workspace.enabled.title": "Espacios de trabajo habilitados", + "toast.workspace.enabled.description": "Ahora se muestran varios worktrees en la barra lateral", + "toast.workspace.disabled.title": "Espacios de trabajo deshabilitados", + "toast.workspace.disabled.description": "Solo se muestra el worktree principal en la barra lateral", + "toast.model.none.title": "Ningún modelo seleccionado", "toast.model.none.description": "Conecta un proveedor para resumir esta sesión", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index fa3dccd9afa..41c8b45547e 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Passer au niveau d'effort suivant", "command.permissions.autoaccept.enable": "Accepter automatiquement les modifications", "command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications", + "command.workspace.toggle": "Basculer les espaces de travail", "command.session.undo": "Annuler", "command.session.undo.description": "Annuler le dernier message", "command.session.redo": "Rétablir", @@ -352,6 +353,11 @@ export const dict = { "toast.permissions.autoaccept.off.description": "Les permissions de modification et d'écriture nécessiteront une approbation", + "toast.workspace.enabled.title": "Espaces de travail activés", + "toast.workspace.enabled.description": "Plusieurs worktrees sont désormais affichés dans la barre latérale", + "toast.workspace.disabled.title": "Espaces de travail désactivés", + "toast.workspace.disabled.description": "Seul le worktree principal est affiché dans la barre latérale", + "toast.model.none.title": "Aucun modèle sélectionné", "toast.model.none.description": "Connectez un fournisseur pour résumer cette session", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 4fccbd77e78..d2530f5e517 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "次の思考レベルに切り替え", "command.permissions.autoaccept.enable": "編集を自動承認", "command.permissions.autoaccept.disable": "編集の自動承認を停止", + "command.workspace.toggle": "ワークスペースを切り替え", "command.session.undo": "元に戻す", "command.session.undo.description": "最後のメッセージを元に戻す", "command.session.redo": "やり直す", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 5b5d29c0e08..f81164ce3b5 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "다음 생각 수준으로 전환", "command.permissions.autoaccept.enable": "편집 자동 수락", "command.permissions.autoaccept.disable": "편집 자동 수락 중지", + "command.workspace.toggle": "작업 공간 전환", "command.session.undo": "실행 취소", "command.session.undo.description": "마지막 메시지 실행 취소", "command.session.redo": "다시 실행", @@ -351,6 +352,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "편집 자동 수락 중지됨", "toast.permissions.autoaccept.off.description": "편집 및 쓰기 권한 승인이 필요합니다", + "toast.workspace.enabled.title": "작업 공간 활성화됨", + "toast.workspace.enabled.description": "이제 사이드바에 여러 작업 트리가 표시됩니다", + "toast.workspace.disabled.title": "작업 공간 비활성화됨", + "toast.workspace.disabled.description": "사이드바에 메인 작업 트리만 표시됩니다", + "toast.model.none.title": "선택된 모델 없음", "toast.model.none.description": "이 세션을 요약하려면 공급자를 연결하세요", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 89614ce853d..d1f2bc7fdc5 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -73,6 +73,7 @@ export const dict = { "command.model.variant.cycle.description": "Bytt til neste innsatsnivå", "command.permissions.autoaccept.enable": "Godta endringer automatisk", "command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk", + "command.workspace.toggle": "Veksle arbeidsområder", "command.session.undo": "Angre", "command.session.undo.description": "Angre siste melding", "command.session.redo": "Gjør om", @@ -351,6 +352,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Sluttet å godta endringer automatisk", "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetillatelser vil kreve godkjenning", + "toast.workspace.enabled.title": "Arbeidsområder aktivert", + "toast.workspace.enabled.description": "Flere worktrees vises nå i sidefeltet", + "toast.workspace.disabled.title": "Arbeidsområder deaktivert", + "toast.workspace.disabled.description": "Kun hoved-worktree vises i sidefeltet", + "toast.model.none.title": "Ingen modell valgt", "toast.model.none.description": "Koble til en leverandør for å oppsummere denne sesjonen", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index b89921a9bc6..f1211c45993 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku", "command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji", "command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji", + "command.workspace.toggle": "Przełącz przestrzenie robocze", "command.session.undo": "Cofnij", "command.session.undo.description": "Cofnij ostatnią wiadomość", "command.session.redo": "Ponów", @@ -349,6 +350,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie edycji", "toast.permissions.autoaccept.off.description": "Uprawnienia do edycji i zapisu będą wymagały zatwierdzenia", + "toast.workspace.enabled.title": "Przestrzenie robocze włączone", + "toast.workspace.enabled.description": "Kilka worktree jest teraz wyświetlanych na pasku bocznym", + "toast.workspace.disabled.title": "Przestrzenie robocze wyłączone", + "toast.workspace.disabled.description": "Tylko główny worktree jest wyświetlany na pasku bocznym", + "toast.model.none.title": "Nie wybrano modelu", "toast.model.none.description": "Połącz dostawcę, aby podsumować tę sesję", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index e99abbd0819..e0efffa41bc 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Переключиться к следующему уровню усилий", "command.permissions.autoaccept.enable": "Авто-принятие изменений", "command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений", + "command.workspace.toggle": "Переключить рабочие пространства", "command.session.undo": "Отменить", "command.session.undo.description": "Отменить последнее сообщение", "command.session.redo": "Повторить", @@ -350,6 +351,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Авто-принятие остановлено", "toast.permissions.autoaccept.off.description": "Редактирование и запись потребуют подтверждения", + "toast.workspace.enabled.title": "Рабочие пространства включены", + "toast.workspace.enabled.description": "В боковой панели теперь отображаются несколько рабочих деревьев", + "toast.workspace.disabled.title": "Рабочие пространства отключены", + "toast.workspace.disabled.description": "В боковой панели отображается только главное рабочее дерево", + "toast.model.none.title": "Модель не выбрана", "toast.model.none.description": "Подключите провайдера для суммаризации сессии", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 0da6f9acc75..cfe439d510c 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป", "command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ", "command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", + "command.workspace.toggle": "สลับพื้นที่ทำงาน", "command.session.undo": "ยกเลิก", "command.session.undo.description": "ยกเลิกข้อความล่าสุด", "command.session.redo": "ทำซ้ำ", @@ -349,10 +350,15 @@ export const dict = { "toast.scheme.title": "โทนสี", "toast.permissions.autoaccept.on.title": "กำลังยอมรับการแก้ไขโดยอัตโนมัติ", - "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและเขียนจะได้รับการอนุมัติโดยอัตโนมัติ", + "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและจะได้รับเขียนการอนุมัติโดยอัตโนมัติ", "toast.permissions.autoaccept.off.title": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", "toast.permissions.autoaccept.off.description": "สิทธิ์การแก้ไขและเขียนจะต้องได้รับการอนุมัติ", + "toast.workspace.enabled.title": "เปิดใช้งานพื้นที่ทำงานแล้ว", + "toast.workspace.enabled.description": "ตอนนี้จะแสดง worktree หลายรายการในแถบด้านข้าง", + "toast.workspace.disabled.title": "ปิดใช้งานพื้นที่ทำงานแล้ว", + "toast.workspace.disabled.description": "จะแสดงเฉพาะ worktree หลักในแถบด้านข้าง", + "toast.model.none.title": "ไม่ได้เลือกโมเดล", "toast.model.none.description": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index a7e1659ec3e..81bb23db9d8 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "切换到下一个强度等级", "command.permissions.autoaccept.enable": "自动接受编辑", "command.permissions.autoaccept.disable": "停止自动接受编辑", + "command.workspace.toggle": "切换工作区", "command.session.undo": "撤销", "command.session.undo.description": "撤销上一条消息", "command.session.redo": "重做", @@ -344,7 +345,12 @@ export const dict = { "toast.language.description": "已切换到{{language}}", "toast.theme.title": "主题已切换", - "toast.scheme.title": "配色方案", + "toast.scheme.title": "颜色方案", + + "toast.workspace.enabled.title": "工作区已启用", + "toast.workspace.enabled.description": "侧边栏现在显示多个工作树", + "toast.workspace.disabled.title": "工作区已禁用", + "toast.workspace.disabled.description": "侧边栏只显示主工作树", "toast.permissions.autoaccept.on.title": "自动接受编辑", "toast.permissions.autoaccept.on.description": "编辑和写入权限将自动获批", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 7b8849b9a0c..f01c1ce0b14 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "切換到下一個強度等級", "command.permissions.autoaccept.enable": "自動接受編輯", "command.permissions.autoaccept.disable": "停止自動接受編輯", + "command.workspace.toggle": "切換工作區", "command.session.undo": "復原", "command.session.undo.description": "復原上一則訊息", "command.session.redo": "重做", @@ -341,7 +342,12 @@ export const dict = { "toast.language.description": "已切換到 {{language}}", "toast.theme.title": "主題已切換", - "toast.scheme.title": "配色方案", + "toast.scheme.title": "顏色方案", + + "toast.workspace.enabled.title": "工作區已啟用", + "toast.workspace.enabled.description": "側邊欄現在顯示多個工作樹", + "toast.workspace.disabled.title": "工作區已停用", + "toast.workspace.disabled.description": "側邊欄只顯示主工作樹", "toast.permissions.autoaccept.on.title": "自動接受編輯", "toast.permissions.autoaccept.on.description": "編輯和寫入權限將自動獲准", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index fe8618b7391..ba888a2805d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1138,6 +1138,29 @@ export default function Layout(props: ParentProps) { if (session) archiveSession(session) }, }, + { + id: "workspace.toggle", + title: language.t("command.workspace.toggle"), + description: language.t("command.workspace.toggle.description"), + category: language.t("command.category.workspace"), + slash: "workspace", + disabled: !currentProject() || currentProject()?.vcs !== "git", + onSelect: () => { + const project = currentProject() + if (!project) return + if (project.vcs !== "git") return + const wasEnabled = layout.sidebar.workspaces(project.worktree)() + layout.sidebar.toggleWorkspaces(project.worktree) + showToast({ + title: wasEnabled + ? language.t("toast.workspace.disabled.title") + : language.t("toast.workspace.enabled.title"), + description: wasEnabled + ? language.t("toast.workspace.disabled.description") + : language.t("toast.workspace.enabled.description"), + }) + }, + }, { id: "theme.cycle", title: language.t("command.theme.cycle"), From a9fca05d8b8f0e87dc9774f6d660fe65831b6da5 Mon Sep 17 00:00:00 2001 From: Luiz Guilherme D'Abruzzo Pereira <707366+luiz290788@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:52:32 -0300 Subject: [PATCH 133/148] feat(server): add --mdns-domain flag to customize mDNS hostname (#11796) --- packages/opencode/src/cli/cmd/web.ts | 2 +- packages/opencode/src/cli/network.ts | 9 ++++++++- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/server/mdns.ts | 5 +++-- packages/opencode/src/server/server.ts | 10 ++++++++-- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 5fa2bb42640..0fe056f21f2 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -63,7 +63,7 @@ export const WebCommand = cmd({ UI.println( UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, - `opencode.local:${server.port}`, + `${opts.mdnsDomain}:${server.port}`, ) } diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index fe5731d0713..dd09e1689f5 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -17,6 +17,11 @@ const options = { describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", default: false, }, + "mdns-domain": { + type: "string" as const, + describe: "custom domain name for mDNS service (default: opencode.local)", + default: "opencode.local", + }, cors: { type: "string" as const, array: true, @@ -36,9 +41,11 @@ export async function resolveNetworkOptions(args: NetworkOptions) { const portExplicitlySet = process.argv.includes("--port") const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") + const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain") const corsExplicitlySet = process.argv.includes("--cors") const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) + const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"]) const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) const hostname = hostnameExplicitlySet ? args.hostname @@ -49,5 +56,5 @@ export async function resolveNetworkOptions(args: NetworkOptions) { const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const cors = [...configCors, ...argsCors] - return { hostname, port, mdns, cors } + return { hostname, port, mdns, mdnsDomain, cors } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b0164e8aa86..54ca94ae4d3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -860,6 +860,7 @@ export namespace Config { port: z.number().int().positive().optional().describe("Port to listen on"), hostname: z.string().optional().describe("Hostname to listen on"), mdns: z.boolean().optional().describe("Enable mDNS service discovery"), + mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"), cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"), }) .strict() diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 953269de444..778afa26ac7 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -7,17 +7,18 @@ export namespace MDNS { let bonjour: Bonjour | undefined let currentPort: number | undefined - export function publish(port: number) { + export function publish(port: number, domain?: string) { if (currentPort === port) return if (bonjour) unpublish() try { + const host = domain ?? "opencode.local" const name = `opencode-${port}` bonjour = new Bonjour() const service = bonjour.publish({ name, type: "http", - host: "opencode.local", + host, port, txt: { path: "/" }, }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f6dd0d122f8..015553802a4 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -563,7 +563,13 @@ export namespace Server { return result } - export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { + export function listen(opts: { + port: number + hostname: string + mdns?: boolean + mdnsDomain?: string + cors?: string[] + }) { _corsWhitelist = opts.cors ?? [] const args = { @@ -591,7 +597,7 @@ export namespace Server { opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!) + MDNS.publish(server.port!, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } From a3f1918489942eb2d99c1ef4e3b8628d55d0dfc7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 20:53:36 +0000 Subject: [PATCH 134/148] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ packages/sdk/openapi.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 085c9d9c7ed..0cf70241ef6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1332,6 +1332,10 @@ export type ServerConfig = { * Enable mDNS service discovery */ mdns?: boolean + /** + * Custom domain name for mDNS service (default: opencode.local) + */ + mdnsDomain?: string /** * Additional domains to allow for CORS */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 72327a8d72a..d179ed8b8c4 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8910,6 +8910,10 @@ "description": "Enable mDNS service discovery", "type": "boolean" }, + "mdnsDomain": { + "description": "Custom domain name for mDNS service (default: opencode.local)", + "type": "string" + }, "cors": { "description": "Additional domains to allow for CORS", "type": "array", From aa6b552c39fce24d35097de4feb6d1aa0598b1c5 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:28:02 -0600 Subject: [PATCH 135/148] Revert pr that was mistakenly merged (#11844) --- packages/opencode/src/session/processor.ts | 10 +-- packages/opencode/src/session/prompt.ts | 69 +++++++------------ packages/opencode/src/tool/batch.ts | 8 +-- packages/opencode/src/tool/read.ts | 4 ++ packages/opencode/src/tool/tool.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 62 ----------------- 6 files changed, 33 insertions(+), 122 deletions(-) delete mode 100644 packages/opencode/test/session/prompt.test.ts diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 24b4a4f9fbc..b5289e903a1 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -172,14 +172,6 @@ export namespace SessionProcessor { case "tool-result": { const match = toolcalls[value.toolCallId] if (match && match.state.status === "running") { - const attachments = value.output.attachments?.map( - (attachment: Omit) => ({ - ...attachment, - id: Identifier.ascending("part"), - messageID: match.messageID, - sessionID: match.sessionID, - }), - ) await Session.updatePart({ ...match, state: { @@ -192,7 +184,7 @@ export namespace SessionProcessor { start: match.state.time.start, end: Date.now(), }, - attachments, + attachments: value.output.attachments, }, }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 98dce97ba90..e0861c4df52 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -187,17 +187,13 @@ export namespace SessionPrompt { text: template, }, ] - const matches = ConfigMarkdown.files(template) + const files = ConfigMarkdown.files(template) const seen = new Set() - const names = matches - .map((match) => match[1]) - .filter((name) => { - if (seen.has(name)) return false + await Promise.all( + files.map(async (match) => { + const name = match[1] + if (seen.has(name)) return seen.add(name) - return true - }) - const resolved = await Promise.all( - names.map(async (name) => { const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) : path.resolve(Instance.worktree, name) @@ -205,34 +201,33 @@ export namespace SessionPrompt { const stats = await fs.stat(filepath).catch(() => undefined) if (!stats) { const agent = await Agent.get(name) - if (!agent) return undefined - return { - type: "agent", - name: agent.name, - } satisfies PromptInput["parts"][number] + if (agent) { + parts.push({ + type: "agent", + name: agent.name, + }) + } + return } if (stats.isDirectory()) { - return { + parts.push({ type: "file", url: `file://${filepath}`, filename: name, mime: "application/x-directory", - } satisfies PromptInput["parts"][number] + }) + return } - return { + parts.push({ type: "file", url: `file://${filepath}`, filename: name, mime: "text/plain", - } satisfies PromptInput["parts"][number] + }) }), ) - for (const item of resolved) { - if (!item) continue - parts.push(item) - } return parts } @@ -432,12 +427,6 @@ export namespace SessionPrompt { assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) if (result && part.state.status === "running") { - const attachments = result.attachments?.map((attachment) => ({ - ...attachment, - id: Identifier.ascending("part"), - messageID: assistantMessage.id, - sessionID: assistantMessage.sessionID, - })) await Session.updatePart({ ...part, state: { @@ -446,7 +435,7 @@ export namespace SessionPrompt { title: result.title, metadata: result.metadata, output: result.output, - attachments, + attachments: result.attachments, time: { ...part.state.time, end: Date.now(), @@ -785,13 +774,16 @@ export namespace SessionPrompt { ) const textParts: string[] = [] - const attachments: Omit[] = [] + const attachments: MessageV2.FilePart[] = [] for (const contentItem of result.content) { if (contentItem.type === "text") { textParts.push(contentItem.text) } else if (contentItem.type === "image") { attachments.push({ + id: Identifier.ascending("part"), + sessionID: input.session.id, + messageID: input.processor.message.id, type: "file", mime: contentItem.mimeType, url: `data:${contentItem.mimeType};base64,${contentItem.data}`, @@ -803,6 +795,9 @@ export namespace SessionPrompt { } if (resource.blob) { attachments.push({ + id: Identifier.ascending("part"), + sessionID: input.session.id, + messageID: input.processor.message.id, type: "file", mime: resource.mimeType ?? "application/octet-stream", url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, @@ -1051,7 +1046,6 @@ export namespace SessionPrompt { pieces.push( ...result.attachments.map((attachment) => ({ ...attachment, - id: Identifier.ascending("part"), synthetic: true, filename: attachment.filename ?? part.filename, messageID: info.id, @@ -1189,18 +1183,7 @@ export namespace SessionPrompt { }, ] }), - ) - .then((x) => x.flat()) - .then((drafts) => - drafts.map( - (part): MessageV2.Part => ({ - ...part, - id: Identifier.ascending("part"), - messageID: info.id, - sessionID: input.sessionID, - }), - ), - ) + ).then((x) => x.flat()) await Plugin.trigger( "chat.message", diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index b5c3ad0a12b..ba34eb48f5c 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -77,12 +77,6 @@ export const BatchTool = Tool.define("batch", async () => { }) const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) - const attachments = result.attachments?.map((attachment) => ({ - ...attachment, - id: Identifier.ascending("part"), - messageID: ctx.messageID, - sessionID: ctx.sessionID, - })) await Session.updatePart({ id: partID, @@ -97,7 +91,7 @@ export const BatchTool = Tool.define("batch", async () => { output: result.output, title: result.title, metadata: result.metadata, - attachments, + attachments: result.attachments, time: { start: callStartTime, end: Date.now(), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 13236d44dd4..f230cdf44cb 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -6,6 +6,7 @@ import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" +import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" @@ -78,6 +79,9 @@ export const ReadTool = Tool.define("read", { }, attachments: [ { + id: Identifier.ascending("part"), + sessionID: ctx.sessionID, + messageID: ctx.messageID, type: "file", mime, url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 0e78ba665cf..3d17ea192d3 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -36,7 +36,7 @@ export namespace Tool { title: string metadata: M output: string - attachments?: Omit[] + attachments?: MessageV2.FilePart[] }> formatValidationError?(error: z.ZodError): string }> diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts deleted file mode 100644 index e778bfe5146..00000000000 --- a/packages/opencode/test/session/prompt.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import path from "path" -import { describe, expect, test } from "bun:test" -import { Session } from "../../src/session" -import { SessionPrompt } from "../../src/session/prompt" -import { MessageV2 } from "../../src/session/message-v2" -import { Instance } from "../../src/project/instance" -import { Log } from "../../src/util/log" -import { tmpdir } from "../fixture/fixture" - -Log.init({ print: false }) - -describe("SessionPrompt ordering", () => { - test("keeps @file order with read output parts", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write(path.join(dir, "a.txt"), "28\n") - await Bun.write(path.join(dir, "b.txt"), "42\n") - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const template = "What numbers are written in files @a.txt and @b.txt ?" - const parts = await SessionPrompt.resolvePromptParts(template) - const fileParts = parts.filter((part) => part.type === "file") - - expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"]) - - const message = await SessionPrompt.prompt({ - sessionID: session.id, - parts, - noReply: true, - }) - const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) - const items = stored.parts - const aPath = path.join(tmp.path, "a.txt") - const bPath = path.join(tmp.path, "b.txt") - const sequence = items.flatMap((part) => { - if (part.type === "text") { - if (part.text.includes(aPath)) return ["input:a"] - if (part.text.includes(bPath)) return ["input:b"] - if (part.text.includes("00001| 28")) return ["output:a"] - if (part.text.includes("00001| 42")) return ["output:b"] - return [] - } - if (part.type === "file") { - if (part.filename === "a.txt") return ["file:a"] - if (part.filename === "b.txt") return ["file:b"] - } - return [] - }) - - expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"]) - - await Session.remove(session.id) - }, - }) - }) -}) From 531357b40c22be2ac0ff020962f85d393163e015 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:53:59 -0600 Subject: [PATCH 136/148] fix(app): sidebar losing projects on collapse --- packages/app/src/pages/layout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index ba888a2805d..2f963ae28d8 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2420,7 +2420,7 @@ export default function Layout(props: ParentProps) { } const projectName = () => props.project.name || getFilename(props.project.worktree) - const trigger = ( + const Trigger = () => ( { @@ -2499,14 +2499,14 @@ export default function Layout(props: ParentProps) { return ( // @ts-ignore
- + }> } onOpenChange={(value) => { if (menu()) return setOpen(value) From aadd2e13d785dc9c4e78cbb1812d6a0eefc2f4d1 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:02:56 +0100 Subject: [PATCH 137/148] fix(app): prompt input overflow issue (#11840) --- packages/app/src/components/prompt-input.tsx | 42 +++++++++++++------ .../src/components/session-context-usage.tsx | 2 +- packages/ui/src/components/select.tsx | 4 +- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5162c0b0806..619d4e5d92e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1896,8 +1896,8 @@ export const PromptInput: Component = (props) => {
-
-
+
+
@@ -1909,6 +1909,7 @@ export const PromptInput: Component = (props) => { @@ -1916,7 +1917,8 @@ export const PromptInput: Component = (props) => { options={local.agent.list().map((agent) => agent.name)} current={local.agent.current()?.name ?? ""} onSelect={local.agent.set} - class="capitalize" + class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`} + valueClass="truncate" variant="ghost" /> @@ -1925,36 +1927,51 @@ export const PromptInput: Component = (props) => { fallback={ - } > - + - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + + 0}> @@ -1971,6 +1988,7 @@ export const PromptInput: Component = (props) => { @@ -2000,7 +2018,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+