Merge remote-tracking branch 'origin/dev' into sqlite2

This commit is contained in:
Dax Raad
2026-02-07 01:11:15 -05:00
363 changed files with 25929 additions and 8534 deletions

View File

@@ -6,7 +6,7 @@ runs:
- name: Mount Bun Cache - name: Mount Bun Cache
uses: useblacksmith/stickydisk@v1 uses: useblacksmith/stickydisk@v1
with: with:
key: ${{ github.repository }}-bun-cache key: ${{ github.repository }}-bun-cache-${{ runner.os }}
path: ~/.bun path: ~/.bun
- name: Setup Bun - name: Setup Bun

View File

@@ -7,8 +7,32 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
test: unit:
name: test (${{ matrix.settings.name }}) name: unit (linux)
runs-on: blacksmith-4vcpu-ubuntu-2404
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Configure git identity
run: |
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Run unit tests
run: bun turbo test
e2e:
name: e2e (${{ matrix.settings.name }})
needs: unit
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -16,17 +40,12 @@ jobs:
- name: linux - name: linux
host: blacksmith-4vcpu-ubuntu-2404 host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps playwright: bunx playwright install --with-deps
workdir: .
command: |
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
bun turbo test
- name: windows - name: windows
host: windows-latest host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install playwright: bunx playwright install
workdir: packages/app
command: bun test:e2e:local
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
env:
PLAYWRIGHT_BROWSERS_PATH: 0
defaults: defaults:
run: run:
shell: bash shell: bash
@@ -43,87 +62,10 @@ jobs:
working-directory: packages/app working-directory: packages/app
run: ${{ matrix.settings.playwright }} run: ${{ matrix.settings.playwright }}
- name: Set OS-specific paths - name: Run app e2e tests
run: | run: bun --cwd packages/app test:e2e:local
if [ "${{ runner.os }}" = "Windows" ]; then
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV"
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV"
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV"
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
else
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV"
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
fi
- name: Seed opencode data
if: matrix.settings.name != 'windows'
working-directory: packages/opencode
run: bun script/seed-e2e.ts
env:
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
- name: Run opencode server
if: matrix.settings.name != 'windows'
working-directory: packages/opencode
run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 &
env:
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
OPENCODE_CLIENT: "app"
- name: Wait for opencode server
if: matrix.settings.name != 'windows'
run: |
for i in {1..120}; do
curl -fsS "http://127.0.0.1:4096/global/health" > /dev/null && exit 0
sleep 1
done
exit 1
- name: run
working-directory: ${{ matrix.settings.workdir }}
run: ${{ matrix.settings.command }}
env: env:
CI: true CI: true
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
PLAYWRIGHT_SERVER_HOST: "127.0.0.1"
PLAYWRIGHT_SERVER_PORT: "4096"
VITE_OPENCODE_SERVER_HOST: "127.0.0.1"
VITE_OPENCODE_SERVER_PORT: "4096"
OPENCODE_CLIENT: "app"
timeout-minutes: 30 timeout-minutes: 30
- name: Upload Playwright artifacts - name: Upload Playwright artifacts
@@ -136,3 +78,18 @@ jobs:
path: | path: |
packages/app/e2e/test-results packages/app/e2e/test-results
packages/app/e2e/playwright-report packages/app/e2e/playwright-report
required:
name: test (linux)
runs-on: blacksmith-4vcpu-ubuntu-2404
needs:
- unit
- e2e
if: always()
steps:
- name: Verify upstream test jobs passed
run: |
echo "unit=${{ needs.unit.result }}"
echo "e2e=${{ needs.e2e.result }}"
test "${{ needs.unit.result }}" = "success"
test "${{ needs.e2e.result }}" = "success"

View File

@@ -1,2 +1,2 @@
sst-env.d.ts sst-env.d.ts
desktop/src/bindings.ts packages/desktop/src/bindings.ts

View File

@@ -1,6 +1,7 @@
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- The default branch in this repo is `dev`. - The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. - Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
## Style Guide ## Style Guide

View File

@@ -188,6 +188,7 @@
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
@@ -286,7 +287,7 @@
"@ai-sdk/vercel": "1.0.33", "@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51", "@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.4.0", "@gitlab/gitlab-ai-provider": "3.5.0",
"@gitlab/opencode-gitlab-auth": "1.3.2", "@gitlab/opencode-gitlab-auth": "1.3.2",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
@@ -499,6 +500,9 @@
"web-tree-sitter", "web-tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
], ],
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
},
"overrides": { "overrides": {
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
@@ -518,7 +522,7 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.5", "@types/bun": "1.3.8",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
@@ -961,7 +965,7 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@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=="], "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.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-OoAwCz4fOci3h/2l+PRHMclclh3IaFq8w1es2wvBJ8ca7vtglKsBYT7dvmYpsXlu7pg9mopbjcexvmVCQEUTAQ=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.2", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-pvGrC+aDVLY8bRCC/fZaG/Qihvt2r4by5xbTo5JTSz9O7yIcR6xG2d9Wkuu4bcXFz674z2C+i5bUk+J/RSdBpg=="], "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.2", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-pvGrC+aDVLY8bRCC/fZaG/Qihvt2r4by5xbTo5JTSz9O7yIcR6xG2d9Wkuu4bcXFz674z2C+i5bUk+J/RSdBpg=="],
@@ -1843,7 +1847,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -2159,7 +2163,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],

View File

@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-ufEpxjmlJeft9tI+WxxO+Zbh1pdAaLOURCDBpoQqR0w=", "x86_64-linux": "sha256-UBz5qXhO+Xy6XptVdbo9V0wKsvZgItmHkWDm6I5VRCk=",
"aarch64-linux": "sha256-z3K6W5oYZNUdV0rjoAZjvNQcifM5bXamLIrD+ZvJ4kA=", "aarch64-linux": "sha256-G2ezu/ThZR3kYfHnbD0EOcLoAa6hwtICpmo9r+bqibE=",
"aarch64-darwin": "sha256-+QikplmNhxGF2Nd4L1BG/xyl+24GVhDYMTtK6xCKy/s=", "aarch64-darwin": "sha256-PhSE23OzNlyfNFP5LffA3AtyN+hsyCeGInmDBBRjr0g=",
"x86_64-darwin": "sha256-hAcrCT2X02ymwgj/0BAmD2gF66ylGYzbfcqPta/LVEU=" "x86_64-darwin": "sha256-vWusYJD+7ClDLUFy1wEqRLf9hY8V43iqdqnZ6YWkh1Q="
} }
} }

View File

@@ -4,7 +4,7 @@
"description": "AI-powered development tool", "description": "AI-powered development tool",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.8",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:desktop": "bun --cwd packages/desktop tauri dev",
@@ -23,7 +23,7 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@types/bun": "1.3.5", "@types/bun": "1.3.8",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"ulid": "3.0.1", "ulid": "3.0.1",
@@ -102,5 +102,7 @@
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:" "@types/node": "catalog:"
}, },
"patchedDependencies": {} "patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
}
} }

View File

@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./vite": "./vite.js" "./vite": "./vite.js",
"./index.css": "./src/index.css"
}, },
"scripts": { "scripts": {
"typecheck": "tsgo -b", "typecheck": "tsgo -b",
@@ -13,7 +14,9 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "playwright test", "test": "bun run test:unit",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:local": "bun script/e2e-local.ts", "test:e2e:local": "bun script/e2e-local.ts",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",

View File

@@ -36,7 +36,7 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
}) })
} }
describe.skip("SerializeAddon", () => { describe("SerializeAddon", () => {
describe("ANSI color preservation", () => { describe("ANSI color preservation", () => {
test("should preserve text attributes (bold, italic, underline)", async () => { test("should preserve text attributes (bold, italic, underline)", async () => {
const { term, addon } = createTerminal() const { term, addon } = createTerminal()

View File

@@ -56,6 +56,39 @@ interface IBufferCell {
isDim(): boolean isDim(): boolean
} }
type TerminalBuffers = {
active?: IBuffer
normal?: IBuffer
alternate?: IBuffer
}
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null
}
const isBuffer = (value: unknown): value is IBuffer => {
if (!isRecord(value)) return false
if (typeof value.length !== "number") return false
if (typeof value.cursorX !== "number") return false
if (typeof value.cursorY !== "number") return false
if (typeof value.baseY !== "number") return false
if (typeof value.viewportY !== "number") return false
if (typeof value.getLine !== "function") return false
if (typeof value.getNullCell !== "function") return false
return true
}
const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => {
if (!isRecord(value)) return
const raw = value.buffer
if (!isRecord(raw)) return
const active = isBuffer(raw.active) ? raw.active : undefined
const normal = isBuffer(raw.normal) ? raw.normal : undefined
const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined
if (!active && !normal) return
return { active, normal, alternate }
}
// ============================================================================ // ============================================================================
// Types // Types
// ============================================================================ // ============================================================================
@@ -498,14 +531,13 @@ export class SerializeAddon implements ITerminalAddon {
throw new Error("Cannot use addon until it has been loaded") throw new Error("Cannot use addon until it has been loaded")
} }
const terminal = this._terminal as any const buffer = getTerminalBuffers(this._terminal)
const buffer = terminal.buffer
if (!buffer) { if (!buffer) {
return "" return ""
} }
const normalBuffer = buffer.normal || buffer.active const normalBuffer = buffer.normal ?? buffer.active
const altBuffer = buffer.alternate const altBuffer = buffer.alternate
if (!normalBuffer) { if (!normalBuffer) {
@@ -533,14 +565,13 @@ export class SerializeAddon implements ITerminalAddon {
throw new Error("Cannot use addon until it has been loaded") throw new Error("Cannot use addon until it has been loaded")
} }
const terminal = this._terminal as any const buffer = getTerminalBuffers(this._terminal)
const buffer = terminal.buffer
if (!buffer) { if (!buffer) {
return "" return ""
} }
const activeBuffer = buffer.active || buffer.normal const activeBuffer = buffer.active ?? buffer.normal
if (!activeBuffer) { if (!activeBuffer) {
return "" return ""
} }

View File

@@ -30,7 +30,7 @@ import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { Suspense } from "solid-js" import { Suspense, JSX } from "solid-js"
const Home = lazy(() => import("@/pages/home")) const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
@@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) {
) )
} }
export function AppInterface(props: { defaultUrl?: string }) { export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element }) {
const platform = usePlatform() const platform = usePlatform()
const stored = (() => { const stored = (() => {
@@ -111,7 +111,7 @@ export function AppInterface(props: { defaultUrl?: string }) {
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>
<Router <Router
root={(props) => ( root={(routerProps) => (
<SettingsProvider> <SettingsProvider>
<PermissionProvider> <PermissionProvider>
<LayoutProvider> <LayoutProvider>
@@ -119,7 +119,10 @@ export function AppInterface(props: { defaultUrl?: string }) {
<ModelsProvider> <ModelsProvider>
<CommandProvider> <CommandProvider>
<HighlightsProvider> <HighlightsProvider>
<Layout>{props.children}</Layout> <Layout>
{props.children}
{routerProps.children}
</Layout>
</HighlightsProvider> </HighlightsProvider>
</CommandProvider> </CommandProvider>
</ModelsProvider> </ModelsProvider>

View File

@@ -124,16 +124,16 @@ export function DialogCustomProvider(props: Props) {
const key = apiKey && !env ? apiKey : undefined const key = apiKey && !env ? apiKey : undefined
const idError = !providerID const idError = !providerID
? "Provider ID is required" ? language.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID) : !PROVIDER_ID.test(providerID)
? "Use lowercase letters, numbers, hyphens, or underscores" ? language.t("provider.custom.error.providerID.format")
: undefined : undefined
const nameError = !name ? "Display name is required" : undefined const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL const urlError = !baseURL
? "Base URL is required" ? language.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL) : !/^https?:\/\//.test(baseURL)
? "Must start with http:// or https://" ? language.t("provider.custom.error.baseURL.format")
: undefined : undefined
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID) const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
@@ -141,21 +141,21 @@ export function DialogCustomProvider(props: Props) {
const existsError = idError const existsError = idError
? undefined ? undefined
: existingProvider && !disabled : existingProvider && !disabled
? "That provider ID already exists" ? language.t("provider.custom.error.providerID.exists")
: undefined : undefined
const seenModels = new Set<string>() const seenModels = new Set<string>()
const modelErrors = form.models.map((m) => { const modelErrors = form.models.map((m) => {
const id = m.id.trim() const id = m.id.trim()
const modelIdError = !id const modelIdError = !id
? "Required" ? language.t("provider.custom.error.required")
: seenModels.has(id) : seenModels.has(id)
? "Duplicate" ? language.t("provider.custom.error.duplicate")
: (() => { : (() => {
seenModels.add(id) seenModels.add(id)
return undefined return undefined
})() })()
const modelNameError = !m.name.trim() ? "Required" : undefined const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError } return { id: modelIdError, name: modelNameError }
}) })
const modelsValid = modelErrors.every((m) => !m.id && !m.name) const modelsValid = modelErrors.every((m) => !m.id && !m.name)
@@ -168,14 +168,14 @@ export function DialogCustomProvider(props: Props) {
if (!key && !value) return {} if (!key && !value) return {}
const keyError = !key const keyError = !key
? "Required" ? language.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase()) : seenHeaders.has(key.toLowerCase())
? "Duplicate" ? language.t("provider.custom.error.duplicate")
: (() => { : (() => {
seenHeaders.add(key.toLowerCase()) seenHeaders.add(key.toLowerCase())
return undefined return undefined
})() })()
const valueError = !value ? "Required" : undefined const valueError = !value ? language.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError } return { key: keyError, value: valueError }
}) })
const headersValid = headerErrors.every((h) => !h.key && !h.value) const headersValid = headerErrors.every((h) => !h.key && !h.value)
@@ -278,64 +278,64 @@ export function DialogCustomProvider(props: Props) {
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]"> <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center"> <div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" /> <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">Custom provider</div> <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
</div> </div>
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6"> <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base"> <p class="text-14-regular text-text-base">
Configure an OpenAI-compatible provider. See the{" "} {language.t("provider.custom.description.prefix")}
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}> <Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
provider config docs {language.t("provider.custom.description.link")}
</Link> </Link>
. {language.t("provider.custom.description.suffix")}
</p> </p>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<TextField <TextField
autofocus autofocus
label="Provider ID" label={language.t("provider.custom.field.providerID.label")}
placeholder="myprovider" placeholder={language.t("provider.custom.field.providerID.placeholder")}
description="Lowercase letters, numbers, hyphens, or underscores" description={language.t("provider.custom.field.providerID.description")}
value={form.providerID} value={form.providerID}
onChange={setForm.bind(null, "providerID")} onChange={setForm.bind(null, "providerID")}
validationState={errors.providerID ? "invalid" : undefined} validationState={errors.providerID ? "invalid" : undefined}
error={errors.providerID} error={errors.providerID}
/> />
<TextField <TextField
label="Display name" label={language.t("provider.custom.field.name.label")}
placeholder="My AI Provider" placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name} value={form.name}
onChange={setForm.bind(null, "name")} onChange={setForm.bind(null, "name")}
validationState={errors.name ? "invalid" : undefined} validationState={errors.name ? "invalid" : undefined}
error={errors.name} error={errors.name}
/> />
<TextField <TextField
label="Base URL" label={language.t("provider.custom.field.baseURL.label")}
placeholder="https://api.myprovider.com/v1" placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL} value={form.baseURL}
onChange={setForm.bind(null, "baseURL")} onChange={setForm.bind(null, "baseURL")}
validationState={errors.baseURL ? "invalid" : undefined} validationState={errors.baseURL ? "invalid" : undefined}
error={errors.baseURL} error={errors.baseURL}
/> />
<TextField <TextField
label="API key" label={language.t("provider.custom.field.apiKey.label")}
placeholder="API key" placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description="Optional. Leave empty if you manage auth via headers." description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey} value={form.apiKey}
onChange={setForm.bind(null, "apiKey")} onChange={setForm.bind(null, "apiKey")}
/> />
</div> </div>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<label class="text-12-medium text-text-weak">Models</label> <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}> <For each={form.models}>
{(m, i) => ( {(m, i) => (
<div class="flex gap-2 items-start"> <div class="flex gap-2 items-start">
<div class="flex-1"> <div class="flex-1">
<TextField <TextField
label="ID" label={language.t("provider.custom.models.id.label")}
hideLabel hideLabel
placeholder="model-id" placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id} value={m.id}
onChange={(v) => setForm("models", i(), "id", v)} onChange={(v) => setForm("models", i(), "id", v)}
validationState={errors.models[i()]?.id ? "invalid" : undefined} validationState={errors.models[i()]?.id ? "invalid" : undefined}
@@ -344,9 +344,9 @@ export function DialogCustomProvider(props: Props) {
</div> </div>
<div class="flex-1"> <div class="flex-1">
<TextField <TextField
label="Name" label={language.t("provider.custom.models.name.label")}
hideLabel hideLabel
placeholder="Display Name" placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name} value={m.name}
onChange={(v) => setForm("models", i(), "name", v)} onChange={(v) => setForm("models", i(), "name", v)}
validationState={errors.models[i()]?.name ? "invalid" : undefined} validationState={errors.models[i()]?.name ? "invalid" : undefined}
@@ -360,26 +360,26 @@ export function DialogCustomProvider(props: Props) {
class="mt-1.5" class="mt-1.5"
onClick={() => removeModel(i())} onClick={() => removeModel(i())}
disabled={form.models.length <= 1} disabled={form.models.length <= 1}
aria-label="Remove model" aria-label={language.t("provider.custom.models.remove")}
/> />
</div> </div>
)} )}
</For> </For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start"> <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
Add model {language.t("provider.custom.models.add")}
</Button> </Button>
</div> </div>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<label class="text-12-medium text-text-weak">Headers (optional)</label> <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
<For each={form.headers}> <For each={form.headers}>
{(h, i) => ( {(h, i) => (
<div class="flex gap-2 items-start"> <div class="flex gap-2 items-start">
<div class="flex-1"> <div class="flex-1">
<TextField <TextField
label="Header" label={language.t("provider.custom.headers.key.label")}
hideLabel hideLabel
placeholder="Header-Name" placeholder={language.t("provider.custom.headers.key.placeholder")}
value={h.key} value={h.key}
onChange={(v) => setForm("headers", i(), "key", v)} onChange={(v) => setForm("headers", i(), "key", v)}
validationState={errors.headers[i()]?.key ? "invalid" : undefined} validationState={errors.headers[i()]?.key ? "invalid" : undefined}
@@ -388,9 +388,9 @@ export function DialogCustomProvider(props: Props) {
</div> </div>
<div class="flex-1"> <div class="flex-1">
<TextField <TextField
label="Value" label={language.t("provider.custom.headers.value.label")}
hideLabel hideLabel
placeholder="value" placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value} value={h.value}
onChange={(v) => setForm("headers", i(), "value", v)} onChange={(v) => setForm("headers", i(), "value", v)}
validationState={errors.headers[i()]?.value ? "invalid" : undefined} validationState={errors.headers[i()]?.value ? "invalid" : undefined}
@@ -404,18 +404,18 @@ export function DialogCustomProvider(props: Props) {
class="mt-1.5" class="mt-1.5"
onClick={() => removeHeader(i())} onClick={() => removeHeader(i())}
disabled={form.headers.length <= 1} disabled={form.headers.length <= 1}
aria-label="Remove header" aria-label={language.t("provider.custom.headers.remove")}
/> />
</div> </div>
)} )}
</For> </For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start"> <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
Add header {language.t("provider.custom.headers.add")}
</Button> </Button>
</div> </div>
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}> <Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
{form.saving ? "Saving..." : language.t("common.submit")} {form.saving ? language.t("common.saving") : language.t("common.submit")}
</Button> </Button>
</form> </form>
</div> </div>

View File

@@ -223,7 +223,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
value={store.startup} value={store.startup}
onChange={(v) => setStore("startup", v)} onChange={(v) => setStore("startup", v)}
spellcheck={false} spellcheck={false}
class="max-h-40 w-full font-mono text-xs no-scrollbar" class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/> />
</div> </div>

View File

@@ -15,6 +15,7 @@ import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
type EntryType = "command" | "file" | "session" type EntryType = "command" | "file" | "session"
@@ -30,6 +31,7 @@ type Entry = {
directory?: string directory?: string
sessionID?: string sessionID?: string
archived?: number archived?: number
updated?: number
} }
type DialogSelectFileMode = "all" | "files" type DialogSelectFileMode = "all" | "files"
@@ -120,6 +122,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
title: string title: string
description: string description: string
archived?: number archived?: number
updated?: number
}): Entry => ({ }): Entry => ({
id: `session:${input.directory}:${input.id}`, id: `session:${input.directory}:${input.id}`,
type: "session", type: "session",
@@ -129,6 +132,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
directory: input.directory, directory: input.directory,
sessionID: input.id, sessionID: input.id,
archived: input.archived, archived: input.archived,
updated: input.updated,
}) })
const list = createMemo(() => allowed().map(commandItem)) const list = createMemo(() => allowed().map(commandItem))
@@ -214,6 +218,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
description, description,
directory, directory,
archived: s.time?.archived, archived: s.time?.archived,
updated: s.time?.updated,
})), })),
) )
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[]) .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
@@ -384,6 +389,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</Show> </Show>
</div> </div>
</div> </div>
<Show when={item.updated}>
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
{getRelativeTime(new Date(item.updated!).toISOString())}
</span>
</Show>
</div> </div>
</Match> </Match>
</Switch> </Switch>

View File

@@ -87,11 +87,13 @@ const ModelList: Component<{
) )
} }
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: { type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
export function ModelSelectorPopover(props: {
provider?: string provider?: string
children?: JSX.Element children?: JSX.Element
triggerAs?: T triggerAs?: ValidComponent
triggerProps?: ComponentProps<T> triggerProps?: ModelSelectorTriggerProps
}) { }) {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
open: boolean open: boolean
@@ -176,11 +178,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
placement="top-start" placement="top-start"
gutter={8} gutter={8}
> >
<Kobalte.Trigger <Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
ref={(el) => setStore("trigger", el)}
as={props.triggerAs ?? "div"}
{...(props.triggerProps as any)}
>
{props.children} {props.children}
</Kobalte.Trigger> </Kobalte.Trigger>
<Kobalte.Portal> <Kobalte.Portal>

View File

@@ -1,4 +1,4 @@
import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js" import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -6,17 +6,15 @@ import { List } from "@opencode-ai/ui/list"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { ServerRow } from "@/components/server/server-row"
type ServerStatus = { healthy: boolean; version?: string } import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface AddRowProps { interface AddRowProps {
value: string value: string
@@ -40,19 +38,6 @@ interface EditRowProps {
onBlur: () => void onBlur: () => void
} }
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal,
})
return sdk.global
.health()
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
.catch(() => ({ healthy: false }))
}
function AddRow(props: AddRowProps) { function AddRow(props: AddRowProps) {
return ( return (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1"> <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
@@ -131,7 +116,7 @@ export function DialogSelectServer() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<string, ServerStatus | undefined>, status: {} as Record<string, ServerHealth | undefined>,
addServer: { addServer: {
url: "", url: "",
adding: false, adding: false,
@@ -165,6 +150,7 @@ export function DialogSelectServer() {
{ initialValue: null }, { initialValue: null },
) )
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const fetcher = platform.fetch ?? globalThis.fetch
const looksComplete = (value: string) => { const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
@@ -180,7 +166,7 @@ export function DialogSelectServer() {
if (!looksComplete(value)) return if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) return if (!normalized) return
const result = await checkHealth(normalized, platform) const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy) setStatus(result.healthy)
} }
@@ -227,7 +213,7 @@ export function DialogSelectServer() {
if (!list.length) return list if (!list.length) return list
const active = current() const active = current()
const order = new Map(list.map((url, index) => [url, index] as const)) const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerStatus) => { const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0 if (value?.healthy === true) return 0
if (value?.healthy === false) return 2 if (value?.healthy === false) return 2
return 1 return 1
@@ -242,10 +228,10 @@ export function DialogSelectServer() {
}) })
async function refreshHealth() { async function refreshHealth() {
const results: Record<string, ServerStatus> = {} const results: Record<string, ServerHealth> = {}
await Promise.all( await Promise.all(
items().map(async (url) => { items().map(async (url) => {
results[url] = await checkHealth(url, platform) results[url] = await checkServerHealth(url, fetcher)
}), }),
) )
setStore("status", reconcile(results)) setStore("status", reconcile(results))
@@ -300,7 +286,7 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" }) setStore("addServer", { adding: true, error: "" })
const result = await checkHealth(normalized, platform) const result = await checkServerHealth(normalized, fetcher)
setStore("addServer", { adding: false }) setStore("addServer", { adding: false })
if (!result.healthy) { if (!result.healthy) {
@@ -327,7 +313,7 @@ export function DialogSelectServer() {
setStore("editServer", { busy: true, error: "" }) setStore("editServer", { busy: true, error: "" })
const result = await checkHealth(normalized, platform) const result = await checkServerHealth(normalized, fetcher)
setStore("editServer", { busy: false }) setStore("editServer", { busy: false })
if (!result.healthy) { if (!result.healthy) {
@@ -369,6 +355,9 @@ export function DialogSelectServer() {
async function handleRemove(url: string) { async function handleRemove(url: string) {
server.remove(url) server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null)
}
} }
return ( return (
@@ -410,35 +399,6 @@ export function DialogSelectServer() {
} }
> >
{(i) => { {(i) => {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
setTruncated(nameTruncated || versionTruncated)
}
createEffect(() => {
check()
window.addEventListener("resize", check)
onCleanup(() => window.removeEventListener("resize", check))
})
const tooltipValue = () => {
const name = serverDisplayName(i)
const version = store.status[i]?.version
return (
<span class="flex items-center gap-2">
<span>{name}</span>
<Show when={version}>
<span class="text-text-invert-base">{version}</span>
</Show>
</span>
)
}
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show <Show
@@ -456,34 +416,19 @@ export function DialogSelectServer() {
/> />
} }
> >
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}> <ServerRow
<div url={i}
status={store.status[i]}
dimmed={store.status[i]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1" class="flex items-center gap-3 px-4 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }} badge={
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span ref={nameRef} class="truncate">
{serverDisplayName(i)}
</span>
<Show when={store.status[i]?.version}>
<span ref={versionRef} class="text-text-weak text-14-regular truncate">
{store.status[i]?.version}
</span>
</Show>
<Show when={defaultUrl() === i}> <Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
</Show> </Show>
</div> }
</Tooltip> />
</Show> </Show>
<Show when={store.editServer.id !== i}> <Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4"> <div class="flex items-center justify-center gap-5 pl-4">

View File

@@ -0,0 +1,77 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
let shouldListRoot: typeof import("./file-tree").shouldListRoot
let shouldListExpanded: typeof import("./file-tree").shouldListExpanded
let dirsToExpand: typeof import("./file-tree").dirsToExpand
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useParams: () => ({}),
}))
mock.module("@/context/file", () => ({
useFile: () => ({
tree: {
state: () => undefined,
list: () => Promise.resolve(),
children: () => [],
expand: () => {},
collapse: () => {},
},
}),
}))
mock.module("@opencode-ai/ui/collapsible", () => ({
Collapsible: {
Trigger: (props: { children?: unknown }) => props.children,
Content: (props: { children?: unknown }) => props.children,
},
}))
mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null }))
mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null }))
mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children }))
const mod = await import("./file-tree")
shouldListRoot = mod.shouldListRoot
shouldListExpanded = mod.shouldListExpanded
dirsToExpand = mod.dirsToExpand
})
describe("file tree fetch discipline", () => {
test("root lists on mount unless already loaded or loading", () => {
expect(shouldListRoot({ level: 0 })).toBe(true)
expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
expect(shouldListRoot({ level: 1 })).toBe(false)
})
test("nested dirs list only when expanded and stale", () => {
expect(shouldListExpanded({ level: 1 })).toBe(false)
expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false)
expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true)
expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false)
expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false)
expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false)
})
test("allowed auto-expand picks only collapsed dirs", () => {
const expanded = new Set<string>()
const filter = { dirs: new Set(["src", "src/components"]) }
const first = dirsToExpand({
level: 0,
filter,
expanded: (dir) => expanded.has(dir),
})
expect(first).toEqual(["src", "src/components"])
for (const dir of first) expanded.add(dir)
const second = dirsToExpand({
level: 0,
filter,
expanded: (dir) => expanded.has(dir),
})
expect(second).toEqual([])
expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([])
})
})

View File

@@ -8,6 +8,7 @@ import {
createMemo, createMemo,
For, For,
Match, Match,
on,
Show, Show,
splitProps, splitProps,
Switch, Switch,
@@ -18,6 +19,14 @@ import {
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { FileNode } from "@opencode-ai/sdk/v2"
function pathToFileUrl(filepath: string): string {
const encodedPath = filepath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
return `file://${encodedPath}`
}
type Kind = "add" | "del" | "mix" type Kind = "add" | "del" | "mix"
type Filter = { type Filter = {
@@ -25,6 +34,34 @@ type Filter = {
dirs: Set<string> dirs: Set<string>
} }
export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
if (input.level !== 0) return false
if (input.dir?.loaded) return false
if (input.dir?.loading) return false
return true
}
export function shouldListExpanded(input: {
level: number
dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean }
}) {
if (input.level === 0) return false
if (!input.dir?.expanded) return false
if (input.dir.loaded) return false
if (input.dir.loading) return false
return true
}
export function dirsToExpand(input: {
level: number
filter?: { dirs: Set<string> }
expanded: (dir: string) => boolean
}) {
if (input.level !== 0) return []
if (!input.filter) return []
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
}
export default function FileTree(props: { export default function FileTree(props: {
path: string path: string
class?: string class?: string
@@ -111,19 +148,30 @@ export default function FileTree(props: {
createEffect(() => { createEffect(() => {
const current = filter() const current = filter()
if (!current) return const dirs = dirsToExpand({
if (level !== 0) return level,
filter: current,
for (const dir of current.dirs) { expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false })
if (expanded) continue for (const dir of dirs) file.tree.expand(dir)
file.tree.expand(dir)
}
}) })
createEffect(
on(
() => props.path,
(path) => {
const dir = untrack(() => file.tree.state(path))
if (!shouldListRoot({ level, dir })) return
void file.tree.list(path)
},
{ defer: false },
),
)
createEffect(() => { createEffect(() => {
const path = props.path const dir = file.tree.state(props.path)
untrack(() => void file.tree.list(path)) if (!shouldListExpanded({ level, dir })) return
void file.tree.list(props.path)
}) })
const nodes = createMemo(() => { const nodes = createMemo(() => {
@@ -207,7 +255,7 @@ export default function FileTree(props: {
onDragStart={(e: DragEvent) => { onDragStart={(e: DragEvent) => {
if (!draggable()) return if (!draggable()) return
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`) e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`) e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy" if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
const dragImage = document.createElement("div") const dragImage = document.createElement("div")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
import { onCleanup, onMount } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { useLanguage } from "@/context/language"
import { getCursorPosition } from "./editor-dom"
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined
isFocused: () => boolean
isDialogActive: () => boolean
setDragging: (value: boolean) => void
addPart: (part: ContentPart) => void
}
export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = usePrompt()
const language = useLanguage()
const addImageAttachment = async (file: File) => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
const reader = new FileReader()
reader.onload = () => {
const editor = input.editor()
if (!editor) return
const dataUrl = reader.result as string
const attachment: ImageAttachmentPart = {
type: "image",
id: crypto.randomUUID(),
filename: file.name,
mime: file.type,
dataUrl,
}
const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursorPosition)
}
reader.readAsDataURL(file)
}
const removeImageAttachment = (id: string) => {
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
prompt.set(next, prompt.cursor())
}
const handlePaste = async (event: ClipboardEvent) => {
if (!input.isFocused()) return
const clipboardData = event.clipboardData
if (!clipboardData) return
event.preventDefault()
event.stopPropagation()
const items = Array.from(clipboardData.items)
const fileItems = items.filter((item) => item.kind === "file")
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) {
for (const item of imageItems) {
const file = item.getAsFile()
if (file) await addImageAttachment(file)
}
return
}
if (fileItems.length > 0) {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
return
}
const plainText = clipboardData.getData("text/plain") ?? ""
if (!plainText) return
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
const handleGlobalDragOver = (event: DragEvent) => {
if (input.isDialogActive()) return
event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files")
if (hasFiles) {
input.setDragging(true)
}
}
const handleGlobalDragLeave = (event: DragEvent) => {
if (input.isDialogActive()) return
if (!event.relatedTarget) {
input.setDragging(false)
}
}
const handleGlobalDrop = async (event: DragEvent) => {
if (input.isDialogActive()) return
event.preventDefault()
input.setDragging(false)
const dropped = event.dataTransfer?.files
if (!dropped) return
for (const file of Array.from(dropped)) {
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
await addImageAttachment(file)
}
}
}
onMount(() => {
document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop)
})
onCleanup(() => {
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
})
return {
addImageAttachment,
removeImageAttachment,
handlePaste,
}
}

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { buildRequestParts } from "./build-request-parts"
describe("buildRequestParts", () => {
test("builds typed request and optimistic parts without cast path", () => {
const prompt: Prompt = [
{ type: "text", content: "hello", start: 0, end: 5 },
{
type: "file",
path: "src/foo.ts",
content: "@src/foo.ts",
start: 5,
end: 16,
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
},
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
]
const result = buildRequestParts({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
text: "hello @src/foo.ts @planner",
messageID: "msg_1",
sessionID: "ses_1",
sessionDirectory: "/repo",
})
expect(result.requestParts[0]?.type).toBe("text")
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
expect(
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
).toBe(true)
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
const result = buildRequestParts({
prompt,
context: [
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
text: "@src/foo.ts",
messageID: "msg_2",
sessionID: "ses_2",
sessionDirectory: "/repo",
})
const fooFiles = result.requestParts.filter(
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
)
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
expect(fooFiles).toHaveLength(2)
expect(synthetic).toHaveLength(1)
})
})

View File

@@ -0,0 +1,180 @@
import { getFilename } from "@opencode-ai/util/path"
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
import type { FileSelection } from "@/context/file"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
type ContextFile = {
key: string
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
type BuildRequestPartsInput = {
prompt: Prompt
context: ContextFile[]
images: ImageAttachmentPart[]
text: string
messageID: string
sessionID: string
sessionDirectory: string
}
const absolute = (directory: string, path: string) =>
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
const encodeFilePath = (filepath: string): string =>
filepath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
const range =
start === undefined || end === undefined
? "this file"
: start === end
? `line ${start}`
: `lines ${start} through ${end}`
return `The user made the following comment regarding ${range} of ${path}: ${comment}`
}
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
if (part.type === "text") {
return {
id: part.id,
type: "text",
text: part.text,
synthetic: part.synthetic,
ignored: part.ignored,
time: part.time,
metadata: part.metadata,
sessionID,
messageID,
}
}
if (part.type === "file") {
return {
id: part.id,
type: "file",
mime: part.mime,
filename: part.filename,
url: part.url,
source: part.source,
sessionID,
messageID,
}
}
return {
id: part.id,
type: "agent",
name: part.name,
source: part.source,
sessionID,
messageID,
}
}
export function buildRequestParts(input: BuildRequestPartsInput) {
const requestParts: PromptRequestPart[] = [
{
id: Identifier.ascending("part"),
type: "text",
text: input.text,
},
]
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
return {
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
filename: getFilename(attachment.path),
source: {
type: "file",
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path,
},
} satisfies PromptRequestPart
})
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
return {
id: Identifier.ascending("part"),
type: "agent",
name: attachment.name,
source: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
} satisfies PromptRequestPart
})
const used = new Set(files.map((part) => part.url))
const context = input.context.flatMap((item) => {
const path = absolute(input.sessionDirectory, item.path)
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(url)) return []
used.add(url)
const filePart = {
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(item.path),
} satisfies PromptRequestPart
if (!comment) return [filePart]
return [
{
id: Identifier.ascending("part"),
type: "text",
text: commentNote(item.path, item.selection, comment),
synthetic: true,
} satisfies PromptRequestPart,
filePart,
]
})
const images = input.images.map((attachment) => {
return {
id: Identifier.ascending("part"),
type: "file",
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
} satisfies PromptRequestPart
})
requestParts.push(...files, ...context, ...agents, ...images)
return {
requestParts,
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
}
}

View File

@@ -0,0 +1,82 @@
import { Component, For, Show } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
import type { ContextItem } from "@/context/prompt"
type PromptContextItem = ContextItem & { key: string }
type ContextItemsProps = {
items: PromptContextItem[]
active: (item: PromptContextItem) => boolean
openComment: (item: PromptContextItem) => void
remove: (item: PromptContextItem) => void
t: (key: string) => string
}
export const PromptContextItems: Component<ContextItemsProps> = (props) => {
return (
<Show when={props.items.length > 0}>
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<Tooltip
value={
<span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
{getDirectory(item.path)}
</span>
<span class="shrink-0">{getFilename(item.path)}</span>
</span>
}
placement="top"
openDelay={2000}
>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
props.active(item),
"bg-background-stronger": !props.active(item),
}}
onClick={() => props.openComment(item)}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap shrink-0">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
</div>
<IconButton
type="button"
icon="close-small"
variant="ghost"
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
onClick={(e) => {
e.stopPropagation()
props.remove(item)
}}
aria-label={props.t("prompt.context.removeFile")}
/>
</div>
<Show when={item.comment}>
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show>
</div>
</Tooltip>
)}
</For>
</div>
</Show>
)
}

View File

@@ -0,0 +1,20 @@
import { Component, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
type PromptDragOverlayProps = {
dragging: boolean
label: string
}
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
return (
<Show when={props.dragging}>
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
<div class="flex flex-col items-center gap-2 text-text-weak">
<Icon name="photo" class="size-8" />
<span class="text-14-regular">{props.label}</span>
</div>
</div>
</Show>
)
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test"
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
describe("prompt-input editor dom", () => {
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
const fragment = createTextFragment("foo\n\nbar")
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(5)
expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[2]?.textContent).toBe("\u200B")
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[4]?.textContent).toBe("bar")
})
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
const container = document.createElement("div")
container.appendChild(document.createTextNode("ab\u200B"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("cd"))
expect(getNodeLength(container.childNodes[0]!)).toBe(2)
expect(getNodeLength(container.childNodes[1]!)).toBe(1)
expect(getTextLength(container)).toBe(5)
})
test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
const container = document.createElement("div")
const pill = document.createElement("span")
pill.dataset.type = "file"
pill.textContent = "@file"
container.appendChild(document.createTextNode("ab"))
container.appendChild(pill)
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("cd"))
document.body.appendChild(container)
setCursorPosition(container, 2)
expect(getCursorPosition(container)).toBe(2)
setCursorPosition(container, 7)
expect(getCursorPosition(container)).toBe(7)
setCursorPosition(container, 8)
expect(getCursorPosition(container)).toBe(8)
container.remove()
})
})

View File

@@ -0,0 +1,135 @@
export function createTextFragment(content: string): DocumentFragment {
const fragment = document.createDocumentFragment()
const segments = content.split("\n")
segments.forEach((segment, index) => {
if (segment) {
fragment.appendChild(document.createTextNode(segment))
} else if (segments.length > 1) {
fragment.appendChild(document.createTextNode("\u200B"))
}
if (index < segments.length - 1) {
fragment.appendChild(document.createElement("br"))
}
})
return fragment
}
export function getNodeLength(node: Node): number {
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
return (node.textContent ?? "").replace(/\u200B/g, "").length
}
export function getTextLength(node: Node): number {
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
let length = 0
for (const child of Array.from(node.childNodes)) {
length += getTextLength(child)
}
return length
}
export function getCursorPosition(parent: HTMLElement): number {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0)
if (!parent.contains(range.startContainer)) return 0
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(parent)
preCaretRange.setEnd(range.startContainer, range.startOffset)
return getTextLength(preCaretRange.cloneContents())
}
export function setCursorPosition(parent: HTMLElement, position: number) {
let remaining = position
let node = parent.firstChild
while (node) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isPill =
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
const range = document.createRange()
const selection = window.getSelection()
range.setStart(node, remaining)
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
return
}
if ((isPill || isBreak) && remaining <= length) {
const range = document.createRange()
const selection = window.getSelection()
if (remaining === 0) {
range.setStartBefore(node)
}
if (remaining > 0 && isPill) {
range.setStartAfter(node)
}
if (remaining > 0 && isBreak) {
const next = node.nextSibling
if (next && next.nodeType === Node.TEXT_NODE) {
range.setStart(next, 0)
}
if (!next || next.nodeType !== Node.TEXT_NODE) {
range.setStartAfter(node)
}
}
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
return
}
remaining -= length
node = node.nextSibling
}
const fallbackRange = document.createRange()
const fallbackSelection = window.getSelection()
const last = parent.lastChild
if (last && last.nodeType === Node.TEXT_NODE) {
const len = last.textContent ? last.textContent.length : 0
fallbackRange.setStart(last, len)
}
if (!last || last.nodeType !== Node.TEXT_NODE) {
fallbackRange.selectNodeContents(parent)
}
fallbackRange.collapse(false)
fallbackSelection?.removeAllRanges()
fallbackSelection?.addRange(fallbackRange)
}
export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
let remaining = offset
const nodes = Array.from(parent.childNodes)
for (const node of nodes) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isPill =
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
if (edge === "end") range.setEnd(node, remaining)
return
}
if ((isPill || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
if (edge === "end" && remaining > 0) range.setEndAfter(node)
return
}
remaining -= length
}
}

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
describe("prompt-input history", () => {
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
const first = prependHistoryEntry([], DEFAULT_PROMPT)
expect(first).toEqual([])
const withOne = prependHistoryEntry([], text("hello"))
expect(withOne).toHaveLength(1)
const deduped = prependHistoryEntry(withOne, text("hello"))
expect(deduped).toBe(withOne)
})
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
const entries = [text("third"), text("second"), text("first")]
const up = navigatePromptHistory({
direction: "up",
entries,
historyIndex: -1,
currentPrompt: text("draft"),
savedPrompt: null,
})
expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled")
expect(up.historyIndex).toBe(0)
expect(up.cursor).toBe("start")
const down = navigatePromptHistory({
direction: "down",
entries,
historyIndex: up.historyIndex,
currentPrompt: text("ignored"),
savedPrompt: up.savedPrompt,
})
expect(down.handled).toBe(true)
if (!down.handled) throw new Error("expected handled")
expect(down.historyIndex).toBe(-1)
expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
})
test("helpers clone prompt and count text content length", () => {
const original: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 3,
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
]
const copy = clonePromptParts(original)
expect(copy).not.toBe(original)
expect(promptLength(copy)).toBe(12)
if (copy[1]?.type !== "file") throw new Error("expected file")
copy[1].selection!.startLine = 9
if (original[1]?.type !== "file") throw new Error("expected file")
expect(original[1].selection?.startLine).toBe(1)
})
})

View File

@@ -0,0 +1,160 @@
import type { Prompt } from "@/context/prompt"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100
export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: part.selection ? { ...part.selection } : undefined,
}
})
}
export function promptLength(prompt: Prompt) {
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
}
export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
const text = prompt
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim()
const hasImages = prompt.some((part) => part.type === "image")
if (!text && !hasImages) return entries
const entry = clonePromptParts(prompt)
const last = entries[0]
if (last && isPromptEqual(last, entry)) return entries
return [entry, ...entries].slice(0, max)
}
function isPromptEqual(promptA: Prompt, promptB: Prompt) {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
const partA = promptA[i]
const partB = promptB[i]
if (partA.type !== partB.type) return false
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
if (partA.type === "file") {
if (partA.path !== (partB.type === "file" ? partB.path : "")) return false
const a = partA.selection
const b = partB.type === "file" ? partB.selection : undefined
const sameSelection =
(!a && !b) ||
(!!a &&
!!b &&
a.startLine === b.startLine &&
a.startChar === b.startChar &&
a.endLine === b.endLine &&
a.endChar === b.endChar)
if (!sameSelection) return false
}
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
}
return true
}
type HistoryNavInput = {
direction: "up" | "down"
entries: Prompt[]
historyIndex: number
currentPrompt: Prompt
savedPrompt: Prompt | null
}
type HistoryNavResult =
| {
handled: false
historyIndex: number
savedPrompt: Prompt | null
}
| {
handled: true
historyIndex: number
savedPrompt: Prompt | null
prompt: Prompt
cursor: "start" | "end"
}
export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult {
if (input.direction === "up") {
if (input.entries.length === 0) {
return {
handled: false,
historyIndex: input.historyIndex,
savedPrompt: input.savedPrompt,
}
}
if (input.historyIndex === -1) {
return {
handled: true,
historyIndex: 0,
savedPrompt: clonePromptParts(input.currentPrompt),
prompt: input.entries[0],
cursor: "start",
}
}
if (input.historyIndex < input.entries.length - 1) {
const next = input.historyIndex + 1
return {
handled: true,
historyIndex: next,
savedPrompt: input.savedPrompt,
prompt: input.entries[next],
cursor: "start",
}
}
return {
handled: false,
historyIndex: input.historyIndex,
savedPrompt: input.savedPrompt,
}
}
if (input.historyIndex > 0) {
const next = input.historyIndex - 1
return {
handled: true,
historyIndex: next,
savedPrompt: input.savedPrompt,
prompt: input.entries[next],
cursor: "end",
}
}
if (input.historyIndex === 0) {
if (input.savedPrompt) {
return {
handled: true,
historyIndex: -1,
savedPrompt: null,
prompt: input.savedPrompt,
cursor: "end",
}
}
return {
handled: true,
historyIndex: -1,
savedPrompt: null,
prompt: DEFAULT_PROMPT,
cursor: "end",
}
}
return {
handled: false,
historyIndex: input.historyIndex,
savedPrompt: input.savedPrompt,
}
}

View File

@@ -0,0 +1,51 @@
import { Component, For, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import type { ImageAttachmentPart } from "@/context/prompt"
type PromptImageAttachmentsProps = {
attachments: ImageAttachmentPart[]
onOpen: (attachment: ImageAttachmentPart) => void
onRemove: (id: string) => void
removeLabel: string
}
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
return (
<Show when={props.attachments.length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={props.attachments}>
{(attachment) => (
<div class="relative group">
<Show
when={attachment.mime.startsWith("image/")}
fallback={
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
>
<img
src={attachment.dataUrl}
alt={attachment.filename}
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
onClick={() => props.onOpen(attachment)}
/>
</Show>
<button
type="button"
onClick={() => props.onRemove(attachment.id)}
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
aria-label={props.removeLabel}
>
<Icon name="close" class="size-3 text-text-weak" />
</button>
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div>
</div>
)}
</For>
</div>
</Show>
)
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test"
import { promptPlaceholder } from "./placeholder"
describe("promptPlaceholder", () => {
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
test("returns shell placeholder in shell mode", () => {
const value = promptPlaceholder({
mode: "shell",
commentCount: 0,
example: "example",
t,
})
expect(value).toBe("prompt.placeholder.shell")
})
test("returns summarize placeholders for comment context", () => {
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
"prompt.placeholder.summarizeComment",
)
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
"prompt.placeholder.summarizeComments",
)
})
test("returns default placeholder with example", () => {
const value = promptPlaceholder({
mode: "normal",
commentCount: 0,
example: "translated-example",
t,
})
expect(value).toBe("prompt.placeholder.normal:translated-example")
})
})

View File

@@ -0,0 +1,13 @@
type PromptPlaceholderInput = {
mode: "normal" | "shell"
commentCount: number
example: string
t: (key: string, params?: Record<string, string>) => string
}
export function promptPlaceholder(input: PromptPlaceholderInput) {
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
return input.t("prompt.placeholder.normal", { example: input.example })
}

View File

@@ -0,0 +1,144 @@
import { Component, For, Match, Show, Switch } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
export type AtOption =
| { type: "agent"; name: string; display: string }
| { type: "file"; path: string; display: string; recent?: boolean }
export interface SlashCommand {
id: string
trigger: string
title: string
description?: string
keybind?: string
type: "builtin" | "custom"
source?: "command" | "mcp" | "skill"
}
type PromptPopoverProps = {
popover: "at" | "slash" | null
setSlashPopoverRef: (el: HTMLDivElement) => void
atFlat: AtOption[]
atActive?: string
atKey: (item: AtOption) => string
setAtActive: (id: string) => void
onAtSelect: (item: AtOption) => void
slashFlat: SlashCommand[]
slashActive?: string
setSlashActive: (id: string) => void
onSlashSelect: (item: SlashCommand) => void
commandKeybind: (id: string) => string | undefined
t: (key: string) => string
}
export const PromptPopover: Component<PromptPopoverProps> = (props) => {
return (
<Show when={props.popover}>
<div
ref={(el) => {
if (props.popover === "slash") props.setSlashPopoverRef(el)
}}
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
onMouseDown={(e) => e.preventDefault()}
>
<Switch>
<Match when={props.popover === "at"}>
<Show
when={props.atFlat.length > 0}
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
>
<For each={props.atFlat.slice(0, 10)}>
{(item) => (
<button
classList={{
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
"bg-surface-raised-base-hover": props.atActive === props.atKey(item),
}}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(props.atKey(item))}
>
<Show
when={item.type === "agent"}
fallback={
<>
<FileIcon
node={{ path: item.type === "file" ? item.path : "", type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
{item.type === "file"
? item.path.endsWith("/")
? item.path
: getDirectory(item.path)
: ""}
</span>
<Show when={item.type === "file" && !item.path.endsWith("/")}>
<span class="text-text-strong whitespace-nowrap">
{item.type === "file" ? getFilename(item.path) : ""}
</span>
</Show>
</div>
</>
}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">
@{item.type === "agent" ? item.name : ""}
</span>
</Show>
</button>
)}
</For>
</Show>
</Match>
<Match when={props.popover === "slash"}>
<Show
when={props.slashFlat.length > 0}
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
>
<For each={props.slashFlat}>
{(cmd) => (
<button
data-slash-id={cmd.id}
classList={{
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
}}
onClick={() => props.onSlashSelect(cmd)}
onMouseEnter={() => props.setSlashActive(cmd.id)}
>
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
<Show when={cmd.description}>
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</span>
</Show>
<Show when={props.commandKeybind(cmd.id)}>
<span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
</Show>
</div>
</button>
)}
</For>
</Show>
</Match>
</Switch>
</div>
</Show>
)
}

View File

@@ -0,0 +1,411 @@
import { Accessor } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLocal } from "@/context/local"
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import type { FileSelection } from "@/context/file"
import { setCursorPosition } from "./editor-dom"
import { buildRequestParts } from "./build-request-parts"
type PendingPrompt = {
abort: AbortController
cleanup: VoidFunction
}
const pending = new Map<string, PendingPrompt>()
type PromptSubmitInput = {
info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number>
mode: Accessor<"normal" | "shell">
working: Accessor<boolean>
editor: () => HTMLDivElement | undefined
queueScroll: () => void
promptLength: (prompt: Prompt) => number
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
resetHistoryNavigation: () => void
setMode: (mode: "normal" | "shell") => void
setPopover: (popover: "at" | "slash" | null) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
onSubmit?: () => void
}
type CommentItem = {
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const platform = usePlatform()
const local = useLocal()
const prompt = usePrompt()
const layout = useLayout()
const language = useLanguage()
const params = useParams()
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
const abort = async () => {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
const queued = pending.get(sessionID)
if (queued) {
queued.abort.abort()
queued.cleanup()
pending.delete(sessionID)
return Promise.resolve()
}
return sdk.client.session
.abort({
sessionID,
})
.catch(() => {})
}
const restoreCommentItems = (items: CommentItem[]) => {
for (const item of items) {
prompt.context.add({
type: "file",
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})
}
}
const removeCommentItems = (items: { key: string }[]) => {
for (const item of items) {
prompt.context.remove(item.key)
}
}
const handleSubmit = async (event: Event) => {
event.preventDefault()
const currentPrompt = prompt.current()
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
const images = input.imageAttachments().slice()
const mode = input.mode()
if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
if (input.working()) abort()
return
}
const currentModel = local.model.current()
const currentAgent = local.agent.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
description: language.t("prompt.toast.modelAgentRequired.description"),
})
return
}
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
const projectDirectory = sdk.directory
const isNewSession = !params.id
const worktreeSelection = input.newSessionWorktree ?? "main"
let sessionDirectory = projectDirectory
let client = sdk.client
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await client.worktree
.create({ directory: projectDirectory })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
})
return undefined
})
if (!createdWorktree?.directory) {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: language.t("common.requestFailed"),
})
return
}
WorktreeState.pending(createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
if (sessionDirectory !== projectDirectory) {
client = createOpencodeClient({
baseUrl: sdk.url,
fetch: platform.fetch,
directory: sessionDirectory,
throwOnError: true,
})
globalSync.child(sessionDirectory)
}
input.onNewSessionWorktreeReset?.()
}
let session = input.info()
if (!session && isNewSession) {
session = await client.session
.create()
.then((x) => x.data ?? undefined)
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
})
return undefined
})
if (session) {
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
}
if (!session) return
input.onSubmit?.()
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const variant = local.model.variant.current()
const clearInput = () => {
prompt.reset()
input.setMode("normal")
input.setPopover(null)
}
const restoreInput = () => {
prompt.set(currentPrompt, input.promptLength(currentPrompt))
input.setMode(mode)
input.setPopover(null)
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll()
})
}
if (mode === "shell") {
clearInput()
client.session
.shell({
sessionID: session.id,
agent,
model,
command: text,
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
client.session
.command({
sessionID: session.id,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
}
}
const context = prompt.context.items().slice()
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const { requestParts, optimisticParts } = buildRequestParts({
prompt: currentPrompt,
context,
images,
text,
sessionID: session.id,
messageID,
sessionDirectory,
})
const optimisticMessage: Message = {
id: messageID,
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent,
model,
}
const addOptimisticMessage = () =>
sync.session.optimistic.add({
directory: sessionDirectory,
sessionID: session.id,
message: optimisticMessage,
parts: optimisticParts,
})
const removeOptimisticMessage = () =>
sync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
removeCommentItems(commentItems)
clearInput()
addOptimisticMessage()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
}
removeOptimisticMessage()
restoreCommentItems(commentItems)
restoreInput()
}
pending.set(session.id, { abort: controller, cleanup })
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
resolve({ status: "failed", message: "aborted" })
return
}
controller.signal.addEventListener(
"abort",
() => {
resolve({ status: "failed", message: "aborted" })
},
{ once: true },
)
})
const timeoutMs = 5 * 60 * 1000
const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => {
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
}, timeoutMs)
})
const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(session.id)
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
}
const send = async () => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.prompt({
sessionID: session.id,
agent,
model,
messageID,
parts: requestParts,
variant,
})
}
void send().catch((err) => {
pending.delete(session.id)
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
removeOptimisticMessage()
restoreCommentItems(commentItems)
restoreInput()
})
}
return {
abort,
handleSubmit,
}
}

View File

@@ -0,0 +1,77 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
url: string
status?: ServerHealth
class?: string
nameClass?: string
versionClass?: string
dimmed?: boolean
badge?: JSXElement
}
export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
setTruncated(nameTruncated || versionTruncated)
}
createEffect(() => {
props.url
props.status?.version
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(check)
return
}
check()
})
onMount(() => {
check()
if (typeof window === "undefined") return
window.addEventListener("resize", check)
onCleanup(() => window.removeEventListener("resize", check))
})
const tooltipValue = () => (
<span class="flex items-center gap-2">
<span>{serverDisplayName(props.url)}</span>
<Show when={props.status?.version}>
<span class="text-text-invert-base">{props.status?.version}</span>
</Show>
</span>
)
return (
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": props.status?.healthy === true,
"bg-icon-critical-base": props.status?.healthy === false,
"bg-border-weak-base": props.status === undefined,
}}
/>
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
{serverDisplayName(props.url)}
</span>
<Show when={props.status?.version}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
{props.status?.version}
</span>
</Show>
{props.badge}
{props.children}
</div>
</Tooltip>
)
}

View File

@@ -3,12 +3,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
import { findLast } from "@opencode-ai/util/array"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
interface SessionContextUsageProps { interface SessionContextUsageProps {
variant?: "button" | "indicator" variant?: "button" | "indicator"
@@ -34,26 +33,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}), }),
) )
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const context = createMemo(() => metrics().context)
const cost = createMemo(() => { const cost = createMemo(() => {
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) return usd().format(metrics().totalCost)
return usd().format(total)
})
const context = createMemo(() => {
const locale = language.locale()
const last = findLast(messages(), (x) => {
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
}) as AssistantMessage
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
return {
tokens: total.toLocaleString(locale),
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
}) })
const openContext = () => { const openContext = () => {
@@ -67,7 +50,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const circle = () => ( const circle = () => (
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} /> <ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
</div> </div>
) )
@@ -77,11 +60,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
{(ctx) => ( {(ctx) => (
<> <>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().tokens}</span> <span class="text-text-invert-strong">{ctx().total.toLocaleString(language.locale())}</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span> <span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span> <span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span> <span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
</div> </div>
</> </>

View File

@@ -0,0 +1,93 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContextMetrics } from "./session-context-metrics"
const assistant = (
id: string,
tokens: { input: number; output: number; reasoning: number; read: number; write: number },
cost: number,
providerID = "openai",
modelID = "gpt-4.1",
) => {
return {
id,
role: "assistant",
providerID,
modelID,
cost,
tokens: {
input: tokens.input,
output: tokens.output,
reasoning: tokens.reasoning,
cache: {
read: tokens.read,
write: tokens.write,
},
},
time: { created: 1 },
} as unknown as Message
}
const user = (id: string) => {
return {
id,
role: "user",
cost: 0,
time: { created: 1 },
} as unknown as Message
}
describe("getSessionContextMetrics", () => {
test("computes totals and usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
]
const providers = [
{
id: "openai",
name: "OpenAI",
models: {
"gpt-4.1": {
name: "GPT-4.1",
limit: { context: 1000 },
},
},
},
]
const metrics = getSessionContextMetrics(messages, providers)
expect(metrics.totalCost).toBe(1.75)
expect(metrics.context?.message.id).toBe("a2")
expect(metrics.context?.total).toBe(500)
expect(metrics.context?.usage).toBe(50)
expect(metrics.context?.providerLabel).toBe("OpenAI")
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
})
test("preserves fallback labels and null usage when model metadata is missing", () => {
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
const providers = [{ id: "p-1", models: {} }]
const metrics = getSessionContextMetrics(messages, providers)
expect(metrics.context?.providerLabel).toBe("p-1")
expect(metrics.context?.modelLabel).toBe("m-1")
expect(metrics.context?.limit).toBeUndefined()
expect(metrics.context?.usage).toBeNull()
})
test("memoizes by message and provider array identity", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }]
const one = getSessionContextMetrics(messages, providers)
const two = getSessionContextMetrics(messages, providers)
const three = getSessionContextMetrics([...messages], providers)
expect(two).toBe(one)
expect(three).not.toBe(one)
})
})

View File

@@ -0,0 +1,94 @@
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
type Provider = {
id: string
name?: string
models: Record<string, Model | undefined>
}
type Model = {
name?: string
limit: {
context: number
}
}
type Context = {
message: AssistantMessage
provider?: Provider
model?: Model
providerLabel: string
modelLabel: string
limit: number | undefined
input: number
output: number
reasoning: number
cacheRead: number
cacheWrite: number
total: number
usage: number | null
}
type Metrics = {
totalCost: number
context: Context | undefined
}
const cache = new WeakMap<Message[], WeakMap<Provider[], Metrics>>()
const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
}
const lastAssistantWithTokens = (messages: Message[]) => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
if (tokenTotal(msg) <= 0) continue
return msg
}
}
const build = (messages: Message[], providers: Provider[]): Metrics => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const message = lastAssistantWithTokens(messages)
if (!message) return { totalCost, context: undefined }
const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID]
const limit = model?.limit.context
const total = tokenTotal(message)
return {
totalCost,
context: {
message,
provider,
model,
providerLabel: provider?.name ?? message.providerID,
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
output: message.tokens.output,
reasoning: message.tokens.reasoning,
cacheRead: message.tokens.cache.read,
cacheWrite: message.tokens.cache.write,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
},
}
}
export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
const byProvider = cache.get(messages)
if (byProvider) {
const hit = byProvider.get(providers)
if (hit) return hit
}
const value = build(messages, providers)
const next = byProvider ?? new WeakMap<Provider[], Metrics>()
next.set(providers, value)
if (!byProvider) cache.set(messages, next)
return value
}

View File

@@ -11,8 +11,9 @@ import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code" import { Code } from "@opencode-ai/ui/code"
import { Markdown } from "@opencode-ai/ui/markdown" import { Markdown } from "@opencode-ai/ui/markdown"
import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "./session-context-metrics"
interface SessionContextTabProps { interface SessionContextTabProps {
messages: () => Message[] messages: () => Message[]
@@ -34,44 +35,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
}), }),
) )
const ctx = createMemo(() => { const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const last = findLast(props.messages(), (x) => { const ctx = createMemo(() => metrics().context)
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
}) as AssistantMessage
if (!last) return
const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
const model = provider?.models[last.modelID]
const limit = model?.limit.context
const input = last.tokens.input
const output = last.tokens.output
const reasoning = last.tokens.reasoning
const cacheRead = last.tokens.cache.read
const cacheWrite = last.tokens.cache.write
const total = input + output + reasoning + cacheRead + cacheWrite
const usage = limit ? Math.round((total / limit) * 100) : null
return {
message: last,
provider,
model,
limit,
input,
output,
reasoning,
cacheRead,
cacheWrite,
total,
usage,
}
})
const cost = createMemo(() => { const cost = createMemo(() => {
const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) return usd().format(metrics().totalCost)
return usd().format(total)
}) })
const counts = createMemo(() => { const counts = createMemo(() => {
@@ -114,14 +82,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
const providerLabel = createMemo(() => { const providerLabel = createMemo(() => {
const c = ctx() const c = ctx()
if (!c) return "—" if (!c) return "—"
return c.provider?.name ?? c.message.providerID return c.providerLabel
}) })
const modelLabel = createMemo(() => { const modelLabel = createMemo(() => {
const c = ctx() const c = ctx()
if (!c) return "—" if (!c) return "—"
if (c.model?.name) return c.model.name return c.modelLabel
return c.message.modelID
}) })
const breakdown = createMemo( const breakdown = createMemo(

View File

@@ -67,9 +67,39 @@ export function SessionHeader() {
"xcode", "xcode",
"android-studio", "android-studio",
"powershell", "powershell",
"sublime-text",
] as const ] as const
type OpenApp = (typeof OPEN_APPS)[number] type OpenApp = (typeof OPEN_APPS)[number]
const MAC_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const LINUX_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => { const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
if (platform.platform === "desktop" && platform.os) return platform.os if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown" if (typeof navigator !== "object") return "unknown"
@@ -80,38 +110,44 @@ export function SessionHeader() {
return "unknown" return "unknown"
}) })
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
createEffect(() => {
if (platform.platform !== "desktop") return
if (!platform.checkAppExists) return
const list = os()
const apps = list === "macos" ? MAC_APPS : list === "windows" ? WINDOWS_APPS : list === "linux" ? LINUX_APPS : []
if (apps.length === 0) return
void Promise.all(
apps.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith)).then((value) => {
const ok = Boolean(value)
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
return [app.id, ok] as const
}),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
})
const options = createMemo(() => { const options = createMemo(() => {
if (os() === "macos") { if (os() === "macos") {
return [ return [{ id: "finder", label: "Finder", icon: "finder" }, ...MAC_APPS.filter((app) => exists[app.id])] as const
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
{ id: "finder", label: "Finder", icon: "finder" },
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
] as const
} }
if (os() === "windows") { if (os() === "windows") {
return [ return [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, { id: "finder", label: "File Explorer", icon: "file-explorer" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, ...WINDOWS_APPS.filter((app) => exists[app.id]),
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "finder", label: "File Explorer", icon: "finder" },
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
] as const ] as const
} }
return [ return [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "finder", label: "File Manager", icon: "finder" }, { id: "finder", label: "File Manager", icon: "finder" },
...LINUX_APPS.filter((app) => exists[app.id]),
] as const ] as const
}) })
@@ -268,6 +304,7 @@ export function SessionHeader() {
<Portal mount={mount()}> <Portal mount={mount()}>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Show when={projectDirectory()}> <Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show <Show
when={canOpen()} when={canOpen()}
fallback={ fallback={
@@ -278,7 +315,9 @@ export function SessionHeader() {
aria-label={language.t("session.header.open.copyPath")} aria-label={language.t("session.header.open.copyPath")}
> >
<Icon name="copy" size="small" class="text-icon-base" /> <Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">{language.t("session.header.open.copyPath")}</span> <span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button> </Button>
} }
> >
@@ -336,6 +375,7 @@ export function SessionHeader() {
</DropdownMenu> </DropdownMenu>
</div> </div>
</Show> </Show>
</div>
</Show> </Show>
<StatusPopover /> <StatusPopover />
<Show when={showShare()}> <Show when={showShare()}>

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -7,30 +7,15 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { DialogSelectServer } from "./dialog-select-server" import { DialogSelectServer } from "./dialog-select-server"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { ServerRow } from "@/components/server/server-row"
type ServerStatus = { healthy: boolean; version?: string } import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal,
})
return sdk.global
.health()
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
.catch(() => ({ healthy: false }))
}
export function StatusPopover() { export function StatusPopover() {
const sync = useSync() const sync = useSync()
@@ -42,10 +27,11 @@ export function StatusPopover() {
const navigate = useNavigate() const navigate = useNavigate()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<string, ServerStatus | undefined>, status: {} as Record<string, ServerHealth | undefined>,
loading: null as string | null, loading: null as string | null,
defaultServerUrl: undefined as string | undefined, defaultServerUrl: undefined as string | undefined,
}) })
const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => { const servers = createMemo(() => {
const current = server.url const current = server.url
@@ -60,7 +46,7 @@ export function StatusPopover() {
if (!list.length) return list if (!list.length) return list
const active = server.url const active = server.url
const order = new Map(list.map((url, index) => [url, index] as const)) const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerStatus) => { const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0 if (value?.healthy === true) return 0
if (value?.healthy === false) return 2 if (value?.healthy === false) return 2
return 1 return 1
@@ -75,10 +61,10 @@ export function StatusPopover() {
}) })
async function refreshHealth() { async function refreshHealth() {
const results: Record<string, ServerStatus> = {} const results: Record<string, ServerHealth> = {}
await Promise.all( await Promise.all(
servers().map(async (url) => { servers().map(async (url) => {
results[url] = await checkHealth(url, platform) results[url] = await checkServerHealth(url, fetcher)
}), }),
) )
setStore("status", reconcile(results)) setStore("status", reconcile(results))
@@ -213,41 +199,12 @@ export function StatusPopover() {
const isDefault = () => url === store.defaultServerUrl const isDefault = () => url === store.defaultServerUrl
const status = () => store.status[url] const status = () => store.status[url]
const isBlocked = () => status()?.healthy === false const isBlocked = () => status()?.healthy === false
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
onMount(() => {
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
setTruncated(nameTruncated || versionTruncated)
}
check()
window.addEventListener("resize", check)
onCleanup(() => window.removeEventListener("resize", check))
})
const tooltipValue = () => {
const name = serverDisplayName(url)
const version = status()?.version
return (
<span class="flex items-center gap-2">
<span>{name}</span>
<Show when={version}>
<span class="text-text-invert-base">{version}</span>
</Show>
</span>
)
}
return ( return (
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
<button <button
type="button" type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left" class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{ classList={{
"opacity-50": isBlocked(),
"hover:bg-surface-raised-base-hover": !isBlocked(), "hover:bg-surface-raised-base-hover": !isBlocked(),
"cursor-not-allowed": isBlocked(), "cursor-not-allowed": isBlocked(),
}} }}
@@ -258,33 +215,27 @@ export function StatusPopover() {
navigate("/") navigate("/")
}} }}
> >
<div <ServerRow
classList={{ url={url}
"size-1.5 rounded-full shrink-0": true, status={status()}
"bg-icon-success-base": status()?.healthy === true, dimmed={isBlocked()}
"bg-icon-critical-base": status()?.healthy === false, class="flex items-center gap-2 w-full min-w-0"
"bg-border-weak-base": status() === undefined, nameClass="text-14-regular text-text-base truncate"
}} versionClass="text-12-regular text-text-weak truncate"
/> badge={
<span ref={nameRef} class="text-14-regular text-text-base truncate">
{serverDisplayName(url)}
</span>
<Show when={status()?.version}>
<span ref={versionRef} class="text-12-regular text-text-weak truncate">
{status()?.version}
</span>
</Show>
<Show when={isDefault()}> <Show when={isDefault()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")} {language.t("common.default")}
</span> </span>
</Show> </Show>
}
>
<div class="flex-1" /> <div class="flex-1" />
<Show when={isActive()}> <Show when={isActive()}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" /> <Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show> </Show>
</ServerRow>
</button> </button>
</Tooltip>
) )
}} }}
</For> </For>

View File

@@ -8,6 +8,7 @@ import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
export interface TerminalProps extends ComponentProps<"div"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
@@ -111,17 +112,13 @@ export const Terminal = (props: TerminalProps) => {
const colors = getTerminalColors() const colors = getTerminalColors()
setTerminalColors(colors) setTerminalColors(colors)
if (!term) return if (!term) return
const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption setOptionIfSupported(term, "theme", colors)
if (!setOption) return
setOption("theme", colors)
}) })
createEffect(() => { createEffect(() => {
const font = monoFontFamily(settings.appearance.font()) const font = monoFontFamily(settings.appearance.font())
if (!term) return if (!term) return
const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption setOptionIfSupported(term, "fontFamily", font)
if (!setOption) return
setOption("fontFamily", font)
}) })
const focusTerminal = () => { const focusTerminal = () => {
@@ -146,12 +143,12 @@ export const Terminal = (props: TerminalProps) => {
const t = term const t = term
if (!t) return if (!t) return
const link = (t as unknown as { currentHoveredLink?: { text: string } }).currentHoveredLink const text = getHoveredLinkText(t)
if (!link?.text) return if (!text) return
event.preventDefault() event.preventDefault()
event.stopImmediatePropagation() event.stopImmediatePropagation()
platform.openLink(link.text) platform.openLink(text)
} }
onMount(() => { onMount(() => {
@@ -250,7 +247,7 @@ export const Terminal = (props: TerminalProps) => {
const fit = new mod.FitAddon() const fit = new mod.FitAddon()
const serializer = new SerializeAddon() const serializer = new SerializeAddon()
cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.()) cleanups.push(() => disposeIfDisposable(fit))
t.loadAddon(serializer) t.loadAddon(serializer)
t.loadAddon(fit) t.loadAddon(fit)
fitAddon = fit fitAddon = fit
@@ -290,6 +287,27 @@ export const Terminal = (props: TerminalProps) => {
handleResize = () => fit.fit() handleResize = () => fit.fit()
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize)) cleanups.push(() => window.removeEventListener("resize", handleResize))
const limit = 16_384
const min = 32
const windowMs = 750
const seed = tail.length > limit ? tail.slice(-limit) : tail
let sync = seed.length >= min
let syncUntil = 0
const stopSync = () => {
sync = false
syncUntil = 0
}
const overlap = (data: string) => {
if (!seed) return 0
const max = Math.min(seed.length, data.length)
if (max < min) return 0
for (let i = max; i >= min; i--) {
if (seed.slice(-i) === data.slice(0, i)) return i
}
return 0
}
const onResize = t.onResize(async (size) => { const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty await sdk.client.pty
@@ -303,38 +321,27 @@ export const Terminal = (props: TerminalProps) => {
.catch(() => {}) .catch(() => {})
} }
}) })
cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.()) cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => { const onData = t.onData((data) => {
if (data) stopSync()
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
socket.send(data) socket.send(data)
} }
}) })
cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.()) cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => { const onKey = t.onKey((key) => {
if (key.key == "Enter") { if (key.key == "Enter") {
props.onSubmit?.() props.onSubmit?.()
} }
}) })
cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.()) cleanups.push(() => disposeIfDisposable(onKey))
// t.onScroll((ydisp) => { // t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp) // console.log("Scroll position:", ydisp)
// }) // })
const limit = 16_384
const seed = tail
let sync = !!seed
const overlap = (data: string) => {
if (!seed) return 0
const max = Math.min(seed.length, data.length)
for (let i = max; i > 0; i--) {
if (seed.slice(-i) === data.slice(0, i)) return i
}
return 0
}
const handleOpen = () => { const handleOpen = () => {
local.onConnect?.() local.onConnect?.()
if (sync) syncUntil = Date.now() + windowMs
sdk.client.pty sdk.client.pty
.update({ .update({
ptyID: local.pty.id, ptyID: local.pty.id,
@@ -349,18 +356,23 @@ export const Terminal = (props: TerminalProps) => {
cleanups.push(() => socket.removeEventListener("open", handleOpen)) cleanups.push(() => socket.removeEventListener("open", handleOpen))
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
if (disposed) return
const data = typeof event.data === "string" ? event.data : "" const data = typeof event.data === "string" ? event.data : ""
if (!data) return if (!data) return
const next = (() => { const next = (() => {
if (!sync) return data if (!sync) return data
if (syncUntil && Date.now() > syncUntil) {
stopSync()
return data
}
const n = overlap(data) const n = overlap(data)
if (!n) { if (!n) {
sync = false stopSync()
return data return data
} }
const trimmed = data.slice(n) const trimmed = data.slice(n)
if (trimmed) sync = false if (trimmed) stopSync()
return trimmed return trimmed
})() })()

View File

@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test"
import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history"
function history(): TitlebarHistory {
return { stack: [], index: 0, action: undefined }
}
describe("titlebar history", () => {
test("append and trim keeps max bounded", () => {
let state = history()
state = applyPath(state, "/", 3)
state = applyPath(state, "/a", 3)
state = applyPath(state, "/b", 3)
state = applyPath(state, "/c", 3)
expect(state.stack).toEqual(["/a", "/b", "/c"])
expect(state.stack.length).toBe(3)
expect(state.index).toBe(2)
})
test("back and forward indexes stay correct after trimming", () => {
let state = history()
state = applyPath(state, "/", 3)
state = applyPath(state, "/a", 3)
state = applyPath(state, "/b", 3)
state = applyPath(state, "/c", 3)
expect(state.stack).toEqual(["/a", "/b", "/c"])
expect(state.index).toBe(2)
const back = backPath(state)
expect(back?.to).toBe("/b")
expect(back?.state.index).toBe(1)
const afterBack = applyPath(back!.state, back!.to, 3)
expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
expect(afterBack.index).toBe(1)
const forward = forwardPath(afterBack)
expect(forward?.to).toBe("/c")
expect(forward?.state.index).toBe(2)
const afterForward = applyPath(forward!.state, forward!.to, 3)
expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
expect(afterForward.index).toBe(2)
})
test("action-driven navigation does not push duplicate history entries", () => {
const state: TitlebarHistory = {
stack: ["/", "/a", "/b"],
index: 2,
action: undefined,
}
const back = backPath(state)
expect(back?.to).toBe("/a")
const next = applyPath(back!.state, back!.to, 10)
expect(next.stack).toEqual(["/", "/a", "/b"])
expect(next.index).toBe(1)
expect(next.action).toBeUndefined()
})
})

View File

@@ -0,0 +1,57 @@
export const MAX_TITLEBAR_HISTORY = 100
export type TitlebarAction = "back" | "forward" | undefined
export type TitlebarHistory = {
stack: string[]
index: number
action: TitlebarAction
}
export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
if (!state.stack.length) {
const stack = current === "/" ? ["/"] : ["/", current]
return { stack, index: stack.length - 1, action: undefined }
}
const active = state.stack[state.index]
if (current === active) {
if (!state.action) return state
return { ...state, action: undefined }
}
if (state.action) return { ...state, action: undefined }
return pushPath(state, current, max)
}
export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
const stack = state.stack.slice(0, state.index + 1).concat(path)
const next = trimHistory(stack, stack.length - 1, max)
return { ...state, ...next, action: undefined }
}
export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) {
if (stack.length <= max) return { stack, index }
const cut = stack.length - max
return {
stack: stack.slice(cut),
index: Math.max(0, index - cut),
}
}
export function backPath(state: TitlebarHistory) {
if (state.index <= 0) return
const index = state.index - 1
const to = state.stack[index]
if (!to) return
return { state: { ...state, index, action: "back" as const }, to }
}
export function forwardPath(state: TitlebarHistory) {
if (state.index >= state.stack.length - 1) return
const index = state.index + 1
const to = state.stack[index]
if (!to) return
return { state: { ...state, index, action: "forward" as const }, to }
}

View File

@@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
export function Titlebar() { export function Titlebar() {
const layout = useLayout() const layout = useLayout()
@@ -39,25 +40,9 @@ export function Titlebar() {
const current = path() const current = path()
untrack(() => { untrack(() => {
if (!history.stack.length) { const next = applyPath(history, current)
const stack = current === "/" ? ["/"] : ["/", current] if (next === history) return
setHistory({ stack, index: stack.length - 1 }) setHistory(next)
return
}
const active = history.stack[history.index]
if (current === active) {
if (history.action) setHistory("action", undefined)
return
}
if (history.action) {
setHistory("action", undefined)
return
}
const next = history.stack.slice(0, history.index + 1).concat(current)
setHistory({ stack: next, index: next.length - 1 })
}) })
}) })
@@ -65,29 +50,47 @@ export function Titlebar() {
const canForward = createMemo(() => history.index < history.stack.length - 1) const canForward = createMemo(() => history.index < history.stack.length - 1)
const back = () => { const back = () => {
if (!canBack()) return const next = backPath(history)
const index = history.index - 1 if (!next) return
const to = history.stack[index] setHistory(next.state)
if (!to) return navigate(next.to)
setHistory({ index, action: "back" })
navigate(to)
} }
const forward = () => { const forward = () => {
if (!canForward()) return const next = forwardPath(history)
const index = history.index + 1 if (!next) return
const to = history.stack[index] setHistory(next.state)
if (!to) return navigate(next.to)
setHistory({ index, action: "forward" })
navigate(to)
} }
command.register(() => [
{
id: "common.goBack",
title: language.t("common.goBack"),
category: language.t("command.category.view"),
onSelect: back,
},
{
id: "common.goForward",
title: language.t("common.goForward"),
category: language.t("command.category.view"),
onSelect: forward,
},
])
const getWin = () => { const getWin = () => {
if (platform.platform !== "desktop") return if (platform.platform !== "desktop") return
const tauri = ( const tauri = (
window as unknown as { window as unknown as {
__TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise<void> } } } __TAURI__?: {
window?: {
getCurrentWindow?: () => {
startDragging?: () => Promise<void>
toggleMaximize?: () => Promise<void>
}
}
}
} }
).__TAURI__ ).__TAURI__
if (!tauri?.window?.getCurrentWindow) return if (!tauri?.window?.getCurrentWindow) return
@@ -133,17 +136,30 @@ export function Titlebar() {
void win.startDragging().catch(() => undefined) void win.startDragging().catch(() => undefined)
} }
const maximize = (e: MouseEvent) => {
if (platform.platform !== "desktop") return
if (interactive(e.target)) return
if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return
const win = getWin()
if (!win?.toggleMaximize) return
e.preventDefault()
void win.toggleMaximize().catch(() => undefined)
}
return ( return (
<header <header
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center" class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
style={{ "min-height": minHeight() }} style={{ "min-height": minHeight() }}
onMouseDown={drag}
onDblClick={maximize}
> >
<div <div
classList={{ classList={{
"flex items-center min-w-0": true, "flex items-center min-w-0": true,
"pl-2": !mac(), "pl-2": !mac(),
}} }}
onMouseDown={drag}
> >
<Show when={mac()}> <Show when={mac()}>
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} /> <div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { formatKeybind, matchKeybind, parseKeybind } from "./command"
describe("command keybind helpers", () => {
test("parseKeybind handles aliases and multiple combos", () => {
const keybinds = parseKeybind("control+option+k, mod+shift+comma")
expect(keybinds).toHaveLength(2)
expect(keybinds[0]).toEqual({
key: "k",
ctrl: true,
meta: false,
shift: false,
alt: true,
})
expect(keybinds[1]?.shift).toBe(true)
expect(keybinds[1]?.key).toBe("comma")
expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true)
})
test("parseKeybind treats none and empty as disabled", () => {
expect(parseKeybind("none")).toEqual([])
expect(parseKeybind("")).toEqual([])
})
test("matchKeybind normalizes punctuation keys", () => {
const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space")
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true)
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true)
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true)
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
})
test("formatKeybind returns human readable output", () => {
const display = formatKeybind("ctrl+alt+arrowup")
expect(display).toContain("↑")
expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true)
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
expect(formatKeybind("none")).toBe("")
})
})

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test"
import { upsertCommandRegistration } from "./command"
describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => {
const one = () => [{ id: "one", title: "One" }]
const two = () => [{ id: "two", title: "Two" }]
const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
expect(next).toHaveLength(1)
expect(next[0]?.options).toBe(two)
})
test("keeps unkeyed registrations additive", () => {
const one = () => [{ id: "one", title: "One" }]
const two = () => [{ id: "two", title: "Two" }]
const next = upsertCommandRegistration([{ options: one }], { options: two })
expect(next).toHaveLength(2)
expect(next[0]?.options).toBe(two)
expect(next[1]?.options).toBe(one)
})
})

View File

@@ -64,6 +64,16 @@ export type CommandCatalogItem = {
slash?: string slash?: string
} }
export type CommandRegistration = {
key?: string
options: Accessor<CommandOption[]>
}
export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
if (entry.key === undefined) return [entry, ...registrations]
return [entry, ...registrations.filter((x) => x.key !== entry.key)]
}
export function parseKeybind(config: string): Keybind[] { export function parseKeybind(config: string): Keybind[] {
if (!config || config === "none") return [] if (!config || config === "none") return []
@@ -166,9 +176,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const settings = useSettings() const settings = useSettings()
const language = useLanguage() const language = useLanguage()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
registrations: [] as Accessor<CommandOption[]>[], registrations: [] as CommandRegistration[],
suspendCount: 0, suspendCount: 0,
}) })
const warnedDuplicates = new Set<string>()
const [catalog, setCatalog, _, catalogReady] = persisted( const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"), Persist.global("command.catalog.v1"),
@@ -187,8 +198,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const all: CommandOption[] = [] const all: CommandOption[] = []
for (const reg of store.registrations) { for (const reg of store.registrations) {
for (const opt of reg()) { for (const opt of reg.options()) {
if (seen.has(opt.id)) continue if (seen.has(opt.id)) {
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
warnedDuplicates.add(opt.id)
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
}
continue
}
seen.add(opt.id) seen.add(opt.id)
all.push(opt) all.push(opt)
} }
@@ -296,14 +313,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
document.removeEventListener("keydown", handleKeyDown) document.removeEventListener("keydown", handleKeyDown)
}) })
return { function register(cb: () => CommandOption[]): void
register(cb: () => CommandOption[]) { function register(key: string, cb: () => CommandOption[]): void
const results = createMemo(cb) function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
setStore("registrations", (arr) => [results, ...arr]) const id = typeof key === "string" ? key : undefined
const next = typeof key === "function" ? key : cb
if (!next) return
const options = createMemo(next)
const entry: CommandRegistration = {
key: id,
options,
}
setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
onCleanup(() => { onCleanup(() => {
setStore("registrations", (arr) => arr.filter((x) => x !== results)) setStore("registrations", (arr) => arr.filter((x) => x !== entry))
}) })
}, }
return {
register,
trigger(id: string, source?: "palette" | "keybind" | "slash") { trigger(id: string, source?: "palette" | "keybind" | "slash") {
run(id, source) run(id, source)
}, },

View File

@@ -0,0 +1,111 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot } from "solid-js"
import type { LineComment } from "./comments"
let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useParams: () => ({}),
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./comments")
createCommentSessionForTest = mod.createCommentSessionForTest
})
function line(file: string, id: string, time: number): LineComment {
return {
id,
file,
comment: id,
time,
selection: { start: 1, end: 1 },
}
}
describe("comments session indexing", () => {
test("keeps file list behavior and aggregate chronological order", () => {
createRoot((dispose) => {
const now = Date.now()
const comments = createCommentSessionForTest({
"a.ts": [line("a.ts", "a-late", now + 20_000), line("a.ts", "a-early", now + 1_000)],
"b.ts": [line("b.ts", "b-mid", now + 10_000)],
})
expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a-late", "a-early"])
expect(comments.all().map((item) => item.id)).toEqual(["a-early", "b-mid", "a-late"])
const next = comments.add({
file: "b.ts",
comment: "next",
selection: { start: 2, end: 2 },
})
expect(comments.list("b.ts").at(-1)?.id).toBe(next.id)
expect(comments.all().map((item) => item.time)).toEqual(
comments
.all()
.map((item) => item.time)
.slice()
.sort((a, b) => a - b),
)
dispose()
})
})
test("remove updates file and aggregate indexes consistently", () => {
createRoot((dispose) => {
const comments = createCommentSessionForTest({
"a.ts": [line("a.ts", "a1", 10), line("a.ts", "shared", 20)],
"b.ts": [line("b.ts", "shared", 30)],
})
comments.setFocus({ file: "a.ts", id: "shared" })
comments.setActive({ file: "a.ts", id: "shared" })
comments.remove("a.ts", "shared")
expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a1"])
expect(
comments
.all()
.filter((item) => item.id === "shared")
.map((item) => item.file),
).toEqual(["b.ts"])
expect(comments.focus()).toBeNull()
expect(comments.active()).toEqual({ file: "a.ts", id: "shared" })
dispose()
})
})
test("clear resets file and aggregate indexes plus focus state", () => {
createRoot((dispose) => {
const comments = createCommentSessionForTest({
"a.ts": [line("a.ts", "a1", 10)],
})
const next = comments.add({
file: "b.ts",
comment: "next",
selection: { start: 2, end: 2 },
})
comments.setActive({ file: "b.ts", id: next.id })
comments.clear()
expect(comments.list("a.ts")).toEqual([])
expect(comments.list("b.ts")).toEqual([])
expect(comments.all()).toEqual([])
expect(comments.focus()).toBeNull()
expect(comments.active()).toBeNull()
dispose()
})
})
})

View File

@@ -1,8 +1,9 @@
import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { createScopedCache } from "@/utils/scoped-cache"
import type { SelectedLineRange } from "@/context/file" import type { SelectedLineRange } from "@/context/file"
export type LineComment = { export type LineComment = {
@@ -18,28 +19,28 @@ type CommentFocus = { file: string; id: string }
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"
const MAX_COMMENT_SESSIONS = 20 const MAX_COMMENT_SESSIONS = 20
type CommentSession = ReturnType<typeof createCommentSession> type CommentStore = {
comments: Record<string, LineComment[]>
type CommentCacheEntry = {
value: CommentSession
dispose: VoidFunction
} }
function createCommentSession(dir: string, id: string | undefined) { function aggregate(comments: Record<string, LineComment[]>) {
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1` return Object.keys(comments)
.flatMap((file) => comments[file] ?? [])
.slice()
.sort((a, b) => a.time - b.time)
}
const [store, setStore, _, ready] = persisted( function insert(items: LineComment[], next: LineComment) {
Persist.scoped(dir, id, "comments", [legacy]), const index = items.findIndex((item) => item.time > next.time)
createStore<{ if (index < 0) return [...items, next]
comments: Record<string, LineComment[]> return [...items.slice(0, index), next, ...items.slice(index)]
}>({ }
comments: {},
}),
)
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({ const [state, setState] = createStore({
focus: null as CommentFocus | null, focus: null as CommentFocus | null,
active: null as CommentFocus | null, active: null as CommentFocus | null,
all: aggregate(store.comments),
}) })
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
@@ -59,6 +60,7 @@ function createCommentSession(dir: string, id: string | undefined) {
batch(() => { batch(() => {
setStore("comments", input.file, (items) => [...(items ?? []), next]) setStore("comments", input.file, (items) => [...(items ?? []), next])
setState("all", (items) => insert(items, next))
setFocus({ file: input.file, id: next.id }) setFocus({ file: input.file, id: next.id })
}) })
@@ -66,37 +68,72 @@ function createCommentSession(dir: string, id: string | undefined) {
} }
const remove = (file: string, id: string) => { const remove = (file: string, id: string) => {
setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id)) batch(() => {
setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
setFocus((current) => (current?.id === id ? null : current)) setFocus((current) => (current?.id === id ? null : current))
})
} }
const clear = () => { const clear = () => {
batch(() => { batch(() => {
setStore("comments", {}) setStore("comments", reconcile({}))
setState("all", [])
setFocus(null) setFocus(null)
setActive(null) setActive(null)
}) })
} }
const all = createMemo(() => { return {
const files = Object.keys(store.comments) list,
const items = files.flatMap((file) => store.comments[file] ?? []) all: () => state.all,
return items.slice().sort((a, b) => a.time - b.time) add,
remove,
clear,
focus: () => state.focus,
setFocus,
clearFocus: () => setFocus(null),
active: () => state.active,
setActive,
clearActive: () => setActive(null),
reindex: () => setState("all", aggregate(store.comments)),
}
}
export function createCommentSessionForTest(comments: Record<string, LineComment[]> = {}) {
const [store, setStore] = createStore<CommentStore>({ comments })
return createCommentSessionState(store, setStore)
}
function createCommentSession(dir: string, id: string | undefined) {
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "comments", [legacy]),
createStore<CommentStore>({
comments: {},
}),
)
const session = createCommentSessionState(store, setStore)
createEffect(() => {
if (!ready()) return
session.reindex()
}) })
return { return {
ready, ready,
list, list: session.list,
all, all: session.all,
add, add: session.add,
remove, remove: session.remove,
clear, clear: session.clear,
focus: createMemo(() => state.focus), focus: session.focus,
setFocus, setFocus: session.setFocus,
clearFocus: () => setFocus(null), clearFocus: session.clearFocus,
active: createMemo(() => state.active), active: session.active,
setActive, setActive: session.setActive,
clearActive: () => setActive(null), clearActive: session.clearActive,
} }
} }
@@ -105,44 +142,27 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const cache = new Map<string, CommentCacheEntry>() const cache = createScopedCache(
(key) => {
const disposeAll = () => { const split = key.lastIndexOf("\n")
for (const entry of cache.values()) { const dir = split >= 0 ? key.slice(0, split) : key
entry.dispose() const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
} return createRoot((dispose) => ({
cache.clear() value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
}
onCleanup(disposeAll)
const prune = () => {
while (cache.size > MAX_COMMENT_SESSIONS) {
const first = cache.keys().next().value
if (!first) return
const entry = cache.get(first)
entry?.dispose()
cache.delete(first)
}
}
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createCommentSession(dir, id),
dispose, dispose,
})) }))
},
{
maxEntries: MAX_COMMENT_SESSIONS,
dispose: (entry) => entry.dispose(),
},
)
cache.set(key, entry) onCleanup(() => cache.clear())
prune()
return entry.value const load = (dir: string, id: string | undefined) => {
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
return cache.get(key).value
} }
const session = createMemo(() => load(params.dir!, params.id)) const session = createMemo(() => load(params.dir!, params.id))

View File

@@ -0,0 +1,65 @@
import { afterEach, describe, expect, test } from "bun:test"
import {
evictContentLru,
getFileContentBytesTotal,
getFileContentEntryCount,
removeFileContentBytes,
resetFileContentLru,
setFileContentBytes,
touchFileContent,
} from "./file/content-cache"
describe("file content eviction accounting", () => {
afterEach(() => {
resetFileContentLru()
})
test("updates byte totals incrementally for set, overwrite, remove, and reset", () => {
setFileContentBytes("a", 10)
setFileContentBytes("b", 15)
expect(getFileContentBytesTotal()).toBe(25)
expect(getFileContentEntryCount()).toBe(2)
setFileContentBytes("a", 5)
expect(getFileContentBytesTotal()).toBe(20)
expect(getFileContentEntryCount()).toBe(2)
touchFileContent("a")
expect(getFileContentBytesTotal()).toBe(20)
removeFileContentBytes("b")
expect(getFileContentBytesTotal()).toBe(5)
expect(getFileContentEntryCount()).toBe(1)
resetFileContentLru()
expect(getFileContentBytesTotal()).toBe(0)
expect(getFileContentEntryCount()).toBe(0)
})
test("evicts by entry cap using LRU order", () => {
for (const i of Array.from({ length: 41 }, (_, n) => n)) {
setFileContentBytes(`f-${i}`, 1)
}
const evicted: string[] = []
evictContentLru(undefined, (path) => evicted.push(path))
expect(evicted).toEqual(["f-0"])
expect(getFileContentEntryCount()).toBe(40)
expect(getFileContentBytesTotal()).toBe(40)
})
test("evicts by byte cap while preserving protected entries", () => {
const chunk = 8 * 1024 * 1024
setFileContentBytes("a", chunk)
setFileContentBytes("b", chunk)
setFileContentBytes("c", chunk)
const evicted: string[] = []
evictContentLru(new Set(["a"]), (path) => evicted.push(path))
expect(evicted).toEqual(["b"])
expect(getFileContentEntryCount()).toBe(2)
expect(getFileContentBytesTotal()).toBe(chunk * 2)
})
})

View File

@@ -1,269 +1,45 @@
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js" import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist" import { createPathHelpers } from "./file/path"
import {
approxBytes,
evictContentLru,
getFileContentBytesTotal,
getFileContentEntryCount,
hasFileContent,
removeFileContentBytes,
resetFileContentLru,
setFileContentBytes,
touchFileContent,
} from "./file/content-cache"
import { createFileViewCache } from "./file/view-cache"
import { createFileTreeStore } from "./file/tree-store"
import { invalidateFromWatcher } from "./file/watcher"
import {
selectionFromLines,
type FileState,
type FileSelection,
type FileViewState,
type SelectedLineRange,
} from "./file/types"
export type FileSelection = { export type { FileSelection, SelectedLineRange, FileViewState, FileState }
startLine: number export { selectionFromLines }
startChar: number export {
endLine: number evictContentLru,
endChar: number getFileContentBytesTotal,
} getFileContentEntryCount,
removeFileContentBytes,
export type SelectedLineRange = { resetFileContentLru,
start: number setFileContentBytes,
end: number touchFileContent,
side?: "additions" | "deletions"
endSide?: "additions" | "deletions"
}
export type FileViewState = {
scrollTop?: number
scrollLeft?: number
selectedLines?: SelectedLineRange | null
}
export type FileState = {
path: string
name: string
loaded?: boolean
loading?: boolean
error?: string
content?: FileContent
}
type DirectoryState = {
expanded: boolean
loaded?: boolean
loading?: boolean
error?: string
children?: string[]
}
function stripFileProtocol(input: string) {
if (!input.startsWith("file://")) return input
return input.slice("file://".length)
}
function stripQueryAndHash(input: string) {
const hashIndex = input.indexOf("#")
const queryIndex = input.indexOf("?")
if (hashIndex !== -1 && queryIndex !== -1) {
return input.slice(0, Math.min(hashIndex, queryIndex))
}
if (hashIndex !== -1) return input.slice(0, hashIndex)
if (queryIndex !== -1) return input.slice(0, queryIndex)
return input
}
function unquoteGitPath(input: string) {
if (!input.startsWith('"')) return input
if (!input.endsWith('"')) return input
const body = input.slice(1, -1)
const bytes: number[] = []
for (let i = 0; i < body.length; i++) {
const char = body[i]!
if (char !== "\\") {
bytes.push(char.charCodeAt(0))
continue
}
const next = body[i + 1]
if (!next) {
bytes.push("\\".charCodeAt(0))
continue
}
if (next >= "0" && next <= "7") {
const chunk = body.slice(i + 1, i + 4)
const match = chunk.match(/^[0-7]{1,3}/)
if (!match) {
bytes.push(next.charCodeAt(0))
i++
continue
}
bytes.push(parseInt(match[0], 8))
i += match[0].length
continue
}
const escaped =
next === "n"
? "\n"
: next === "r"
? "\r"
: next === "t"
? "\t"
: next === "b"
? "\b"
: next === "f"
? "\f"
: next === "v"
? "\v"
: next === "\\" || next === '"'
? next
: undefined
bytes.push((escaped ?? next).charCodeAt(0))
i++
}
return new TextDecoder().decode(new Uint8Array(bytes))
}
export function selectionFromLines(range: SelectedLineRange): FileSelection {
const startLine = Math.min(range.start, range.end)
const endLine = Math.max(range.start, range.end)
return {
startLine,
endLine,
startChar: 0,
endChar: 0,
}
}
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
if (range.start <= range.end) return range
const startSide = range.side
const endSide = range.endSide ?? startSide
return {
...range,
start: range.end,
end: range.start,
side: endSide,
endSide: startSide !== endSide ? startSide : undefined,
}
}
const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
const MAX_FILE_CONTENT_ENTRIES = 40
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
const contentLru = new Map<string, number>()
function approxBytes(content: FileContent) {
const patchBytes =
content.patch?.hunks.reduce((total, hunk) => {
return total + hunk.lines.reduce((sum, line) => sum + line.length, 0)
}, 0) ?? 0
return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
}
function touchContent(path: string, bytes?: number) {
const prev = contentLru.get(path)
if (prev === undefined && bytes === undefined) return
const value = bytes ?? prev ?? 0
contentLru.delete(path)
contentLru.set(path, value)
}
type ViewSession = ReturnType<typeof createViewSession>
type ViewCacheEntry = {
value: ViewSession
dispose: VoidFunction
}
function createViewSession(dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted(
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
const meta = { pruned: false }
const pruneView = (keep?: string) => {
const keys = Object.keys(view.file)
if (keys.length <= MAX_VIEW_FILES) return
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
if (drop.length === 0) return
setView(
produce((draft) => {
for (const key of drop) {
delete draft.file[key]
}
}),
)
}
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
pruneView()
})
const scrollTop = (path: string) => view.file[path]?.scrollTop
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
const selectedLines = (path: string) => view.file[path]?.selectedLines
const setScrollTop = (path: string, top: number) => {
setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
pruneView(path)
}
const setScrollLeft = (path: string, left: number) => {
setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
pruneView(path)
}
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
pruneView(path)
}
return {
ready,
scrollTop,
scrollLeft,
selectedLines,
setScrollTop,
setScrollLeft,
setSelectedLines,
}
} }
export const { use: useFile, provider: FileProvider } = createSimpleContext({ export const { use: useFile, provider: FileProvider } = createSimpleContext({
@@ -271,170 +47,75 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
gate: false, gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() useSync()
const params = useParams() const params = useParams()
const language = useLanguage() const language = useLanguage()
const scope = createMemo(() => sdk.directory) const scope = createMemo(() => sdk.directory)
const path = createPathHelpers(scope)
function normalize(input: string) {
const root = scope()
const prefix = root.endsWith("/") ? root : root + "/"
let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
if (path.startsWith(prefix)) {
path = path.slice(prefix.length)
}
if (path.startsWith(root)) {
path = path.slice(root.length)
}
if (path.startsWith("./")) {
path = path.slice(2)
}
if (path.startsWith("/")) {
path = path.slice(1)
}
return path
}
function tab(input: string) {
const path = normalize(input)
return `file://${path}`
}
function pathFromTab(tabValue: string) {
if (!tabValue.startsWith("file://")) return
return normalize(tabValue)
}
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const treeInflight = new Map<string, Promise<void>>()
const search = (query: string, dirs: "true" | "false") =>
sdk.client.find.files({ query, dirs }).then(
(x) => (x.data ?? []).map(normalize),
() => [],
)
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
file: Record<string, FileState> file: Record<string, FileState>
}>({ }>({
file: {}, file: {},
}) })
const [tree, setTree] = createStore<{ const tree = createFileTreeStore({
node: Record<string, FileNode> scope,
dir: Record<string, DirectoryState> normalizeDir: path.normalizeDir,
}>({ list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
node: {}, onError: (message) => {
dir: { "": { expanded: true } }, showToast({
variant: "error",
title: language.t("toast.file.listFailed.title"),
description: message,
})
},
}) })
const evictContent = (keep?: Set<string>) => { const evictContent = (keep?: Set<string>) => {
const protectedSet = keep ?? new Set<string>() evictContentLru(keep, (target) => {
const total = () => { if (!store.file[target]) return
return Array.from(contentLru.values()).reduce((sum, bytes) => sum + bytes, 0)
}
while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || total() > MAX_FILE_CONTENT_BYTES) {
const path = contentLru.keys().next().value
if (!path) return
if (protectedSet.has(path)) {
touchContent(path)
if (contentLru.size <= protectedSet.size) return
continue
}
contentLru.delete(path)
if (!store.file[path]) continue
setStore( setStore(
"file", "file",
path, target,
produce((draft) => { produce((draft) => {
draft.content = undefined draft.content = undefined
draft.loaded = false draft.loaded = false
}), }),
) )
} })
} }
createEffect(() => { createEffect(() => {
scope() scope()
inflight.clear() inflight.clear()
treeInflight.clear() resetFileContentLru()
contentLru.clear()
batch(() => { batch(() => {
setStore("file", reconcile({})) setStore("file", reconcile({}))
setTree("node", reconcile({})) tree.reset()
setTree("dir", reconcile({}))
setTree("dir", "", { expanded: true })
}) })
}) })
const viewCache = new Map<string, ViewCacheEntry>() const viewCache = createFileViewCache()
const view = createMemo(() => viewCache.load(scope(), params.id))
const disposeViews = () => { const ensure = (file: string) => {
for (const entry of viewCache.values()) { if (!file) return
entry.dispose() if (store.file[file]) return
} setStore("file", file, { path: file, name: getFilename(file) })
viewCache.clear()
} }
const pruneViews = () => { const load = (input: string, options?: { force?: boolean }) => {
while (viewCache.size > MAX_FILE_VIEW_SESSIONS) { const file = path.normalize(input)
const first = viewCache.keys().next().value if (!file) return Promise.resolve()
if (!first) return
const entry = viewCache.get(first)
entry?.dispose()
viewCache.delete(first)
}
}
const loadView = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = viewCache.get(key)
if (existing) {
viewCache.delete(key)
viewCache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createViewSession(dir, id),
dispose,
}))
viewCache.set(key, entry)
pruneViews()
return entry.value
}
const view = createMemo(() => loadView(scope(), params.id))
function ensure(path: string) {
if (!path) return
if (store.file[path]) return
setStore("file", path, { path, name: getFilename(path) })
}
function load(input: string, options?: { force?: boolean }) {
const path = normalize(input)
if (!path) return Promise.resolve()
const directory = scope() const directory = scope()
const key = `${directory}\n${path}` const key = `${directory}\n${file}`
const client = sdk.client ensure(file)
ensure(path) const current = store.file[file]
const current = store.file[path]
if (!options?.force && current?.loaded) return Promise.resolve() if (!options?.force && current?.loaded) return Promise.resolve()
const pending = inflight.get(key) const pending = inflight.get(key)
@@ -442,21 +123,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setStore( setStore(
"file", "file",
path, file,
produce((draft) => { produce((draft) => {
draft.loading = true draft.loading = true
draft.error = undefined draft.error = undefined
}), }),
) )
const promise = client.file const promise = sdk.client.file
.read({ path }) .read({ path: file })
.then((x) => { .then((x) => {
if (scope() !== directory) return if (scope() !== directory) return
const content = x.data const content = x.data
setStore( setStore(
"file", "file",
path, file,
produce((draft) => { produce((draft) => {
draft.loaded = true draft.loaded = true
draft.loading = false draft.loading = false
@@ -465,14 +146,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
) )
if (!content) return if (!content) return
touchContent(path, approxBytes(content)) touchFileContent(file, approxBytes(content))
evictContent(new Set([path])) evictContent(new Set([file]))
}) })
.catch((e) => { .catch((e) => {
if (scope() !== directory) return if (scope() !== directory) return
setStore( setStore(
"file", "file",
path, file,
produce((draft) => { produce((draft) => {
draft.loading = false draft.loading = false
draft.error = e.message draft.error = e.message
@@ -492,225 +173,79 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise return promise
} }
function normalizeDir(input: string) { const search = (query: string, dirs: "true" | "false") =>
return normalize(input).replace(/\/+$/, "") sdk.client.find.files({ query, dirs }).then(
} (x) => (x.data ?? []).map(path.normalize),
() => [],
function ensureDir(path: string) {
if (tree.dir[path]) return
setTree("dir", path, { expanded: false })
}
function listDir(input: string, options?: { force?: boolean }) {
const dir = normalizeDir(input)
ensureDir(dir)
const current = tree.dir[dir]
if (!options?.force && current?.loaded) return Promise.resolve()
const pending = treeInflight.get(dir)
if (pending) return pending
setTree(
"dir",
dir,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
) )
const directory = scope()
const promise = sdk.client.file
.list({ path: dir })
.then((x) => {
if (scope() !== directory) return
const nodes = x.data ?? []
const prevChildren = tree.dir[dir]?.children ?? []
const nextChildren = nodes.map((node) => node.path)
const nextSet = new Set(nextChildren)
setTree(
"node",
produce((draft) => {
const removedDirs: string[] = []
for (const child of prevChildren) {
if (nextSet.has(child)) continue
const existing = draft[child]
if (existing?.type === "directory") removedDirs.push(child)
delete draft[child]
}
if (removedDirs.length > 0) {
const keys = Object.keys(draft)
for (const key of keys) {
for (const removed of removedDirs) {
if (!key.startsWith(removed + "/")) continue
delete draft[key]
break
}
}
}
for (const node of nodes) {
draft[node.path] = node
}
}),
)
setTree(
"dir",
dir,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.children = nextChildren
}),
)
})
.catch((e) => {
if (scope() !== directory) return
setTree(
"dir",
dir,
produce((draft) => {
draft.loading = false
draft.error = e.message
}),
)
showToast({
variant: "error",
title: language.t("toast.file.listFailed.title"),
description: e.message,
})
})
.finally(() => {
treeInflight.delete(dir)
})
treeInflight.set(dir, promise)
return promise
}
function expandDir(input: string) {
const dir = normalizeDir(input)
ensureDir(dir)
setTree("dir", dir, "expanded", true)
void listDir(dir)
}
function collapseDir(input: string) {
const dir = normalizeDir(input)
ensureDir(dir)
setTree("dir", dir, "expanded", false)
}
function dirState(input: string) {
const dir = normalizeDir(input)
return tree.dir[dir]
}
function children(input: string) {
const dir = normalizeDir(input)
const ids = tree.dir[dir]?.children
if (!ids) return []
const out: FileNode[] = []
for (const id of ids) {
const node = tree.node[id]
if (node) out.push(node)
}
return out
}
const stop = sdk.event.listen((e) => { const stop = sdk.event.listen((e) => {
const event = e.details invalidateFromWatcher(e.details, {
if (event.type !== "file.watcher.updated") return normalize: path.normalize,
const path = normalize(event.properties.file) hasFile: (file) => Boolean(store.file[file]),
if (!path) return loadFile: (file) => {
if (path.startsWith(".git/")) return void load(file, { force: true })
},
if (store.file[path]) { node: tree.node,
load(path, { force: true }) isDirLoaded: tree.isLoaded,
} refreshDir: (dir) => {
void tree.listDir(dir, { force: true })
const kind = event.properties.event },
if (kind === "change") { })
const dir = (() => {
if (path === "") return ""
const node = tree.node[path]
if (node?.type !== "directory") return
return path
})()
if (dir === undefined) return
if (!tree.dir[dir]?.loaded) return
listDir(dir, { force: true })
return
}
if (kind !== "add" && kind !== "unlink") return
const parent = path.split("/").slice(0, -1).join("/")
if (!tree.dir[parent]?.loaded) return
listDir(parent, { force: true })
}) })
const get = (input: string) => { const get = (input: string) => {
const path = normalize(input) const file = path.normalize(input)
const file = store.file[path] const state = store.file[file]
const content = file?.content const content = state?.content
if (!content) return file if (!content) return state
if (contentLru.has(path)) { if (hasFileContent(file)) {
touchContent(path) touchFileContent(file)
return file return state
} }
touchContent(path, approxBytes(content)) touchFileContent(file, approxBytes(content))
return file return state
} }
const scrollTop = (input: string) => view().scrollTop(normalize(input)) const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
const scrollLeft = (input: string) => view().scrollLeft(normalize(input)) const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
const selectedLines = (input: string) => view().selectedLines(normalize(input)) const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
const setScrollTop = (input: string, top: number) => { const setScrollTop = (input: string, top: number) => {
const path = normalize(input) view().setScrollTop(path.normalize(input), top)
view().setScrollTop(path, top)
} }
const setScrollLeft = (input: string, left: number) => { const setScrollLeft = (input: string, left: number) => {
const path = normalize(input) view().setScrollLeft(path.normalize(input), left)
view().setScrollLeft(path, left)
} }
const setSelectedLines = (input: string, range: SelectedLineRange | null) => { const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
const path = normalize(input) view().setSelectedLines(path.normalize(input), range)
view().setSelectedLines(path, range)
} }
onCleanup(() => { onCleanup(() => {
stop() stop()
disposeViews() viewCache.clear()
}) })
return { return {
ready: () => view().ready(), ready: () => view().ready(),
normalize, normalize: path.normalize,
tab, tab: path.tab,
pathFromTab, pathFromTab: path.pathFromTab,
tree: { tree: {
list: listDir, list: tree.listDir,
refresh: (input: string) => listDir(input, { force: true }), refresh: (input: string) => tree.listDir(input, { force: true }),
state: dirState, state: tree.dirState,
children, children: tree.children,
expand: expandDir, expand: tree.expandDir,
collapse: collapseDir, collapse: tree.collapseDir,
toggle(input: string) { toggle(input: string) {
if (dirState(input)?.expanded) { if (tree.dirState(input)?.expanded) {
collapseDir(input) tree.collapseDir(input)
return return
} }
expandDir(input) tree.expandDir(input)
}, },
}, },
get, get,

View File

@@ -0,0 +1,88 @@
import type { FileContent } from "@opencode-ai/sdk/v2"
const MAX_FILE_CONTENT_ENTRIES = 40
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
const lru = new Map<string, number>()
let total = 0
export function approxBytes(content: FileContent) {
const patchBytes =
content.patch?.hunks.reduce((sum, hunk) => {
return sum + hunk.lines.reduce((lineSum, line) => lineSum + line.length, 0)
}, 0) ?? 0
return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
}
function setBytes(path: string, nextBytes: number) {
const prev = lru.get(path)
if (prev !== undefined) total -= prev
lru.delete(path)
lru.set(path, nextBytes)
total += nextBytes
}
function touch(path: string, bytes?: number) {
const prev = lru.get(path)
if (prev === undefined && bytes === undefined) return
setBytes(path, bytes ?? prev ?? 0)
}
function remove(path: string) {
const prev = lru.get(path)
if (prev === undefined) return
lru.delete(path)
total -= prev
}
function reset() {
lru.clear()
total = 0
}
export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
const set = keep ?? new Set<string>()
while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) {
const path = lru.keys().next().value
if (!path) return
if (set.has(path)) {
touch(path)
if (lru.size <= set.size) return
continue
}
remove(path)
evict(path)
}
}
export function resetFileContentLru() {
reset()
}
export function setFileContentBytes(path: string, bytes: number) {
setBytes(path, bytes)
}
export function removeFileContentBytes(path: string) {
remove(path)
}
export function touchFileContent(path: string, bytes?: number) {
touch(path, bytes)
}
export function getFileContentBytesTotal() {
return total
}
export function getFileContentEntryCount() {
return lru.size
}
export function hasFileContent(path: string) {
return lru.has(path)
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import { createPathHelpers, stripQueryAndHash, unquoteGitPath } from "./path"
describe("file path helpers", () => {
test("normalizes file inputs against workspace root", () => {
const path = createPathHelpers(() => "/repo")
expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts")
expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts")
expect(path.normalize("./src/app.ts")).toBe("src/app.ts")
expect(path.normalizeDir("src/components///")).toBe("src/components")
expect(path.tab("src/app.ts")).toBe("file://src/app.ts")
expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts")
expect(path.pathFromTab("other://src/app.ts")).toBeUndefined()
})
test("keeps query/hash stripping behavior stable", () => {
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts")
})
test("unquotes git escaped octal path strings", () => {
expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt")
expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname")
expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts")
})
})

View File

@@ -0,0 +1,134 @@
export function stripFileProtocol(input: string) {
if (!input.startsWith("file://")) return input
return input.slice("file://".length)
}
export function stripQueryAndHash(input: string) {
const hashIndex = input.indexOf("#")
const queryIndex = input.indexOf("?")
if (hashIndex !== -1 && queryIndex !== -1) {
return input.slice(0, Math.min(hashIndex, queryIndex))
}
if (hashIndex !== -1) return input.slice(0, hashIndex)
if (queryIndex !== -1) return input.slice(0, queryIndex)
return input
}
export function unquoteGitPath(input: string) {
if (!input.startsWith('"')) return input
if (!input.endsWith('"')) return input
const body = input.slice(1, -1)
const bytes: number[] = []
for (let i = 0; i < body.length; i++) {
const char = body[i]!
if (char !== "\\") {
bytes.push(char.charCodeAt(0))
continue
}
const next = body[i + 1]
if (!next) {
bytes.push("\\".charCodeAt(0))
continue
}
if (next >= "0" && next <= "7") {
const chunk = body.slice(i + 1, i + 4)
const match = chunk.match(/^[0-7]{1,3}/)
if (!match) {
bytes.push(next.charCodeAt(0))
i++
continue
}
bytes.push(parseInt(match[0], 8))
i += match[0].length
continue
}
const escaped =
next === "n"
? "\n"
: next === "r"
? "\r"
: next === "t"
? "\t"
: next === "b"
? "\b"
: next === "f"
? "\f"
: next === "v"
? "\v"
: next === "\\" || next === '"'
? next
: undefined
bytes.push((escaped ?? next).charCodeAt(0))
i++
}
return new TextDecoder().decode(new Uint8Array(bytes))
}
export function decodeFilePath(input: string) {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
export function encodeFilePath(filepath: string): string {
return filepath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
}
export function createPathHelpers(scope: () => string) {
const normalize = (input: string) => {
const root = scope()
const prefix = root.endsWith("/") ? root : root + "/"
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
if (path.startsWith(prefix)) {
path = path.slice(prefix.length)
}
if (path.startsWith(root)) {
path = path.slice(root.length)
}
if (path.startsWith("./")) {
path = path.slice(2)
}
if (path.startsWith("/")) {
path = path.slice(1)
}
return path
}
const tab = (input: string) => {
const path = normalize(input)
return `file://${encodeFilePath(path)}`
}
const pathFromTab = (tabValue: string) => {
if (!tabValue.startsWith("file://")) return
return normalize(tabValue)
}
const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
return {
normalize,
tab,
pathFromTab,
normalizeDir,
}
}

View File

@@ -0,0 +1,170 @@
import { createStore, produce, reconcile } from "solid-js/store"
import type { FileNode } from "@opencode-ai/sdk/v2"
type DirectoryState = {
expanded: boolean
loaded?: boolean
loading?: boolean
error?: string
children?: string[]
}
type TreeStoreOptions = {
scope: () => string
normalizeDir: (input: string) => string
list: (input: string) => Promise<FileNode[]>
onError: (message: string) => void
}
export function createFileTreeStore(options: TreeStoreOptions) {
const [tree, setTree] = createStore<{
node: Record<string, FileNode>
dir: Record<string, DirectoryState>
}>({
node: {},
dir: { "": { expanded: true } },
})
const inflight = new Map<string, Promise<void>>()
const reset = () => {
inflight.clear()
setTree("node", reconcile({}))
setTree("dir", reconcile({}))
setTree("dir", "", { expanded: true })
}
const ensureDir = (path: string) => {
if (tree.dir[path]) return
setTree("dir", path, { expanded: false })
}
const listDir = (input: string, opts?: { force?: boolean }) => {
const dir = options.normalizeDir(input)
ensureDir(dir)
const current = tree.dir[dir]
if (!opts?.force && current?.loaded) return Promise.resolve()
const pending = inflight.get(dir)
if (pending) return pending
setTree(
"dir",
dir,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
const directory = options.scope()
const promise = options
.list(dir)
.then((nodes) => {
if (options.scope() !== directory) return
const prevChildren = tree.dir[dir]?.children ?? []
const nextChildren = nodes.map((node) => node.path)
const nextSet = new Set(nextChildren)
setTree(
"node",
produce((draft) => {
const removedDirs: string[] = []
for (const child of prevChildren) {
if (nextSet.has(child)) continue
const existing = draft[child]
if (existing?.type === "directory") removedDirs.push(child)
delete draft[child]
}
if (removedDirs.length > 0) {
const keys = Object.keys(draft)
for (const key of keys) {
for (const removed of removedDirs) {
if (!key.startsWith(removed + "/")) continue
delete draft[key]
break
}
}
}
for (const node of nodes) {
draft[node.path] = node
}
}),
)
setTree(
"dir",
dir,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.children = nextChildren
}),
)
})
.catch((e) => {
if (options.scope() !== directory) return
setTree(
"dir",
dir,
produce((draft) => {
draft.loading = false
draft.error = e.message
}),
)
options.onError(e.message)
})
.finally(() => {
inflight.delete(dir)
})
inflight.set(dir, promise)
return promise
}
const expandDir = (input: string) => {
const dir = options.normalizeDir(input)
ensureDir(dir)
setTree("dir", dir, "expanded", true)
void listDir(dir)
}
const collapseDir = (input: string) => {
const dir = options.normalizeDir(input)
ensureDir(dir)
setTree("dir", dir, "expanded", false)
}
const dirState = (input: string) => {
const dir = options.normalizeDir(input)
return tree.dir[dir]
}
const children = (input: string) => {
const dir = options.normalizeDir(input)
const ids = tree.dir[dir]?.children
if (!ids) return []
const out: FileNode[] = []
for (const id of ids) {
const node = tree.node[id]
if (node) out.push(node)
}
return out
}
return {
listDir,
expandDir,
collapseDir,
dirState,
children,
node: (path: string) => tree.node[path],
isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded),
reset,
}
}

View File

@@ -0,0 +1,41 @@
import type { FileContent } from "@opencode-ai/sdk/v2"
export type FileSelection = {
startLine: number
startChar: number
endLine: number
endChar: number
}
export type SelectedLineRange = {
start: number
end: number
side?: "additions" | "deletions"
endSide?: "additions" | "deletions"
}
export type FileViewState = {
scrollTop?: number
scrollLeft?: number
selectedLines?: SelectedLineRange | null
}
export type FileState = {
path: string
name: string
loaded?: boolean
loading?: boolean
error?: string
content?: FileContent
}
export function selectionFromLines(range: SelectedLineRange): FileSelection {
const startLine = Math.min(range.start, range.end)
const endLine = Math.max(range.start, range.end)
return {
startLine,
endLine,
startChar: 0,
endChar: 0,
}
}

View File

@@ -0,0 +1,136 @@
import { createEffect, createRoot } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { createScopedCache } from "@/utils/scoped-cache"
import type { FileViewState, SelectedLineRange } from "./types"
const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
if (range.start <= range.end) return range
const startSide = range.side
const endSide = range.endSide ?? startSide
return {
...range,
start: range.end,
end: range.start,
side: endSide,
endSide: startSide !== endSide ? startSide : undefined,
}
}
function createViewSession(dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted(
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
const meta = { pruned: false }
const pruneView = (keep?: string) => {
const keys = Object.keys(view.file)
if (keys.length <= MAX_VIEW_FILES) return
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
if (drop.length === 0) return
setView(
produce((draft) => {
for (const key of drop) {
delete draft.file[key]
}
}),
)
}
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
pruneView()
})
const scrollTop = (path: string) => view.file[path]?.scrollTop
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
const selectedLines = (path: string) => view.file[path]?.selectedLines
const setScrollTop = (path: string, top: number) => {
setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
pruneView(path)
}
const setScrollLeft = (path: string, left: number) => {
setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
pruneView(path)
}
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
pruneView(path)
}
return {
ready,
scrollTop,
scrollLeft,
selectedLines,
setScrollTop,
setScrollLeft,
setSelectedLines,
}
}
export function createFileViewCache() {
const cache = createScopedCache(
(key) => {
const split = key.lastIndexOf("\n")
const dir = split >= 0 ? key.slice(0, split) : key
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
return createRoot((dispose) => ({
value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
dispose,
}))
},
{
maxEntries: MAX_FILE_VIEW_SESSIONS,
dispose: (entry) => entry.dispose(),
},
)
return {
load: (dir: string, id: string | undefined) => {
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
return cache.get(key).value
},
clear: () => cache.clear(),
}
}

View File

@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test"
import { invalidateFromWatcher } from "./watcher"
describe("file watcher invalidation", () => {
test("reloads open files and refreshes loaded parent on add", () => {
const loads: string[] = []
const refresh: string[] = []
invalidateFromWatcher(
{
type: "file.watcher.updated",
properties: {
file: "src/new.ts",
event: "add",
},
},
{
normalize: (input) => input,
hasFile: (path) => path === "src/new.ts",
loadFile: (path) => loads.push(path),
node: () => undefined,
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
},
)
expect(loads).toEqual(["src/new.ts"])
expect(refresh).toEqual(["src"])
})
test("refreshes only changed loaded directory nodes", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "file.watcher.updated",
properties: {
file: "src",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(
{
type: "file.watcher.updated",
properties: {
file: "src/file.ts",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({
path: "src/file.ts",
type: "file",
name: "file.ts",
absolute: "/repo/src/file.ts",
ignored: false,
}),
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
expect(refresh).toEqual(["src"])
})
test("ignores invalid or git watcher updates", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "file.watcher.updated",
properties: {
file: ".git/index.lock",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => true,
loadFile: () => {
throw new Error("should not load")
},
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(
{
type: "project.updated",
properties: {},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
expect(refresh).toEqual([])
})
})

View File

@@ -0,0 +1,52 @@
import type { FileNode } from "@opencode-ai/sdk/v2"
type WatcherEvent = {
type: string
properties: unknown
}
type WatcherOps = {
normalize: (input: string) => string
hasFile: (path: string) => boolean
loadFile: (path: string) => void
node: (path: string) => FileNode | undefined
isDirLoaded: (path: string) => boolean
refreshDir: (path: string) => void
}
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "file.watcher.updated") return
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
const kind = typeof props?.event === "string" ? props.event : undefined
if (!rawPath) return
if (!kind) return
const path = ops.normalize(rawPath)
if (!path) return
if (path.startsWith(".git/")) return
if (ops.hasFile(path)) {
ops.loadFile(path)
}
if (kind === "change") {
const dir = (() => {
if (path === "") return ""
const node = ops.node(path)
if (node?.type !== "directory") return
return path
})()
if (dir === undefined) return
if (!ops.isDirLoaded(dir)) return
ops.refreshDir(dir)
return
}
if (kind !== "add" && kind !== "unlink") return
const parent = path.split("/").slice(0, -1).join("/")
if (!ops.isDirLoaded(parent)) return
ops.refreshDir(parent)
}

View File

@@ -0,0 +1,136 @@
import { describe, expect, test } from "bun:test"
import {
canDisposeDirectory,
estimateRootSessionTotal,
loadRootSessionsWithFallback,
pickDirectoriesToEvict,
} from "./global-sync"
describe("pickDirectoriesToEvict", () => {
test("keeps pinned stores and evicts idle stores", () => {
const now = 5_000
const picks = pickDirectoriesToEvict({
stores: ["a", "b", "c", "d"],
state: new Map([
["a", { lastAccessAt: 1_000 }],
["b", { lastAccessAt: 4_900 }],
["c", { lastAccessAt: 4_800 }],
["d", { lastAccessAt: 3_000 }],
]),
pins: new Set(["a"]),
max: 2,
ttl: 1_500,
now,
})
expect(picks).toEqual(["d", "c"])
})
})
describe("loadRootSessionsWithFallback", () => {
test("uses limited roots query when supported", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({
directory: "dir",
limit: 10,
list: async (query) => {
calls.push(query)
return { data: [] }
},
onFallback: () => {
fallback += 1
},
})
expect(result.data).toEqual([])
expect(result.limited).toBe(true)
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
expect(fallback).toBe(0)
})
test("falls back to full roots query on limited-query failure", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({
directory: "dir",
limit: 25,
list: async (query) => {
calls.push(query)
if (query.limit) throw new Error("unsupported")
return { data: [] }
},
onFallback: () => {
fallback += 1
},
})
expect(result.data).toEqual([])
expect(result.limited).toBe(false)
expect(calls).toEqual([
{ directory: "dir", roots: true, limit: 25 },
{ directory: "dir", roots: true },
])
expect(fallback).toBe(1)
})
})
describe("estimateRootSessionTotal", () => {
test("keeps exact total for full fetches", () => {
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
})
test("marks has-more for full-limit limited fetches", () => {
expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11)
})
test("keeps exact total when limited fetch is under limit", () => {
expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9)
})
})
describe("canDisposeDirectory", () => {
test("rejects pinned or inflight directories", () => {
expect(
canDisposeDirectory({
directory: "dir",
hasStore: true,
pinned: true,
booting: false,
loadingSessions: false,
}),
).toBe(false)
expect(
canDisposeDirectory({
directory: "dir",
hasStore: true,
pinned: false,
booting: true,
loadingSessions: false,
}),
).toBe(false)
expect(
canDisposeDirectory({
directory: "dir",
hasStore: true,
pinned: false,
booting: false,
loadingSessions: true,
}),
).toBe(false)
})
test("accepts idle unpinned directory store", () => {
expect(
canDisposeDirectory({
directory: "dir",
hasStore: true,
pinned: false,
booting: false,
loadingSessions: false,
}),
).toBe(true)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
import {
type Config,
type Path,
type PermissionRequest,
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
type QuestionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { retry } from "@opencode-ai/util/retry"
import { getFilename } from "@opencode-ai/util/path"
import { showToast } from "@opencode-ai/ui/toast"
import { cmp, normalizeProviderList } from "./utils"
import type { State, VcsCache } from "./types"
type GlobalStore = {
ready: boolean
path: Path
project: Project[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
}
export async function bootstrapGlobal(input: {
globalSDK: ReturnType<typeof createOpencodeClient>
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string
setGlobalStore: SetStoreFunction<GlobalStore>
}) {
const health = await input.globalSDK.global
.health()
.then((x) => x.data)
.catch(() => undefined)
if (!health?.healthy) {
showToast({
variant: "error",
title: input.connectErrorTitle,
description: input.connectErrorDescription,
})
input.setGlobalStore("ready", true)
return
}
const tasks = [
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
retry(() =>
input.globalSDK.provider.auth().then((x) => {
input.setGlobalStore("provider_auth", x.data ?? {})
}),
),
]
const results = await Promise.allSettled(tasks)
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
if (errors.length) {
const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
showToast({
variant: "error",
title: input.requestFailedTitle,
description: message + more,
})
}
input.setGlobalStore("ready", true)
}
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
return input.reduce<Record<string, T[]>>((acc, item) => {
if (!item?.id || !item.sessionID) return acc
const list = acc[item.sessionID]
if (list) list.push(item)
if (!list) acc[item.sessionID] = [item]
return acc
}, {})
}
export async function bootstrapDirectory(input: {
directory: string
sdk: ReturnType<typeof createOpencodeClient>
store: Store<State>
setStore: SetStoreFunction<State>
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
}) {
input.setStore("status", "loading")
const blockingRequests = {
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
provider: () =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
}
try {
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
} catch (err) {
console.error("Failed to bootstrap instance", err)
const project = getFilename(input.directory)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: `Failed to reload ${project}`, description: message })
input.setStore("status", "partial")
return
}
if (input.store.status !== "complete") input.setStore("status", "partial")
Promise.all([
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
input.loadSessions(input.directory),
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
input.setStore("status", "complete")
})
}

View File

@@ -0,0 +1,263 @@
import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
import {
DIR_IDLE_TTL_MS,
MAX_DIR_STORES,
type ChildOptions,
type DirState,
type IconCache,
type MetaCache,
type ProjectMeta,
type State,
type VcsCache,
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
export function createChildStoreManager(input: {
owner: Owner
markStats: (activeDirectoryStores: number) => void
incrementEvictions: () => void
isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
onDispose: (directory: string) => void
}) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
const vcsCache = new Map<string, VcsCache>()
const metaCache = new Map<string, MetaCache>()
const iconCache = new Map<string, IconCache>()
const lifecycle = new Map<string, DirState>()
const pins = new Map<string, number>()
const ownerPins = new WeakMap<object, Set<string>>()
const disposers = new Map<string, () => void>()
const mark = (directory: string) => {
if (!directory) return
lifecycle.set(directory, { lastAccessAt: Date.now() })
runEviction()
}
const pin = (directory: string) => {
if (!directory) return
pins.set(directory, (pins.get(directory) ?? 0) + 1)
mark(directory)
}
const unpin = (directory: string) => {
if (!directory) return
const next = (pins.get(directory) ?? 0) - 1
if (next > 0) {
pins.set(directory, next)
return
}
pins.delete(directory)
runEviction()
}
const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
const pinForOwner = (directory: string) => {
const current = getOwner()
if (!current) return
if (current === input.owner) return
const key = current as object
const set = ownerPins.get(key)
if (set?.has(directory)) return
if (set) set.add(directory)
if (!set) ownerPins.set(key, new Set([directory]))
pin(directory)
onCleanup(() => {
const set = ownerPins.get(key)
if (set) {
set.delete(directory)
if (set.size === 0) ownerPins.delete(key)
}
unpin(directory)
})
}
function disposeDirectory(directory: string) {
if (
!canDisposeDirectory({
directory,
hasStore: !!children[directory],
pinned: pinned(directory),
booting: input.isBooting(directory),
loadingSessions: input.isLoadingSessions(directory),
})
) {
return false
}
vcsCache.delete(directory)
metaCache.delete(directory)
iconCache.delete(directory)
lifecycle.delete(directory)
const dispose = disposers.get(directory)
if (dispose) {
dispose()
disposers.delete(directory)
}
delete children[directory]
input.onDispose(directory)
input.markStats(Object.keys(children).length)
return true
}
function runEviction() {
const stores = Object.keys(children)
if (stores.length === 0) return
const list = pickDirectoriesToEvict({
stores,
state: lifecycle,
pins: new Set(stores.filter(pinned)),
max: MAX_DIR_STORES,
ttl: DIR_IDLE_TTL_MS,
now: Date.now(),
})
if (list.length === 0) return
for (const directory of list) {
if (!disposeDirectory(directory)) continue
input.incrementEvictions()
}
}
function ensureChild(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
const vcs = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as VcsInfo | undefined }),
),
)
if (!vcs) throw new Error("Failed to create persisted cache")
const vcsStore = vcs[0]
const vcsReady = vcs[3]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
const meta = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "project", ["project.v1"]),
createStore({ value: undefined as ProjectMeta | undefined }),
),
)
if (!meta) throw new Error("Failed to create persisted project metadata")
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "icon", ["icon.v1"]),
createStore({ value: undefined as string | undefined }),
),
)
if (!icon) throw new Error("Failed to create persisted project icon")
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
const init = () =>
createRoot((dispose) => {
const child = createStore<State>({
project: "",
projectMeta: meta[0].value,
icon: icon[0].value,
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
status: "loading" as const,
agent: [],
command: [],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: vcsStore.value,
limit: 5,
message: {},
part: {},
})
children[directory] = child
disposers.set(directory, dispose)
createEffect(() => {
if (!vcsReady()) return
const cached = vcsStore.value
if (!cached?.branch) return
child[1]("vcs", (value) => value ?? cached)
})
createEffect(() => {
child[1]("projectMeta", meta[0].value)
})
createEffect(() => {
child[1]("icon", icon[0].value)
})
})
runWithOwner(input.owner, init)
input.markStats(Object.keys(children).length)
}
mark(directory)
const childStore = children[directory]
if (!childStore) throw new Error("Failed to create store")
return childStore
}
function child(directory: string, options: ChildOptions = {}) {
const childStore = ensureChild(directory)
pinForOwner(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
return childStore
}
function projectMeta(directory: string, patch: ProjectMeta) {
const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory)
if (!cached) return
const previous = store.projectMeta ?? {}
const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
const next = {
...previous,
...patch,
icon,
commands,
}
cached.setStore("value", next)
setStore("projectMeta", next)
}
function projectIcon(directory: string, value: string | undefined) {
const [store, setStore] = ensureChild(directory)
const cached = iconCache.get(directory)
if (!cached) return
if (store.icon === value) return
cached.setStore("value", value)
setStore("icon", value)
}
return {
children,
ensureChild,
child,
projectMeta,
projectIcon,
mark,
pin,
unpin,
pinned,
disposeDirectory,
runEviction,
vcsCache,
metaCache,
iconCache,
}
}

View File

@@ -0,0 +1,201 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
({
id: input.id,
parentID: input.parentID,
time: {
created: 1,
updated: 1,
archived: input.archived,
},
}) as Session
const userMessage = (id: string, sessionID: string) =>
({
id,
sessionID,
role: "user",
time: { created: 1 },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
}) as Message
const textPart = (id: string, sessionID: string, messageID: string) =>
({
id,
sessionID,
messageID,
type: "text",
text: id,
}) as Part
const baseState = (input: Partial<State> = {}) =>
({
status: "complete",
agent: [],
command: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider: {} as State["provider"],
config: {} as State["config"],
path: { directory: "/tmp" } as State["path"],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: undefined,
limit: 10,
message: {},
part: {},
...input,
}) as State
describe("applyGlobalEvent", () => {
test("upserts project.updated in sorted position", () => {
const project = [{ id: "a" }, { id: "c" }] as Project[]
let refreshCount = 0
applyGlobalEvent({
event: { type: "project.updated", properties: { id: "b" } },
project,
refresh: () => {
refreshCount += 1
},
setGlobalProject(next) {
if (typeof next === "function") next(project)
},
})
expect(project.map((x) => x.id)).toEqual(["a", "b", "c"])
expect(refreshCount).toBe(0)
})
test("handles global.disposed by triggering refresh", () => {
let refreshCount = 0
applyGlobalEvent({
event: { type: "global.disposed" },
project: [],
refresh: () => {
refreshCount += 1
},
setGlobalProject() {},
})
expect(refreshCount).toBe(1)
})
})
describe("applyDirectoryEvent", () => {
test("inserts root sessions in sorted order and updates sessionTotal", () => {
const [store, setStore] = createStore(
baseState({
session: [rootSession({ id: "b" })],
sessionTotal: 1,
}),
)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session.map((x) => x.id)).toEqual(["a", "b"])
expect(store.sessionTotal).toBe(2)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.sessionTotal).toBe(2)
})
test("cleans session caches when archived", () => {
const message = userMessage("msg_1", "ses_1")
const [store, setStore] = createStore(
baseState({
session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })],
sessionTotal: 2,
message: { ses_1: [message] },
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
session_diff: { ses_1: [] },
todo: { ses_1: [] },
permission: { ses_1: [] },
question: { ses_1: [] },
session_status: { ses_1: { type: "busy" } },
}),
)
applyDirectoryEvent({
event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
expect(store.sessionTotal).toBe(1)
expect(store.message.ses_1).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
})
test("routes disposal and lsp events to side-effect handlers", () => {
const [store, setStore] = createStore(baseState())
const pushes: string[] = []
let lspLoads = 0
applyDirectoryEvent({
event: { type: "server.instance.disposed" },
store,
setStore,
push(directory) {
pushes.push(directory)
},
directory: "/tmp",
loadLsp() {
lspLoads += 1
},
})
applyDirectoryEvent({
event: { type: "lsp.updated" },
store,
setStore,
push(directory) {
pushes.push(directory)
},
directory: "/tmp",
loadLsp() {
lspLoads += 1
},
})
expect(pushes).toEqual(["/tmp"])
expect(lspLoads).toBe(1)
})
})

View File

@@ -0,0 +1,337 @@
import { Binary } from "@opencode-ai/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type {
FileDiff,
Message,
Part,
PermissionRequest,
Project,
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed") {
input.refresh()
return
}
if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id)
if (result.found) {
input.setGlobalProject((draft) => {
draft[result.index] = { ...draft[result.index], ...properties }
})
return
}
input.setGlobalProject((draft) => {
draft.splice(result.index, 0, properties)
})
}
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
if (!sessionID) return
const hasAny =
store.message[sessionID] !== undefined ||
store.session_diff[sessionID] !== undefined ||
store.todo[sessionID] !== undefined ||
store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined
if (!hasAny) return
setStore(
produce((draft) => {
const messages = draft.message[sessionID]
if (messages) {
for (const message of messages) {
const id = message?.id
if (!id) continue
delete draft.part[id]
}
}
delete draft.message[sessionID]
delete draft.session_diff[sessionID]
delete draft.todo[sessionID]
delete draft.permission[sessionID]
delete draft.question[sessionID]
delete draft.session_status[sessionID]
}),
)
}
export function applyDirectoryEvent(input: {
event: { type: string; properties?: unknown }
store: Store<State>
setStore: SetStoreFunction<State>
push: (directory: string) => void
directory: string
loadLsp: () => void
vcsCache?: VcsCache
}) {
const event = input.event
switch (event.type) {
case "server.instance.disposed": {
input.push(input.directory)
return
}
case "session.created": {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) {
input.setStore("session", result.index, reconcile(info))
break
}
const next = input.store.session.slice()
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
break
}
case "session.updated": {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) {
if (result.found) {
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
if (result.found) {
input.setStore("session", result.index, reconcile(info))
break
}
const next = input.store.session.slice()
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
break
}
case "session.deleted": {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) {
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
break
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
break
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
input.setStore("session_status", props.sessionID, reconcile(props.status))
break
}
case "message.updated": {
const info = (event.properties as { info: Message }).info
const messages = input.store.message[info.sessionID]
if (!messages) {
input.setStore("message", info.sessionID, [info])
break
}
const result = Binary.search(messages, info.id, (m) => m.id)
if (result.found) {
input.setStore("message", info.sessionID, result.index, reconcile(info))
break
}
input.setStore(
"message",
info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, info)
}),
)
break
}
case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string }
input.setStore(
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const result = Binary.search(messages, props.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[props.messageID]
}),
)
break
}
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
const parts = input.store.part[part.messageID]
if (!parts) {
input.setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) {
input.setStore("part", part.messageID, result.index, reconcile(part))
break
}
input.setStore(
"part",
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, part)
}),
)
break
}
case "message.part.removed": {
const props = event.properties as { messageID: string; partID: string }
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
if (result.found) {
input.setStore(
produce((draft) => {
const list = draft.part[props.messageID]
if (!list) return
const next = Binary.search(list, props.partID, (p) => p.id)
if (!next.found) return
list.splice(next.index, 1)
if (list.length === 0) delete draft.part[props.messageID]
}),
)
}
break
}
case "message.part.delta": {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break
input.setStore(
"part",
props.messageID,
produce((draft) => {
const part = draft[result.index]
const field = props.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + props.delta
}),
)
break
}
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
const next = { branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
break
}
case "permission.asked": {
const permission = event.properties as PermissionRequest
const permissions = input.store.permission[permission.sessionID]
if (!permissions) {
input.setStore("permission", permission.sessionID, [permission])
break
}
const result = Binary.search(permissions, permission.id, (p) => p.id)
if (result.found) {
input.setStore("permission", permission.sessionID, result.index, reconcile(permission))
break
}
input.setStore(
"permission",
permission.sessionID,
produce((draft) => {
draft.splice(result.index, 0, permission)
}),
)
break
}
case "permission.replied": {
const props = event.properties as { sessionID: string; requestID: string }
const permissions = input.store.permission[props.sessionID]
if (!permissions) break
const result = Binary.search(permissions, props.requestID, (p) => p.id)
if (!result.found) break
input.setStore(
"permission",
props.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "question.asked": {
const question = event.properties as QuestionRequest
const questions = input.store.question[question.sessionID]
if (!questions) {
input.setStore("question", question.sessionID, [question])
break
}
const result = Binary.search(questions, question.id, (q) => q.id)
if (result.found) {
input.setStore("question", question.sessionID, result.index, reconcile(question))
break
}
input.setStore(
"question",
question.sessionID,
produce((draft) => {
draft.splice(result.index, 0, question)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
const questions = input.store.question[props.sessionID]
if (!questions) break
const result = Binary.search(questions, props.requestID, (q) => q.id)
if (!result.found) break
input.setStore(
"question",
props.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
input.loadLsp()
break
}
}
}

View File

@@ -0,0 +1,28 @@
import type { DisposeCheck, EvictPlan } from "./types"
export function pickDirectoriesToEvict(input: EvictPlan) {
const overflow = Math.max(0, input.stores.length - input.max)
let pendingOverflow = overflow
const sorted = input.stores
.filter((dir) => !input.pins.has(dir))
.slice()
.sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
const output: string[] = []
for (const dir of sorted) {
const last = input.state.get(dir)?.lastAccessAt ?? 0
const idle = input.now - last >= input.ttl
if (!idle && pendingOverflow <= 0) continue
output.push(dir)
if (pendingOverflow > 0) pendingOverflow -= 1
}
return output
}
export function canDisposeDirectory(input: DisposeCheck) {
if (!input.directory) return false
if (!input.hasStore) return false
if (input.pinned) return false
if (input.booting) return false
if (input.loadingSessions) return false
return true
}

View File

@@ -0,0 +1,83 @@
type QueueInput = {
paused: () => boolean
bootstrap: () => Promise<void>
bootstrapInstance: (directory: string) => Promise<void> | void
}
export function createRefreshQueue(input: QueueInput) {
const queued = new Set<string>()
let root = false
let running = false
let timer: ReturnType<typeof setTimeout> | undefined
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
const take = (count: number) => {
if (queued.size === 0) return [] as string[]
const items: string[] = []
for (const item of queued) {
queued.delete(item)
items.push(item)
if (items.length >= count) break
}
return items
}
const schedule = () => {
if (timer) return
timer = setTimeout(() => {
timer = undefined
void drain()
}, 0)
}
const push = (directory: string) => {
if (!directory) return
queued.add(directory)
if (input.paused()) return
schedule()
}
const refresh = () => {
root = true
if (input.paused()) return
schedule()
}
async function drain() {
if (running) return
running = true
try {
while (true) {
if (input.paused()) return
if (root) {
root = false
await input.bootstrap()
await tick()
continue
}
const dirs = take(2)
if (dirs.length === 0) return
await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir)))
await tick()
}
} finally {
running = false
if (input.paused()) return
if (root || queued.size) schedule()
}
}
return {
push,
refresh,
clear(directory: string) {
queued.delete(directory)
},
dispose() {
if (!timer) return
clearTimeout(timer)
timer = undefined
},
}
}

View File

@@ -0,0 +1,26 @@
import type { RootLoadArgs } from "./types"
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
try {
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
return {
data: result.data,
limit: input.limit,
limited: true,
} as const
} catch {
input.onFallback()
const result = await input.list({ directory: input.directory, roots: true })
return {
data: result.data,
limit: input.limit,
limited: false,
} as const
}
}
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
if (!input.limited) return input.count
if (input.count < input.limit) return input.count
return input.count + 1
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
({
id: input.id,
parentID: input.parentID,
time: {
created: input.created,
updated: input.updated,
archived: input.archived,
},
}) as Session
describe("trimSessions", () => {
test("keeps base roots and recent roots beyond the limit", () => {
const now = 1_000_000
const list = [
session({ id: "a", created: now - 100_000 }),
session({ id: "b", created: now - 90_000 }),
session({ id: "c", created: now - 80_000 }),
session({ id: "d", created: now - 70_000, updated: now - 1_000 }),
session({ id: "e", created: now - 60_000, archived: now - 10 }),
]
const result = trimSessions(list, { limit: 2, permission: {}, now })
expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"])
})
test("keeps children when root is kept, permission exists, or child is recent", () => {
const now = 1_000_000
const list = [
session({ id: "root-1", created: now - 1000 }),
session({ id: "root-2", created: now - 2000 }),
session({ id: "z-root", created: now - 30_000_000 }),
session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }),
session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }),
session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }),
session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }),
]
const result = trimSessions(list, {
limit: 2,
permission: {
"child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
},
now,
})
expect(result.map((x) => x.id)).toEqual([
"child-kept-by-permission",
"child-kept-by-recency",
"child-kept-by-root",
"root-1",
"root-2",
])
})
})

View File

@@ -0,0 +1,56 @@
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
export function sessionUpdatedAt(session: Session) {
return session.time.updated ?? session.time.created
}
export function compareSessionRecent(a: Session, b: Session) {
const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated
return cmp(a.id, b.id)
}
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
if (limit <= 0) return [] as Session[]
const selected: Session[] = []
const seen = new Set<string>()
for (const session of sessions) {
if (!session?.id) continue
if (seen.has(session.id)) continue
seen.add(session.id)
if (sessionUpdatedAt(session) <= cutoff) continue
const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
if (index === -1) selected.push(session)
if (index !== -1) selected.splice(index, 0, session)
if (selected.length > limit) selected.pop()
}
return selected
}
export function trimSessions(
input: Session[],
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
) {
const limit = Math.max(0, options.limit)
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
const all = input
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => cmp(a.id, b.id))
const roots = all.filter((s) => !s.parentID)
const children = all.filter((s) => !!s.parentID)
const base = roots.slice(0, limit)
const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
const keepRoots = [...base, ...recent]
const keepRootIds = new Set(keepRoots.map((s) => s.id))
const keepChildren = children.filter((s) => {
if (s.parentID && keepRootIds.has(s.parentID)) return true
const perms = options.permission[s.id] ?? []
if (perms.length > 0) return true
return sessionUpdatedAt(s) > cutoff
})
return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
}

View File

@@ -0,0 +1,134 @@
import type {
Agent,
Command,
Config,
FileDiff,
LspStatus,
McpStatus,
Message,
Part,
Path,
PermissionRequest,
Project,
ProviderListResponse,
QuestionRequest,
Session,
SessionStatus,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
export type ProjectMeta = {
name?: string
icon?: {
override?: string
color?: string
}
commands?: {
start?: string
}
}
export type State = {
status: "loading" | "partial" | "complete"
agent: Agent[]
command: Command[]
project: string
projectMeta: ProjectMeta | undefined
icon: string | undefined
provider: ProviderListResponse
config: Config
path: Path
session: Session[]
sessionTotal: number
session_status: {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: FileDiff[]
}
todo: {
[sessionID: string]: Todo[]
}
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
mcp: {
[name: string]: McpStatus
}
lsp: LspStatus[]
vcs: VcsInfo | undefined
limit: number
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
}
export type VcsCache = {
store: Store<{ value: VcsInfo | undefined }>
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
ready: Accessor<boolean>
}
export type MetaCache = {
store: Store<{ value: ProjectMeta | undefined }>
setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
ready: Accessor<boolean>
}
export type IconCache = {
store: Store<{ value: string | undefined }>
setStore: SetStoreFunction<{ value: string | undefined }>
ready: Accessor<boolean>
}
export type ChildOptions = {
bootstrap?: boolean
}
export type DirState = {
lastAccessAt: number
}
export type EvictPlan = {
stores: string[]
state: Map<string, DirState>
pins: Set<string>
max: number
ttl: number
now: number
}
export type DisposeCheck = {
directory: string
hasStore: boolean
pinned: boolean
booting: boolean
loadingSessions: boolean
}
export type RootLoadArgs = {
directory: string
limit: number
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
onFallback: () => void
}
export type RootLoadResult = {
data?: Session[]
limit: number
limited: boolean
}
export const MAX_DIR_STORES = 30
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
export const SESSION_RECENT_LIMIT = 50

View File

@@ -0,0 +1,25 @@
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return {
...input,
all: input.all.map((provider) => ({
...provider,
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
})),
}
}
export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project
return {
...project,
icon: {
...project.icon,
url: undefined,
override: undefined,
},
}
}

View File

@@ -76,6 +76,26 @@ const LOCALES: readonly Locale[] = [
"th", "th",
] ]
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
zh,
zht,
ko,
de,
es,
fr,
da,
ja,
pl,
ru,
ar,
no,
br,
th,
bs,
}
void PARITY_CHECK
function detectLocale(): Locale { function detectLocale(): Locale {
if (typeof navigator !== "object") return "en" if (typeof navigator !== "object") return "en"

View File

@@ -1,73 +1,36 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
import { createScrollPersistence } from "./layout-scroll" import { createScrollPersistence } from "./layout-scroll"
describe("createScrollPersistence", () => { describe("createScrollPersistence", () => {
test.skip("debounces persisted scroll writes", async () => { test("debounces persisted scroll writes", async () => {
const key = "layout-scroll.test" const snapshot = {
const data = new Map<string, string>() session: {
const writes: string[] = [] review: { x: 0, y: 0 },
const stats = { flushes: 0 }
const storage = {
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => {
data.set(k, v)
if (k === key) writes.push(v)
}, },
removeItem: (k: string) => { } as Record<string, Record<string, { x: number; y: number }>>
data.delete(k) const writes: Array<Record<string, { x: number; y: number }>> = []
},
} as SyncStorage
await new Promise<void>((resolve, reject) => {
createRoot((dispose) => {
const [raw, setRaw] = createStore({
sessionView: {} as Record<string, { scroll: Record<string, { x: number; y: number }> }>,
})
const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage })
const scroll = createScrollPersistence({ const scroll = createScrollPersistence({
debounceMs: 30, debounceMs: 10,
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll, getSnapshot: (sessionKey) => snapshot[sessionKey],
onFlush: (sessionKey, next) => { onFlush: (sessionKey, next) => {
stats.flushes += 1 snapshot[sessionKey] = next
writes.push(next)
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: next })
return
}
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
}, },
}) })
const run = async () => { for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) {
await new Promise((r) => setTimeout(r, 0))
writes.length = 0
for (const i of Array.from({ length: 100 }, (_, n) => n)) {
scroll.setScroll("session", "review", { x: 0, y: i }) scroll.setScroll("session", "review", { x: 0, y: i })
} }
await new Promise((r) => setTimeout(r, 120)) await new Promise((resolve) => setTimeout(resolve, 40))
expect(stats.flushes).toBeGreaterThanOrEqual(1) expect(writes).toHaveLength(1)
expect(writes.length).toBeGreaterThanOrEqual(1) expect(writes[0]?.review).toEqual({ x: 0, y: 30 })
expect(writes.length).toBeLessThanOrEqual(2)
}
void run() scroll.setScroll("session", "review", { x: 0, y: 30 })
.then(resolve) await new Promise((resolve) => setTimeout(resolve, 20))
.catch(reject)
.finally(() => { expect(writes).toHaveLength(1)
scroll.dispose() scroll.dispose()
dispose()
})
})
})
}) })
}) })

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout"
describe("layout session-key helpers", () => {
test("couples touch and scroll seed in order", () => {
const calls: string[] = []
const result = ensureSessionKey(
"dir/a",
(key) => calls.push(`touch:${key}`),
(key) => calls.push(`seed:${key}`),
)
expect(result).toBe("dir/a")
expect(calls).toEqual(["touch:dir/a", "seed:dir/a"])
})
test("reads dynamic accessor keys lazily", () => {
const seen: string[] = []
createRoot((dispose) => {
const [key, setKey] = createSignal("dir/one")
const read = createSessionKeyReader(key, (value) => seen.push(value))
expect(read()).toBe("dir/one")
setKey("dir/two")
expect(read()).toBe("dir/two")
dispose()
})
expect(seen).toEqual(["dir/one", "dir/two"])
})
})
describe("pruneSessionKeys", () => {
test("keeps active key and drops lowest-used keys", () => {
const drop = pruneSessionKeys({
keep: "k4",
max: 3,
used: new Map([
["k1", 1],
["k2", 2],
["k3", 3],
["k4", 4],
]),
view: ["k1", "k2", "k4"],
tabs: ["k1", "k3", "k4"],
})
expect(drop).toEqual(["k1"])
expect(drop.includes("k4")).toBe(false)
})
test("does not prune without keep key", () => {
const drop = pruneSessionKeys({
keep: undefined,
max: 1,
used: new Map([
["k1", 1],
["k2", 2],
]),
view: ["k1"],
tabs: ["k2"],
})
expect(drop).toEqual([])
})
})

View File

@@ -1,5 +1,5 @@
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
@@ -47,6 +47,43 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type ReviewDiffStyle = "unified" | "split" export type ReviewDiffStyle = "unified" | "split"
export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
touch(key)
seed(key)
return key
}
export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
return () => {
const value = key()
ensure(value)
return value
}
}
export function pruneSessionKeys(input: {
keep?: string
max: number
used: Map<string, number>
view: string[]
tabs: string[]
}) {
if (!input.keep) return []
const keys = new Set<string>([...input.view, ...input.tabs])
if (keys.size <= input.max) return []
const score = (key: string) => {
if (key === input.keep) return Number.MAX_SAFE_INTEGER
return input.used.get(key) ?? 0
}
return Array.from(keys)
.sort((a, b) => score(b) - score(a))
.slice(input.max)
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout", name: "Layout",
init: () => { init: () => {
@@ -172,20 +209,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
} }
function prune(keep?: string) { function prune(keep?: string) {
if (!keep) return const drop = pruneSessionKeys({
keep,
const keys = new Set<string>() max: MAX_SESSION_KEYS,
for (const key of Object.keys(store.sessionView)) keys.add(key) used,
for (const key of Object.keys(store.sessionTabs)) keys.add(key) view: Object.keys(store.sessionView),
if (keys.size <= MAX_SESSION_KEYS) return tabs: Object.keys(store.sessionTabs),
})
const score = (key: string) => {
if (key === keep) return Number.MAX_SAFE_INTEGER
return used.get(key) ?? 0
}
const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
const drop = ordered.slice(MAX_SESSION_KEYS)
if (drop.length === 0) return if (drop.length === 0) return
setStore( setStore(
@@ -233,6 +263,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
}) })
const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey))
createEffect(() => { createEffect(() => {
if (!ready()) return if (!ready()) return
if (meta.pruned) return if (meta.pruned) return
@@ -616,22 +648,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
}, },
view(sessionKey: string | Accessor<string>) { view(sessionKey: string | Accessor<string>) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey const key = createSessionKeyReader(sessionKey, ensureKey)
touch(key())
scroll.seed(key())
createEffect(
on(
key,
(value) => {
touch(value)
scroll.seed(value)
},
{ defer: true },
),
)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const terminalOpened = createMemo(() => store.terminal?.opened ?? false) const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
@@ -711,20 +728,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
} }
}, },
tabs(sessionKey: string | Accessor<string>) { tabs(sessionKey: string | Accessor<string>) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey const key = createSessionKeyReader(sessionKey, ensureKey)
touch(key())
createEffect(
on(
key,
(value) => {
touch(value)
},
{ defer: true },
),
)
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
return { return {
tabs, tabs,

View File

@@ -0,0 +1,66 @@
type NotificationIndexItem = {
directory?: string
session?: string
viewed: boolean
type: string
}
export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
const sessionAll = new Map<string, T[]>()
const sessionUnseen = new Map<string, T[]>()
const sessionUnseenCount = new Map<string, number>()
const sessionUnseenHasError = new Map<string, boolean>()
const projectAll = new Map<string, T[]>()
const projectUnseen = new Map<string, T[]>()
const projectUnseenCount = new Map<string, number>()
const projectUnseenHasError = new Map<string, boolean>()
for (const notification of list) {
const session = notification.session
if (session) {
const all = sessionAll.get(session)
if (all) all.push(notification)
else sessionAll.set(session, [notification])
if (!notification.viewed) {
const unseen = sessionUnseen.get(session)
if (unseen) unseen.push(notification)
else sessionUnseen.set(session, [notification])
sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
if (notification.type === "error") sessionUnseenHasError.set(session, true)
}
}
const directory = notification.directory
if (directory) {
const all = projectAll.get(directory)
if (all) all.push(notification)
else projectAll.set(directory, [notification])
if (!notification.viewed) {
const unseen = projectUnseen.get(directory)
if (unseen) unseen.push(notification)
else projectUnseen.set(directory, [notification])
projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
if (notification.type === "error") projectUnseenHasError.set(directory, true)
}
}
}
return {
session: {
all: sessionAll,
unseen: sessionUnseen,
unseenCount: sessionUnseenCount,
unseenHasError: sessionUnseenHasError,
},
project: {
all: projectAll,
unseen: projectUnseen,
unseenCount: projectUnseenCount,
unseenHasError: projectUnseenHasError,
},
}
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { buildNotificationIndex } from "./notification-index"
type Notification = {
type: "turn-complete" | "error"
session: string
directory: string
viewed: boolean
time: number
}
const turn = (session: string, directory: string, viewed = false): Notification => ({
type: "turn-complete",
session,
directory,
viewed,
time: 1,
})
const error = (session: string, directory: string, viewed = false): Notification => ({
type: "error",
session,
directory,
viewed,
time: 1,
})
describe("buildNotificationIndex", () => {
test("builds unseen counts and unseen error flags", () => {
const list = [
turn("s1", "d1", false),
error("s1", "d1", false),
turn("s1", "d1", true),
turn("s2", "d1", false),
error("s3", "d2", true),
]
const index = buildNotificationIndex(list)
expect(index.session.all.get("s1")?.length).toBe(3)
expect(index.session.unseen.get("s1")?.length).toBe(2)
expect(index.session.unseenCount.get("s1")).toBe(2)
expect(index.session.unseenHasError.get("s1")).toBe(true)
expect(index.session.unseenCount.get("s2")).toBe(1)
expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
expect(index.project.unseenCount.get("d1")).toBe(3)
expect(index.project.unseenHasError.get("d1")).toBe(true)
expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
})
test("updates selectors after viewed transitions", () => {
const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
const before = buildNotificationIndex(list)
const after = buildNotificationIndex(next)
expect(before.session.unseenCount.get("s1")).toBe(2)
expect(before.session.unseenHasError.get("s1")).toBe(true)
expect(before.project.unseenCount.get("d1")).toBe(3)
expect(before.project.unseenHasError.get("d1")).toBe(true)
expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
expect(after.project.unseenCount.get("d1")).toBe(1)
expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
})
})

View File

@@ -13,6 +13,7 @@ import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2" import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound" import { playSound, soundSrc } from "@/utils/sound"
import { buildNotificationIndex } from "./notification-index"
type NotificationBase = { type NotificationBase = {
directory?: string directory?: string
@@ -81,49 +82,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
setStore("list", (list) => pruneNotifications([...list, notification])) setStore("list", (list) => pruneNotifications([...list, notification]))
} }
const index = createMemo(() => { const index = createMemo(() => buildNotificationIndex(store.list))
const sessionAll = new Map<string, Notification[]>()
const sessionUnseen = new Map<string, Notification[]>()
const projectAll = new Map<string, Notification[]>()
const projectUnseen = new Map<string, Notification[]>()
for (const notification of store.list) {
const session = notification.session
if (session) {
const list = sessionAll.get(session)
if (list) list.push(notification)
else sessionAll.set(session, [notification])
if (!notification.viewed) {
const unseen = sessionUnseen.get(session)
if (unseen) unseen.push(notification)
else sessionUnseen.set(session, [notification])
}
}
const directory = notification.directory
if (directory) {
const list = projectAll.get(directory)
if (list) list.push(notification)
else projectAll.set(directory, [notification])
if (!notification.viewed) {
const unseen = projectUnseen.get(directory)
if (unseen) unseen.push(notification)
else projectUnseen.set(directory, [notification])
}
}
}
return {
session: {
all: sessionAll,
unseen: sessionUnseen,
},
project: {
all: projectAll,
unseen: projectUnseen,
},
}
})
const unsub = globalSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
const event = e.details const event = e.details
@@ -208,6 +167,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
unseen(session: string) { unseen(session: string) {
return index().session.unseen.get(session) ?? empty return index().session.unseen.get(session) ?? empty
}, },
unseenCount(session: string) {
return index().session.unseenCount.get(session) ?? 0
},
unseenHasError(session: string) {
return index().session.unseenHasError.get(session) ?? false
},
markViewed(session: string) { markViewed(session: string) {
setStore("list", (n) => n.session === session, "viewed", true) setStore("list", (n) => n.session === session, "viewed", true)
}, },
@@ -219,6 +184,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
unseen(directory: string) { unseen(directory: string) {
return index().project.unseen.get(directory) ?? empty return index().project.unseen.get(directory) ?? empty
}, },
unseenCount(directory: string) {
return index().project.unseenCount.get(directory) ?? 0
},
unseenHasError(directory: string) {
return index().project.unseenHasError.get(directory) ?? false
},
markViewed(directory: string) { markViewed(directory: string) {
setStore("list", (n) => n.directory === directory, "viewed", true) setStore("list", (n) => n.directory === directory, "viewed", true)
}, },

View File

@@ -62,6 +62,9 @@ export type Platform = {
/** Webview zoom level (desktop only) */ /** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number> webviewZoom?: Accessor<number>
/** Check if an editor app exists (desktop only) */
checkAppExists?(appName: string): Promise<boolean>
} }
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({

View File

@@ -1,9 +1,9 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean } type StoredProject = { worktree: string; expanded: boolean }
@@ -94,18 +94,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active) const isReady = createMemo(() => ready() && !!state.active)
const check = (url: string) => { const fetcher = platform.fetch ?? globalThis.fetch
const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal,
})
return sdk.global
.health()
.then((x) => x.data?.healthy === true)
.catch(() => false)
}
createEffect(() => { createEffect(() => {
const url = state.active const url = state.active

View File

@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { applyOptimisticAdd, applyOptimisticRemove } from "./sync"
const userMessage = (id: string, sessionID: string): Message => ({
id,
sessionID,
role: "user",
time: { created: 1 },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
})
const textPart = (id: string, sessionID: string, messageID: string): Part => ({
id,
sessionID,
messageID,
type: "text",
text: id,
})
describe("sync optimistic reducers", () => {
test("applyOptimisticAdd inserts message in sorted order and stores parts", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_2", sessionID)] },
part: {} as Record<string, Part[] | undefined>,
}
applyOptimisticAdd(draft, {
sessionID,
message: userMessage("msg_1", sessionID),
parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")],
})
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
})
test("applyOptimisticRemove removes message and part entries", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
part: {
msg_1: [textPart("prt_1", sessionID, "msg_1")],
msg_2: [textPart("prt_2", sessionID, "msg_2")],
} as Record<string, Part[] | undefined>,
}
applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
expect(draft.part.msg_1).toBeUndefined()
expect(draft.part.msg_2).toHaveLength(1)
})
})

View File

@@ -11,6 +11,43 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (!messages) {
draft.message[input.sessionID] = [input.message]
}
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
}
draft.part[input.message.id] = input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[input.messageID]
}
export const { use: useSync, provider: SyncProvider } = createSimpleContext({ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync", name: "Sync",
init: () => { init: () => {
@@ -21,6 +58,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
type Setter = Child[1] type Setter = Child[1]
const current = createMemo(() => globalSync.child(sdk.directory)) const current = createMemo(() => globalSync.child(sdk.directory))
const target = (directory?: string) => {
if (!directory || directory === sdk.directory) return current()
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const chunk = 400 const chunk = 400
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
@@ -107,6 +148,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}, },
session: { session: {
get: getSession, get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const [, setStore] = target(input.directory)
setStore(
produce((draft) => {
applyOptimisticAdd(draft as OptimisticStore, input)
}),
)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const [, setStore] = target(input.directory)
setStore(
produce((draft) => {
applyOptimisticRemove(draft as OptimisticStore, input)
}),
)
},
},
addOptimisticMessage(input: { addOptimisticMessage(input: {
sessionID: string sessionID: string
messageID: string messageID: string
@@ -122,16 +181,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: input.agent, agent: input.agent,
model: input.model, model: input.model,
} }
current()[1]( const [, setStore] = target()
setStore(
produce((draft) => { produce((draft) => {
const messages = draft.message[input.sessionID] applyOptimisticAdd(draft as OptimisticStore, {
if (!messages) { sessionID: input.sessionID,
draft.message[input.sessionID] = [message] message,
} else { parts: input.parts,
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) => cmp(a.id, b.id))
}), }),
) )
}, },

View File

@@ -0,0 +1,38 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
let getWorkspaceTerminalCacheKey: (dir: string) => string
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useParams: () => ({}),
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
})
describe("getWorkspaceTerminalCacheKey", () => {
test("uses workspace-only directory cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
})
})
describe("getLegacyTerminalStorageKeys", () => {
test("keeps workspace storage path when no legacy session id", () => {
expect(getLegacyTerminalStorageKeys("/repo")).toEqual(["/repo/terminal.v1"])
})
test("includes legacy session path before workspace path", () => {
expect(getLegacyTerminalStorageKeys("/repo", "session-123")).toEqual([
"/repo/terminal/session-123.v1",
"/repo/terminal.v1",
])
})
})

View File

@@ -19,15 +19,24 @@ export type LocalPTY = {
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20 const MAX_TERMINAL_SESSIONS = 20
type TerminalSession = ReturnType<typeof createTerminalSession> export function getWorkspaceTerminalCacheKey(dir: string) {
return `${dir}:${WORKSPACE_KEY}`
}
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
if (!legacySessionID) return [`${dir}/terminal.v1`]
return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
}
type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
type TerminalCacheEntry = { type TerminalCacheEntry = {
value: TerminalSession value: TerminalSession
dispose: VoidFunction dispose: VoidFunction
} }
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, session?: string) { function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`] const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
const numberFromTitle = (title: string) => { const numberFromTitle = (title: string) => {
const match = title.match(/^Terminal (\d+)$/) const match = title.match(/^Terminal (\d+)$/)
@@ -235,8 +244,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
} }
} }
const load = (dir: string, session?: string) => { const loadWorkspace = (dir: string, legacySessionID?: string) => {
const key = `${dir}:${WORKSPACE_KEY}` // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
const key = getWorkspaceTerminalCacheKey(dir)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) { if (existing) {
cache.delete(key) cache.delete(key)
@@ -245,7 +255,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
} }
const entry = createRoot((dispose) => ({ const entry = createRoot((dispose) => ({
value: createTerminalSession(sdk, dir, session), value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
dispose, dispose,
})) }))
@@ -254,7 +264,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value return entry.value
} }
const workspace = createMemo(() => load(params.dir!, params.id)) const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
return { return {
ready: () => workspace().ready(), ready: () => workspace().ready(),

View File

@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "فتح الإعدادات", "command.settings.open": "فتح الإعدادات",
"command.session.previous": "الجلسة السابقة", "command.session.previous": "الجلسة السابقة",
"command.session.next": "الجلسة التالية", "command.session.next": "الجلسة التالية",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "الجلسة غير المقروءة السابقة",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "الجلسة غير المقروءة التالية",
"command.session.archive": "أرشفة الجلسة", "command.session.archive": "أرشفة الجلسة",
"command.palette": "لوحة الأوامر", "command.palette": "لوحة الأوامر",

View File

@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Abrir configurações", "command.settings.open": "Abrir configurações",
"command.session.previous": "Sessão anterior", "command.session.previous": "Sessão anterior",
"command.session.next": "Próxima sessão", "command.session.next": "Próxima sessão",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "Sessão não lida anterior",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "Próxima sessão não lida",
"command.session.archive": "Arquivar sessão", "command.session.archive": "Arquivar sessão",
"command.palette": "Paleta de comandos", "command.palette": "Paleta de comandos",

View File

@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Åbn indstillinger", "command.settings.open": "Åbn indstillinger",
"command.session.previous": "Forrige session", "command.session.previous": "Forrige session",
"command.session.next": "Næste session", "command.session.next": "Næste session",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "Forrige ulæste session",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "Næste ulæste session",
"command.session.archive": "Arkivér session", "command.session.archive": "Arkivér session",
"command.palette": "Kommandopalette", "command.palette": "Kommandopalette",

View File

@@ -32,8 +32,8 @@ export const dict = {
"command.settings.open": "Einstellungen öffnen", "command.settings.open": "Einstellungen öffnen",
"command.session.previous": "Vorherige Sitzung", "command.session.previous": "Vorherige Sitzung",
"command.session.next": "Nächste Sitzung", "command.session.next": "Nächste Sitzung",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "Vorherige ungelesene Sitzung",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "Nächste ungelesene Sitzung",
"command.session.archive": "Sitzung archivieren", "command.session.archive": "Sitzung archivieren",
"command.palette": "Befehlspalette", "command.palette": "Befehlspalette",
@@ -147,6 +147,44 @@ export const dict = {
"provider.connect.toast.connected.title": "{{provider}} verbunden", "provider.connect.toast.connected.title": "{{provider}} verbunden",
"provider.connect.toast.connected.description": "{{provider}} Modelle sind jetzt verfügbar.", "provider.connect.toast.connected.description": "{{provider}} Modelle sind jetzt verfügbar.",
"provider.custom.title": "Benutzerdefinierter Anbieter",
"provider.custom.description.prefix": "Konfigurieren Sie einen OpenAI-kompatiblen Anbieter. Siehe die ",
"provider.custom.description.link": "Anbieter-Konfigurationsdokumente",
"provider.custom.description.suffix": ".",
"provider.custom.field.providerID.label": "Anbieter-ID",
"provider.custom.field.providerID.placeholder": "myprovider",
"provider.custom.field.providerID.description": "Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche",
"provider.custom.field.name.label": "Anzeigename",
"provider.custom.field.name.placeholder": "Mein KI-Anbieter",
"provider.custom.field.baseURL.label": "Basis-URL",
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
"provider.custom.field.apiKey.label": "API-Schlüssel",
"provider.custom.field.apiKey.placeholder": "API-Schlüssel",
"provider.custom.field.apiKey.description":
"Optional. Leer lassen, wenn Sie die Authentifizierung über Header verwalten.",
"provider.custom.models.label": "Modelle",
"provider.custom.models.id.label": "ID",
"provider.custom.models.id.placeholder": "model-id",
"provider.custom.models.name.label": "Name",
"provider.custom.models.name.placeholder": "Anzeigename",
"provider.custom.models.remove": "Modell entfernen",
"provider.custom.models.add": "Modell hinzufügen",
"provider.custom.headers.label": "Header (optional)",
"provider.custom.headers.key.label": "Header",
"provider.custom.headers.key.placeholder": "Header-Name",
"provider.custom.headers.value.label": "Wert",
"provider.custom.headers.value.placeholder": "wert",
"provider.custom.headers.remove": "Header entfernen",
"provider.custom.headers.add": "Header hinzufügen",
"provider.custom.error.providerID.required": "Anbieter-ID ist erforderlich",
"provider.custom.error.providerID.format": "Verwenden Sie Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche",
"provider.custom.error.providerID.exists": "Diese Anbieter-ID existiert bereits",
"provider.custom.error.name.required": "Anzeigename ist erforderlich",
"provider.custom.error.baseURL.required": "Basis-URL ist erforderlich",
"provider.custom.error.baseURL.format": "Muss mit http:// oder https:// beginnen",
"provider.custom.error.required": "Erforderlich",
"provider.custom.error.duplicate": "Duplikat",
"provider.disconnect.toast.disconnected.title": "{{provider}} getrennt", "provider.disconnect.toast.disconnected.title": "{{provider}} getrennt",
"provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.", "provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.",
"model.tag.free": "Kostenlos", "model.tag.free": "Kostenlos",
@@ -380,6 +418,7 @@ export const dict = {
"Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?", "Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?",
"error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?", "error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
"directory.error.invalidUrl": "Ungültiges Verzeichnis in der URL.",
"error.chain.unknown": "Unbekannter Fehler", "error.chain.unknown": "Unbekannter Fehler",
"error.chain.causedBy": "Verursacht durch:", "error.chain.causedBy": "Verursacht durch:",

View File

@@ -149,6 +149,43 @@ export const dict = {
"provider.connect.toast.connected.title": "{{provider}} connected", "provider.connect.toast.connected.title": "{{provider}} connected",
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.", "provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
"provider.custom.title": "Custom provider",
"provider.custom.description.prefix": "Configure an OpenAI-compatible provider. See the ",
"provider.custom.description.link": "provider config docs",
"provider.custom.description.suffix": ".",
"provider.custom.field.providerID.label": "Provider ID",
"provider.custom.field.providerID.placeholder": "myprovider",
"provider.custom.field.providerID.description": "Lowercase letters, numbers, hyphens, or underscores",
"provider.custom.field.name.label": "Display name",
"provider.custom.field.name.placeholder": "My AI Provider",
"provider.custom.field.baseURL.label": "Base URL",
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
"provider.custom.field.apiKey.label": "API key",
"provider.custom.field.apiKey.placeholder": "API key",
"provider.custom.field.apiKey.description": "Optional. Leave empty if you manage auth via headers.",
"provider.custom.models.label": "Models",
"provider.custom.models.id.label": "ID",
"provider.custom.models.id.placeholder": "model-id",
"provider.custom.models.name.label": "Name",
"provider.custom.models.name.placeholder": "Display Name",
"provider.custom.models.remove": "Remove model",
"provider.custom.models.add": "Add model",
"provider.custom.headers.label": "Headers (optional)",
"provider.custom.headers.key.label": "Header",
"provider.custom.headers.key.placeholder": "Header-Name",
"provider.custom.headers.value.label": "Value",
"provider.custom.headers.value.placeholder": "value",
"provider.custom.headers.remove": "Remove header",
"provider.custom.headers.add": "Add header",
"provider.custom.error.providerID.required": "Provider ID is required",
"provider.custom.error.providerID.format": "Use lowercase letters, numbers, hyphens, or underscores",
"provider.custom.error.providerID.exists": "That provider ID already exists",
"provider.custom.error.name.required": "Display name is required",
"provider.custom.error.baseURL.required": "Base URL is required",
"provider.custom.error.baseURL.format": "Must start with http:// or https://",
"provider.custom.error.required": "Required",
"provider.custom.error.duplicate": "Duplicate",
"provider.disconnect.toast.disconnected.title": "{{provider}} disconnected", "provider.disconnect.toast.disconnected.title": "{{provider}} disconnected",
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.", "provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
@@ -404,6 +441,7 @@ export const dict = {
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
"error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?", "error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
"directory.error.invalidUrl": "Invalid directory in URL.",
"error.chain.unknown": "Unknown error", "error.chain.unknown": "Unknown error",
"error.chain.causedBy": "Caused by:", "error.chain.causedBy": "Caused by:",

View File

@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Abrir ajustes", "command.settings.open": "Abrir ajustes",
"command.session.previous": "Sesión anterior", "command.session.previous": "Sesión anterior",
"command.session.next": "Siguiente sesión", "command.session.next": "Siguiente sesión",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "Sesión no leída anterior",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "Siguiente sesión no leída",
"command.session.archive": "Archivar sesión", "command.session.archive": "Archivar sesión",
"command.palette": "Paleta de comandos", "command.palette": "Paleta de comandos",

View File

@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Ouvrir les paramètres", "command.settings.open": "Ouvrir les paramètres",
"command.session.previous": "Session précédente", "command.session.previous": "Session précédente",
"command.session.next": "Session suivante", "command.session.next": "Session suivante",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "Session non lue précédente",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "Session non lue suivante",
"command.session.archive": "Archiver la session", "command.session.archive": "Archiver la session",
"command.palette": "Palette de commandes", "command.palette": "Palette de commandes",

View File

@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "設定を開く", "command.settings.open": "設定を開く",
"command.session.previous": "前のセッション", "command.session.previous": "前のセッション",
"command.session.next": "次のセッション", "command.session.next": "次のセッション",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "前の未読セッション",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "次の未読セッション",
"command.session.archive": "セッションをアーカイブ", "command.session.archive": "セッションをアーカイブ",
"command.palette": "コマンドパレット", "command.palette": "コマンドパレット",

View File

@@ -32,8 +32,8 @@ export const dict = {
"command.settings.open": "설정 열기", "command.settings.open": "설정 열기",
"command.session.previous": "이전 세션", "command.session.previous": "이전 세션",
"command.session.next": "다음 세션", "command.session.next": "다음 세션",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "이전 읽지 않은 세션",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "다음 읽지 않은 세션",
"command.session.archive": "세션 보관", "command.session.archive": "세션 보관",
"command.palette": "명령 팔레트", "command.palette": "명령 팔레트",

View File

@@ -31,8 +31,8 @@ export const dict = {
"command.settings.open": "Åpne innstillinger", "command.settings.open": "Åpne innstillinger",
"command.session.previous": "Forrige sesjon", "command.session.previous": "Forrige sesjon",
"command.session.next": "Neste sesjon", "command.session.next": "Neste sesjon",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "Forrige uleste økt",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "Neste uleste økt",
"command.session.archive": "Arkiver sesjon", "command.session.archive": "Arkiver sesjon",
"command.palette": "Kommandopalett", "command.palette": "Kommandopalett",

View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test"
import { dict as en } from "./en"
import { dict as ar } from "./ar"
import { dict as br } from "./br"
import { dict as bs } from "./bs"
import { dict as da } from "./da"
import { dict as de } from "./de"
import { dict as es } from "./es"
import { dict as fr } from "./fr"
import { dict as ja } from "./ja"
import { dict as ko } from "./ko"
import { dict as no } from "./no"
import { dict as pl } from "./pl"
import { dict as ru } from "./ru"
import { dict as th } from "./th"
import { dict as zh } from "./zh"
import { dict as zht } from "./zht"
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, zh, zht]
const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
describe("i18n parity", () => {
test("non-English locales translate targeted unseen session keys", () => {
for (const locale of locales) {
for (const key of keys) {
expect(locale[key]).toBeDefined()
expect(locale[key]).not.toBe(en[key])
}
}
})
})

View File

@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Otwórz ustawienia", "command.settings.open": "Otwórz ustawienia",
"command.session.previous": "Poprzednia sesja", "command.session.previous": "Poprzednia sesja",
"command.session.next": "Następna sesja", "command.session.next": "Następna sesja",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "Poprzednia nieprzeczytana sesja",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "Następna nieprzeczytana sesja",
"command.session.archive": "Zarchiwizuj sesję", "command.session.archive": "Zarchiwizuj sesję",
"command.palette": "Paleta poleceń", "command.palette": "Paleta poleceń",

View File

@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "Открыть настройки", "command.settings.open": "Открыть настройки",
"command.session.previous": "Предыдущая сессия", "command.session.previous": "Предыдущая сессия",
"command.session.next": "Следующая сессия", "command.session.next": "Следующая сессия",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "Предыдущая непрочитанная сессия",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "Следующая непрочитанная сессия",
"command.session.archive": "Архивировать сессию", "command.session.archive": "Архивировать сессию",
"command.palette": "Палитра команд", "command.palette": "Палитра команд",

View File

@@ -28,8 +28,8 @@ export const dict = {
"command.settings.open": "เปิดการตั้งค่า", "command.settings.open": "เปิดการตั้งค่า",
"command.session.previous": "เซสชันก่อนหน้า", "command.session.previous": "เซสชันก่อนหน้า",
"command.session.next": "เซสชันถัดไป", "command.session.next": "เซสชันถัดไป",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "เซสชันที่ยังไม่ได้อ่านก่อนหน้า",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "เซสชันที่ยังไม่ได้อ่านถัดไป",
"command.session.archive": "จัดเก็บเซสชัน", "command.session.archive": "จัดเก็บเซสชัน",
"command.palette": "คำสั่งค้นหา", "command.palette": "คำสั่งค้นหา",

View File

@@ -32,8 +32,8 @@ export const dict = {
"command.settings.open": "打开设置", "command.settings.open": "打开设置",
"command.session.previous": "上一个会话", "command.session.previous": "上一个会话",
"command.session.next": "下一个会话", "command.session.next": "下一个会话",
"command.session.previous.unseen": "Previous unread session", "command.session.previous.unseen": "上一个未读会话",
"command.session.next.unseen": "Next unread session", "command.session.next.unseen": "下一个未读会话",
"command.session.archive": "归档会话", "command.session.archive": "归档会话",
"command.palette": "命令面板", "command.palette": "命令面板",
@@ -147,6 +147,43 @@ export const dict = {
"provider.connect.toast.connected.title": "{{provider}} 已连接", "provider.connect.toast.connected.title": "{{provider}} 已连接",
"provider.connect.toast.connected.description": "现在可以使用 {{provider}} 模型了。", "provider.connect.toast.connected.description": "现在可以使用 {{provider}} 模型了。",
"provider.custom.title": "自定义提供商",
"provider.custom.description.prefix": "配置与 OpenAI 兼容的提供商。请查看",
"provider.custom.description.link": "提供商配置文档",
"provider.custom.description.suffix": "。",
"provider.custom.field.providerID.label": "提供商 ID",
"provider.custom.field.providerID.placeholder": "myprovider",
"provider.custom.field.providerID.description": "使用小写字母、数字、连字符或下划线",
"provider.custom.field.name.label": "显示名称",
"provider.custom.field.name.placeholder": "我的 AI 提供商",
"provider.custom.field.baseURL.label": "基础 URL",
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
"provider.custom.field.apiKey.label": "API 密钥",
"provider.custom.field.apiKey.placeholder": "API 密钥",
"provider.custom.field.apiKey.description": "可选。如果你通过请求头管理认证,可留空。",
"provider.custom.models.label": "模型",
"provider.custom.models.id.label": "ID",
"provider.custom.models.id.placeholder": "model-id",
"provider.custom.models.name.label": "名称",
"provider.custom.models.name.placeholder": "显示名称",
"provider.custom.models.remove": "移除模型",
"provider.custom.models.add": "添加模型",
"provider.custom.headers.label": "请求头(可选)",
"provider.custom.headers.key.label": "请求头",
"provider.custom.headers.key.placeholder": "Header-Name",
"provider.custom.headers.value.label": "值",
"provider.custom.headers.value.placeholder": "value",
"provider.custom.headers.remove": "移除请求头",
"provider.custom.headers.add": "添加请求头",
"provider.custom.error.providerID.required": "提供商 ID 为必填项",
"provider.custom.error.providerID.format": "请使用小写字母、数字、连字符或下划线",
"provider.custom.error.providerID.exists": "该提供商 ID 已存在",
"provider.custom.error.name.required": "显示名称为必填项",
"provider.custom.error.baseURL.required": "基础 URL 为必填项",
"provider.custom.error.baseURL.format": "必须以 http:// 或 https:// 开头",
"provider.custom.error.required": "必填",
"provider.custom.error.duplicate": "重复",
"provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接", "provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接",
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。", "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
"model.tag.free": "免费", "model.tag.free": "免费",
@@ -380,6 +417,7 @@ export const dict = {
"error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html或者 id 属性拼写错了?", "error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html或者 id 属性拼写错了?",
"error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?", "error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
"directory.error.invalidUrl": "URL 中的目录无效。",
"error.chain.unknown": "未知错误", "error.chain.unknown": "未知错误",
"error.chain.causedBy": "原因:", "error.chain.causedBy": "原因:",

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